diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml
index 1ab22dd1dc9..cf28f6f9d43 100644
--- a/.github/actions/setup-project-bun/action.yml
+++ b/.github/actions/setup-project-bun/action.yml
@@ -8,8 +8,8 @@ description: >-
# (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to
# 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after
# finalizers ran inside an earlier call and spin past its own timeout (#4956).
-# Rejoin only after the upstream runner crash is fixed and a green lane=all
-# dispatch proves the unified version; then delete testRunnerBun and this role input.
+# Both roles now default to the package's 1.4.4 security transition, retaining role validation.
+# Promotion requires a green full cross-platform lane=all run on the exact candidate head.
inputs:
role:
description: Bun role to install (runtime or test-runner).
diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs
index 8973f07a63b..3fc9c675daa 100644
--- a/.github/scripts/enforce-pr-target.test.cjs
+++ b/.github/scripts/enforce-pr-target.test.cjs
@@ -94,7 +94,9 @@ describe("enforce-pr-target workflow", () => {
it("writes exactly one consolidated comment via a single upsert helper", () => {
assert.match(workflow, /GATE_MARKER,/);
- assert.match(workflow, /comment\.body\?\.includes\(GATE_MARKER\)/);
+ // The gate comment is the bot comment that starts with GATE_MARKER; a
+ // marker echoed inside another comment does not classify it.
+ assert.match(workflow, /comments\.find\(isGateComment\)/);
assert.match(workflow, /upsertGateComment/);
assert.match(workflow, /buildGateCommentBody/);
// No legacy two-comment write path remains.
diff --git a/.github/scripts/pr-carry-attribution.cjs b/.github/scripts/pr-carry-attribution.cjs
index fa4894330d8..401b3b50ba2 100644
--- a/.github/scripts/pr-carry-attribution.cjs
+++ b/.github/scripts/pr-carry-attribution.cjs
@@ -49,7 +49,16 @@ const SENTENCE_END_RE = /[.!?](?:\s|$)|\n/;
function carryWindow(text, from) {
const slice = text.slice(from, from + 80);
const end = slice.search(SENTENCE_END_RE);
- return end === -1 ? slice : slice.slice(0, end);
+ if (end !== -1) return slice.slice(0, end);
+
+ // Finish only the number already started in this window (including a bare
+ // hash at the cap). The bounded suffix check and forward digit walk keep
+ // untrusted text linear and leave owner/repo qualifiers intact for REF_RE.
+ let to = from + slice.length;
+ if (/#\d*$/.test(slice)) {
+ while (to < text.length && text[to] >= "0" && text[to] <= "9") to++;
+ }
+ return text.slice(from, to);
}
const TRAILER_RE = /^[ \t]*co-authored-by:[ \t]*(.+)$/gim;
diff --git a/.github/scripts/pr-carry-attribution.test.cjs b/.github/scripts/pr-carry-attribution.test.cjs
index 8d94fc707ba..0f4781dfe67 100644
--- a/.github/scripts/pr-carry-attribution.test.cjs
+++ b/.github/scripts/pr-carry-attribution.test.cjs
@@ -4,6 +4,7 @@ const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
assessCarryAttribution,
+ carryWindow,
referencedCarryNumbers,
} = require("./pr-carry-attribution.cjs");
@@ -291,6 +292,38 @@ describe("assessCarryAttribution", () => {
assert.deepEqual(failures[0].paths, ["#3193"]);
});
+ it("keeps the complete references from the #7223 subject", () => {
+ const subject = "carry Combo visibility, workspace panes, Windows picker and Gentle Shell (#7195, #6767";
+ assert.deepEqual([...referencedCarryNumbers(subject)], [7195, 6767]);
+ });
+
+ it("finishes a number or hash at the cap without admitting the next reference", () => {
+ for (const reference of ["#6767", "other/repo#6767"]) {
+ for (let included = 1; included <= 5; included++) {
+ const padding = " ".repeat(80 - reference.length + 5 - included);
+ const text = padding + reference + " #3192";
+ assert.equal(carryWindow(text, 0), padding + reference);
+ assert.deepEqual(
+ [...referencedCarryNumbers("carry" + text)],
+ reference.startsWith("#") ? [6767] : [],
+ );
+ }
+ }
+ });
+
+ it("excludes references starting at or beyond the cap and after a newline", () => {
+ for (const padding of [80, 81]) {
+ assert.deepEqual([...referencedCarryNumbers("carry" + " ".repeat(padding) + "#3192")], []);
+ }
+ assert.deepEqual([...referencedCarryNumbers("carry #3193\n#3192")], [3193]);
+ });
+
+ it("completes a long numeric suffix without scanning the following text", () => {
+ const digits = "7".repeat(100_000);
+ const text = " ".repeat(79) + "#" + digits;
+ assert.equal(carryWindow(text + " #3192", 0), text);
+ });
+
it("reads a trailer that only exists on a branch commit", () => {
assert.deepEqual(
assessCarryAttribution(
diff --git a/.github/scripts/pr-hygiene.cjs b/.github/scripts/pr-hygiene.cjs
index e0be6ae2c20..82586d4dea4 100644
--- a/.github/scripts/pr-hygiene.cjs
+++ b/.github/scripts/pr-hygiene.cjs
@@ -292,6 +292,109 @@ const HYGIENE_GATE_LABELS = [
"attribution-approved",
];
+/**
+ * Exception approvals vouch for one revision of the change. Each counts only
+ * while the head the hygiene workflow last recorded is still the head being
+ * evaluated and nothing has rewritten the branch since. Sponsorship
+ * (`maintainer-sponsored`) is about which surfaces change, not about one
+ * revision, so it is not head-bound.
+ */
+const HEAD_BOUND_APPROVAL_LABELS = [
+ "test-exception-approved",
+ "suppression-approved",
+ "generated-change-approved",
+ "dependency-change-approved",
+ "attribution-approved",
+];
+
+/** Issue events after which the head can point at an earlier commit again. */
+const HEAD_REWRITE_EVENTS = ["head_ref_force_pushed", "head_ref_restored"];
+
+const APPROVAL_RECORD_TOKEN = "pr-hygiene-approvals";
+const APPROVAL_RECORD_LINE = /^$/;
+const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
+
+function isRewriteCount(value) {
+ return Number.isSafeInteger(value) && value >= 0;
+}
+
+/**
+ * Read the head binding the hygiene workflow wrote as the last line of its
+ * own section. The token must occur exactly once in `source` and sit on that
+ * last line, so echoed text (a changed path can contain newlines) can only
+ * make the record unreadable, never stand in for it.
+ * @returns {{ head: string, headRewrites: number | null } | null}
+ */
+function parseApprovalRecord(source) {
+ if (typeof source !== "string") return null;
+ if (source.split(APPROVAL_RECORD_TOKEN).length !== 2) return null;
+ const lines = source.split("\n").map((line) => line.trim()).filter(Boolean);
+ const match = lines.at(-1)?.match(APPROVAL_RECORD_LINE);
+ if (!match) return null;
+ let value;
+ try {
+ value = JSON.parse(match[1]);
+ } catch {
+ return null;
+ }
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
+ if (Object.keys(value).sort().join(",") !== "head,headRewrites") return null;
+ if (typeof value.head !== "string" || !FULL_SHA.test(value.head)) return null;
+ if (value.headRewrites !== null && !isRewriteCount(value.headRewrites)) return null;
+ return { head: value.head, headRewrites: value.headRewrites };
+}
+
+/** `headRewrites` is null when the history read failed: nothing is confirmed. */
+function formatApprovalRecord({ head, headRewrites }) {
+ const value = { head, headRewrites: isRewriteCount(headRewrites) ? headRewrites : null };
+ return "";
+}
+
+function countHeadRewrites(events) {
+ return (events ?? []).filter((entry) => HEAD_REWRITE_EVENTS.includes(entry?.event)).length;
+}
+
+function presentHeadBoundApprovals(labels) {
+ return HEAD_BOUND_APPROVAL_LABELS.filter((label) => labels.includes(label));
+}
+
+/** True when a current rewrite count could still confirm an approval. */
+function needsHeadRewriteCount({ labels = [], headSha, record = null, eventAction }) {
+ return (
+ eventAction !== "synchronize" &&
+ record !== null &&
+ record.head === headSha &&
+ isRewriteCount(record.headRewrites) &&
+ presentHeadBoundApprovals(labels).length > 0
+ );
+}
+
+/**
+ * Split the head-bound approvals on the pull request into the ones that still
+ * describe the evaluated head and the ones that do not. They are kept only
+ * when the last recorded evaluation saw this same head and the rewrite count,
+ * read before that evaluation's snapshot and again after this one's, has not
+ * changed. Without a rewrite, fast-forward pushes cannot bring the recorded
+ * head back, so the head has not moved since that evaluation, which removed
+ * every approval it could not confirm. A synchronize event, a missing record,
+ * or an unknown count confirms nothing.
+ * @returns {{ kept: string[], stale: string[] }}
+ */
+function reconcileHeadBoundApprovals({
+ labels = [],
+ headSha,
+ record = null,
+ eventAction,
+ headRewriteCount = null,
+}) {
+ const present = presentHeadBoundApprovals(labels);
+ const current =
+ needsHeadRewriteCount({ labels, headSha, record, eventAction }) &&
+ isRewriteCount(headRewriteCount) &&
+ headRewriteCount === record.headRewrites;
+ return current ? { kept: present, stale: [] } : { kept: [], stale: present };
+}
+
/**
* Combine patch-hygiene and sponsored-surface failures into one list so the
* hygiene workflow and the PR quality gate cannot disagree about Ready.
@@ -340,6 +443,10 @@ module.exports = {
addedLines,
assessHygiene,
collectDeterministicHygieneFailures,
+ countHeadRewrites,
+ formatApprovalRecord,
+ HEAD_BOUND_APPROVAL_LABELS,
+ HEAD_REWRITE_EVENTS,
HYGIENE_FAILURE_HINTS,
HYGIENE_GATE_LABELS,
hasEmptyCatch,
@@ -347,6 +454,9 @@ module.exports = {
isBehaviorPath,
isGeneratedPath,
isTestPath,
+ needsHeadRewriteCount,
+ parseApprovalRecord,
+ reconcileHeadBoundApprovals,
resultLines,
resultLinesByHunk,
};
diff --git a/.github/scripts/pr-hygiene.test.cjs b/.github/scripts/pr-hygiene.test.cjs
index 3ab5a8b2ca1..64548f0b011 100644
--- a/.github/scripts/pr-hygiene.test.cjs
+++ b/.github/scripts/pr-hygiene.test.cjs
@@ -6,9 +6,15 @@ const {
addedLines,
assessHygiene,
collectDeterministicHygieneFailures,
+ countHeadRewrites,
+ formatApprovalRecord,
+ HEAD_BOUND_APPROVAL_LABELS,
HYGIENE_FAILURE_HINTS,
HYGIENE_GATE_LABELS,
hasEmptyCatch,
+ needsHeadRewriteCount,
+ parseApprovalRecord,
+ reconcileHeadBoundApprovals,
resultLines,
resultLinesByHunk,
} = require("./pr-hygiene.cjs");
@@ -391,3 +397,100 @@ describe("pr-hygiene workflow trust boundary", () => {
);
});
});
+
+describe("head-bound approvals", () => {
+ const HEAD = "c".repeat(40);
+ const OTHER = "d".repeat(40);
+ const record = (head, headRewrites) =>
+ "";
+
+ it("covers exactly the exception approvals, not sponsorship", () => {
+ assert.deepEqual(
+ [...HEAD_BOUND_APPROVAL_LABELS].sort(),
+ HYGIENE_GATE_LABELS.filter(
+ (label) => label !== "intake: hygiene-blocked" && label !== "maintainer-sponsored",
+ ).sort(),
+ );
+ });
+
+ it("round-trips a record, including an unknown rewrite count", () => {
+ const line = formatApprovalRecord({ head: HEAD, headRewrites: 2 });
+ assert.equal(line, record(HEAD, 2));
+ assert.deepEqual(parseApprovalRecord("status\n\n" + line), { head: HEAD, headRewrites: 2 });
+ assert.deepEqual(parseApprovalRecord(formatApprovalRecord({ head: HEAD, headRewrites: undefined })), {
+ head: HEAD,
+ headRewrites: null,
+ });
+ assert.deepEqual(parseApprovalRecord(record("e".repeat(64), 0)), { head: "e".repeat(64), headRewrites: 0 });
+ });
+
+ it("rejects anything but one well-formed record on the last line", () => {
+ for (const source of [
+ null,
+ "",
+ record(HEAD, 0) + "\nlater line",
+ record(HEAD, 0) + "\n" + record(HEAD, 0),
+ "mentions pr-hygiene-approvals elsewhere\n" + record(HEAD, 0),
+ "",
+ "",
+ "",
+ "",
+ record("abc", 0),
+ record(HEAD.toUpperCase(), 0),
+ record(HEAD, -1),
+ record(HEAD, 1.5),
+ record(HEAD, "1"),
+ ]) {
+ assert.equal(parseApprovalRecord(source), null, String(source));
+ }
+ });
+
+ it("counts only head rewrite events", () => {
+ assert.equal(
+ countHeadRewrites([
+ { event: "head_ref_force_pushed" },
+ { event: "labeled" },
+ { event: "head_ref_restored" },
+ null,
+ ]),
+ 2,
+ );
+ assert.equal(countHeadRewrites(undefined), 0);
+ });
+
+ it("keeps approvals only under an unchanged head and rewrite count", () => {
+ const labels = ["test-exception-approved", "maintainer-sponsored"];
+ const base = { labels, headSha: HEAD, record: { head: HEAD, headRewrites: 1 }, eventAction: "edited" };
+ const kept = { kept: ["test-exception-approved"], stale: [] };
+ const stale = { kept: [], stale: ["test-exception-approved"] };
+ assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1 }), kept);
+ assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 2 }), stale);
+ assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: null }), stale);
+ assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, eventAction: "synchronize" }), stale);
+ assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, headSha: OTHER }), stale);
+ assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, record: null }), stale);
+ assert.deepEqual(
+ reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, record: { head: HEAD, headRewrites: null } }),
+ stale,
+ );
+ assert.deepEqual(
+ reconcileHeadBoundApprovals({ ...base, labels: ["maintainer-sponsored"], headRewriteCount: 1 }),
+ { kept: [], stale: [] },
+ );
+ });
+
+ it("asks for a rewrite count only when one could confirm an approval", () => {
+ const base = {
+ labels: ["suppression-approved"],
+ headSha: HEAD,
+ record: { head: HEAD, headRewrites: 0 },
+ eventAction: "labeled",
+ };
+ assert.equal(needsHeadRewriteCount(base), true);
+ assert.equal(needsHeadRewriteCount({ ...base, labels: [] }), false);
+ assert.equal(needsHeadRewriteCount({ ...base, eventAction: "synchronize" }), false);
+ assert.equal(needsHeadRewriteCount({ ...base, headSha: OTHER }), false);
+ assert.equal(needsHeadRewriteCount({ ...base, record: null }), false);
+ assert.equal(needsHeadRewriteCount({ ...base, record: { head: HEAD, headRewrites: null } }), false);
+ });
+});
diff --git a/.github/scripts/pr-quality-messages.cjs b/.github/scripts/pr-quality-messages.cjs
index e9b642b574b..56233036ebc 100644
--- a/.github/scripts/pr-quality-messages.cjs
+++ b/.github/scripts/pr-quality-messages.cjs
@@ -30,6 +30,18 @@ const HYGIENE_BLOCK_RE = new RegExp(
`^[ \\t]*${HYGIENE_BLOCK_START}[ \\t]*\\n([\\s\\S]*?)\\n[ \\t]*${HYGIENE_BLOCK_END}[ \\t]*$`,
"m"
);
+/**
+ * Whole-line delimiter matchers, the same shape HYGIENE_BLOCK_RE anchors on.
+ * A block is only read when each delimiter occurs on exactly one line: a
+ * changed filename can contain newlines, so an echoed path can add delimiter
+ * lines, and an ambiguous block must never be cut at the first one.
+ */
+const HYGIENE_BLOCK_START_LINE_RE = new RegExp(`^[ \\t]*${HYGIENE_BLOCK_START}[ \\t]*$`, "gm");
+const HYGIENE_BLOCK_END_LINE_RE = new RegExp(`^[ \\t]*${HYGIENE_BLOCK_END}[ \\t]*$`, "gm");
+
+function countDelimiterLines(text, pattern) {
+ return (text.match(pattern) ?? []).length;
+}
function inlineCode(value) {
const text = String(value);
@@ -41,6 +53,37 @@ function inlineCode(value) {
return `${delimiter}${text}${delimiter}`;
}
+/**
+ * Render contributor-supplied text (a changed path) on one line: control
+ * characters, including newlines, become `\uXXXX` escapes. Without this an
+ * echoed path could add whole lines, such as delimiter or record lines, to a
+ * bot comment.
+ */
+function escapeControlCharacters(value) {
+ return String(value).replace(
+ /[\u0000-\u001f\u007f\u2028\u2029]/g,
+ character => "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0")
+ );
+}
+
+/** The consolidated gate comment: written by the workflow bot, marker first. */
+function isGateComment(comment) {
+ return (
+ comment?.user?.login === "github-actions[bot]" &&
+ typeof comment.body === "string" &&
+ comment.body.startsWith(GATE_MARKER)
+ );
+}
+
+/** The standalone hygiene comment posted before a gate comment exists. */
+function isStandaloneHygieneComment(comment) {
+ return (
+ comment?.user?.login === "github-actions[bot]" &&
+ typeof comment.body === "string" &&
+ comment.body.startsWith(HYGIENE_MARKER)
+ );
+}
+
function readinessChecklistLines(readiness) {
return REVIEW_READINESS_ITEMS.map(
(item, index) =>
@@ -121,9 +164,17 @@ function buildGateCommentBody(state, opts) {
* `null` when the comment has none. The gate rebuilds its body from scratch
* every run, so without this round-trip a hygiene update from the separate
* hygiene workflow would be silently dropped on the next gate write.
+ * An ambiguous block (a delimiter on more than one line) reads as `null`, so
+ * the gate drops it on rebuild instead of re-wrapping a truncated section.
*/
function extractHygieneSection(body) {
if (typeof body !== "string") return null;
+ if (
+ countDelimiterLines(body, HYGIENE_BLOCK_START_LINE_RE) !== 1 ||
+ countDelimiterLines(body, HYGIENE_BLOCK_END_LINE_RE) !== 1
+ ) {
+ return null;
+ }
const match = body.match(HYGIENE_BLOCK_RE);
if (!match) return null;
return match[1]
@@ -150,13 +201,40 @@ function withHygieneSection(body, hygieneLines) {
].join("\n");
if (HYGIENE_BLOCK_RE.test(base)) {
- return base.replace(HYGIENE_BLOCK_RE, block);
+ // A callback, not a replacement string: echoed paths can contain $&, $1
+ // and similar tokens that a template would expand into extra lines.
+ return base.replace(HYGIENE_BLOCK_RE, () => block);
}
// No existing block: append one at the end.
return `${base.replace(/\s+$/, "")}\n\n## Hygiene\n\n${block}\n`;
}
+/**
+ * The text the hygiene workflow last wrote for this pull request: the hygiene
+ * block of the first bot comment that starts with GATE_MARKER, or the first
+ * bot comment that starts with HYGIENE_MARKER while that gate comment has no
+ * block yet (the gate never absorbs it). A marker elsewhere in a comment does
+ * not classify it. An ambiguous gate block yields `null`; later comments are
+ * never searched for a more convenient candidate.
+ */
+function authoritativeHygieneSection(comments) {
+ const list = comments ?? [];
+ const gate = list.find(isGateComment);
+ if (gate) {
+ const section = extractHygieneSection(gate.body);
+ if (section !== null) return section;
+ if (
+ countDelimiterLines(gate.body, HYGIENE_BLOCK_START_LINE_RE) > 0 ||
+ countDelimiterLines(gate.body, HYGIENE_BLOCK_END_LINE_RE) > 0
+ ) {
+ return null;
+ }
+ }
+ const standalone = list.find(isStandaloneHygieneComment);
+ return standalone ? standalone.body : null;
+}
+
function descriptionFailureLines(reason) {
switch (reason) {
case "empty":
@@ -323,10 +401,14 @@ module.exports = {
HYGIENE_BLOCK_START,
HYGIENE_BLOCK_END,
inlineCode,
+ escapeControlCharacters,
+ isGateComment,
+ isStandaloneHygieneComment,
readinessChecklistLines,
buildGateCommentBody,
extractHygieneSection,
withHygieneSection,
+ authoritativeHygieneSection,
descriptionFailureLines,
buildFailureSections,
failureSummary,
diff --git a/.github/scripts/pr-quality-messages.test.cjs b/.github/scripts/pr-quality-messages.test.cjs
index c344ce1bc06..f4665ae0d6a 100644
--- a/.github/scripts/pr-quality-messages.test.cjs
+++ b/.github/scripts/pr-quality-messages.test.cjs
@@ -15,6 +15,10 @@ const {
buildGateCommentBody,
extractHygieneSection,
withHygieneSection,
+ authoritativeHygieneSection,
+ escapeControlCharacters,
+ isGateComment,
+ isStandaloneHygieneComment,
descriptionFailureLines,
buildFailureSections,
failureSummary,
@@ -421,3 +425,75 @@ describe("hygiene section round-trip", () => {
assert.equal(afterGateSecond.split(HYGIENE_BLOCK_END).length - 1, 1);
});
});
+
+describe("authoritative hygiene section", () => {
+ const bot = { login: "github-actions[bot]" };
+ const block = (lines) =>
+ `${HYGIENE_BLOCK_START}\n${HYGIENE_MARKER}\n\n${lines.join("\n")}\n\n${HYGIENE_BLOCK_END}`;
+
+ it("reads nothing from a block whose delimiters repeat on whole lines", () => {
+ const body = [GATE_MARKER, HYGIENE_BLOCK_START, "a", HYGIENE_BLOCK_END, "b", HYGIENE_BLOCK_END].join("\n");
+ assert.equal(extractHygieneSection(body), null);
+ const twoStarts = [GATE_MARKER, HYGIENE_BLOCK_START, HYGIENE_BLOCK_START, "a", HYGIENE_BLOCK_END].join("\n");
+ assert.equal(extractHygieneSection(twoStarts), null);
+ });
+
+ it("prefers the gate comment's block over a standalone hygiene comment", () => {
+ const comments = [
+ { user: bot, body: `${HYGIENE_MARKER}\n\nstandalone` },
+ { user: bot, body: `${GATE_MARKER}\n\n${block(["from gate"])}` },
+ ];
+ assert.equal(authoritativeHygieneSection(comments), "from gate");
+ });
+
+ it("falls back to the standalone comment only while the gate comment has no block", () => {
+ const standalone = { user: bot, body: `${HYGIENE_MARKER}\n\nstandalone` };
+ assert.equal(authoritativeHygieneSection([{ user: bot, body: GATE_MARKER }, standalone]), standalone.body);
+ assert.equal(authoritativeHygieneSection([standalone]), standalone.body);
+ const ambiguous = { user: bot, body: [GATE_MARKER, HYGIENE_BLOCK_START, "a", HYGIENE_BLOCK_END, HYGIENE_BLOCK_END].join("\n") };
+ assert.equal(authoritativeHygieneSection([ambiguous, standalone]), null);
+ });
+
+ it("ignores comments not written by the workflow bot", () => {
+ const human = { user: { login: "contributor" }, body: `${GATE_MARKER}\n\n${block(["forged"])}` };
+ assert.equal(authoritativeHygieneSection([human]), null);
+ assert.equal(authoritativeHygieneSection(undefined), null);
+ });
+});
+
+describe("echoed text and comment classification", () => {
+ const bot = { login: "github-actions[bot]" };
+
+ it("escapes control characters so echoed text stays on one line", () => {
+ assert.equal(escapeControlCharacters("src/a.ts"), "src/a.ts");
+ assert.equal(escapeControlCharacters("a\nb\r\tc\u2028"), "a\\u000ab\\u000d\\u0009c\\u2028");
+ assert.ok(!escapeControlCharacters("x\n" + HYGIENE_BLOCK_END).includes("\n"));
+ });
+
+ it("classifies a comment only by the marker its body starts with", () => {
+ assert.equal(isGateComment({ user: bot, body: GATE_MARKER + "\n\nbody" }), true);
+ assert.equal(isGateComment({ user: bot, body: HYGIENE_MARKER + "\n- x " + GATE_MARKER }), false);
+ assert.equal(isGateComment({ user: { login: "someone" }, body: GATE_MARKER }), false);
+ assert.equal(isStandaloneHygieneComment({ user: bot, body: HYGIENE_MARKER + "\n\nok" }), true);
+ assert.equal(isStandaloneHygieneComment({ user: bot, body: GATE_MARKER + "\n" + HYGIENE_MARKER }), false);
+ });
+
+ it("does not read a standalone hygiene comment that echoes the gate marker as the gate block", () => {
+ const standalone = {
+ user: bot,
+ body: [HYGIENE_MARKER, "- x " + GATE_MARKER, HYGIENE_BLOCK_START, "echoed", HYGIENE_BLOCK_END].join("\n"),
+ };
+ assert.equal(authoritativeHygieneSection([standalone]), standalone.body);
+ });
+});
+
+describe("hygiene block replacement", () => {
+ it("writes replacement tokens in hygiene lines literally", () => {
+ const base = withHygieneSection(GATE_MARKER, ["first"]);
+ for (const token of ["$&", "$1", "$`", "$'", "$$"]) {
+ const updated = withHygieneSection(base, ["Paths: src/" + token + ".ts"]);
+ assert.ok(updated.includes("Paths: src/" + token + ".ts"), token);
+ assert.equal(extractHygieneSection(updated), "Paths: src/" + token + ".ts", token);
+ }
+ });
+});
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2df7ddab928..b6d7e7ad467 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,6 @@
# ============================================================================
# CI POLICY (maintainer decision — do not change in a contributor PR)
-# * Pull requests run ONLY the fast Ubuntu subset.
+# * Pull requests run ONLY the fast Ubuntu subset (default: gates + `test changed`).
# * Full cross-platform (macOS/Windows) CI runs ONLY via manual
# workflow_dispatch and on push to main/preview (release lines).
# * NEVER add a dev push trigger or pull_request runs for macOS/Windows jobs.
@@ -25,11 +25,9 @@ on:
# carry contributor prefixes (`fix/`, `feat/`, `agent/`) as readily as
# `codex/`, and contributor stacks need CI most. The `changes` job below is
# the real scope gate, using the same allowlist as the push trigger. Safe to
- # widen here
- # because this workflow is `pull_request` (not `pull_request_target`),
+ # widen here because this workflow is `pull_request` (not `pull_request_target`),
# declares `contents: read`, and reads no secrets.
#
- #
# Pull requests run only the fast Ubuntu subset. Every macOS and Windows
# job (and the macOS/Windows legs of the keyring, npm-global, setup-action
# and remote-helper matrices) is skipped on `pull_request` and runs after
@@ -94,7 +92,6 @@ on:
permissions:
contents: read
-# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46).
concurrency:
# `push` and `pull_request` want supersession: a newer head on the same ref
# makes the older one irrelevant, and cancelling it saves an hour of runners
@@ -216,6 +213,7 @@ jobs:
# of silently making every expensive job skip.
ci: ${{ steps.scope.outputs.ci }}
desktop: ${{ steps.scope.outputs.desktop }}
+ fast: ${{ steps.lane.outputs.fast }} # true: `test changed` replaces the four shards
native: ${{ steps.matrices.outputs.native }}
# Matrix include lists for keyring-smoke and npm-global-smoke, built and
# shape-checked by the same validation step as `native`.
@@ -233,6 +231,10 @@ jobs:
# skipping the check it selects.
setup_action: ${{ steps.narrow.outputs.setup_action }}
remote_helper: ${{ steps.narrow.outputs.remote_helper }}
+ # Pull-request scope for the two smoke jobs that used to ride the whole ci filter.
+ # Push, dispatch and schedule run both regardless; see the docker/keyring filters.
+ docker: ${{ steps.narrow.outputs.docker }}
+ keyring: ${{ steps.narrow.outputs.keyring }}
# Whether this pull request needs the nine Windows shards: the windows path
# filter below OR an added src/tests line naming a Windows marker. Computed
# by scripts/ci/windows-sensitive-diff.sh, which validates its input and
@@ -388,6 +390,44 @@ jobs:
# real only on macOS and Windows, so its job lints and tests the crate on all three.
remote_helper:
- 'native/remote-workspace-helper/**'
+ # docker-smoke and keyring-smoke on a pull request. Both used to run on every PR the
+ # ci filter matched, which is nearly all of them, while each proves something narrow:
+ # the image builds, starts and survives recreation with its volume; the OS keyring
+ # binding creates, reads and deletes a secret. A pull request now runs each only when
+ # its own inputs move. Push to main/preview, workflow_dispatch and the nightly still
+ # request both unconditionally, so the release gate's push run always carries them.
+ # ci.yml is listed so an edit to either job verifies itself. The setup action is left to
+ # its own setup-action job, as the docs and structure filters leave it.
+ docker:
+ - 'Dockerfile'
+ - 'compose.yaml'
+ - '.dockerignore'
+ - 'docker/**'
+ - 'scripts/ci/docker-smoke.ts'
+ - 'scripts/generate-compatibility-version.ts'
+ - 'scripts/model-metadata.source.json'
+ # The image copies and runs src/, then builds the GUI with tsc and Vite.
+ # Include their inputs without selecting GUI tests or documentation alone.
+ - 'src/**'
+ - 'package.json'
+ - 'bun.lock'
+ - 'tsconfig.json'
+ - 'gui/package.json'
+ - 'gui/bun.lock'
+ - 'gui/src/**'
+ - 'gui/public/**'
+ - 'gui/index.html'
+ - 'gui/vite.config.ts'
+ - 'gui/tsconfig*.json'
+ - '.github/workflows/ci.yml'
+ # scripts/keyring-smoke.ts imports only src/lib/keyring-native.ts; the native binding
+ # itself arrives through package.json and bun.lock.
+ keyring:
+ - 'src/lib/keyring-native.ts'
+ - 'scripts/keyring-smoke.ts'
+ - 'package.json'
+ - 'bun.lock'
+ - '.github/workflows/ci.yml'
# Paths whose change selects the nine Windows suite shards on a pull
# request. These carry the Windows service, update, shim, intercept,
# ACL and process code, and the harness every Windows shard runs.
@@ -532,9 +572,11 @@ jobs:
env:
SETUP_ACTION: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.setup_action }}
REMOTE_HELPER: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.remote_helper }}
+ DOCKER_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.docker }}
+ KEYRING_SCOPE: ${{ github.event_name == 'schedule' && 'true' || steps.filter.outputs.keyring }}
run: |
set -euo pipefail
- for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER"; do
+ for pair in "setup_action=$SETUP_ACTION" "remote_helper=$REMOTE_HELPER" "docker=$DOCKER_SCOPE" "keyring=$KEYRING_SCOPE"; do
case "${pair#*=}" in
true|false)
printf '%s\n' "$pair" >> "$GITHUB_OUTPUT"
@@ -546,6 +588,15 @@ jobs:
esac
done
+ - name: Choose the pull request test lane
+ id: lane
+ shell: bash
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ CI_SCOPE: ${{ steps.scope.outputs.ci }}
+ FULL_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
+ run: bash scripts/ci/select-test-lane.sh
+
- name: Decide whether the Windows shards run
id: windows
shell: bash
@@ -578,12 +629,10 @@ jobs:
printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY"
printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA"
- # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming;
- # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821
- # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs
- # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956).
- # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch
- # proves the unified version, then delete testRunnerBun and the action's role input.
+ # Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json.
+ # Keep role validation after the historical isolate preload and spawnSync failures
+ # (#6713, #4956). Promotion requires a green full cross-platform lane=all run on
+ # the exact candidate head; focused local results do not replace that proof.
# The suite, split by file across four Linux runners.
#
# `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file
@@ -605,7 +654,7 @@ jobs:
test:
name: test ${{ matrix.shard }}/4
needs: changes
- if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
+ if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.fast != 'true')
runs-on: ubuntu-latest
# A quarter of the suite. A shard that needs longer than this is wedged, not
# slow — the old 30-minute ceiling was margin for the Windows leg, which no
@@ -668,6 +717,27 @@ jobs:
TEST_SHARD: ${{ matrix.shard }}/4
run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"
+ # Fast lane: import-graph tests for the diff; the batched dev dispatch is the backstop.
+ test-changed:
+ name: test changed
+ needs: changes
+ if: github.event_name == 'pull_request' && needs.changes.outputs.fast == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ persist-credentials: false
+ ref: ${{ needs.changes.outputs.checkout_ref }}
+ fetch-depth: 2 # merge commit + base tip, which the selection diffs against
+ - uses: ./.github/actions/setup-project-bun
+ with:
+ role: test-runner # See the Bun roles contract above test.
+ - name: Install dependencies and build the GUI the tests serve
+ run: bun install --frozen-lockfile && cd gui && bun install --frozen-lockfile && bun run build
+ - name: Test the files this pull request reaches
+ run: bash scripts/ci/run-changed-tests.sh
+
# Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy
# harness. Keep the entire six-file family in one fresh process so a runtime
# failure is bounded to this job instead of poisoning a general test shard.
@@ -997,12 +1067,15 @@ jobs:
# now means Linux + macOS + the gates; Windows re-enters the gate when the
# tracked failures are fixed, not before.
#
- # Dispatch-only proved too late. #6639, #6556, #6605 and #6606 each landed with
- # green pull-request CI while breaking Windows, and the breakage surfaced days
- # later on a manual dispatch. So a pull request whose changes job reports
- # windows=true (a Windows path, or an added src/tests line naming a Windows
- # marker; see scripts/ci/windows-sensitive-diff.sh) now runs the nine shards,
- # and the daily schedule catches whatever that filter misses.
+ # Pull requests never run these shards (AGENTS.md "CI policy", a maintainer
+ # decision): Windows runs on the nightly schedule and on a lane=all dispatch.
+ # The changes job still computes windows=true for a Windows-sensitive pull
+ # request (a Windows path, or an added src/tests line naming a Windows marker;
+ # see scripts/ci/windows-sensitive-diff.sh). That verdict does not schedule
+ # these jobs; it posts a non-blocking notice, and a maintainer dispatches
+ # lane=all on the branch before merging. #6639, #6556, #6605, #6606 and #7468
+ # each landed with green pull-request CI while breaking Windows, which is why
+ # that dispatch is part of merging a Windows-sensitive pull request.
platform-windows:
name: windows ${{ matrix.shard }}/9
needs: [changes, select-windows-runner]
@@ -1145,7 +1218,7 @@ jobs:
keyring-smoke:
name: keyring ${{ matrix.name }}
needs: changes
- if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
+ if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.keyring == 'true')
runs-on: ${{ matrix.runner }}
timeout-minutes: 8
strategy:
@@ -1208,7 +1281,7 @@ jobs:
docker-smoke:
name: docker smoke
needs: changes
- if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
+ if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.docker == 'true')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -1705,7 +1778,7 @@ jobs:
# direct dependencies only, so a failing `select-windows-runner` would
# otherwise reach this gate as nothing at all while its dependents report
# `skipped`, which is the shape the step below is written to catch.
- needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, privacy-gate, npm-global-smoke, widget, desktop-shell, setup-action, remote-helper]
+ needs: [changes, select-windows-runner, test, test-changed, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, privacy-gate, npm-global-smoke, widget, desktop-shell, setup-action, remote-helper]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
@@ -1721,11 +1794,14 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
LANE: ${{ github.event.inputs.lane }}
CHANGES_CI: ${{ needs.changes.outputs.ci }}
+ CHANGES_FAST: ${{ needs.changes.outputs.fast }}
CHANGES_PACKAGING: ${{ needs.changes.outputs.packaging }}
CHANGES_DOCS: ${{ needs.changes.outputs.docs }}
CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }}
CHANGES_SETUP_ACTION: ${{ needs.changes.outputs.setup_action }}
CHANGES_REMOTE_HELPER: ${{ needs.changes.outputs.remote_helper }}
+ CHANGES_DOCKER: ${{ needs.changes.outputs.docker }}
+ CHANGES_KEYRING: ${{ needs.changes.outputs.keyring }}
CHANGES_NATIVE: ${{ needs.changes.outputs.native }}
CHANGES_DESKTOP: ${{ needs.changes.outputs.desktop }}
CHANGES_WINDOWS: ${{ needs.changes.outputs.windows }}
@@ -1749,6 +1825,12 @@ jobs:
if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then
scoped=not-requested
fi
+ full_tests=$scoped # the fast lane swaps the shards for test changed; unset = full
+ changed_tests=not-requested
+ if [ "$EVENT_NAME" = "pull_request" ] && [ "$scoped" = requested ] && [ "${CHANGES_FAST:-false}" = "true" ]; then
+ full_tests=not-requested
+ changed_tests=requested
+ fi
# platform-macos and widget never run on a pull request (macOS runs after merge).
# desktop-shell (ubuntu) carries the ordinary scope gate AND the native or
# package-E2E path filters. This mirrors those expressions exactly; where it
@@ -1782,6 +1864,16 @@ jobs:
if [ "$CHANGES_REMOTE_HELPER" = "true" ]; then
remote_helper=requested
fi
+ # docker-smoke and keyring-smoke follow the ci scope, narrowed on a pull request to
+ # their own filters. Mirrors their `if:` exactly, like desktop_shell above.
+ docker=$scoped
+ if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_DOCKER" != "true" ]; then
+ docker=not-requested
+ fi
+ keyring=$scoped
+ if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_KEYRING" != "true" ]; then
+ keyring=not-requested
+ fi
# privacy-gate runs exactly where `gates` (scoped) does not, on every
# pull request the ci filter declines. Deriving it from `scoped` keeps
# the two scans complementary here as they are in the jobs' own
@@ -1809,7 +1901,7 @@ jobs:
# One line per job, mirroring that job's own `if:`. Adding a job to this workflow
# without adding it here fails the gate by name rather than passing unnoticed.
- GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates"
+ GATED_JOBS="changes select-windows-runner test test-changed storage-policy api-usage gates"
GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke"
GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build"
GATED_JOBS="$GATED_JOBS structure-gate widget"
@@ -1820,8 +1912,12 @@ jobs:
expected_for() {
case "$1" in
changes|select-windows-runner) echo requested ;;
- test|storage-policy|api-usage|gates|keyring-smoke|docker-smoke)
+ test) echo "$full_tests" ;;
+ test-changed) echo "$changed_tests" ;;
+ storage-policy|api-usage|gates)
echo "$scoped" ;;
+ docker-smoke) echo "$docker" ;;
+ keyring-smoke) echo "$keyring" ;;
platform-macos|widget)
echo "$native" ;;
desktop-shell) echo "$desktop_shell" ;;
diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml
index 35f6ea7b38c..dfcb50cc809 100644
--- a/.github/workflows/enforce-pr-target.yml
+++ b/.github/workflows/enforce-pr-target.yml
@@ -44,7 +44,8 @@ jobs:
github.event.label.name == 'test-exception-approved' ||
github.event.label.name == 'suppression-approved' ||
github.event.label.name == 'generated-change-approved' ||
- github.event.label.name == 'dependency-change-approved'))
+ github.event.label.name == 'dependency-change-approved' ||
+ github.event.label.name == 'attribution-approved'))
runs-on: ubuntu-latest
permissions:
contents: read
@@ -229,7 +230,11 @@ jobs:
);
const {
collectDeterministicHygieneFailures,
+ countHeadRewrites,
HYGIENE_FAILURE_HINTS,
+ needsHeadRewriteCount,
+ parseApprovalRecord,
+ reconcileHeadBoundApprovals,
} = require(
path.join(process.cwd(), ".github", "scripts", "pr-hygiene.cjs"),
);
@@ -260,12 +265,13 @@ jobs:
const {
GATE_MARKER,
READINESS_MARKER,
- HYGIENE_MARKER,
- HYGIENE_BLOCK_START,
- HYGIENE_BLOCK_END,
inlineCode,
buildGateCommentBody,
extractHygieneSection,
+ authoritativeHygieneSection,
+ withHygieneSection,
+ escapeControlCharacters,
+ isGateComment,
buildFailureSections,
failureSummary,
buildStaleNotice,
@@ -337,13 +343,10 @@ jobs:
);
// One consolidated comment. The gate finds its own comment by the
- // single GATE_MARKER; the legacy enforcer/readiness markers are
- // matched only to migrate pre-consolidation PRs.
- let gateComment = comments.find(
- comment =>
- comment.user?.login === "github-actions[bot]" &&
- comment.body?.includes(GATE_MARKER)
- );
+ // single GATE_MARKER its body starts with (a marker echoed inside
+ // another bot comment does not count); the legacy enforcer/readiness
+ // markers are matched only to migrate pre-consolidation PRs.
+ let gateComment = comments.find(isGateComment);
let gateCommentId = gateComment?.id ?? null;
const storedGateState = parseGateState(
gateComment?.body,
@@ -437,13 +440,15 @@ jobs:
async function upsertGateComment(state, opts) {
let body = buildGateCommentBody(state, opts).join("\n");
// The hygiene workflow writes its status into this same comment.
- // Preserve whatever it left so a gate rebuild does not drop it.
+ // Preserve whatever it left so a gate rebuild does not drop it;
+ // it carries the approval record the next runs read. Appended
+ // unconditionally (a rebuilt body may not end in whitespace) and
+ // never through a replacement template.
const existingHygiene = extractHygieneSection(gateComment?.body);
- if (existingHygiene && !body.includes("pr-hygiene-block")) {
- body = body.replace(
- /\s+$/,
- `\n\n## Hygiene\n\n${HYGIENE_BLOCK_START}\n${HYGIENE_MARKER}\n\n${existingHygiene}\n\n${HYGIENE_BLOCK_END}`
- );
+ // Delimiter-aware: an echoed path that merely contains the marker
+ // text must not suppress the preservation.
+ if (existingHygiene && extractHygieneSection(body) === null) {
+ body = withHygieneSection(body, existingHygiene.split("\n"));
}
if (gateCommentId) {
// Skip the write when the rebuilt body matches what is already
@@ -747,6 +752,10 @@ jobs:
const changedFiles = [];
const changedFilePaths = [];
let filesTruncated = true;
+ // The head the changed-file list was verified against; null when the
+ // head moved during the snapshot. Head-bound approvals are judged
+ // against the same head the files belong to.
+ let changedFilesHeadSha = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
const { data: fileSnapshot } = await github.rest.pulls.get({
owner,
@@ -774,6 +783,8 @@ jobs:
core.warning(
"PR head moved during changed-file snapshot; treating file list as truncated."
);
+ } else {
+ changedFilesHeadSha = headShaForFiles;
}
changedFiles.length = 0;
changedFiles.push(...listedFiles);
@@ -812,7 +823,37 @@ jobs:
// the Hygiene comment section, but Ready / review-ready must not
// clear while those checks fail. Re-assess here from the same
// trusted scripts so the gate cannot race ahead of hygiene.
- const labelNames = (pr.labels ?? []).map(label => label.name);
+ const liveLabelNames = (pr.labels ?? []).map(label => label.name);
+ // Head-bound exception approvals count only under the head binding
+ // the hygiene workflow recorded. This gate reads that record and
+ // never removes labels or writes it.
+ const approvalInput = {
+ labels: liveLabelNames,
+ // Files listed at another head than this run's PR snapshot
+ // confirm no approval.
+ headSha:
+ changedFilesHeadSha !== null && changedFilesHeadSha === pr.head?.sha
+ ? changedFilesHeadSha
+ : null,
+ record: parseApprovalRecord(authoritativeHygieneSection(comments)),
+ eventAction: context.payload.action,
+ };
+ let headRewriteCount = null;
+ if (needsHeadRewriteCount(approvalInput)) {
+ try {
+ headRewriteCount = countHeadRewrites(await github.paginate(
+ github.rest.issues.listEvents,
+ { owner, repo, issue_number: pull_number, per_page: 100 }
+ ));
+ } catch (error) {
+ core.warning(`Could not read head rewrite history: ${error.message}`);
+ }
+ }
+ const { stale: staleApprovals } = reconcileHeadBoundApprovals({
+ ...approvalInput,
+ headRewriteCount,
+ });
+ const labelNames = liveLabelNames.filter(name => !staleApprovals.includes(name));
// Same inputs the hygiene workflow feeds the carry-attribution
// assessor. Both gates must reach the same verdict or Ready can
// clear while hygiene is still red.
@@ -1241,7 +1282,7 @@ jobs:
const hint = HYGIENE_FAILURE_HINTS[failure.code];
if (!hint) continue;
const paths = failure.paths?.length
- ? ` Paths: ${failure.paths.map(p => inlineCode(p)).join(", ")}.`
+ ? ` Paths: ${failure.paths.map(p => inlineCode(escapeControlCharacters(p))).join(", ")}.`
: "";
actions.push(`Fix **${failure.code}** — ${hint}${paths}`);
}
diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml
index b60943b93a4..74be06069f9 100644
--- a/.github/workflows/pr-hygiene.yml
+++ b/.github/workflows/pr-hygiene.yml
@@ -57,7 +57,12 @@ jobs:
const path = require("node:path");
const {
collectDeterministicHygieneFailures,
+ countHeadRewrites,
+ formatApprovalRecord,
HYGIENE_FAILURE_HINTS,
+ needsHeadRewriteCount,
+ parseApprovalRecord,
+ reconcileHeadBoundApprovals,
} = require(
path.join(process.cwd(), ".github", "scripts", "pr-hygiene.cjs"),
);
@@ -68,8 +73,11 @@ jobs:
path.join(process.cwd(), ".github", "scripts", "pr-referenced-authors.cjs"),
);
const {
- GATE_MARKER,
HYGIENE_MARKER,
+ authoritativeHygieneSection,
+ escapeControlCharacters,
+ isGateComment,
+ isStandaloneHygieneComment,
withHygieneSection
} = require(
path.join(process.cwd(), ".github", "scripts", "pr-quality-messages.cjs"),
@@ -104,33 +112,79 @@ jobs:
}
for (const name of Object.keys(labelDefinitions)) await ensureLabel(name);
+ // Counts head rewrites (force pushes, restored branches); null when
+ // the history cannot be read, which confirms no approval.
+ async function readHeadRewrites() {
+ try {
+ return countHeadRewrites(
+ await github.paginate(github.rest.issues.listEvents, {
+ owner, repo, issue_number: pull_number, per_page: 100,
+ }),
+ );
+ } catch (error) {
+ core.warning(`Could not read head rewrite history: ${error.message}`);
+ return null;
+ }
+ }
+ // Read before the snapshot so the published record never counts a
+ // rewrite that happened after the head it names.
+ const headRewritesBefore = await readHeadRewrites();
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number, per_page: 100,
});
+ // The file list must belong to the head the approvals are judged
+ // against; if the head moved while listing, none is confirmed.
+ const { data: prAfterFiles } = await github.rest.pulls.get({ owner, repo, pull_number });
+ const filesAtHead = prAfterFiles.head?.sha === pr.head.sha;
const labels = new Set(pr.labels.map((label) => label.name));
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner, repo, issue_number: pull_number, per_page: 100,
+ });
// Exception approvals are head-specific: a new commit invalidates
// them, so a contributor cannot obtain one narrow exception and
- // then push unreviewed violations under the same label.
- if (context.payload.action === "synchronize") {
- for (const name of [
- "test-exception-approved",
- "suppression-approved",
- "generated-change-approved",
- "dependency-change-approved",
- "attribution-approved",
- ]) {
- if (labels.has(name)) {
- await github.rest.issues.removeLabel({
- owner, repo, issue_number: pull_number, name,
- });
- labels.delete(name);
- }
+ // then push unreviewed violations under the same label. This is
+ // decided on every run from the head this workflow last recorded,
+ // not from the triggering action: a queued synchronize run can be
+ // replaced by a later event before it executes.
+ const approvalInput = {
+ labels: [...labels],
+ headSha: filesAtHead ? pr.head.sha : null,
+ record: parseApprovalRecord(authoritativeHygieneSection(comments)),
+ eventAction: context.payload.action,
+ };
+ const headRewriteCount = needsHeadRewriteCount(approvalInput)
+ ? await readHeadRewrites()
+ : null;
+ const { stale: staleApprovals } = reconcileHeadBoundApprovals({
+ ...approvalInput,
+ headRewriteCount,
+ });
+ for (const name of staleApprovals) {
+ try {
+ await github.rest.issues.removeLabel({
+ owner, repo, issue_number: pull_number, name,
+ });
+ } catch (error) {
+ if (error.status !== 404) throw error;
}
+ labels.delete(name);
}
+ // Written last in the hygiene section on every outcome; the next
+ // run of this workflow and the PR gate both read it.
+ const approvalLines = [
+ ...(staleApprovals.length === 0
+ ? []
+ : [
+ "",
+ `ℹ️ Removed ${staleApprovals.map((name) => "\x60" + name + "\x60").join(", ")}: these approvals could not be confirmed for this head (\x60${pr.head.sha.slice(0, 7)}\x60). A maintainer can re-apply them after reviewing it.`,
+ ]),
+ "",
+ formatApprovalRecord({ head: pr.head.sha, headRewrites: headRewritesBefore }),
+ ];
// Sponsorship is head-independent: it is about which surfaces the
// change touches, not about the state of a particular revision, so
- // it is NOT cleared by the synchronize sweep above.
+ // it is not head-bound.
// author_association is not enough: a read/triage collaborator can
// be COLLABORATOR without write access. Match the PR quality gate.
let authorPermission = null;
@@ -188,17 +242,18 @@ jobs:
}
async function upsert(body) {
- const comments = await github.paginate(github.rest.issues.listComments, {
- owner, repo, issue_number: pull_number, per_page: 100,
- });
// The single consolidated bot comment is the one the PR gate
// owns (GATE_MARKER). Write the hygiene status into that same
// comment so there is one editable message, not two. Fall back
// to a standalone hygiene comment only when the gate has not
- // posted yet (the next gate run absorbs it).
- const gateComment = comments.find(
- (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(GATE_MARKER),
- );
+ // posted yet; the next hygiene run writes into the gate comment.
+ // The comment list was read once above, before the approval
+ // check; both workflows share one concurrency group, so no gate
+ // write lands in between.
+ // Classified by the marker each body starts with, the same rule
+ // the approval-record reader uses: echoed paths can contain a
+ // marker string but never start the comment.
+ const gateComment = comments.find(isGateComment);
if (gateComment) {
// The hygiene block already carries the marker; strip the
// standalone body's own marker line before merging.
@@ -210,9 +265,7 @@ jobs:
await github.rest.issues.updateComment({ owner, repo, comment_id: gateComment.id, body: merged });
return;
}
- const existingHygiene = comments.find(
- (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
- );
+ const existingHygiene = comments.find(isStandaloneHygieneComment);
if (existingHygiene) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existingHygiene.id, body });
} else {
@@ -222,17 +275,17 @@ jobs:
if (failures.length === 0) {
await setBlocked(false);
- await upsert(`${marker}\n\n✅ **Deterministic PR hygiene checks passed.**`);
+ await upsert([marker, "", "✅ **Deterministic PR hygiene checks passed.**", ...approvalLines].join("\n"));
return;
}
const lines = failures.map((failure) => {
const paths = failure.paths?.length
- ? ` Paths: ${failure.paths.map((p) => `\`${p}\``).join(", ")}.`
+ ? ` Paths: ${failure.paths.map((p) => `\`${escapeControlCharacters(p)}\``).join(", ")}.`
: "";
return `- **${failure.code}** — ${HYGIENE_FAILURE_HINTS[failure.code] ?? failure.code}${paths}`;
});
await setBlocked(true);
- await upsert([marker, "", "⚠️ **Deterministic hygiene checks failed.**", "", ...lines].join("\n"));
+ await upsert([marker, "", "⚠️ **Deterministic hygiene checks failed.**", "", ...lines, ...approvalLines].join("\n"));
core.setFailed(`PR hygiene failed: ${failures.map((f) => f.code).join(", ")}`);
diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml
index d8ac13ab366..93d8a38e3df 100644
--- a/.github/workflows/react-doctor.yml
+++ b/.github/workflows/react-doctor.yml
@@ -14,8 +14,15 @@ name: React Doctor
on:
# Scans the PR's changed files relative to the merge base of the target branch.
+ # The scan reads only gui/ (`directory: gui` below), so a pull request that
+ # changes nothing there has nothing to scan: it used to start a full-history
+ # checkout and an npm install on every PR for an empty changed-file list. No
+ # branch rule requires this check, so a PR it skips is not left waiting on it.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
+ paths:
+ - "gui/**"
+ - ".github/workflows/react-doctor.yml"
# Scans `main` on every push to track the health-score trend.
push:
branches: ["main"]
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 764eb0622a6..152d4a8adaa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -150,8 +150,11 @@ jobs:
dependency_cpu: arm64
smoke: false
runs-on: ${{ matrix.os }}
- timeout-minutes: 25
+ # The Windows leg can wait up to an hour for a release-signing approval in SignPath.
+ timeout-minutes: ${{ matrix.target == 'bun-windows-x64' && 90 || 25 }}
permissions:
+ # actions: read lets the SignPath action read this job and download the unsigned artifact.
+ actions: read
contents: read
steps:
- name: Checkout
@@ -162,6 +165,23 @@ jobs:
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
+ # Windows Authenticode signing through SignPath Foundation (#7207). Decided before the build, so
+ # a broken SignPath configuration fails in seconds instead of after the build. The rules
+ # (no token: skip; partial configuration: fail; test-signing: dry runs only) live in
+ # scripts/ci/windows-signing-plan.sh, where tests/ci-workflows/release-windows-signing.test.ts
+ # exercises them.
+ - name: Decide Windows code signing
+ id: windows-signing
+ if: runner.os == 'Windows'
+ shell: bash
+ env:
+ SIGNPATH_CONFIGURED: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
+ SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ SIGNPATH_SIGNING_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ run: bash scripts/ci/windows-signing-plan.sh
+
- name: Install dependencies
run: bun install --frozen-lockfile --os=${{ matrix.dependency_os }} --cpu=${{ matrix.dependency_cpu }}
@@ -201,6 +221,58 @@ jobs:
Invoke-WebRequest -UseBasicParsing http://127.0.0.1:10177/ | Out-Null
} finally { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue }
+ # Signed after the smoke test and before the archive, so the zip and its checksum are built
+ # from the signed binary. The artifact holds ocx.exe alone at its root, which is the shape the
+ # windows-standalone-zip artifact configuration expects. The keyring addons beside it are
+ # third-party builds that the SignPath Foundation policy forbids signing with its certificate.
+ - name: Upload the unsigned Windows binary for signing
+ id: unsigned-ocx
+ if: steps.windows-signing.outputs.mode == 'sign'
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: signpath-unsigned-${{ matrix.target }}
+ path: dist/standalone/${{ matrix.target }}/ocx.exe
+ if-no-files-found: error
+ retention-days: 1
+
+ # continue-on-error on every signing step: a SignPath outage, timeout or rejection must never
+ # fail a release. The apply step reads this outcome and keeps the unsigned binary with a
+ # warning. Under release-signing every request waits for a maintainer's manual approval in
+ # app.signpath.io, hence the hour.
+ - name: Sign the Windows binary through SignPath
+ id: sign-ocx
+ if: steps.windows-signing.outputs.mode == 'sign' && steps.unsigned-ocx.outcome == 'success'
+ continue-on-error: true
+ uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
+ timeout-minutes: 65
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ signing-policy-slug: ${{ steps.windows-signing.outputs.policy }}
+ artifact-configuration-slug: windows-standalone-zip
+ github-artifact-id: ${{ steps.unsigned-ocx.outputs.artifact-id }}
+ wait-for-completion: true
+ wait-for-completion-timeout-in-seconds: 3600
+ output-artifact-directory: ${{ runner.temp }}/signpath-signed-ocx
+
+ - name: Swap in the verified signed Windows binary
+ if: steps.windows-signing.outputs.mode == 'sign'
+ continue-on-error: true
+ shell: pwsh
+ env:
+ STANDALONE_TARGET: ${{ matrix.target }}
+ SIGNPATH_OUTCOME: ${{ steps.sign-ocx.outcome }}
+ SIGNING_TRUST: ${{ steps.windows-signing.outputs.trust }}
+ run: |
+ ./scripts/ci/apply-signed-windows-artifact.ps1 `
+ -Target "dist/standalone/$env:STANDALONE_TARGET/ocx.exe" `
+ -SignedDirectory (Join-Path $env:RUNNER_TEMP "signpath-signed-ocx") `
+ -SubmitOutcome "$env:SIGNPATH_OUTCOME" `
+ -Trust $env:SIGNING_TRUST
+
+
- name: Archive standalone release
shell: bash
env:
@@ -262,8 +334,11 @@ jobs:
sidecar-targets: x86_64-unknown-linux-gnu
artifact-suffixes: linux-x86_64.AppImage,linux-amd64.deb
runs-on: ${{ matrix.os }}
- timeout-minutes: 45
+ # The Windows leg can wait up to an hour for a release-signing approval in SignPath.
+ timeout-minutes: ${{ matrix.target == 'x86_64-pc-windows-msvc' && 105 || 45 }}
permissions:
+ # actions: read lets the SignPath action read this job and download the unsigned artifact.
+ actions: read
contents: read
env:
# Whether this run holds the Developer ID material at all. A run without it still builds
@@ -278,6 +353,23 @@ jobs:
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
+ # Windows Authenticode signing through SignPath Foundation (#7207). Decided before the build, so
+ # a broken SignPath configuration fails in seconds instead of after the build. The rules
+ # (no token: skip; partial configuration: fail; test-signing: dry runs only) live in
+ # scripts/ci/windows-signing-plan.sh, where tests/ci-workflows/release-windows-signing.test.ts
+ # exercises them.
+ - name: Decide Windows code signing
+ id: windows-signing
+ if: runner.os == 'Windows'
+ shell: bash
+ env:
+ SIGNPATH_CONFIGURED: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
+ SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ SIGNPATH_SIGNING_POLICY: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+ DRY_RUN: ${{ inputs.dry-run }}
+ run: bash scripts/ci/windows-signing-plan.sh
+
# The desktop build takes its version from tauri.conf.json and Cargo.toml (the widget
# plist inherits it), not from package.json or this input, while the updater manifest is
# derived from the input. A mismatch ships an app that reports the previous version under a
@@ -485,6 +577,80 @@ jobs:
# diagnostics on the first attempt; Apple signing commands stay non-verbose.
run: bunx tauri ${{ runner.os == 'Linux' && '--verbose' || '' }} build --ci --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config "${{ runner.os == 'Windows' && format('{0}/opencodex-msi.json', runner.temp) || '{}' }}"
+ # The Windows installer goes to SignPath after the build and before the asset rename, so the
+ # renamed asset, its checksum and its updater signature all describe the signed MSI. The
+ # windows-msi-zip artifact configuration signs every .exe inside the installer (the app and
+ # the bundled ocx.exe sidecar) and then the MSI itself.
+ - name: Locate the Windows installer for signing
+ id: windows-msi
+ if: steps.windows-signing.outputs.mode == 'sign'
+ continue-on-error: true
+ shell: bash
+ env:
+ DESKTOP_TARGET: ${{ matrix.target }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ installers=("desktop/src-tauri/target/$DESKTOP_TARGET/release/bundle/msi/"*.msi)
+ if [ "${#installers[@]}" -ne 1 ]; then
+ echo "::warning::Expected one MSI to sign, found ${#installers[@]}; the installer ships unsigned."
+ exit 1
+ fi
+ echo "path=${installers[0]}" >> "$GITHUB_OUTPUT"
+
+ - name: Upload the unsigned Windows installer for signing
+ id: unsigned-msi
+ if: steps.windows-signing.outputs.mode == 'sign' && steps.windows-msi.outcome == 'success'
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: signpath-unsigned-${{ matrix.target }}
+ path: ${{ steps.windows-msi.outputs.path }}
+ if-no-files-found: error
+ retention-days: 1
+
+ # continue-on-error, as for the standalone binary: an outage, timeout or rejection keeps the
+ # unsigned installer and its original updater signature.
+ - name: Sign the Windows installer through SignPath
+ id: sign-msi
+ if: steps.windows-signing.outputs.mode == 'sign' && steps.unsigned-msi.outcome == 'success'
+ continue-on-error: true
+ uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
+ timeout-minutes: 65
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ signing-policy-slug: ${{ steps.windows-signing.outputs.policy }}
+ artifact-configuration-slug: windows-msi-zip
+ github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }}
+ wait-for-completion: true
+ wait-for-completion-timeout-in-seconds: 3600
+ output-artifact-directory: ${{ runner.temp }}/signpath-signed-msi
+
+ # Authenticode rewrites the MSI, so the Tauri updater signature made during the build no
+ # longer matches it. The apply script regenerates .sig over the signed bytes with the
+ # same key before swapping both in; verify-release then checks that signature against the
+ # final MSI and writes latest.json from it.
+ - name: Swap in the verified signed Windows installer
+ if: steps.windows-signing.outputs.mode == 'sign' && steps.windows-msi.outcome == 'success'
+ continue-on-error: true
+ shell: pwsh
+ env:
+ MSI_PATH: ${{ steps.windows-msi.outputs.path }}
+ SIGNPATH_OUTCOME: ${{ steps.sign-msi.outcome }}
+ SIGNING_TRUST: ${{ steps.windows-signing.outputs.trust }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: |
+ ./scripts/ci/apply-signed-windows-artifact.ps1 `
+ -Target "$env:MSI_PATH" `
+ -SignedDirectory (Join-Path $env:RUNNER_TEMP "signpath-signed-msi") `
+ -SubmitOutcome "$env:SIGNPATH_OUTCOME" `
+ -Trust $env:SIGNING_TRUST `
+ -Installer `
+ -UpdaterSignature
+
- name: Verify the packaged universal macOS runtime
if: runner.os == 'macOS'
run: |
diff --git a/.github/workflows/windows-signing-test.yml b/.github/workflows/windows-signing-test.yml
new file mode 100644
index 00000000000..06aefeba8a1
--- /dev/null
+++ b/.github/workflows/windows-signing-test.yml
@@ -0,0 +1,251 @@
+name: Windows signing test
+
+# A non-publishing, end-to-end exercise of Windows Authenticode signing through SignPath (#7207).
+# It builds the standalone ocx.exe and the desktop MSI on GitHub-hosted Windows runners exactly as
+# the release workflow does, submits both with the test-signing policy, and verifies the returned
+# signatures. Nothing is tagged, published or attached to a release: the test-signed files are kept
+# only as short-lived workflow artifacts, so the run can be shown to SignPath for review.
+#
+# The policy is fixed to test-signing here on purpose. release-signing requests need a
+# maintainer's manual approval and belong to real releases (release.yml); this workflow must never
+# spend one. Unlike the release workflow, every failure here is a red run, because a green run is
+# the evidence this workflow exists to produce.
+on:
+ workflow_dispatch:
+
+permissions: {}
+
+concurrency:
+ group: windows-signing-test
+ cancel-in-progress: false
+
+env:
+ SIGNPATH_TEST_POLICY: test-signing
+
+jobs:
+ standalone:
+ name: sign ocx.exe (test-signing)
+ runs-on: windows-latest
+ timeout-minutes: 40
+ permissions:
+ # actions: read lets the SignPath action read this job and download the unsigned artifact.
+ actions: read
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ persist-credentials: false
+
+ - name: Setup project Bun
+ uses: ./.github/actions/setup-project-bun
+
+ - name: Require the SignPath configuration
+ shell: bash
+ env:
+ SIGNPATH_CONFIGURED: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
+ SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ run: |
+ set -euo pipefail
+ missing=""
+ [ "$SIGNPATH_CONFIGURED" = "true" ] || missing="$missing SIGNPATH_API_TOKEN"
+ [ -n "$SIGNPATH_ORGANIZATION_ID" ] || missing="$missing SIGNPATH_ORGANIZATION_ID"
+ [ -n "$SIGNPATH_PROJECT_SLUG" ] || missing="$missing SIGNPATH_PROJECT_SLUG"
+ if [ -n "$missing" ]; then
+ echo "::error::This signing test needs the SignPath configuration. Missing:$missing"
+ exit 1
+ fi
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile --os=win32 --cpu=x64
+
+ - name: Build dashboard
+ run: bun run build:gui
+
+ - name: Build standalone binary
+ run: bun run build:standalone --target bun-windows-x64
+
+ - name: Smoke test the unsigned binary
+ shell: pwsh
+ run: |
+ & dist/standalone/bun-windows-x64/ocx.exe --version
+ if ($LASTEXITCODE -ne 0) { throw "ocx.exe --version failed (exit code $LASTEXITCODE)" }
+
+ - name: Upload the unsigned Windows binary for signing
+ id: unsigned-ocx
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: signpath-unsigned-bun-windows-x64
+ path: dist/standalone/bun-windows-x64/ocx.exe
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Sign the Windows binary through SignPath
+ id: sign-ocx
+ uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
+ timeout-minutes: 20
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ signing-policy-slug: ${{ env.SIGNPATH_TEST_POLICY }}
+ artifact-configuration-slug: windows-standalone-zip
+ github-artifact-id: ${{ steps.unsigned-ocx.outputs.artifact-id }}
+ wait-for-completion: true
+ wait-for-completion-timeout-in-seconds: 900
+ output-artifact-directory: ${{ runner.temp }}/signpath-signed-ocx
+
+ - name: Link the SignPath signing request
+ if: always() && steps.sign-ocx.outputs.signing-request-web-url != ''
+ shell: bash
+ env:
+ SIGNING_REQUEST_URL: ${{ steps.sign-ocx.outputs.signing-request-web-url }}
+ run: echo "- SignPath signing request $SIGNING_REQUEST_URL" >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Swap in and verify the signed Windows binary
+ shell: pwsh
+ run: |
+ ./scripts/ci/apply-signed-windows-artifact.ps1 `
+ -Target "dist/standalone/bun-windows-x64/ocx.exe" `
+ -SignedDirectory (Join-Path $env:RUNNER_TEMP "signpath-signed-ocx") `
+ -Trust test `
+ -FailOnError
+
+ - name: Keep the test-signed binary for review
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-signed-ocx-bun-windows-x64
+ path: dist/standalone/bun-windows-x64/ocx.exe
+ if-no-files-found: error
+ retention-days: 7
+
+ installer:
+ name: sign the MSI (test-signing)
+ runs-on: windows-latest
+ timeout-minutes: 75
+ permissions:
+ actions: read
+ contents: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+ with:
+ persist-credentials: false
+
+ - name: Setup project Bun
+ uses: ./.github/actions/setup-project-bun
+
+ - name: Require the SignPath configuration
+ shell: bash
+ env:
+ SIGNPATH_CONFIGURED: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
+ SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ run: |
+ set -euo pipefail
+ missing=""
+ [ "$SIGNPATH_CONFIGURED" = "true" ] || missing="$missing SIGNPATH_API_TOKEN"
+ [ -n "$SIGNPATH_ORGANIZATION_ID" ] || missing="$missing SIGNPATH_ORGANIZATION_ID"
+ [ -n "$SIGNPATH_PROJECT_SLUG" ] || missing="$missing SIGNPATH_PROJECT_SLUG"
+ if [ -n "$missing" ]; then
+ echo "::error::This signing test needs the SignPath configuration. Missing:$missing"
+ exit 1
+ fi
+
+ - name: Install project dependencies
+ run: bun install --frozen-lockfile --os=win32 --cpu=x64
+
+ - name: Build dashboard
+ run: bun run build:gui
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master
+ with:
+ toolchain: stable
+
+ - name: Install desktop dependencies
+ working-directory: desktop
+ run: bun install --frozen-lockfile
+
+ - name: Prepare sidecar
+ run: bun desktop/scripts/prepare-sidecar.ts --target x86_64-pc-windows-msvc
+
+ # The MSI version comes from the desktop configuration, as in a release. Updater artifacts
+ # are switched off: this run holds no updater key and publishes no manifest, and the release
+ # workflow is where the updater signature over the signed MSI is regenerated.
+ - name: Prepare Windows installer configuration
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="$(bun -e 'console.log(JSON.parse(await Bun.file("desktop/src-tauri/tauri.conf.json").text()).version)')"
+ bun desktop/scripts/windows-installer-config.ts "$version" "$RUNNER_TEMP/opencodex-msi-base.json"
+ bun -e 'const dir = process.env.RUNNER_TEMP; const config = JSON.parse(await Bun.file(dir + "/opencodex-msi-base.json").text()); config.bundle.createUpdaterArtifacts = false; await Bun.write(dir + "/opencodex-msi.json", JSON.stringify(config));'
+
+ - name: Build the Windows installer
+ working-directory: desktop
+ run: bunx tauri build --ci --target x86_64-pc-windows-msvc --bundles msi --config "${{ runner.temp }}/opencodex-msi.json"
+
+ - name: Locate the Windows installer
+ id: windows-msi
+ shell: bash
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+ installers=(desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi)
+ if [ "${#installers[@]}" -ne 1 ]; then
+ echo "::error::Expected one MSI, found ${#installers[@]}"
+ exit 1
+ fi
+ echo "path=${installers[0]}" >> "$GITHUB_OUTPUT"
+
+ - name: Upload the unsigned Windows installer for signing
+ id: unsigned-msi
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: signpath-unsigned-x86_64-pc-windows-msvc
+ path: ${{ steps.windows-msi.outputs.path }}
+ if-no-files-found: error
+ retention-days: 1
+
+ - name: Sign the Windows installer through SignPath
+ id: sign-msi
+ uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
+ timeout-minutes: 20
+ with:
+ api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+ organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+ project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+ signing-policy-slug: ${{ env.SIGNPATH_TEST_POLICY }}
+ artifact-configuration-slug: windows-msi-zip
+ github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }}
+ wait-for-completion: true
+ wait-for-completion-timeout-in-seconds: 900
+ output-artifact-directory: ${{ runner.temp }}/signpath-signed-msi
+
+ - name: Link the SignPath signing request
+ if: always() && steps.sign-msi.outputs.signing-request-web-url != ''
+ shell: bash
+ env:
+ SIGNING_REQUEST_URL: ${{ steps.sign-msi.outputs.signing-request-web-url }}
+ run: echo "- SignPath signing request $SIGNING_REQUEST_URL" >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Swap in and verify the signed Windows installer
+ shell: pwsh
+ env:
+ MSI_PATH: ${{ steps.windows-msi.outputs.path }}
+ run: |
+ ./scripts/ci/apply-signed-windows-artifact.ps1 `
+ -Target "$env:MSI_PATH" `
+ -SignedDirectory (Join-Path $env:RUNNER_TEMP "signpath-signed-msi") `
+ -Trust test `
+ -Installer `
+ -FailOnError
+
+ - name: Keep the test-signed installer for review
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: test-signed-msi-x86_64-pc-windows-msvc
+ path: ${{ steps.windows-msi.outputs.path }}
+ if-no-files-found: error
+ retention-days: 7
diff --git a/AGENTS.md b/AGENTS.md
index 2ba03009389..2e1c13e6dca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -367,7 +367,12 @@ than nudged.
## CI policy (maintainer decision — do not change)
-- Pull requests run **only the fast Ubuntu subset**.
+- Pull requests run **only the fast Ubuntu subset**. By default that is `gates` plus
+ `test changed` (import-graph tests for the diff); the four full Ubuntu shards run on a
+ pull request only with the `ci:full` label or a CI/harness/toolchain/dependency change.
+ Merged pull requests are verified in batches by a manual `dev` dispatch
+ (`MAINTAINERS.md`, "Batched merges"). Because `test changed` follows the module graph,
+ add `ci:full` for changes it cannot see (subprocesses, files read as data, generated output).
- Full cross-platform (macOS/Windows) CI runs **only** via manual
`workflow_dispatch` and on push to `main`/`preview` (release lines).
- **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9ae2a4555e7..bbcd248f8f8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst
## Local validation and hooks
-The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.2.
-`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses
+The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4.
+`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses
`OCX_TEST_RUNNER_BUN`; install that version locally if it is missing.
+The roles remain independently checked even when their versions match. Promotion requires
+a successful full cross-platform `lane=all` run on the candidate head.
Run `bun run test` before review readiness. If the full local suite is too costly
for the task or available resources, run at least focused regression tests for
diff --git a/Dockerfile b/Dockerfile
index 7e36f2063a0..54e0153e0f1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,14 @@
# syntax=docker/dockerfile:1
# Keep the runtime aligned with package.json and pin the multi-platform image index.
-# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests run on 1.4.3
-# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821;
-# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync
-# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the
-# upstream crash fix passes a unified-version lane=all dispatch, then delete
-# package.json's testRunnerBun pin and setup-project-bun's role input.
-ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895
+# Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS
+# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner
+# defaults are both 1.4.4; keep package.json's testRunnerBun pin and
+# setup-project-bun's role input independently validated. Promotion requires
+# an exact-head cross-platform lane=all dispatch, including multi-file --isolate:
+# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0
+# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks.
+ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d
FROM ${BUN_IMAGE} AS manifest
WORKDIR /home/bun/app
diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index 18741234818..db2798905e0 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -62,6 +62,16 @@ when a maintainer steps down.
the ruleset does not check ancestry (see the note under the change log).
- Pull requests require successful required CI checks before merge. Contributor pull requests
normally require approval from at least one maintainer other than the author.
+- **Batched merges.** A pull request's own CI is the fast lane by default: `gates` plus
+ `test changed` (the tests Bun's import graph reaches from the diff), not the four full
+ Ubuntu shards. The shards still run on a pull request that carries the `ci:full` label at
+ push time or touches the CI/test harness, fixtures, toolchain or dependency set
+ (`scripts/ci/select-test-lane.sh`). Add `ci:full` before pushing when a change reaches shared
+ contracts in ways an import graph cannot see (subprocesses, files read as data, generated
+ output). Merge green pull requests one at a time; after roughly every five merges, run
+ `gh workflow run ci.yml --ref dev` (the default `lane=all`). If that run is red, stop merging,
+ fix `dev` from the batch it covers, and resume once it is green. Always run it before a
+ promotion to `main`.
- A current maintainer with GitHub `maintain` or `admin` access may explicitly integrate a pull
request into `dev` without another maintainer's approval, including their own pull request.
Record that choice and the exact-head verification in the pull-request description or comment.
diff --git a/README.md b/README.md
index e8223c7eac8..5fba47f1840 100644
--- a/README.md
+++ b/README.md
@@ -113,7 +113,7 @@ platform from the [latest release](https://github.com/lidge-jun/opencodex/releas
| Platform | File | Notes |
|---|---|---|
| macOS 13+ (Apple Silicon and Intel) | `OpenCodex--macos.dmg` | Universal build, signed with a Developer ID and notarized |
-| Windows (x64) | `OpenCodex--windows-x64.msi` | Not code-signed yet: SmartScreen asks once, choose **More info → Run anyway** |
+| Windows (x64) | `OpenCodex--windows-x64.msi` | Code signing via SignPath Foundation is rolling out ([code signing policy](https://opencodex.me/reference/code-signing/)); until a release is signed, SmartScreen asks once: choose **More info → Run anyway** |
| Linux (x86_64) | `OpenCodex--linux-x86_64.AppImage` or `-linux-amd64.deb` | The tray needs an AppIndicator-capable desktop |
Every file has a `.sha256` next to it on the release page. On macOS 14+ the app also ships a
@@ -471,6 +471,16 @@ Contributor work that landed through a maintainer carry or reimplementation,
where the commit does not name its original author, is recorded in
**[CREDITS.md](./CREDITS.md)**.
+## Code signing policy
+
+Windows binaries are moving to Authenticode signing ([#7207](https://github.com/lidge-jun/opencodex/issues/7207)). The [Windows Code Signing guide](https://opencodex.me/reference/code-signing/) covers the rollout, how to verify a signature, and the full policy.
+
+Free code signing provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
+
+- Committers and reviewers: [lidge-jun](https://github.com/lidge-jun)
+- Approvers: [lidge-jun](https://github.com/lidge-jun)
+- Privacy: This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
+
## Disclaimer
opencodex is an independent, community-maintained project and is **not affiliated with or endorsed by OpenAI, Anthropic, or any other provider**.
diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift
index 0a1267303b3..bf9d0881ac2 100644
--- a/app/Sources/MenuBarCore/ProxyModels.swift
+++ b/app/Sources/MenuBarCore/ProxyModels.swift
@@ -163,6 +163,7 @@ public struct UsageAccountRow: Decodable, Equatable, Sendable {
}
public struct QuotaWindow: Decodable, Equatable, Sendable {
+ public let modelId: String?
public let label: String?
public let percent: Double?
public let resetAt: Double?
@@ -188,6 +189,7 @@ public struct QuotaReport: Decodable, Equatable, Sendable {
/// A provider-agnostic view of quota, since the window key differs per provider.
public struct NormalizedQuota: Equatable, Sendable {
+ public let modelId: String?
public let provider: String
public let providerLabel: String
public let percent: Double?
@@ -219,10 +221,10 @@ public extension QuotaReport {
let name = label ?? provider
var windows: [NormalizedQuota] = []
- func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?) {
+ func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?, _ modelId: String? = nil) {
guard percent != nil || resetAt != nil else { return }
windows.append(NormalizedQuota(
- provider: provider, providerLabel: name, percent: percent,
+ modelId: modelId, provider: provider, providerLabel: name, percent: percent,
windowLabel: windowLabel, resetAt: Self.date(from: resetAt)
))
}
@@ -232,7 +234,7 @@ public extension QuotaReport {
append(quota?.monthlyPercent, "month", quota?.monthlyResetAt)
for window in quota?.customWindows ?? [] {
- append(window.percent, window.label ?? "window", window.resetAt)
+ append(window.percent, window.label ?? "window", window.resetAt, window.modelId)
}
return windows
@@ -240,7 +242,8 @@ public extension QuotaReport {
/// The single window that best represents current pressure, for the compact row.
///
- /// Selection is **highest reported usage**, not longest horizon. Every window can
+ /// Diagnostic model rows are retained in details and excluded here. Selection is
+ /// **highest reported usage**, not longest horizon. Every aggregate window can
/// stop work: a provider at 99% of a five-hour limit and 10% of its monthly limit is
/// blocked right now, and showing the monthly 10% would paint that row green while
/// the user cannot make a request. Ties break toward the longer horizon, since that
@@ -250,7 +253,7 @@ public extension QuotaReport {
/// em dash rather than a misleading zero.
func normalized() -> NormalizedQuota {
let name = label ?? provider
- let windows = normalizedWindows()
+ let windows = normalizedWindows().filter { $0.modelId == nil }
// Longer horizons rank higher only as a tie-breaker.
func horizonRank(_ label: String) -> Int {
@@ -271,7 +274,7 @@ public extension QuotaReport {
} ?? windows.first
return preferred ?? NormalizedQuota(
- provider: provider, providerLabel: name, percent: nil,
+ modelId: nil, provider: provider, providerLabel: name, percent: nil,
windowLabel: "—", resetAt: nil
)
}
diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift
index 0ec0012e621..f79580bbc49 100644
--- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift
+++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift
@@ -230,6 +230,23 @@ enum ModelDecodingSuite {
t.equal(report.normalized().percent, 12)
}
+ t.test("quotas: model diagnostics stay detailed and cannot become compact pressure") {
+ let json = """
+ {"provider":"google-antigravity","quota":{"customWindows":[
+ {"label":"aux","modelId":"aux","percent":100},
+ {"label":"Gem","percent":20}]}}
+ """
+ let report = try decode(QuotaReport.self, json)
+ t.equal(report.normalized().percent, 20)
+ t.equal(report.normalized().windowLabel, "Gem")
+ t.equal(report.normalizedWindows().count, 2)
+ t.equal(report.quota?.customWindows?.first?.modelId, "aux")
+ t.equal(report.normalizedWindows().first?.modelId, "aux")
+ let onlyModel = try decode(QuotaReport.self,
+ #"{"provider":"p","quota":{"customWindows":[{"label":"aux","modelId":"aux","percent":100}]}}"#)
+ t.isNil(onlyModel.normalized().percent, "model-only compact percent")
+ }
+
t.test("quotas: an absent quota normalizes to a nil percent") {
let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized()
t.isNil(normalized.percent, "percent")
diff --git a/bin/ocx.mjs b/bin/ocx.mjs
index 6baf5168b5f..22c391d4b42 100755
--- a/bin/ocx.mjs
+++ b/bin/ocx.mjs
@@ -37,8 +37,10 @@ import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs";
+import { scopeTranspilerCacheToRuntime } from "../src/lib/bun-transpiler-cache-scope.mjs";
import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs";
import { npmInvocation } from "../src/update/npm-invocation.mjs";
+import { runNpmInstall } from "../src/update/npm-install.mjs";
import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs";
import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs";
import {
@@ -55,10 +57,13 @@ import {
runNpmCachePreflight,
} from "../src/update/npm-cache-preflight.mjs";
import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "../src/update/tray-update-plan.mjs";
-import { bootRestoreProbe, launcherUsableAfterNpmUpdate, transactionalNpmUpdate } from "../src/update/transactional-install.mjs";
+import { bootRestoreProbe, launcherUsableAfterNpmUpdate, transactionalNpmUpdate as transactionalNpmUpdateSync, transactionalNpmUpdateAsync } from "../src/update/transactional-install.mjs";
import { manualUpdateFailureGuidance, npmUpdateFailureGuidance } from "../src/update/update-failure-guidance.mjs";
import {
+ captureCodexCliTransportEnv,
+ CODEX_CLI_TRANSPORT_ENV_KEYS,
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
+ codexCliInspectionLookupSeeds,
isCodexCliUpdateInspectionArgv,
} from "../src/update/codex-cli-update-launch-policy.mjs";
import {
@@ -209,7 +214,7 @@ function runningPnpmShimPath() {
return resolve(invoked);
}
-function runPackageManagerSelfUpdate(manager) {
+async function runPackageManagerSelfUpdate(manager) {
const current = currentPackageVersion();
const tag = updateTag(current);
let owner;
@@ -846,7 +851,8 @@ function runPackageManagerSelfUpdate(manager) {
try {
if (manager === "npm") {
const packageDir = resolve(here, "..");
- const tx = transactionalNpmUpdate({
+ const transactionalNpmUpdate = process.platform === "win32" ? transactionalNpmUpdateAsync : transactionalNpmUpdateSync;
+ const tx = await transactionalNpmUpdate({
packageDir,
pkgName: PKG,
targetVersion: latest || undefined,
@@ -854,6 +860,9 @@ function runPackageManagerSelfUpdate(manager) {
cachePath: npmCachePath,
runNpm: (args) => {
const invocation = npmInvocation(args);
+ if (process.platform === "win32") return runNpmInstall(invocation, {
+ env: unprivilegedOwnershipMutationEnvironment(invocation?.options?.env ?? process.env),
+ });
if (!invocation) return { status: 1 };
return spawnSync(invocation.file, invocation.args, {
...invocation.options,
@@ -1003,6 +1012,15 @@ function findBunBinary(bunDir) {
return null;
}
+function bundledBunVersion(bunDir) {
+ try {
+ const version = JSON.parse(readFileSync(join(bunDir, "package.json"), "utf8")).version;
+ return typeof version === "string" ? version : null;
+ } catch {
+ return null;
+ }
+}
+
function fail(msg) {
const reinstall = installMethod === "pnpm"
? "pnpm add -g --allow-build=bun @bitkyc08/opencodex"
@@ -1039,7 +1057,7 @@ function resolveBun({ allowInstall = true } = {}) {
} catch { /* Missing dependency can still fall back to a validated PATH Bun. */ }
let bin = bunDir ? findBunBinary(bunDir) : null;
- if (bin) return { path: bin, source: "bundled" };
+ if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) };
// Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the
// ~450-byte placeholder stub. Run the bun package's own installer once.
@@ -1048,7 +1066,7 @@ function resolveBun({ allowInstall = true } = {}) {
const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" });
if (r.status === 0) bin = findBunBinary(bunDir);
}
- if (bin) return { path: bin, source: "bundled" };
+ if (bin) return { path: bin, source: "bundled", version: bundledBunVersion(bunDir) };
// Reached only when the bundled runtime is unusable. The two probes start the candidate Bun
// cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for
@@ -1056,7 +1074,7 @@ function resolveBun({ allowInstall = true } = {}) {
const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS });
if (pathBun) {
console.error(`opencodex: using PATH Bun ${pathBun.version}.`);
- return { path: pathBun.path, source: "process" };
+ return { path: pathBun.path, source: "process", version: pathBun.version };
}
fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed.");
}
@@ -1122,8 +1140,8 @@ if (process.argv[2] === "update" && installMethod === "mise") {
}
if (process.argv[2] === "update" && isNodeModulesInstall() && !isBunGlobalInstall()) {
- if (installMethod === "npm") runNpmSelfUpdate();
- if (installMethod === "pnpm") runPnpmSelfUpdate();
+ if (installMethod === "npm") await runNpmSelfUpdate();
+ if (installMethod === "pnpm") await runPnpmSelfUpdate();
}
// #1849 boot probe: a prior update that lost power (or double-faulted) mid-swap leaves a
@@ -1182,14 +1200,25 @@ function createNodeLaunchContext() {
: configuredCodexCliPath;
const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null;
const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null;
+ // HOME and NODE_PATH seed the candidate Node module lookup the update
+ // inspection models, so they are proof-bound here for the same reason as
+ // PATH: the live environment could have been rewritten after selection. They
+ // stay in the inherited environment regardless — the child may need them for
+ // its own work; only the snapshot copy is optional.
+ const preBunHome = typeof process.env.HOME === "string" ? process.env.HOME : null;
+ const preBunNodePath = typeof process.env.NODE_PATH === "string" ? process.env.NODE_PATH : null;
const preBunCodexCliManagerRoots = Object.fromEntries(
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => {
const value = process.env[name];
return typeof value === "string" && value !== "" ? [[name, value]] : [];
}),
);
+ // Proxy/CA settings are captured once, here, before Bun can add project dotenv
+ // values. They are part of the required selection snapshot, so they count
+ // against the context budget below before any optional lookup seed does.
+ const preBunTransportEnv = captureCodexCliTransportEnv(process.env);
const launchProof = randomBytes(32).toString("base64url");
- const launchContext = JSON.stringify({
+ const serializeContext = lookupSeeds => JSON.stringify({
version: 1,
proof: launchProof,
anthropicEnvSlots: preBunAnthropicSlots,
@@ -1197,11 +1226,30 @@ function createNodeLaunchContext() {
codexCliPath: preBunCodexCliPath,
path: preBunPath,
pathExt: preBunPathExt,
+ home: lookupSeeds.home,
+ nodePath: lookupSeeds.nodePath,
managerRoots: preBunCodexCliManagerRoots,
+ transportEnv: preBunTransportEnv,
+ // The published Codex entrypoint selects its native backing by the arch of
+ // the Node that runs it; a Bun override or PATH fallback can run another
+ // arch under Rosetta, so this Node launcher's arch is the bound value.
+ arch: process.arch,
configDir: configDir(),
} : null,
});
- // The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as
+ let launchContext = serializeContext({ home: preBunHome, nodePath: preBunNodePath });
+ // The lookup seeds are optional: a Windows inspection never models the global
+ // Node lookup, and an oversized NODE_PATH must not push the serialized
+ // context past the reader's limit — that would discard the whole selection
+ // snapshot, while dropped seeds merely leave the fallback lane unverifiable
+ // and fail only the content observation closed.
+ const lookupSeeds = codexCliInspectionLookupSeeds(
+ process.platform, preBunHome, preBunNodePath, launchContext.length,
+ );
+ if (lookupSeeds.home !== preBunHome || lookupSeeds.nodePath !== preBunNodePath) {
+ launchContext = serializeContext(lookupSeeds);
+ }
+ // The inspection snapshot above already carries PATH, PATHEXT, manager roots and proxy/CA settings as
// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than
// from the live environment. Inheriting them again would spend the 32,767-character Windows
// environment block twice, so a large-but-valid shell environment could stop the Bun child
@@ -1214,7 +1262,8 @@ function createNodeLaunchContext() {
const inheritedEnv = { ...process.env };
if (codexCliUpdateInspection) {
const snapshotted = new Set(
- ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()),
+ ["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
+ ...CODEX_CLI_TRANSPORT_ENV_KEYS].map(name => name.toLowerCase()),
);
for (const name of Object.keys(inheritedEnv)) {
if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];
@@ -1230,7 +1279,8 @@ const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`,
// the long-running Bun child, and closing that window kills the proxy (#1236).
windowsHide: true,
env: {
- ...inheritedEnv,
+ // A cache shared with another Bun version (the CI test runner's) is evicted on every start.
+ ...scopeTranspilerCacheToRuntime(inheritedEnv, bunRuntime.version, join),
[NODE_LAUNCH_CONTEXT_ENV]: launchContext,
[BUN_RUNTIME_SOURCE_ENV]: bunRuntime.source,
[BUN_RUNTIME_PATH_ENV]: bunRuntime.path,
diff --git a/bun.lock b/bun.lock
index 063207baa8b..fd75af84840 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,10 +6,10 @@
"name": "@bitkyc08/opencodex",
"dependencies": {
"@bufbuild/protobuf": "^2.14.0",
- "@modelcontextprotocol/sdk": "^1.30.0",
+ "@modelcontextprotocol/sdk": "^1.32.1",
"@napi-rs/keyring": "1.3.0",
- "bun": "1.4.2",
- "zod": "4.4.3",
+ "bun": "1.4.4",
+ "zod": "4.6.6",
},
"devDependencies": {
"@types/bun": "1.4.0",
@@ -25,17 +25,18 @@
],
"overrides": {
"@hono/node-server": "2.1.0",
+ "body-parser": "2.4.0",
"fast-uri": "^3.1.7",
- "hono": "4.13.8",
- "ip-address": "^10.4.0",
- "qs": "^6.16.0",
+ "hono": "4.13.13",
+ "ip-address": "^10.7.4",
+ "qs": "^6.16.1",
},
"packages": {
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.14.0", "", {}, "sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w=="],
"@hono/node-server": ["@hono/node-server@2.1.0", "", { "peerDependencies": { "hono": "^4" } }, "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg=="],
- "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.31.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw=="],
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.32.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw=="],
"@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="],
@@ -63,29 +64,29 @@
"@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="],
- "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="],
+ "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zW/hxwc8sJAILGwB5eTC4i48ouGXf0V4lrxmEOk3JIE6JkYVW1TZh1E2pIempqsezgW1sckTIIDudFON8bJ2Mw=="],
- "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="],
+ "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-oKGcxLsvh4oHjAo28nPm8c/xN0o/OmGyj1IHYc94vprkWsW2hNg2keEZt5f40cI5Hvu4jDlSRTR0UNpv1UIAxw=="],
- "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="],
+ "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-klvA9FlwLxZj3JYIOPQA4bbnHIC3uas8kBE4GaGGkeVgAQP/k8hAzYkOqkSaqHBDbxLrTyFEQArEAevJq6NJzg=="],
- "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="],
+ "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sF1xS/scbyN94P2d0eTiarO6JHxrIvhZNT9hnWjQXW5nwKRtfuP+UYnIMNyvW1BQPcZFP/cvfLBNUIe1ea4zPA=="],
- "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="],
+ "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZTVtpf9zmHSBVPX1sBwe5agGb/rN34yPGg/Hby8e2Ax2uCUA2URZKlZH7qLntK7qaFJCMXh9v0GWNYN2zINz1w=="],
- "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="],
+ "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.4", "", { "os": "android", "cpu": "arm64" }, "sha512-5lvn3at5vRC0GxvmiqEiV2/e23tnRi0q5pzR40yfDvDA3OW7PSkt4chF1nA9vnAJhro4/l+ol1FmNP6D3cf/oA=="],
- "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="],
+ "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-QFd0P+mHJxbZFbUla4MgiE5OwxysHaUIYxL2pPNkpWtaI7v4ShOpr8OhBmsuyB0xn5rv/ZJnPsj2y8+bB9+g/A=="],
- "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="],
+ "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-CHbbkmHVM28T1GFq+6GL4Ao4ujwwYwj1CxFFwEFzLanPfNsId9AhbIP0Q4reWuWBkGimUkoQygrK7Rgjtj3vog=="],
- "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="],
+ "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.4", "", { "os": "android", "cpu": "x64" }, "sha512-3dMicj9C1DhaIfDkwt/9GtpU0W+q+GazbG9OIlEyL3Q1BBEz2e6ZRz/JiO8D/ECC0caXAqvnpFudehcLlgIwWQ=="],
- "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="],
+ "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lEEjNOJJrbGZu2NVcPI4uXdILcpspezWkA8R/Jr0RCGhM6zQd0Y7JBIIKNAmbX59EeWpeEz7Q78iLbpcAUypUA=="],
- "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="],
+ "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-n0ep5ZRPCZY1+dqFJpnnRUZB1wy4GYU6At8RF+pyzySCLjkN9R4rFh2PtBHAgF/lg1vOl3/CeER3pDM+p40ATA=="],
- "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="],
+ "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-a3LNctBVO8PjePsFZfwDleF2JXYf2BfQ1u/PLRnBDLnC2Riml+4+LgJpKj4xRGhB92KweGyQI5PXaTkP9qypMQ=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
@@ -137,9 +138,9 @@
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
- "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
+ "body-parser": ["body-parser@2.4.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ=="],
- "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="],
+ "bun": ["bun@1.4.4", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.4", "@oven/bun-darwin-x64": "1.4.4", "@oven/bun-freebsd-aarch64": "1.4.4", "@oven/bun-freebsd-x64": "1.4.4", "@oven/bun-linux-aarch64": "1.4.4", "@oven/bun-linux-aarch64-android": "1.4.4", "@oven/bun-linux-aarch64-musl": "1.4.4", "@oven/bun-linux-x64": "1.4.4", "@oven/bun-linux-x64-android": "1.4.4", "@oven/bun-linux-x64-musl": "1.4.4", "@oven/bun-windows-aarch64": "1.4.4", "@oven/bun-windows-x64": "1.4.4" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-hj0ASdG6PNjLZGFAXktWb3Uaj/dcv/1wCsIttoEXADNIQBsGjTr+5uLKYbrDkuc7NT0Tkj4yPIqJI2jDxXdvVg=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
@@ -211,7 +212,7 @@
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
- "hono": ["hono@4.13.8", "", {}, "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ=="],
+ "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -219,7 +220,7 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
- "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="],
+ "ip-address": ["ip-address@10.7.4", "", {}, "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -265,7 +266,7 @@
"proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="],
- "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="],
+ "qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
@@ -315,11 +316,13 @@
"wreq-js": ["wreq-js@2.3.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA=="],
- "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+ "zod": ["zod@4.6.6", "", {}, "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
- "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+ "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
+
+ "body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
}
diff --git a/bunfig.toml b/bunfig.toml
index 1a6ced2c5dc..7376005a5c5 100644
--- a/bunfig.toml
+++ b/bunfig.toml
@@ -16,8 +16,10 @@ root = "tests"
#
# That qualifier is load-bearing. Bun resolves bunfig.toml from the CURRENT WORKING
# DIRECTORY, so `cd /tmp && bun test /tests/x.test.ts` loads no preload at all: nothing
-# is sandboxed, the guard stays disarmed, and every `getConfigDir()` in that process returns
-# the developer's real `~/.opencodex`. On 2026-09-15 such a run deleted one. A test that needs
-# a config directory pins its own OPENCODEX_HOME instead of trusting this line, and
-# `tests/ci-workflows/test-home-guard.test.ts` enforces that for the destructive case.
+# is sandboxed, and every `getConfigDir()` in that process returns the developer's real
+# `~/.opencodex`. On 2026-09-15 such a run deleted one, and in #7407 another wrote auth.json.
+# The write guard now arms itself from the test-file entry in that case (src/lib/test-home-guard.ts),
+# so such writes throw instead of landing, but a test that needs a config directory still pins its
+# own OPENCODEX_HOME instead of trusting this line, and `tests/ci-workflows/test-home-guard.test.ts`
+# enforces that for the destructive case.
preload = ["./tests/preload.ts"]
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index b994d029dc7..63f898f81ec 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -2645,7 +2645,7 @@ dependencies = [
[[package]]
name = "opencodex-desktop"
-version = "2.86.0"
+version = "2.93.0-preview.20261027"
dependencies = [
"base64 0.22.1",
"dbus",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index a65970d63d9..28072923678 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "opencodex-desktop"
-version = "2.86.0"
+version = "2.93.0-preview.20261027"
description = "OpenCodex desktop shell"
authors = ["OpenCodex contributors"]
license = "MIT"
diff --git a/desktop/src-tauri/src/claim.rs b/desktop/src-tauri/src/claim.rs
index 22d0cfcbc24..d7e2d53ef51 100644
--- a/desktop/src-tauri/src/claim.rs
+++ b/desktop/src-tauri/src/claim.rs
@@ -163,7 +163,12 @@ pub async fn run(app: &AppHandle, argv: Vec, deadline: Instant) -> Claim
return ClaimResult::Failed(format!("the bundled CLI could not be started ({error})"))
}
};
- match timeout_at(deadline, command.output()).await {
+ match timeout_at(
+ deadline,
+ crate::process_spawn::with_spawn_async(command.output()),
+ )
+ .await
+ {
Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr),
Ok(Err(error)) => {
ClaimResult::Failed(format!("the bundled CLI could not be run ({error})"))
diff --git a/desktop/src-tauri/src/cli_command.rs b/desktop/src-tauri/src/cli_command.rs
index a89361e9fd7..7e05e54f85e 100644
--- a/desktop/src-tauri/src/cli_command.rs
+++ b/desktop/src-tauri/src/cli_command.rs
@@ -454,18 +454,20 @@ mod tests {
}
fn selected_observation_case(case: &str) {
let t = Temp::new();
- let result = std::process::Command::new(std::env::current_exe().unwrap())
- .args([
- "--exact",
- "cli_command::tests::selected_observation_child",
- "--nocapture",
- ])
- .env("OCX_CLI_OBSERVATION_TEST", case)
- .env("HOME", &t.0)
- .env("ZDOTDIR", &t.0)
- .env("XDG_CONFIG_HOME", t.0.join(".config"))
- .output()
- .unwrap();
+ let result = crate::process_spawn::with_spawn(|| {
+ std::process::Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "cli_command::tests::selected_observation_child",
+ "--nocapture",
+ ])
+ .env("OCX_CLI_OBSERVATION_TEST", case)
+ .env("HOME", &t.0)
+ .env("ZDOTDIR", &t.0)
+ .env("XDG_CONFIG_HOME", t.0.join(".config"))
+ .output()
+ })
+ .unwrap();
assert!(result.status.success(), "selected-observation-failed");
assert!(
String::from_utf8_lossy(&result.stdout).contains("1 passed"),
@@ -578,16 +580,18 @@ mod tests {
}
}
fn disabled_selector_case(action: &str) {
- let result = std::process::Command::new(std::env::current_exe().unwrap())
- .args([
- "--exact",
- "cli_command::tests::disabled_selector_child",
- "--nocapture",
- ])
- .env("OCX_CLI_SELECTOR_TEST", action)
- .env("ZDOTDIR", "/invalid:zdotdir")
- .output()
- .unwrap();
+ let result = crate::process_spawn::with_spawn(|| {
+ std::process::Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "cli_command::tests::disabled_selector_child",
+ "--nocapture",
+ ])
+ .env("OCX_CLI_SELECTOR_TEST", action)
+ .env("ZDOTDIR", "/invalid:zdotdir")
+ .output()
+ })
+ .unwrap();
assert!(result.status.success(), "disabled-selector-failed");
assert!(
String::from_utf8_lossy(&result.stdout).contains("1 passed"),
diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs
index 8ab0fcf8295..b3016bd9ff4 100644
--- a/desktop/src-tauri/src/cli_command_posix.rs
+++ b/desktop/src-tauri/src/cli_command_posix.rs
@@ -673,6 +673,7 @@ pub fn remove_plan(store: &Store, current: &Record) -> Result<(Record, Vec Temp {
@@ -701,13 +702,13 @@ mod tests {
command.arg("-N");
}
assert!(
- command
+ with_spawn(|| command
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
- .status()
- .unwrap()
- .success(),
+ .status())
+ .unwrap()
+ .success(),
"acl-command-failed"
);
}
@@ -969,7 +970,7 @@ mod tests {
if let Some(path) = path {
command.env("PATH", path);
}
- let output = command.output().unwrap();
+ let output = with_spawn(|| command.output()).unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
}
@@ -988,14 +989,16 @@ mod tests {
0o700,
)
.unwrap();
- let out = std::process::Command::new(&shim)
- .env_clear()
- .env("ANTHROPIC_AUTH_TOKEN", "dummy")
- .env("ANTHROPIC_API_KEY", "")
- .env("OCX_NODE_LAUNCH_CONTEXT", "untrusted")
- .arg("status")
- .output()
- .unwrap();
+ let out = with_spawn(|| {
+ std::process::Command::new(&shim)
+ .env_clear()
+ .env("ANTHROPIC_AUTH_TOKEN", "dummy")
+ .env("ANTHROPIC_API_KEY", "")
+ .env("OCX_NODE_LAUNCH_CONTEXT", "untrusted")
+ .arg("status")
+ .output()
+ })
+ .unwrap();
assert!(out.status.success());
let text = String::from_utf8(out.stdout).unwrap();
let lines: Vec<_> = text.lines().collect();
@@ -1011,9 +1014,7 @@ mod tests {
assert!(!text.contains("dummy"));
fs::remove_file(cli).unwrap();
assert_eq!(
- std::process::Command::new(&shim)
- .env_clear()
- .output()
+ with_spawn(|| std::process::Command::new(&shim).env_clear().output())
.unwrap()
.status
.code(),
@@ -1035,13 +1036,18 @@ mod tests {
.replace("-N32", "-N1"),
] {
record::atomic(&t.0.join("cli"), None, inspect, 0o700).unwrap();
- fs::write(&shim, rendered).unwrap();
- let out = std::process::Command::new(&shim)
- .env_clear()
- .env("OCX_NODE_LAUNCH_CONTEXT", "inherited")
- .arg("status")
- .output()
- .unwrap();
+ {
+ let _writer = crate::process_spawn::executable_write();
+ fs::write(&shim, rendered).unwrap();
+ }
+ let out = with_spawn(|| {
+ std::process::Command::new(&shim)
+ .env_clear()
+ .env("OCX_NODE_LAUNCH_CONTEXT", "inherited")
+ .arg("status")
+ .output()
+ })
+ .unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"\nstatus\n");
fs::remove_file(t.0.join("cli")).unwrap();
@@ -1197,15 +1203,17 @@ mod tests {
// System rc files may print to stdout (Ubuntu's /etc/bash.bashrc sudo hint), so only
// the marked result lines count; a failed lookup or CLI run still fails the shell, and
// the end marker makes any extra CLI output line fail the comparison.
- let output = command
- .args([
- "-i",
- "-c",
- "p=$(command -v ocx) || exit 11; o=$(ocx hello) || exit 12; \
+ let output = with_spawn(|| {
+ command
+ .args([
+ "-i",
+ "-c",
+ "p=$(command -v ocx) || exit 11; o=$(ocx hello) || exit 12; \
printf 'OCX-PATH:%s\\nOCX-OUT:%s:OCX-END\\n' \"$p\" \"$o\"",
- ])
- .output()
- .expect("cannot start real shell");
+ ])
+ .output()
+ })
+ .expect("cannot start real shell");
assert!(output.status.success(), "{shell}: command failed");
let stdout = String::from_utf8(output.stdout).expect("shell stdout is not UTF-8");
let marked = |tag: &str| {
diff --git a/desktop/src-tauri/src/cli_command_record.rs b/desktop/src-tauri/src/cli_command_record.rs
index 294c0d86346..950993a3b4f 100644
--- a/desktop/src-tauri/src/cli_command_record.rs
+++ b/desktop/src-tauri/src/cli_command_record.rs
@@ -548,6 +548,8 @@ fn replace(from: &Path, to: &Path) -> Result<()> {
io(fs::rename(from, to))
}
pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Result<()> {
+ #[cfg(unix)]
+ let _executable_write = (mode & 0o111 != 0).then(crate::process_spawn::executable_write);
let parent = path.parent().ok_or("unsafe-file")?;
check(parent, true)?;
rc_acl_check(path)?;
@@ -593,6 +595,8 @@ pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Re
if read_bytes(path)?.as_deref() != before {
return Err("concurrent-edit".into());
}
+ // Publish only after releasing our writer; a terminal may execute the new shim immediately.
+ drop(f);
replace(&tmp, path)?;
#[cfg(unix)]
io(fs::File::open(parent).and_then(|f| f.sync_all()))?;
@@ -1145,6 +1149,40 @@ impl Store {
#[cfg(test)]
pub(crate) mod tests {
use super::*;
+ #[cfg(unix)]
+ use crate::process_spawn::with_spawn;
+ #[cfg(unix)]
+ #[test]
+ fn executable_writes_wait_for_process_spawns_but_record_writes_do_not() {
+ use std::{sync::mpsc, thread, time::Duration};
+ let t = Temp::new();
+ let path = t.0.join("shim");
+ let (started_tx, started_rx) = mpsc::channel();
+ let (done_tx, done_rx) = mpsc::channel();
+ let (writer, early_completion) = with_spawn(|| {
+ // Non-executable record writes must still overlap the process-spawn stress test.
+ atomic(&t.0.join("record"), None, b"record", 0o600).unwrap();
+ let path = path.clone();
+ let writer = thread::spawn(move || {
+ started_tx.send(()).unwrap();
+ let result = atomic(&path, None, b"#!/bin/sh\nexit 0\n", 0o700);
+ done_tx.send(()).unwrap();
+ result
+ });
+ started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
+ let early_completion = done_rx.recv_timeout(Duration::from_millis(100));
+ (writer, early_completion)
+ });
+ if early_completion.is_err() {
+ done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
+ }
+ writer.join().unwrap().unwrap();
+ assert!(matches!(
+ early_completion,
+ Err(mpsc::RecvTimeoutError::Timeout)
+ ));
+ assert_eq!(fs::read(path).unwrap(), b"#!/bin/sh\nexit 0\n");
+ }
pub(crate) struct Temp(pub PathBuf);
impl Temp {
pub(crate) fn new() -> Self {
@@ -1554,13 +1592,13 @@ pub(crate) mod tests {
command.arg("-N");
}
assert!(
- command
+ with_spawn(|| command
.arg(path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
- .status()
- .unwrap()
- .success(),
+ .status())
+ .unwrap()
+ .success(),
"acl-command-failed"
);
}
@@ -1631,12 +1669,14 @@ pub(crate) mod tests {
fn linux_acl_inheritance_clear_and_existing_child_refusal() {
let t = Temp::new();
let set = |path: &Path, spec: &str| {
- std::process::Command::new("setfacl")
- .args(["-m", spec])
- .arg(path)
- .stdout(std::process::Stdio::null())
- .stderr(std::process::Stdio::null())
- .status()
+ with_spawn(|| {
+ std::process::Command::new("setfacl")
+ .args(["-m", spec])
+ .arg(path)
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .status()
+ })
};
let result = set(&t.0, "d:u:65534:rwx");
if result
@@ -1906,7 +1946,9 @@ pub(crate) mod tests {
ready.wait();
let mut spawns = 0;
while !stop.load(Ordering::Relaxed) {
- assert!(Command::new(TRUE).status().unwrap().success());
+ assert!(with_spawn(|| Command::new(TRUE).status())
+ .unwrap()
+ .success());
spawns += 1;
}
spawns
diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs
index 8950c338638..9c3d0040881 100644
--- a/desktop/src-tauri/src/exit.rs
+++ b/desktop/src-tauri/src/exit.rs
@@ -535,7 +535,7 @@ async fn finish_and_exit_after(app: &AppHandle, reason: ExitReason) {
match (reason, verdict) {
(ExitReason::UserQuit, _) => app.exit(0),
(ExitReason::CoordinatedRestart, DrainVerdict::Drained) => {
- app.restart();
+ crate::process_spawn::with_spawn(|| app.restart());
}
(ExitReason::CoordinatedRestart, _) => {
crate::logging::log_once("the update restart was refused", verdict.describe());
@@ -574,7 +574,7 @@ pub async fn prepare_restart(app: &AppHandle) -> RestartReadiness {
/// Come back, once the installer has finished and returned.
pub fn complete_restart(app: &AppHandle) -> ! {
- app.restart()
+ crate::process_spawn::with_spawn(|| app.restart())
}
/// Stop the runtime this app owns and confirm it is gone.
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index d4e3dec7e32..255954ed767 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -46,6 +46,7 @@ mod tray_login;
#[allow(dead_code)]
#[path = "popup.rs"]
mod popup_portable_test;
+mod process_spawn;
mod proxy;
mod resolve;
mod runtime_stop;
@@ -373,6 +374,9 @@ pub fn run() {
.inner_size(1100.0, 720.0)
.visible(false)
.user_agent(&window::webview_user_agent())
+ // Keep dashboard HTML drag/drop (including chat panes) in the webview;
+ // Tauri's native drop handler intercepts it on Windows.
+ .disable_drag_drop_handler()
// Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on
// macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted
// to the loopback dashboard by `capabilities/dashboard-zoom.json`.
diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs
index 43ea2ae71e2..4adf3f78550 100644
--- a/desktop/src-tauri/src/login_registration.rs
+++ b/desktop/src-tauri/src/login_registration.rs
@@ -527,13 +527,16 @@ mod command {
pub(super) fn run(program: &str, args: &[&str], timeout: Duration) -> Result {
let slot = Slot::take().ok_or(RunError::Failed)?;
let deadline = Instant::now() + timeout;
- let mut child = Command::new(program)
- .args(args)
- .stdin(Stdio::null())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()
- .map_err(|_| RunError::Failed)?;
+ let spawn = || {
+ Command::new(program)
+ .args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ };
+ let child = crate::process_spawn::with_spawn(spawn);
+ let mut child = child.map_err(|_| RunError::Failed)?;
let readers = [drain(child.stdout.take()), drain(child.stderr.take())];
let status = loop {
match child.try_wait() {
@@ -579,6 +582,29 @@ mod command {
#[test]
fn a_command_past_its_deadline_holds_the_only_slot_until_it_ends() {
+ // Measure command deadlines in a fresh process: the parent's executable-fixture
+ // writers may hold the test spawn lock longer than this test's timing budget.
+ const CHILD: &str = "OCX_LOGIN_COMMAND_DEADLINE_CHILD";
+ if std::env::var_os(CHILD).is_none() {
+ let output = crate::process_spawn::with_spawn(|| {
+ Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+ "login_registration::command::tests::a_command_past_its_deadline_holds_the_only_slot_until_it_ends",
+ "--nocapture",
+ ])
+ .env(CHILD, "1")
+ .output()
+ })
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
+ return;
+ }
let started = Instant::now();
assert_eq!(
run("/bin/sleep", &["1"], Duration::from_millis(50)),
diff --git a/desktop/src-tauri/src/process_spawn.rs b/desktop/src-tauri/src/process_spawn.rs
new file mode 100644
index 00000000000..51cdcd9936e
--- /dev/null
+++ b/desktop/src-tauri/src/process_spawn.rs
@@ -0,0 +1,203 @@
+//! Keep Unix forks from inheriting a writable descriptor for a soon-to-be-published executable.
+//! CLOEXEC only closes it at exec; closing the parent's copy does not close a child's copy.
+
+// A queued writer must not block another spawner: a plugin launcher can wait for the main
+// thread, where a second launcher may run. std::RwLock may prefer the queued writer and deadlock
+// those two readers. Use reader preference; only an active writer excludes process creation.
+#[cfg(unix)]
+static EXECUTABLE_IO: Gate = Gate::new();
+
+#[cfg(unix)]
+struct Gate {
+ access: std::sync::Mutex,
+ changed: std::sync::Condvar,
+}
+
+#[cfg(unix)]
+struct Access {
+ spawners: usize,
+ writing: bool,
+}
+
+#[cfg(unix)]
+struct Guard<'a> {
+ gate: &'a Gate,
+ writing: bool,
+}
+
+#[cfg(unix)]
+impl Gate {
+ const fn new() -> Self {
+ Self {
+ access: std::sync::Mutex::new(Access {
+ spawners: 0,
+ writing: false,
+ }),
+ changed: std::sync::Condvar::new(),
+ }
+ }
+
+ fn access(&self) -> std::sync::MutexGuard<'_, Access> {
+ self.access
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+
+ fn acquire(&self, writing: bool) -> Guard<'_> {
+ let mut access = self
+ .changed
+ .wait_while(self.access(), |s| s.writing || (writing && s.spawners != 0))
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ if writing {
+ access.writing = true;
+ } else {
+ access.spawners += 1;
+ }
+ Guard {
+ gate: self,
+ writing,
+ }
+ }
+}
+
+#[cfg(unix)]
+impl Drop for Guard<'_> {
+ fn drop(&mut self) {
+ let mut access = self.gate.access();
+ if self.writing {
+ access.writing = false;
+ } else {
+ access.spawners -= 1;
+ }
+ self.gate.changed.notify_all();
+ }
+}
+
+#[cfg(unix)]
+pub(crate) fn executable_write() -> impl Drop {
+ EXECUTABLE_IO.acquire(true)
+}
+
+/// Cover process creation through exec, not the subsequent wait for the child's exit.
+/// Never request an executable writer from a spawner, or call this from a writer/pre_exec hook.
+pub(crate) fn with_spawn(spawn: impl FnOnce() -> T) -> T {
+ #[cfg(unix)]
+ let _guard = EXECUTABLE_IO.acquire(false);
+ spawn()
+}
+
+/// The pinned shell plugin's output future spawns synchronously when polled. Guard each poll
+/// so creation is covered even for a lazy future; release before Pending, never across await.
+pub(crate) async fn with_spawn_async(future: F) -> F::Output {
+ let mut future = std::pin::pin!(future);
+ std::future::poll_fn(|cx| with_spawn(|| future.as_mut().poll(cx))).await
+}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use super::*;
+ use std::{
+ future::{poll_fn, Future},
+ sync::{mpsc, Arc},
+ task::{Context, Poll, Waker},
+ time::Duration,
+ };
+
+ #[test]
+ fn a_spawn_waits_for_the_writer_and_releases_before_child_exit() {
+ let writer = executable_write();
+ let (started_tx, started_rx) = mpsc::channel();
+ let (spawned_tx, spawned_rx) = mpsc::channel();
+ let spawner = std::thread::spawn(move || {
+ started_tx.send(()).unwrap();
+ let child = with_spawn(|| {
+ std::process::Command::new("/bin/cat")
+ .stdin(std::process::Stdio::piped())
+ .spawn()
+ });
+ spawned_tx.send(child).unwrap();
+ });
+ started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
+ let early = spawned_rx.recv_timeout(Duration::from_millis(100));
+ drop(writer);
+ let mut child = match early {
+ Err(mpsc::RecvTimeoutError::Timeout) => spawned_rx
+ .recv_timeout(Duration::from_secs(5))
+ .unwrap()
+ .unwrap(),
+ Ok(Ok(mut child)) => {
+ drop(child.stdin.take());
+ let _ = child.wait();
+ panic!("spawn overlapped an executable writer");
+ }
+ _ => panic!("spawn failed before the writer was released"),
+ };
+ spawner.join().unwrap();
+ // cat is still waiting for EOF, but executable publication must already be unblocked.
+ let writer = executable_write();
+ assert!(child.try_wait().unwrap().is_none());
+ drop(writer);
+ drop(child.stdin.take());
+ assert!(child.wait().unwrap().success());
+ }
+
+ #[test]
+ fn queued_writer_does_not_block_a_launchers_main_thread_callback() {
+ // An independent instance of the production gate keeps unrelated fixture/spawn stress
+ // from consuming this scheduling test's deadline.
+ let gate = Arc::new(Gate::new());
+ let launcher = gate.acquire(false);
+ let writer_gate = gate.clone();
+ let (started_tx, started_rx) = mpsc::channel();
+ let (done_tx, done_rx) = mpsc::channel();
+ let writer = std::thread::spawn(move || {
+ started_tx.send(()).unwrap();
+ let _writer = writer_gate.acquire(true);
+ done_tx.send(()).unwrap();
+ });
+ started_rx.recv_timeout(Duration::from_secs(5)).unwrap();
+ assert!(matches!(
+ done_rx.recv_timeout(Duration::from_millis(100)),
+ Err(mpsc::RecvTimeoutError::Timeout)
+ ));
+ // Model an install helper waiting for a main-thread callback that launches a URL.
+ let callback = gate.acquire(false);
+ assert_eq!(gate.access().spawners, 2);
+ drop(callback);
+ drop(launcher);
+ done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
+ writer.join().unwrap();
+ }
+
+ #[test]
+ fn async_spawn_is_guarded_on_poll_without_holding_a_lock_across_pending() {
+ let mut polled = false;
+ let future = with_spawn_async(poll_fn(|_| {
+ assert!(
+ EXECUTABLE_IO.access().spawners > 0,
+ "async spawn poll must hold shared access"
+ );
+ if polled {
+ Poll::Ready(42)
+ } else {
+ polled = true;
+ Poll::Pending
+ }
+ }));
+ fn assert_send(_: &impl Send) {}
+ assert_send(&future);
+ let mut future = std::pin::pin!(future);
+ let mut cx = Context::from_waker(Waker::noop());
+ assert_eq!(future.as_mut().poll(&mut cx), Poll::Pending);
+ // A paused output reader cannot stop a reconcile from writing its shim.
+ drop(executable_write());
+ assert_eq!(future.as_mut().poll(&mut cx), Poll::Ready(42));
+ }
+
+ #[test]
+ fn failed_spawns_release_the_guard() {
+ let result = with_spawn(|| std::process::Command::new("/ocx-missing-executable").spawn());
+ assert!(result.is_err());
+ drop(executable_write());
+ }
+}
diff --git a/desktop/src-tauri/src/provider_icons.rs b/desktop/src-tauri/src/provider_icons.rs
index 2799453c795..515d9b9f643 100644
--- a/desktop/src-tauri/src/provider_icons.rs
+++ b/desktop/src-tauri/src/provider_icons.rs
@@ -37,6 +37,8 @@ const ALIASES: &[(&str, &str)] = &[
("commandcode", "commandcode-color.svg"),
("cursor", "cursor-color.svg"),
("deepseek", "deepseek-color.svg"),
+ ("dsh-account", "deepseek-color.svg"),
+ ("workbuddy-ai", "workbuddy-color.svg"),
("devin", "devin.svg"),
("firepass", "firepass-color.svg"),
("fireworks", "fireworks-color.svg"),
@@ -162,6 +164,7 @@ fn svg(file: &str) -> Option<&'static str> {
"cursor-color.svg" => svg!("cursor-color.svg"),
"deepinfra.svg" => svg!("deepinfra.svg"),
"deepseek-color.svg" => svg!("deepseek-color.svg"),
+ "workbuddy-color.svg" => svg!("workbuddy-color.svg"),
"devin.svg" => svg!("devin.svg"),
"digitalocean.svg" => svg!("digitalocean.svg"),
"featherless.svg" => svg!("featherless.svg"),
diff --git a/desktop/src-tauri/src/resolve.rs b/desktop/src-tauri/src/resolve.rs
index 1f9ddcfdda0..7e590e53189 100644
--- a/desktop/src-tauri/src/resolve.rs
+++ b/desktop/src-tauri/src/resolve.rs
@@ -304,7 +304,12 @@ pub async fn run(app: &AppHandle, deadline: Instant) -> Resolution {
return Resolution::Unknown(format!("the bundled CLI could not be started ({error})"))
}
};
- match timeout_at(deadline, command.output()).await {
+ match timeout_at(
+ deadline,
+ crate::process_spawn::with_spawn_async(command.output()),
+ )
+ .await
+ {
Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr),
Ok(Err(error)) => {
Resolution::Unknown(format!("the bundled CLI could not be run ({error})"))
diff --git a/desktop/src-tauri/src/runtime_stop.rs b/desktop/src-tauri/src/runtime_stop.rs
index c92528a5f4b..664da927067 100644
--- a/desktop/src-tauri/src/runtime_stop.rs
+++ b/desktop/src-tauri/src/runtime_stop.rs
@@ -270,7 +270,12 @@ async fn run_with_args(app: &AppHandle, deadline: Instant, argv: Vec) ->
return StopResult::Failed(format!("the bundled CLI could not be started ({error})"))
}
};
- match timeout_at(deadline, command.output()).await {
+ match timeout_at(
+ deadline,
+ crate::process_spawn::with_spawn_async(command.output()),
+ )
+ .await
+ {
Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr),
Ok(Err(error)) => StopResult::Failed(format!("the bundled CLI could not be run ({error})")),
Err(_) => {
diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs
index 71f16db8f0a..e601498fe0f 100644
--- a/desktop/src-tauri/src/sidecar.rs
+++ b/desktop/src-tauri/src/sidecar.rs
@@ -205,7 +205,8 @@ pub fn start(
.args(["start", "--port", &endpoint.port.to_string()])
.env("OPENCODEX_GUI_DIST", gui_dist)
.env(SUPERVISED_ENV, "1");
- let (events, child) = command.spawn().map_err(|error| error.to_string())?;
+ let (events, child) =
+ crate::process_spawn::with_spawn(|| command.spawn()).map_err(|error| error.to_string())?;
let watch = watch.for_child(child.pid());
watch.follow(events);
Ok(child)
diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs
index c702bc7c240..ec76efdf4a7 100644
--- a/desktop/src-tauri/src/tray.rs
+++ b/desktop/src-tauri/src/tray.rs
@@ -224,9 +224,10 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> {
else {
return;
};
- let _ = app
- .opener()
- .open_url(format!("{}#/usage", endpoint.url("/")), None::);
+ let _ = crate::process_spawn::with_spawn(|| {
+ app.opener()
+ .open_url(format!("{}#/usage", endpoint.url("/")), None::)
+ });
}
"start-at-login" => {
#[cfg(target_os = "macos")]
diff --git a/desktop/src-tauri/src/tray_login.rs b/desktop/src-tauri/src/tray_login.rs
index 5126c1f44e7..3a1948ed855 100644
--- a/desktop/src-tauri/src/tray_login.rs
+++ b/desktop/src-tauri/src/tray_login.rs
@@ -151,10 +151,11 @@ fn perform(app: &AppHandle, action: ClickAction) {
.map_err(|error| error.to_string()),
ClickAction::Enable => crate::login_autostart::enable(app),
// launchd keeps its override whatever the plist says; the person turns it back on.
- ClickAction::OpenLoginItems => app
- .opener()
- .open_url(login_registration::LOGIN_ITEMS_SETTINGS, None::)
- .map_err(|error| error.to_string()),
+ ClickAction::OpenLoginItems => crate::process_spawn::with_spawn(|| {
+ app.opener()
+ .open_url(login_registration::LOGIN_ITEMS_SETTINGS, None::)
+ })
+ .map_err(|error| error.to_string()),
ClickAction::Nothing => Ok(()),
};
if let Err(error) = result {
diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs
index bcaf3349533..cc02ae9e9fc 100644
--- a/desktop/src-tauri/src/updater.rs
+++ b/desktop/src-tauri/src/updater.rs
@@ -410,7 +410,7 @@ pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> {
));
}
- if let Err(error) = update.install(package) {
+ if let Err(error) = crate::process_spawn::with_spawn(|| update.install(package)) {
// The installer returned a failure. The runtime was stopped for an install that did not
// happen, so the app brings one back instead of sitting drained.
recover_after_failed_install(app);
diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs
index 5c69ae17fe7..7b71614c64a 100644
--- a/desktop/src-tauri/src/window.rs
+++ b/desktop/src-tauri/src/window.rs
@@ -67,7 +67,9 @@ fn opens_in_default_browser(url: &Url) -> bool {
pub fn open_in_default_browser(url: &Url) {
if opens_in_default_browser(url) {
- let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>);
+ let _ = crate::process_spawn::with_spawn(|| {
+ tauri_plugin_opener::open_url(url.as_str(), None::<&str>)
+ });
}
}
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index c1bb2b933d3..1a08a75500d 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCodex",
- "version": "2.86.0",
+ "version": "2.93.0-preview.20261027",
"identifier": "com.opencodex.desktop",
"build": {
"frontendDist": "../ui",
diff --git a/devlog/_fin/261015_argon_thought_boundary/000_plan.md b/devlog/_fin/261015_argon_thought_boundary/000_plan.md
new file mode 100644
index 00000000000..7497e8bb94d
--- /dev/null
+++ b/devlog/_fin/261015_argon_thought_boundary/000_plan.md
@@ -0,0 +1,97 @@
+# Gemini Argon output separation
+
+A report of reasoning-looking assistant output prompted comparison of the Google response
+classifier with CLIProxyAPI and cc-switch. The inspected classifier already separates marked
+thoughts. This unit verifies that separation through every client protocol using synthetic
+markers, and documents the boundary without claiming the original observation is reproduced.
+
+## Loop specification
+
+- Archetype: satisfy-spec; one C3 verification/documentation work phase.
+- Trigger/goal: investigate Argon output separation and deliver an evidence-backed PR to dev.
+- In scope: synthetic Argon integration tests, their layout registration, and current Google docs.
+- Non-goals: heuristic text censorship, runtime changes without a demonstrated defect, private
+ session transcripts, user configuration changes, deployment, other worktrees, native PR stacks.
+- Verifier: new protocol test plus adjacent Google/direct-encoder tests; typecheck, test layout,
+ file-size, privacy and structure gates; docs build; required CI at the PR head.
+- Stop: coverage/docs merged with passing relevant checks, or a concrete blocker recorded.
+- Memory artifact: this unit and local `.tmp/argon-protocol/` evidence; no memory-store writes.
+- Outcomes: DONE for the verification PR; runtime NOOP if marked thoughts remain separated.
+ Do not claim the reported live symptom fixed merely because synthetic tests pass.
+- Escalation: a demonstrated defect changes the plan before patching; unavailable live evidence
+ remains an explicit limitation. Existing user authority includes push/PR/merge, no release.
+- Bounds: none stated; host limits apply. Tools: local Bun, read-only public Aside/GitHub research,
+ repository-scoped GitHub publication. No production credentials are needed for tests.
+
+## Evidence and hypotheses
+
+- H1: the adapter converts marked thoughts to answer text. Falsifier: mixed marked/unmarked
+ synthetic parts preserve separate channels in streaming and buffered responses.
+- H2: a client converter collapses channels. Falsifier: actual Responses, Chat and Messages
+ handlers keep thought markers out of ordinary answer fields with direct routing on and off.
+- H3: upstream ordinary text or client rendering explains the observation. Static tests cannot
+ establish this; never reclassify prose by its wording or infer a root cause from test success.
+
+Source anchors: `src/adapters/google.ts:716`, `:989`, `:1378`, `:1654`;
+`src/protocols/encoders/chat.ts:133`; `src/protocols/encoders/messages.ts:135`.
+CLIProxyAPI `5fc580b1d72d87a7bcfe5cfb6f479c1aea5625b3` maps `thought` to
+`reasoning_content`; cc-switch `76f99d41a66e24a5ed2e8982bc494399f306bfe6` filters
+marked thoughts from visible text. Public Aside reads succeeded with complete=true,
+truncated=false, contentVerified=true for Google thinking docs and CLIProxyAPI #6607.
+Those docs distinguish thought signatures from ordinary text visibility; #6607 is a buffering
+decision, not evidence of text leakage. Argon upstream behavior is not independently certified.
+
+Baseline: `bun test tests/adapters/google/google-hardening.test.ts
+tests/adapters/google/google-adapter.test.ts tests/adapters/google/gemini-4-argon-antigravity.test.ts
+tests/responses/responses-show-thinking-summary.test.ts`: exit 0, 158 pass at 7640a942b3.
+Work then refreshed to dev 0b13396af4; adjacent checks will run on the final patch.
+Typecheck's `include: ["src"]` does not validate new tests; Bun execution does.
+
+## Consultation
+
+Architect proposal and reflection are retained in the local coordinator evidence.
+- ARGON-SEP-01/02 accepted: keep one classifier and existing output channels.
+- ARGON-SEP-03 accepted: provenance is not proof of observed upstream behavior.
+- ARGON-SEP-04 amended: add handler-level coverage and current contract clarification.
+ Preserve historical ADR-0055 per `structure/AGENTS.md`; do not rewrite the old decision.
+- Same-architect reflection: ALIGNED against `010_verification.md`; require assertions on both
+ deltas and final accumulated payloads, and correct tool signature attachment where exposed.
+ Independent plan audit: PASS. Refreshed-base adjacent direct/encoder baseline: 131 pass, 0 fail.
+
+No new runtime fields or enums. No new runtime enforcement: tests are regression checks only;
+they can be bypassed by skipping tests, and cannot certify external client rendering.
+
+## Verification result
+
+The investigation's runtime outcome is NOOP: the tested marked thoughts stay out of ordinary
+answer fields. New coverage adds 75 cases. Deliberately changing the classifier to emit marked
+thoughts as text fails all 75; byte-for-byte restoration returns all 75 to green. No classifier
+mutation is part of the patch. The live observation's cause remains unresolved.
+
+Final focused validation on the repository-pinned Bun 1.4.3: 364 pass / 0 fail across the new
+matrix and eight adjacent Google/direct-protocol files; layout/tooling/size: 36 pass / 0 fail.
+`bun run test:changed` selects the new file only and passes all 75 cases. `bun run typecheck`,
+`bun run structure:check`, and `bun run privacy:scan` pass. `cd docs-site && bun run build`
+builds 577 pages and checks 81,202 links. Raw logs stay in local `.tmp/argon-protocol/`.
+An initial layout run under the shell's Bun 1.4.0 failed the pinned-runner check; using the
+already-installed Bun 1.4.3 resolved it without source changes.
+
+Initial fixture expectations incorrectly treated a disabled provider display default as a
+client opt-out and omitted the Messages thinking opt-in. The current tests explicitly request
+the protocol's display state, matching `core-normalize.ts` and `claude/inbound.ts`; they still
+force upstream marked thoughts when hidden to test output suppression, not just request flags.
+
+The full 2,330-file suite is not run locally: this tests/docs-only change has bounded integration
+coverage and shares the host with concurrent coding workloads. Broader coverage remains with
+the PR's required CI. Neither this exception nor synthetic tests certify external harness UI.
+
+Fresh independent final review: PASS, no actionable defects. The fresh reader identified the
+result as synthetic regression coverage, with the live symptom explicitly unverified. No prose
+change was requested. The investigation is closed with that limitation; PR integration is
+tracked by the coordinator. Runtime source is unchanged.
+
+Hosted review follow-up: restore all fixture globals in finally if spend-lease release throws;
+add independent client-only opt-outs with provider opt-in still true for all three protocols.
+A Chat opt-out mutation fails 18 cases; a Messages display-omitted mutation fails 6. All
+production files were restored byte-for-byte. These probes confirm the added cases detect
+the independent client preference, rather than passing only because the provider default is off.
diff --git a/devlog/_fin/261015_argon_thought_boundary/010_verification.md b/devlog/_fin/261015_argon_thought_boundary/010_verification.md
new file mode 100644
index 00000000000..8f328c5c6e7
--- /dev/null
+++ b/devlog/_fin/261015_argon_thought_boundary/010_verification.md
@@ -0,0 +1,33 @@
+# One phase: verify the Argon output boundary
+
+Depends on current Google adapter and existing client handlers; no runtime design changes.
+
+1. NEW `tests/responses/protocol-argon-thought-boundary.test.ts`: isolated homes and synthetic
+ credentials, mock Google transport only, invoke real Responses/Chat/Messages handlers.
+ Generate `thought: true` marker parts, ordinary text (absent and false markers), signature-only
+ parts, an unmarked signed text part, and an optional tool call with synthetic signature.
+ Exercise buffered and SSE, coalesced and split SSE frames, and direct/bridge Chat/Messages.
+ Assert exact ordinary answer text in both deltas and accumulated final payloads,
+ thought marker only in reasoning/thinking fields, no
+ signature-only text, preserved tool name/args and call-bound signature where the client wire
+ exposes it, successful terminal. For independent client-only opt-outs (Responses/Chat summary none and Messages display omitted),
+ force upstream thought parts anyway and require suppression while answer and tool survive.
+ Inspect protocol trace so the direct/bridge cases prove the intended lane executed.
+2. MODIFY `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`:
+ register the new test in responses, using existing shard conventions.
+3. MODIFY `structure/providers/google.md`: describe signature-independent text classification
+ and reference the new test; preserve the historical decision record.
+4. MODIFY `docs-site/src/content/docs/guides/providers.md`: clarify Gemini thought summaries,
+ client field separation, and that unmarked ordinary text is not censored by wording.
+5. Run new tests and adjacent Google/direct-lane/encoder tests. Validate the new oracle by a
+ temporary local classifier mutation (marked thoughts become text), require failure, restore
+ bytes, then require green. No mutation may be committed.
+6. Run typecheck, test-layout/tooling, file-size, privacy, structure and docs build. Full suite
+ default applies; if concurrent workload makes it disproportionate for tests/docs only, record
+ exact focused scope and leave remaining platform/integration coverage to required CI.
+7. Fresh independent review, fix valid findings, create ordinary PR to dev using template.
+ Inspect exact-head required CI and maintainer-integration policy before the authorized merge.
+
+Acceptance is protocol correctness for the synthetic fixtures and prevention of regression.
+The original live symptom and other harnesses' rendering remain unverified; the PR and final
+report must say so. A newly demonstrated runtime defect requires a plan amendment and audit.
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/000_plan.md b/devlog/_plan/260928_remote_thread_provider_policy/000_plan.md
new file mode 100644
index 00000000000..530fa2bd582
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/000_plan.md
@@ -0,0 +1,32 @@
+# 5848: native remote-list provider policy before a backend relay
+
+Status: **proposal and executable specifications only; no production fix**.
+Date: 2026-09-28.
+
+Related: [#5848](https://github.com/lidge-jun/opencodex/issues/5848),
+[duplicate #5906](https://github.com/lidge-jun/opencodex/issues/5906),
+[upstream #48358](https://github.com/openai/codex/issues/48358).
+
+## Proposed decision
+
+Prefer a narrowly scoped, operator-opt-in policy in **native Codex app-server**
+for remote `thread/list` requests that do not supply a provider array. Keep the
+normal default-provider behavior, all existing authorization, and history intact.
+The mobile client's explicit all-provider request remains the simplest upstream
+client correction. Compare both with the previously tested local backend relay;
+do not turn that experimental relay into an installed feature in this PR.
+
+The current OpenCodex runtime and [ADR-5848](../../../structure/decisions/ADR-5848-provider-table-remote-history-visibility.md)
+remain unchanged. This proposal does **not** close #5848.
+
+## Work remaining
+
+- [x] Identify the native remote-connection boundary and existing provider predicate.
+- [x] Specify precedence and test the native-policy proposal offline.
+- [x] Retain and rerun the isolated loopback relay alternative.
+- [ ] Obtain upstream agreement on the native configuration/API contract.
+- [ ] Implement config/schema, trusted-origin plumbing, and Rust regressions upstream.
+- [ ] Verify native pagination, managed policy, account changes, and actual mobile resume.
+- [ ] Establish released-version/capability detection before adding any OpenCodex toggle.
+
+See [design](010_design.md) and [verification](020_verification.md).
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/010_design.md b/devlog/_plan/260928_remote_thread_provider_policy/010_design.md
new file mode 100644
index 00000000000..a0f8ca22474
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/010_design.md
@@ -0,0 +1,134 @@
+# Design comparison and proposed native contract
+
+This is an RFC, not an accepted architecture change or a released setting.
+`probes/native_policy.py` is an executable specification, **not native Rust code**.
+No probe is imported by OpenCodex or registered in its runtime/CLI/test suite.
+
+## Evidence and scope
+
+The source baseline is OpenAI Codex
+`1cc7e2361237ce7244430ee1d581c77f95c57ac8` and OpenCodex `dev`
+`eb7f0f0970c2298f8b2d66d170c4d4be869f301b`.
+
+- [Native list predicate](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server/src/request_processors/thread_processor.rs#L5456-L5530): explicit nonempty arrays filter to those ids; `[]` removes the provider predicate; omission defaults to the configured provider, except for related-thread queries.
+- [Remote connection origin](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs): native remote sessions open with `ConnectionOrigin::RemoteControl`; messages become native transport events.
+- [Original rationale, #5658](https://github.com/openai/codex/pull/5658): provider annotations/filtering address cross-provider resume/decryption failures. Visibility is not proof that a conversation is safe to resume under a different provider.
+- [OpenCodex mitigation, #6007](https://github.com/lidge-jun/opencodex/pull/6007): warnings and documentation without rewriting native history or intercepting native RPC.
+
+## Alternatives
+
+| Approach | Can leave official mobile app unchanged? | Additional ownership | Recommendation |
+| --- | --- | --- | --- |
+| Mobile explicitly sends `modelProviders: []` (or a chosen list) | No | Client's list behavior and resume UX | Simplest client correction; continue upstream tracking. |
+| Native, opt-in, remote-only default list policy | Yes, once a compatible native build ships | Native config and request policy, no extra service | **Preferred implementation proposal** when the host needs explicit control. |
+| Local relay selected through `chatgpt_base_url` | Potentially; live service not verified | Shared backend traffic, token forwarding, connection lifecycle | Research fallback, not a default or currently supported remedy. |
+| Globally change omitted filter to all providers | Yes | Changes behavior for every caller | Do not use: loses the existing default isolation behavior. |
+| Rewrite `openai` history tags to `opencodex` | Yes in some observations | Native SQLite/rollout state | Do not use: violates the retained history-writer boundary. |
+
+The native proposal minimizes new connection and credential handling; this is an
+engineering recommendation, not evidence of upstream acceptance or deployment.
+Neither upstream route can be delivered by modifying OpenCodex's `/v1` inference
+proxy alone. This PR publishes the comparison rather than disguising a probe as a fix.
+
+## Proposed native setting (name subject to upstream review)
+
+**Illustrative only; do not add this to current Codex/OpenCodex configuration.**
+
+```toml
+[remote_control]
+thread_list_model_providers = ["openai", "opencodex"]
+```
+
+Absent setting means opt-out. An explicitly empty array means all providers;
+a nonempty array means exactly those ids. Do not automatically infer equivalence
+from provider names, model names, or a shared URL. The setting changes listing,
+not authentication, permission checks, routing, compaction, or resume semantics.
+An operator who only needs two provider ids need not opt into every provider.
+
+### Precedence
+
+1. Existing authentication and managed remote-control policy still run first.
+2. A request's explicit provider **array**, including `[]`, always wins.
+3. With no array, parent/ancestor queries retain their current no-default-filter behavior.
+4. Only a server-identified remote connection may use the operator's configured policy.
+5. With no policy, and for every non-remote connection, preserve the existing default.
+
+Typed Rust `Option>` treats omission and JSON `null` alike. This
+proposal deliberately applies its default to both. The raw relay probe preserves
+all present JSON keys, including null; that difference is documented and must not
+be mistaken for identical behavior between the two alternatives.
+
+Use the native `ConnectionOrigin` attached to the connection. Never infer remote
+origin from clientInfo.name, a user-agent, JSON fields, or a caller-supplied header.
+The Python enum tests only model this trusted input; they do not prove authentication.
+
+### Native implementation boundary
+
+The upstream change must add and validate the config contract, snapshot it with
+the native app-server's effective configuration, carry the trusted connection
+origin to the `thread/list` handler, and resolve the provider predicate before
+calling the existing store pagination. A new RPC layer is unnecessary.
+
+Conceptual selection, **not a drop-in patch**:
+
+```text
+if request supplies provider array: use that array
+else if parent/ancestor query: use existing related-thread behavior
+else if trusted origin is RemoteControl and operator policy is configured:
+ use the configured array
+else: use existing configured-default behavior
+```
+
+Do not add a global exception to `list_threads_common` for all callers. Do not
+change `thread/start`, `thread/resume`, `turn/start`, stored provider metadata,
+or request error handling. Keep source/cwd/archive/project filters and ordering.
+The new configuration's trust/precedence should prevent project content from
+silently opting an operator into a broader default; upstream must choose and test
+the appropriate configuration layers and any managed restrictions.
+
+Hold one effective policy throughout a listing/pagination sequence. A changed
+policy requires a fresh listing cursor; do not combine pages obtained under
+incompatible filters. Native tests must pin behavior with actual cursor semantics.
+The fixture tests here use integer pagination, not native opaque cursors.
+
+## Local relay alternative: what was and was not shown
+
+[Native startup](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server/src/lib.rs)
+uses `config.chatgpt_base_url` for remote control. The
+[URL protocol](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs)
+accepts loopback URLs. A host-side relay is therefore a concrete design alternative:
+
+```text
+unchanged mobile <-> existing ChatGPT backend <-> local relay <-> native app-server
+```
+
+Connection establishment starts on the host. Enroll/pair/refresh can be forwarded
+rather than reimplemented. However, `chatgpt_base_url` is shared with
+[authentication configuration](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/core/src/config/auth_keyring.rs)
+and other backend consumers. A WebSocket-only implementation is insufficient.
+[Enrollment persistence](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs)
+also keys state by URL/account/client, so changing the URL can affect enrollment
+selection; whether live re-pairing is required remains unverified.
+
+The retained relay fixture accepts only literal `127.0.0.1` HTTP upstreams and
+synthetic credentials. It is not a production service and cannot be configured
+for ChatGPT. It handles regular and single-chunk frames; multi-chunk messages are
+passed unchanged. Actual protocol-v3 segmentation, native reconnect/ACK/cursors,
+account changes, managed network policy, and full backend compatibility remain
+release gates, not claims supported by these tests. See native
+[WebSocket handling](https://github.com/openai/codex/blob/1cc7e2361237ce7244430ee1d581c77f95c57ac8/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs).
+
+No global base-URL mutation, token-pool integration, production listener,
+configuration migration, history write, or authentication bypass is proposed here.
+
+## Upstream and downstream follow-through
+
+An upstream implementation needs Rust config/schema, origin-scoping, explicit/null/
+related-query, pagination, and managed-policy tests. Keep existing defaults intact.
+Document the omitted/default behavior and regenerate affected schema/TS fixtures.
+
+After an accepted implementation is released, OpenCodex may consider an opt-in
+integration with positive capability/version evidence and precise configuration
+ownership/restoration. Merely writing an unknown TOML key is not a feature test.
+Until then, retain #6007's warnings and keep #5848 open. Do not suppress the warning
+because an experimental setting was written or a fixture passed.
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md
new file mode 100644
index 00000000000..520f602b369
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/020_verification.md
@@ -0,0 +1,65 @@
+# Verification
+
+Status: local evidence for an RFC. The probes are executable specifications that
+run on demand; no CI workflow runs them, and nothing in the OpenCodex build, test,
+or runtime path imports them. All inputs, accounts, tokens, thread ids, and
+database rows are synthetic. No native Codex process, user home, installed
+config, or real service is accessed.
+
+## How to run
+
+From the repository root:
+
+```sh
+cd devlog/_plan/260928_remote_thread_provider_policy/probes
+python3 -m unittest -v test_native_policy test_probe
+```
+
+The socket suite needs aiohttp. Install it into a scratch directory, not the
+repository or a system site-packages:
+
+```sh
+python3 -m pip install --target "$SCRATCH_DIR" aiohttp
+PYTHONPATH="$SCRATCH_DIR" python3 -m unittest -v test_native_policy test_probe test_loopback_bridge
+```
+
+## Inventory
+
+63 tests across three suites:
+
+- 23 tests (`test_native_policy`) specify the proposed native policy: opt-out,
+ trusted-origin scoping, explicit arrays, null semantics, parent/ancestor
+ exceptions, immutable policy, exact ids, UUID thread-id validation, and
+ synthetic paginated fixture reads.
+- 29 raw-frame tests (`test_probe`) cover the relay alternative's
+ ordinary/single-chunk rewrites, byte-identical pass-through, limits, and
+ unsupported inputs.
+- 11 localhost HTTP/WebSocket tests (`test_loopback_bridge`) use a mock host and
+ mock backend, checking two-way traffic, fixture auth/headers, explicit filters,
+ enrollment, unrelated HTTP, fixture endpoint restrictions, and both directions
+ of close-code forwarding.
+
+The first 52 use the Python standard library only.
+
+The synthetic database has 5,200 `openai` rows, one `opencodex` row, and one
+`other` row. Default filtering yields one; the two-id opt-in yields 5,201; all
+providers yields 5,202. Page sizes 1, 37, and 1,000 are exercised. The fixture
+dump hash stays unchanged after reads. This demonstrates the specification, not
+native SQLite schema compatibility or actual mobile results.
+
+## Recorded runs
+
+| Date | Environment | Suites | Result |
+| --- | --- | --- | --- |
+| 2026-09-28 | author, local | native policy + raw frame | 52 passed |
+| 2026-10-13 | Linux, Python 3.12.14, aiohttp 3.14.4 | all three | 63 passed |
+| 2026-10-15 | macOS, Python 3.9.6, aiohttp 3.13.5 | all three | 63 passed |
+
+Each run reflects the probe source at that time; a later edit to a probe needs a
+fresh local run.
+
+## Not executed / not established
+
+- Native Rust implementation, compilation, config/schema generation, or native tests.
+- Actual ChatGPT mobile pairing, full pagination, resume, token renewal, or reconnect.
+- Production multi-segment relay support or management/security review of a relay.
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py
new file mode 100644
index 00000000000..80859ac8395
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/native_policy.py
@@ -0,0 +1,103 @@
+"""Executable specification for a PROPOSED native Codex policy, not an integration.
+
+No socket, authentication, config, history, or rollout access. The real Rust
+implementation must obtain origin from its authenticated server-side connection,
+not from JSON, clientInfo.name, or an HTTP header. This enum only models that input.
+None as the result means no provider predicate, not no results.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from uuid import UUID
+import re
+
+# Rust's Uuid::parse_str accepts exactly four textual shapes - hyphenated, simple,
+# braced-hyphenated and the urn:uuid prefix. Python's UUID() is looser: it tolerates
+# arbitrary hyphen placement inside a 32-hex payload, so wrapper/hyphen variants
+# must be rejected by shape before parsing instead of leaning on the constructor.
+_UUID_ACCEPTED_SHAPES = re.compile(
+ r"(?:"
+ r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
+ r"|[0-9a-fA-F]{32}"
+ r"|\{[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\}"
+ r"|urn:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
+ r")\Z"
+)
+
+
+class ConnectionOrigin(Enum):
+ STDIO = "stdio"
+ WEBSOCKET = "websocket"
+ IN_PROCESS = "in_process"
+ REMOTE_CONTROL = "remote_control"
+
+
+def _validate_ids(value: tuple[str, ...], label: str) -> None:
+ if not isinstance(value, tuple):
+ raise ValueError(f"{label} must be a tuple")
+ if any(not isinstance(item, str) or not item.strip() for item in value):
+ raise ValueError(f"{label} must contain non-empty strings")
+ if len(set(value)) != len(value):
+ raise ValueError(f"{label} must not contain duplicate identifiers")
+
+
+def _validate_thread_id(value: str, label: str) -> None:
+ """Upstream parses ThreadId as a UUID; a non-UUID string must not widen the list."""
+ if not isinstance(value, str) or not _UUID_ACCEPTED_SHAPES.match(value):
+ raise ValueError(f"{label} must be a UUID thread id")
+ try:
+ UUID(value)
+ except (ValueError, AttributeError, TypeError):
+ raise ValueError(f"{label} must be a UUID thread id")
+
+
+@dataclass(frozen=True)
+class RemoteListPolicy:
+ # None: opt-out; (): explicitly all; non-empty: exactly these provider ids.
+ providers: tuple[str, ...] | None = None
+
+ def __post_init__(self) -> None:
+ if self.providers is not None:
+ _validate_ids(self.providers, "providers")
+
+
+def resolve_provider_filter(
+ *,
+ origin: ConnectionOrigin,
+ default_provider: str,
+ requested: tuple[str, ...] | None = None,
+ parent_thread_id: str | None = None,
+ ancestor_thread_id: str | None = None,
+ policy: RemoteListPolicy = RemoteListPolicy(),
+) -> tuple[str, ...] | None:
+ """Model precedence after typed request decoding and existing authorization.
+
+ Rust Option> decodes both omission and JSON null as None. They
+ intentionally have the same semantics in this native-policy proposal. The
+ separate raw-frame relay probe instead preserves every present JSON key.
+ Empty requested tuple is an explicit client request for all providers.
+ The policy applies only to thread/list; callers must not reuse it for resume.
+ """
+ if not isinstance(origin, ConnectionOrigin):
+ raise ValueError("origin must be supplied by the trusted connection context")
+ _validate_ids((default_provider,), "default_provider")
+ for label, tid in (
+ ("parent_thread_id", parent_thread_id),
+ ("ancestor_thread_id", ancestor_thread_id),
+ ):
+ if tid is not None:
+ _validate_thread_id(tid, label)
+ if parent_thread_id is not None and ancestor_thread_id is not None:
+ raise ValueError("parent_thread_id and ancestor_thread_id are mutually exclusive")
+ if requested is not None:
+ # Preserve every typed client array, including duplicate/empty ids.
+ # Native Vec accepts them; this proposal must not add rejection.
+ if not isinstance(requested, tuple) or any(not isinstance(item, str) for item in requested):
+ raise ValueError("requested must model a typed string array")
+ return requested or None
+ if parent_thread_id is not None or ancestor_thread_id is not None:
+ return None
+ if origin is ConnectionOrigin.REMOTE_CONTROL and policy.providers is not None:
+ return policy.providers or None
+ return (default_provider,)
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py
new file mode 100644
index 00000000000..a2622cdf18e
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/remote_list_probe.py
@@ -0,0 +1,160 @@
+"""Offline research probe; NOT an installed OpenCodex feature or production proxy.
+
+Based on openai/codex commit 1cc7e2361237ce7244430ee1d581c77f95c57ac8.
+Only changes omitted thread/list.modelProviders in explicit opt-in mode.
+No network, authentication, filesystem, database, or rollout writes occur here.
+Complete multi-segment messages need a separate bounded streaming reassembler;
+this probe deliberately passes those messages through unchanged.
+"""
+from __future__ import annotations
+
+import base64
+import binascii
+import json
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Policy:
+ enabled: bool = False
+ # An empty tuple explicitly requests all providers, matching modelProviders: [].
+ providers: tuple[str, ...] = ("openai", "opencodex")
+ max_wire_bytes: int = 150 * 1024
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.providers, tuple) or any(
+ not isinstance(p, str) or not p.strip() for p in self.providers
+ ):
+ raise ValueError("providers must be a tuple of non-empty strings")
+ if len(set(self.providers)) != len(self.providers):
+ raise ValueError("duplicate provider identifiers")
+ if self.max_wire_bytes < 64:
+ raise ValueError("max_wire_bytes must be at least 64")
+
+
+@dataclass(frozen=True)
+class Outcome:
+ text: str
+ changed: bool
+ reason: str
+
+
+def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise ValueError("duplicate JSON member")
+ result[key] = value
+ return result
+
+
+def _reject_constant(value: str) -> None:
+ raise ValueError("non-standard JSON constant")
+
+
+def decode(text: str | bytes) -> Any:
+ return json.loads(text, object_pairs_hook=_unique_object,
+ parse_constant=_reject_constant)
+
+
+def encode(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"),
+ allow_nan=False)
+
+
+def _patch_message(message: Any, policy: Policy) -> tuple[bool, str]:
+ if not isinstance(message, dict) or message.get("method") != "thread/list":
+ return False, "not_thread_list"
+ request_id = message.get("id")
+ if isinstance(request_id, bool) or not isinstance(request_id, (int, str)):
+ return False, "not_request"
+ if "result" in message or "error" in message:
+ return False, "ambiguous_request"
+ params = message.get("params", {})
+ if not isinstance(params, dict):
+ return False, "non_object_params"
+ # Preserve explicit client choices, including an explicit null.
+ if "modelProviders" in params:
+ return False, "explicit_filter_preserved"
+ # Native related-thread queries deliberately bypass the omitted-provider default.
+ if params.get("parentThreadId") is not None or params.get("ancestorThreadId") is not None:
+ return False, "relation_query_preserved"
+ params["modelProviders"] = list(policy.providers)
+ message["params"] = params
+ return True, "omitted_filter_patched"
+
+
+def rewrite_backend_frame(text: str, policy: Policy) -> Outcome:
+ """Process ONE backend-to-host text frame. No authentication decision is made.
+
+ The caller must already enforce the authorized connection and user opt-in.
+ Unmodified and unsupported messages are returned byte-for-byte unchanged.
+ """
+ if not isinstance(text, str):
+ raise TypeError("text frame must be str")
+ if not policy.enabled:
+ return Outcome(text, False, "disabled")
+ try:
+ if len(text.encode("utf-8")) > policy.max_wire_bytes:
+ return Outcome(text, False, "input_size_limit")
+ envelope = decode(text)
+ if not isinstance(envelope, dict):
+ return Outcome(text, False, "not_envelope")
+ if not isinstance(envelope.get("client_id"), str) or not envelope["client_id"]:
+ return Outcome(text, False, "missing_client_id")
+ kind = envelope.get("type")
+ if kind == "client_message":
+ changed, reason = _patch_message(envelope.get("message"), policy)
+ elif kind == "client_message_chunk":
+ if type(envelope.get("segment_count")) is not int or type(envelope.get("segment_id")) is not int:
+ return Outcome(text, False, "invalid_segment_index")
+ if envelope["segment_count"] != 1 or envelope["segment_id"] != 0:
+ return Outcome(text, False, "multi_segment_not_implemented")
+ size = envelope.get("message_size_bytes")
+ if type(size) is not int or size < 0 or size > policy.max_wire_bytes:
+ return Outcome(text, False, "invalid_segment_size")
+ payload_b64 = envelope.get("message_chunk_base64")
+ if not isinstance(payload_b64, str):
+ return Outcome(text, False, "invalid_segment_payload")
+ payload = base64.b64decode(payload_b64, validate=True)
+ if len(payload) != size:
+ return Outcome(text, False, "segment_length_mismatch")
+ message = decode(payload)
+ changed, reason = _patch_message(message, policy)
+ if changed:
+ encoded_payload = encode(message).encode("utf-8")
+ envelope["message_size_bytes"] = len(encoded_payload)
+ envelope["message_chunk_base64"] = base64.b64encode(encoded_payload).decode("ascii")
+ else:
+ return Outcome(text, False, "unrelated_envelope")
+ if not changed:
+ return Outcome(text, False, reason)
+ output = encode(envelope)
+ if len(output.encode("utf-8")) > policy.max_wire_bytes:
+ return Outcome(text, False, "output_size_limit")
+ return Outcome(output, True, reason)
+ except (ValueError, TypeError, UnicodeError, binascii.Error, RecursionError):
+ return Outcome(text, False, "invalid_frame_preserved")
+
+
+def make_envelope(message: dict[str, Any], *, chunk: bool = False) -> dict[str, Any]:
+ """Synthetic fixture builder; all identifiers below are test-only."""
+ envelope: dict[str, Any] = {
+ "client_id": "mock-client", "stream_id": "mock-stream",
+ "seq_id": 7, "cursor": "mock-backend-cursor",
+ }
+ if chunk:
+ payload = encode(message).encode("utf-8")
+ envelope.update(type="client_message_chunk", segment_id=0, segment_count=1,
+ message_size_bytes=len(payload),
+ message_chunk_base64=base64.b64encode(payload).decode("ascii"))
+ else:
+ envelope.update(type="client_message", message=message)
+ return envelope
+
+
+def extract_message(envelope: dict[str, Any]) -> dict[str, Any]:
+ if envelope["type"] == "client_message":
+ return envelope["message"]
+ return decode(base64.b64decode(envelope["message_chunk_base64"], validate=True))
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py
new file mode 100644
index 00000000000..2438a4334a6
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_loopback_bridge.py
@@ -0,0 +1,259 @@
+"""Loopback-only mock transport proof. NEVER connects to ChatGPT/OpenAI.
+
+This is a test fixture, not a production proxy. Native Codex is not executed.
+The mock backend validates a hard-coded, non-secret fixture token.
+"""
+from __future__ import annotations
+import asyncio
+import contextlib
+import unittest
+from urllib.parse import urlsplit
+
+import aiohttp
+from aiohttp import web
+from remote_list_probe import Policy, decode, encode, make_envelope, extract_message, rewrite_backend_frame
+
+BASE = "/backend-api"
+WS_PATH = BASE + "/wham/remote/control/server"
+MOCK_AUTH = "Bearer NOT-A-REAL-TOKEN-MOCK-ONLY"
+
+
+def assert_loopback_only(base: str) -> None:
+ url = urlsplit(base)
+ if url.scheme != "http" or url.hostname != "127.0.0.1" or not url.port or url.username or url.password:
+ raise ValueError("this research fixture permits only HTTP at literal 127.0.0.1")
+ if url.query or url.fragment or url.path:
+ raise ValueError("mock base must contain only origin")
+
+
+def selected_headers(headers) -> dict[str, str]:
+ return {key: value for key, value in headers.items()
+ if key.lower() in ("authorization", "chatgpt-account-id", "content-type")
+ or key.lower().startswith("x-codex-")}
+
+
+class LoopbackFixtureRelay:
+ def __init__(self, mock_backend: str, session: aiohttp.ClientSession, policy: Policy):
+ assert_loopback_only(mock_backend)
+ self.backend = mock_backend
+ self.session = session
+ self.policy = policy
+
+ async def handle(self, request: web.Request) -> web.StreamResponse:
+ # Test-only origin/host guard. No remote bind and no arbitrary upstream selection.
+ if request.headers.get("Origin"):
+ return web.Response(status=403, text="browser-origin-blocked")
+ if not request.host.startswith("127.0.0.1:"):
+ return web.Response(status=403, text="unexpected-host")
+ if request.path == WS_PATH and request.headers.get("Upgrade", "").lower() == "websocket":
+ return await self.handle_websocket(request)
+ target = self.backend + request.raw_path
+ async with self.session.request(request.method, target,
+ data=await request.read(),
+ headers=selected_headers(request.headers),
+ allow_redirects=False) as response:
+ body = await response.read()
+ headers = {key: value for key, value in response.headers.items()
+ if key.lower() in ("content-type", "x-request-id", "retry-after")}
+ return web.Response(status=response.status, body=body, headers=headers)
+
+ async def handle_websocket(self, request: web.Request) -> web.StreamResponse:
+ target = self.backend + request.raw_path
+ try:
+ upstream = await self.session.ws_connect(target,
+ headers=selected_headers(request.headers), autoping=False)
+ except aiohttp.WSServerHandshakeError as exc:
+ return web.Response(status=exc.status, text="mock-upstream-rejected")
+ downstream = web.WebSocketResponse(autoping=False)
+ await downstream.prepare(request)
+
+ async def pump(source, destination, transform: bool):
+ async for message in source:
+ if message.type == aiohttp.WSMsgType.TEXT:
+ text = rewrite_backend_frame(message.data, self.policy).text if transform else message.data
+ await destination.send_str(text)
+ elif message.type == aiohttp.WSMsgType.BINARY:
+ await destination.send_bytes(message.data)
+ elif message.type == aiohttp.WSMsgType.PING:
+ await destination.ping(message.data)
+ elif message.type == aiohttp.WSMsgType.PONG:
+ await destination.pong(message.data)
+ else:
+ break
+ # Forward the peer's close so the other side sees the same code
+ # instead of hanging on a connection that already ended.
+ close_code = getattr(source, "close_code", None) or 1000
+ if not destination.closed:
+ await destination.close(code=close_code)
+
+ tasks = [asyncio.create_task(pump(upstream, downstream, True)),
+ asyncio.create_task(pump(downstream, upstream, False))]
+ try:
+ done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
+ for task in done:
+ task.result()
+ finally:
+ for task in tasks:
+ task.cancel()
+ await asyncio.gather(*tasks, return_exceptions=True)
+ await upstream.close()
+ await downstream.close()
+ return downstream
+
+
+async def start_loopback_app(app: web.Application):
+ runner = web.AppRunner(app, access_log=None)
+ await runner.setup()
+ site = web.TCPSite(runner, "127.0.0.1", 0)
+ await site.start()
+ port = runner.addresses[0][1]
+ return runner, f"http://127.0.0.1:{port}"
+
+
+class LoopbackBridgeTests(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ self.frames = []
+ self.received = asyncio.Queue()
+ self.handshake_headers = {}
+ self.http_received = []
+ self.backend_closed = asyncio.Event()
+ self.backend_close_code = None
+ self.backend_close_on_connect = None
+ self.backend_app = web.Application()
+ self.backend_app.router.add_route("*", "/backend-api/{tail:.*}", self.mock_backend)
+ self.backend_runner, self.backend_base = await start_loopback_app(self.backend_app)
+ self.relay_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5))
+ self.relay = LoopbackFixtureRelay(self.backend_base, self.relay_session, Policy(enabled=True))
+ relay_app = web.Application()
+ relay_app.router.add_route("*", "/backend-api/{tail:.*}", self.relay.handle)
+ self.relay_runner, self.relay_base = await start_loopback_app(relay_app)
+ self.host = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5))
+
+ async def asyncTearDown(self):
+ await self.host.close()
+ await self.relay_runner.cleanup()
+ await self.relay_session.close()
+ await self.backend_runner.cleanup()
+
+ async def mock_backend(self, request: web.Request):
+ if request.headers.get("Authorization") != MOCK_AUTH:
+ return web.Response(status=401, text="mock-auth-required")
+ if request.path == WS_PATH and request.headers.get("Upgrade", "").lower() == "websocket":
+ self.handshake_headers = dict(request.headers)
+ websocket = web.WebSocketResponse(autoping=False)
+ await websocket.prepare(request)
+ for frame in self.frames:
+ await websocket.send_str(frame)
+ if self.backend_close_on_connect is not None:
+ await websocket.close(code=self.backend_close_on_connect)
+ return websocket
+ async for message in websocket:
+ if message.type == aiohttp.WSMsgType.TEXT:
+ await self.received.put(message.data)
+ elif message.type == aiohttp.WSMsgType.PING:
+ await websocket.pong(message.data)
+ self.backend_close_code = websocket.close_code
+ self.backend_closed.set()
+ return websocket
+ body = await request.read()
+ self.http_received.append((request.path, body, selected_headers(request.headers)))
+ if request.path.endswith("/enroll"):
+ return web.json_response({"server_id": "mock-server", "environment_id": "mock-env",
+ "remote_control_token": "MOCK-ONLY", "expires_at": "2099-01-01T00:00:00Z"},
+ status=201, headers={"x-request-id": "mock-http-request"})
+ return web.Response(status=200, body=b"mock-unrelated-backend-endpoint")
+
+ def add_request(self, params=None, *, chunk=False):
+ request = {"id": 42, "method": "thread/list", "params": params or {"limit": 20}}
+ self.frames.append(encode(make_envelope(request, chunk=chunk)))
+
+ async def receive_host_frame(self, headers=None):
+ async with self.host.ws_connect(self.relay_base + WS_PATH,
+ headers=headers or {"Authorization": MOCK_AUTH}) as host:
+ message = await asyncio.wait_for(host.receive(), 2)
+ self.assertEqual(message.type, aiohttp.WSMsgType.TEXT)
+ return message.data
+
+ async def test_backend_to_host_rewrite_and_host_reply_passthrough(self):
+ self.add_request()
+ async with self.host.ws_connect(self.relay_base + WS_PATH,
+ headers={"Authorization": MOCK_AUTH}) as host:
+ incoming = await asyncio.wait_for(host.receive(), 2)
+ envelope = decode(incoming.data)
+ self.assertEqual(extract_message(envelope)["params"]["modelProviders"], ["openai", "opencodex"])
+ response = '{ "type":"server_message", "client_id":"mock-client", "stream_id":"mock-stream", "seq_id":9, "message":{"id":42,"result":{"data":[]}} }'
+ await host.send_str(response)
+ self.assertEqual(await asyncio.wait_for(self.received.get(), 2), response)
+
+ async def test_auth_and_protocol_handshake_headers_preserved(self):
+ self.add_request()
+ headers = {"Authorization": MOCK_AUTH, "chatgpt-account-id": "mock-account",
+ "x-codex-protocol-version": "3", "x-codex-server-id": "mock-server",
+ "x-codex-installation-id": "mock-installation", "x-codex-subscribe-cursor": "mock-resume-cursor"}
+ await self.receive_host_frame(headers)
+ actual = {k.lower(): v for k, v in self.handshake_headers.items()}
+ for name, value in headers.items():
+ self.assertEqual(actual[name.lower()], value)
+
+ async def test_invalid_auth_stays_denied(self):
+ with self.assertRaises(aiohttp.WSServerHandshakeError) as ctx:
+ await self.host.ws_connect(self.relay_base + WS_PATH,
+ headers={"Authorization": "Bearer WRONG-MOCK"})
+ self.assertEqual(ctx.exception.status, 401)
+
+ async def test_explicit_filter_frame_is_byte_identical(self):
+ self.add_request({"modelProviders": ["other"], "limit": 20})
+ self.assertEqual(await self.receive_host_frame(), self.frames[0])
+
+ async def test_single_chunk_transport(self):
+ self.add_request(chunk=True)
+ incoming = decode(await self.receive_host_frame())
+ self.assertEqual(extract_message(incoming)["params"]["modelProviders"], ["openai", "opencodex"])
+ self.assertEqual(incoming["seq_id"], 7)
+ self.assertEqual(incoming["cursor"], "mock-backend-cursor")
+
+ async def test_host_close_code_reaches_backend(self):
+ async with self.host.ws_connect(self.relay_base + WS_PATH,
+ headers={"Authorization": MOCK_AUTH}) as host:
+ await host.close(code=1001)
+ await asyncio.wait_for(self.backend_closed.wait(), 5)
+ self.assertEqual(self.backend_close_code, 1001)
+
+ async def test_backend_close_code_reaches_host(self):
+ self.backend_close_on_connect = 1011
+ async with self.host.ws_connect(self.relay_base + WS_PATH,
+ headers={"Authorization": MOCK_AUTH}) as host:
+ async for message in host:
+ if message.type in (aiohttp.WSMsgType.CLOSE,
+ aiohttp.WSMsgType.CLOSED,
+ aiohttp.WSMsgType.CLOSING):
+ break
+ self.assertEqual(host.close_code, 1011)
+
+ async def test_enrollment_http_forwarding_with_fake_credentials(self):
+ body = b'{"name":"mock-host","installation_id":"mock-installation"}'
+ async with self.host.post(self.relay_base + WS_PATH + "/enroll", data=body,
+ headers={"Authorization": MOCK_AUTH, "chatgpt-account-id": "mock-account"}) as response:
+ self.assertEqual(response.status, 201)
+ self.assertEqual(response.headers["x-request-id"], "mock-http-request")
+ self.assertEqual((await response.json())["server_id"], "mock-server")
+ self.assertEqual(self.http_received[0][1], body)
+
+ async def test_unrelated_http_backend_endpoint_forwarded(self):
+ async with self.host.get(self.relay_base + BASE + "/mock/account", headers={"Authorization": MOCK_AUTH}) as response:
+ self.assertEqual(response.status, 200)
+ self.assertEqual(await response.read(), b"mock-unrelated-backend-endpoint")
+
+ async def test_cross_origin_browser_request_denied(self):
+ async with self.host.get(self.relay_base + BASE + "/mock/account", headers={"Authorization": MOCK_AUTH, "Origin": "https://example.invalid"}) as response:
+ self.assertEqual(response.status, 403)
+ self.assertFalse(self.http_received)
+
+ async def test_fixture_rejects_real_service_upstream(self):
+ for base in ("https://chatgpt.com", "http://example.invalid", "http://0.0.0.0:1234"):
+ with self.subTest(base=base):
+ with self.assertRaises(ValueError):
+ LoopbackFixtureRelay(base, self.relay_session, Policy(enabled=True))
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py
new file mode 100644
index 00000000000..58d07be97f2
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_native_policy.py
@@ -0,0 +1,198 @@
+"""Offline contract tests. This does NOT run Rust, native Codex, or a mobile app."""
+from __future__ import annotations
+
+import hashlib
+import json
+import sqlite3
+import unittest
+from dataclasses import FrozenInstanceError
+from native_policy import ConnectionOrigin as Origin, RemoteListPolicy, resolve_provider_filter
+
+
+class NativePolicyTests(unittest.TestCase):
+ def resolve(self, **kwargs):
+ defaults = {"origin": Origin.REMOTE_CONTROL, "default_provider": "opencodex"}
+ defaults.update(kwargs)
+ return resolve_provider_filter(**defaults)
+
+ def test_opt_out_keeps_existing_default_for_every_origin(self):
+ for origin in Origin:
+ with self.subTest(origin=origin):
+ self.assertEqual(self.resolve(origin=origin), ("opencodex",))
+
+ def test_only_native_remote_origin_uses_opt_in(self):
+ policy = RemoteListPolicy(("openai", "opencodex"))
+ for origin in Origin:
+ with self.subTest(origin=origin):
+ expected = policy.providers if origin is Origin.REMOTE_CONTROL else ("opencodex",)
+ self.assertEqual(self.resolve(origin=origin, policy=policy), expected)
+
+ def test_explicit_nonempty_filter_wins_over_every_policy(self):
+ for policy in (RemoteListPolicy(), RemoteListPolicy(()), RemoteListPolicy(("openai",))):
+ for origin in Origin:
+ with self.subTest(policy=policy, origin=origin):
+ for requested in (("other",), ("other", "other"), ("",), (" other ",)):
+ self.assertEqual(self.resolve(origin=origin, requested=requested, policy=policy), requested)
+
+ def test_explicit_empty_filter_is_all_for_every_origin(self):
+ for origin in Origin:
+ with self.subTest(origin=origin):
+ self.assertIsNone(self.resolve(origin=origin, requested=(), policy=RemoteListPolicy(("openai",))))
+
+ def test_opt_in_all_is_not_the_default(self):
+ self.assertIsNone(self.resolve(policy=RemoteListPolicy(())))
+ self.assertEqual(self.resolve(), ("opencodex",))
+
+ def test_parent_query_keeps_native_no_default_filter(self):
+ for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))):
+ self.assertIsNone(self.resolve(parent_thread_id="00000000-0000-4000-8000-0000000000aa", policy=policy))
+
+ def test_ancestor_query_keeps_native_no_default_filter(self):
+ for policy in (RemoteListPolicy(), RemoteListPolicy(("opencodex",))):
+ self.assertIsNone(self.resolve(ancestor_thread_id="00000000-0000-4000-8000-0000000000bb", policy=policy))
+
+ def test_related_thread_ids_follow_native_validation(self):
+ # Upstream thread_list_response_inner rejects malformed ids and rejects
+ # parent+ancestor together; a malformed id must not silently widen the list.
+ for relation in (
+ {"parent_thread_id": ""},
+ {"ancestor_thread_id": " "},
+ {"parent_thread_id": "fixture-parent"},
+ {"ancestor_thread_id": "fixture-ancestor"},
+ {"parent_thread_id": "p"},
+ ):
+ with self.subTest(relation=relation), self.assertRaises(ValueError):
+ self.resolve(**relation)
+ # Python's UUID() tolerates hyphen shapes Rust Uuid::parse_str rejects:
+ # shifted hyphens, a bare 32-hex blob inside braces, an uppercased URN, and
+ # a braced simple string all stay invalid no matter the provider shape.
+ malformed = (
+ "000-00000-0000-4000-8000-0000000000aa",
+ "{000000000000400080000000000000aa}",
+ "URN:UUID:00000000-0000-4000-8000-0000000000aa",
+ "{000000000000-4000-8000-0000000000aa}",
+ )
+ for key in ("parent_thread_id", "ancestor_thread_id"):
+ for bad in malformed:
+ for requested in (None, ("openai",)):
+ with self.subTest(key=key, bad=bad, requested=requested), self.assertRaises(ValueError):
+ self.resolve(**{key: bad}, requested=requested)
+ # The four shapes upstream accepts stay valid for both relations.
+ for key in ("parent_thread_id", "ancestor_thread_id"):
+ for good in (
+ "00000000-0000-4000-8000-0000000000aa",
+ "000000000000400080000000000000aa",
+ "{00000000-0000-4000-8000-0000000000aa}",
+ "urn:uuid:00000000-0000-4000-8000-0000000000aa",
+ ):
+ with self.subTest(key=key, good=good):
+ self.assertIsNone(self.resolve(**{key: good}))
+ with self.assertRaises(ValueError):
+ self.resolve(
+ parent_thread_id="00000000-0000-4000-8000-0000000000aa",
+ ancestor_thread_id="00000000-0000-4000-8000-0000000000bb",
+ )
+
+ def test_explicit_filter_still_wins_for_related_queries(self):
+ for relation in (
+ {"parent_thread_id": "00000000-0000-4000-8000-0000000000aa"},
+ {"ancestor_thread_id": "00000000-0000-4000-8000-0000000000bb"},
+ ):
+ self.assertEqual(self.resolve(requested=("other",), policy=RemoteListPolicy(()), **relation), ("other",))
+
+ def test_null_and_omitted_match_typed_native_option_semantics(self):
+ for wire in ('{}', '{"modelProviders": null}'):
+ requested = json.loads(wire).get("modelProviders")
+ self.assertIsNone(requested)
+ self.assertEqual(self.resolve(requested=requested, policy=RemoteListPolicy(("openai",))), ("openai",))
+
+ def test_provider_names_are_not_hardcoded(self):
+ self.assertEqual(self.resolve(default_provider="custom"), ("custom",))
+ self.assertEqual(self.resolve(policy=RemoteListPolicy(("azure-team", "local"))), ("azure-team", "local"))
+
+ def test_provider_ids_are_exact_not_aliases(self):
+ self.assertEqual(self.resolve(policy=RemoteListPolicy(("OpenAI", " openai "))), ("OpenAI", " openai "))
+
+ def test_policy_cannot_be_mutated_during_pagination(self):
+ policy = RemoteListPolicy(("openai",))
+ with self.assertRaises(FrozenInstanceError):
+ policy.providers = ()
+
+ def test_rejects_invalid_policy(self):
+ for value in (["openai"], "openai", ("",), (" ",), (1,), ("openai", "openai")):
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ RemoteListPolicy(value)
+
+ def test_rejects_untrusted_origin_string(self):
+ with self.assertRaises(ValueError):
+ self.resolve(origin="remote_control")
+
+ def test_rejects_invalid_default_provider(self):
+ for value in ("", " ", None, 1):
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ self.resolve(default_provider=value)
+
+ def test_rejects_invalid_requested_filter(self):
+ for value in (["other"], "other", (1,), (None,)):
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ self.resolve(requested=value)
+
+
+class SyntheticPaginationTests(unittest.TestCase):
+ """Fixture SQL illustrates the contract, not the native Codex store schema."""
+ def setUp(self):
+ self.db = sqlite3.connect(":memory:")
+ self.addCleanup(self.db.close)
+ self.db.execute("CREATE TABLE threads (id INTEGER PRIMARY KEY, provider TEXT NOT NULL)")
+ self.db.executemany("INSERT INTO threads VALUES (?, ?)",
+ [(i, "openai") for i in range(1, 5201)] + [(5201, "opencodex"), (5202, "other")])
+ self.before = self.digest()
+
+ def digest(self):
+ return hashlib.sha256("\n".join(self.db.iterdump()).encode()).hexdigest()
+
+ def list_all(self, policy, limit=37, requested=None):
+ providers = resolve_provider_filter(origin=Origin.REMOTE_CONTROL,
+ default_provider="opencodex", requested=requested, policy=policy)
+ rows, last_id = [], 0
+ while True:
+ values = [last_id]
+ sql = "SELECT id, provider FROM threads WHERE id > ?"
+ if providers is not None:
+ sql += " AND provider IN (" + ",".join("?" for _ in providers) + ")"
+ values.extend(providers)
+ sql += " ORDER BY id LIMIT ?"
+ values.append(limit)
+ page = self.db.execute(sql, values).fetchall()
+ if not page:
+ break
+ rows.extend(page)
+ last_id = page[-1][0]
+ self.assertEqual(self.digest(), self.before, "read must not retag fixture rows")
+ self.assertEqual(len({row[0] for row in rows}), len(rows))
+ return rows
+
+ def test_default_remains_one_thread(self):
+ self.assertEqual(self.list_all(RemoteListPolicy()), [(5201, "opencodex")])
+
+ def test_opt_in_two_provider_list_returns_5201_rows(self):
+ rows = self.list_all(RemoteListPolicy(("openai", "opencodex")))
+ self.assertEqual(len(rows), 5201)
+ self.assertNotIn("other", {row[1] for row in rows})
+
+ def test_explicit_all_returns_5202_rows(self):
+ self.assertEqual(len(self.list_all(RemoteListPolicy(()))), 5202)
+
+ def test_explicit_client_filter_not_broadened(self):
+ self.assertEqual(self.list_all(RemoteListPolicy(()), requested=("other",)), [(5202, "other")])
+
+ def test_unlisted_provider_returns_empty_without_fallback(self):
+ self.assertEqual(self.list_all(RemoteListPolicy(("not-in-fixture",))), [])
+
+ def test_every_page_uses_same_policy_without_duplicates(self):
+ policy = RemoteListPolicy(("openai", "opencodex"))
+ self.assertEqual(self.list_all(policy, limit=1), self.list_all(policy, limit=1000))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py
new file mode 100644
index 00000000000..ffdb0c6e24d
--- /dev/null
+++ b/devlog/_plan/260928_remote_thread_provider_policy/probes/test_probe.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+import base64
+import hashlib
+import json
+import sqlite3
+import unittest
+from copy import deepcopy
+from remote_list_probe import Policy, decode, encode, make_envelope, extract_message, rewrite_backend_frame
+
+
+class RewriteTests(unittest.TestCase):
+ def setUp(self):
+ self.policy = Policy(enabled=True)
+ self.request = {"id": 27, "method": "thread/list", "params": {"limit": 20, "cursor": "opaque"}}
+
+ def frame(self, request=None, chunk=False):
+ return encode(make_envelope(deepcopy(self.request if request is None else request), chunk=chunk))
+
+ def result(self, request=None, chunk=False, policy=None):
+ return rewrite_backend_frame(self.frame(request, chunk), policy or self.policy)
+
+ def assert_preserved(self, raw):
+ outcome = rewrite_backend_frame(raw, self.policy)
+ self.assertFalse(outcome.changed)
+ self.assertEqual(raw, outcome.text)
+
+ def test_opt_in_required(self):
+ raw = self.frame()
+ self.assertEqual(rewrite_backend_frame(raw, Policy()).text, raw)
+ self.assertFalse(rewrite_backend_frame(raw, Policy()).changed)
+
+ def test_plain_omitted_filter(self):
+ out = self.result()
+ self.assertTrue(out.changed)
+ self.assertEqual(extract_message(decode(out.text))["params"]["modelProviders"], ["openai", "opencodex"])
+
+ def test_explicit_all_provider_mode(self):
+ out = self.result(policy=Policy(enabled=True, providers=()))
+ self.assertEqual(extract_message(decode(out.text))["params"]["modelProviders"], [])
+
+ def test_preserves_envelope_metadata(self):
+ before = decode(self.frame())
+ after = decode(self.result().text)
+ for key in ("client_id", "stream_id", "seq_id", "cursor"):
+ self.assertEqual(before[key], after[key])
+
+ def test_preserves_all_other_parameters(self):
+ self.request["params"].update(archived=True, cwd="C:/mock", sourceKinds=["cli"],
+ sortDirection="asc", searchTerm="mock", isPinned=False)
+ after = extract_message(decode(self.result().text))
+ del after["params"]["modelProviders"]
+ self.assertEqual(after, self.request)
+
+ def test_explicit_filter_preserved(self):
+ self.request["params"]["modelProviders"] = ["other"]
+ self.assert_preserved(self.frame())
+
+ def test_explicit_empty_filter_preserved(self):
+ self.request["params"]["modelProviders"] = []
+ self.assert_preserved(self.frame())
+
+ def test_explicit_null_filter_preserved(self):
+ self.request["params"]["modelProviders"] = None
+ self.assert_preserved(self.frame())
+
+ def test_missing_params_created(self):
+ del self.request["params"]
+ self.assertTrue(self.result().changed)
+
+ def test_non_object_params_preserved(self):
+ for value in (None, [], "not-an-object", 1):
+ with self.subTest(value=value):
+ self.request["params"] = value
+ self.assert_preserved(self.frame())
+
+ def test_resume_and_other_methods_unchanged(self):
+ for method in ("thread/resume", "thread/start", "turn/start", "turn/interrupt", "initialize", "thread/search"):
+ with self.subTest(method=method):
+ self.request["method"] = method
+ self.assert_preserved(self.frame())
+
+ def test_notification_without_id_unchanged(self):
+ del self.request["id"]
+ self.assert_preserved(self.frame())
+
+ def test_request_id_preserved_exactly(self):
+ for request_id in ("request-string", 9007199254740993, 0, -1):
+ with self.subTest(request_id=request_id):
+ self.request["id"] = request_id
+ self.assertEqual(extract_message(decode(self.result().text))["id"], request_id)
+
+ def test_response_like_object_unchanged(self):
+ self.request["result"] = {"data": []}
+ self.assert_preserved(self.frame())
+
+ def test_relation_query_bypass_preserved(self):
+ for key in ("parentThreadId", "ancestorThreadId"):
+ with self.subTest(key=key):
+ self.request["params"] = {key: "00000000-0000-4000-8000-0000000000aa"}
+ self.assert_preserved(self.frame())
+
+ def test_null_relation_does_not_block(self):
+ self.request["params"]["parentThreadId"] = None
+ self.assertTrue(self.result().changed)
+
+ def test_ping_ack_close_unchanged(self):
+ for kind in ("ping", "ack", "client_closed", "unknown_future_event"):
+ with self.subTest(kind=kind):
+ self.assert_preserved(encode({"type": kind, "client_id": "mock-client", "seq_id": 3}))
+
+ def test_invalid_json_preserved(self):
+ for raw in ("{broken", "[]", "null", '{"type":"client_message","client_id":"x","message":NaN}'):
+ with self.subTest(raw=raw):
+ self.assert_preserved(raw)
+
+ def test_duplicate_members_preserved(self):
+ raw = self.frame().replace('"method":"thread/list"', '"method":"thread/list","method":"thread/resume"')
+ self.assert_preserved(raw)
+
+ def test_unknown_envelope_fields_preserved(self):
+ envelope = decode(self.frame())
+ envelope["future_field"] = {"tag": [1, 2, 3]}
+ out = rewrite_backend_frame(encode(envelope), self.policy)
+ self.assertEqual(decode(out.text)["future_field"], envelope["future_field"])
+
+ def test_single_chunk_transformed_with_correct_byte_size(self):
+ self.request["params"]["searchTerm"] = "한글🙂"
+ out = self.result(chunk=True)
+ self.assertTrue(out.changed)
+ after = decode(out.text)
+ payload = base64.b64decode(after["message_chunk_base64"], validate=True)
+ self.assertEqual(len(payload), after["message_size_bytes"])
+ self.assertEqual(decode(payload)["params"]["modelProviders"], ["openai", "opencodex"])
+ self.assertEqual(after["seq_id"], 7)
+
+ def test_single_chunk_existing_filter_unchanged(self):
+ self.request["params"]["modelProviders"] = ["other"]
+ self.assert_preserved(self.frame(chunk=True))
+
+ def test_multi_segment_deliberately_not_implemented(self):
+ envelope = decode(self.frame(chunk=True))
+ envelope["segment_count"] = 2
+ out = rewrite_backend_frame(encode(envelope), self.policy)
+ self.assertEqual(out.reason, "multi_segment_not_implemented")
+ self.assertFalse(out.changed)
+
+ def test_bad_chunk_length_unchanged(self):
+ envelope = decode(self.frame(chunk=True))
+ envelope["message_size_bytes"] += 1
+ self.assert_preserved(encode(envelope))
+
+ def test_bad_chunk_base64_unchanged(self):
+ envelope = decode(self.frame(chunk=True))
+ envelope["message_chunk_base64"] = "not@@base64"
+ self.assert_preserved(encode(envelope))
+
+ def test_oversized_input_unchanged(self):
+ raw = self.frame()
+ out = rewrite_backend_frame(raw, Policy(enabled=True, max_wire_bytes=64))
+ self.assertFalse(out.changed)
+ self.assertEqual(out.text, raw)
+
+ def test_oversized_output_unchanged(self):
+ raw = self.frame()
+ out = rewrite_backend_frame(raw, Policy(enabled=True, max_wire_bytes=len(raw.encode())))
+ self.assertFalse(out.changed)
+ self.assertEqual(out.reason, "output_size_limit")
+
+ def test_invalid_policy_rejected(self):
+ for providers in (("",), ("a", "a"), (1,), ["a"]):
+ with self.subTest(providers=providers):
+ with self.assertRaises(ValueError):
+ Policy(enabled=True, providers=providers)
+
+ def test_synthetic_sqlite_visibility_and_no_mutation(self):
+ # Synthetic fixture, NOT the user's database and NOT native Codex.
+ db = sqlite3.connect(":memory:")
+ try:
+ db.execute("CREATE TABLE threads (id INTEGER, model_provider TEXT)")
+ db.executemany("INSERT INTO threads VALUES (?, ?)", [(i, "openai") for i in range(5200)])
+ db.execute("INSERT INTO threads VALUES (5200, 'opencodex')")
+ db.execute("INSERT INTO threads VALUES (5201, 'unrelated')")
+ before = hashlib.sha256("\n".join(db.iterdump()).encode()).hexdigest()
+ self.assertEqual(db.execute("SELECT count(*) FROM threads WHERE model_provider = 'opencodex'").fetchone()[0], 1)
+ providers = extract_message(decode(self.result().text))["params"]["modelProviders"]
+ placeholders = ",".join("?" for _ in providers)
+ scoped = db.execute(f"SELECT count(*) FROM threads WHERE model_provider IN ({placeholders})", providers).fetchone()[0]
+ self.assertEqual(scoped, 5201)
+ self.assertEqual(db.execute("SELECT count(*) FROM threads").fetchone()[0], 5202)
+ after = hashlib.sha256("\n".join(db.iterdump()).encode()).hexdigest()
+ self.assertEqual(before, after)
+ finally:
+ db.close()
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/devlog/_plan/261004_codex_remote_messaging/000_scope_and_boundary.md b/devlog/_plan/261004_codex_remote_messaging/000_scope_and_boundary.md
new file mode 100644
index 00000000000..10a9697d3dc
--- /dev/null
+++ b/devlog/_plan/261004_codex_remote_messaging/000_scope_and_boundary.md
@@ -0,0 +1,63 @@
+# 000 — remote Codex messaging: second contribution
+
+Status: implemented locally; bounded contribution qualification recorded. No publication,
+deployment or maintainer acceptance of the remote boundary is implied.
+
+## Baseline and direction
+
+The contribution branch is rebuilt on upstream `dev`
+`0dd7451976e40f1e80e023e3c604e38d1966a68e`, containing integrated local messaging
+[PR #6544](https://github.com/lidge-jun/opencodex/pull/6544).
+It does not carry the superseded local PR commits.
+The broader deployed experiment remains separate.
+
+[Proposal #6478](https://github.com/lidge-jun/opencodex/issues/6478) and
+[scoped lifecycle feedback](https://github.com/lidge-jun/opencodex/issues/6478#issuecomment-5975718099)
+require a bounded optional owner, aggregate cross-peer limits, idempotent owned
+cleanup and no replay. Foreground ownership is a revisable proposal, not accepted
+remote architecture. Enrollment/authentication/SSH require independent review.
+
+## Included scope
+
+- CLI-only Codex-to-Codex delivery to exact loaded recipients on individually
+ enrolled SSH peers, selected by exact alias or machine UUID.
+- Explicit enable, fingerprint probe/confirmation, enrollment, list, removal,
+ status and foreground `ocx message serve`.
+- Stable per-configuration-home machine identity and protected directional
+ capabilities, with versioned SSH-stdio enrollment and retained transactions
+ for unknown enrollment outcomes.
+- IPv4-loopback gateway: mutual capability-derived proofs on the actual data
+ connection before attaching to the existing native Unix daemon. No capability
+ in URLs, argv, upgrade headers, ordinary receipts or logs.
+- Initialization, loaded discovery, projected metadata and direct experimental
+ `thread/queue/add`; no generic RPC, lifecycle, turns or approval/config writes.
+- An owned -L/-R pair and bounded return lease support replies without a second
+ SSH login. Live owner proof, not persisted ports, establishes route readiness.
+- Initiating enrollment grants outbound delivery and correlated replies only.
+ Broader local inbound delivery requires explicit per-peer consent; return transport
+ and capability possession do not substitute for that consent.
+- Discovery, final recipient revalidation and queue submission share one
+ authenticated connection; exact receipt correlation, queued/not_sent/unknown,
+ and no automatic resend.
+- Shared owner-wide capacities, cancellation, shutdown, focused regressions,
+ isolated real-SSH and stock-native qualification, docs and ownership records.
+
+## Deliberate exclusions
+
+No mesh/coordinator, dashboard/API, caches, isolation policy, Claude, managed
+skills, prompt/delegation policy, idle triggers or session creation/resume.
+No proxy startup hook, daemon start/restart/install, remote installation or
+automatic tunnel recovery. Unrelated local extensions are not carried.
+
+The gateway is a protected OpenCodex wrapper protocol, not a native WebSocket
+endpoint: raw `codex queue --remote` does not perform its mutual proof handshake.
+Native daemon auth/configuration is unchanged. SSH provides network encryption;
+capability proof is endpoint identity/admission, not transport encryption or a
+same-UID isolation boundary.
+
+## Publication boundary
+
+Prepare a local PR description and exact-head evidence. Do not push or open the
+next PR without user direction. Reference #6478 rather than closing the broader
+proposal. Linux qualification does not establish macOS interoperability; required
+current-head hosted CI and maintainer/security acceptance remain separate gates.
diff --git a/devlog/_plan/261004_codex_remote_messaging/010_extraction_and_ownership.md b/devlog/_plan/261004_codex_remote_messaging/010_extraction_and_ownership.md
new file mode 100644
index 00000000000..96429a4bb7f
--- /dev/null
+++ b/devlog/_plan/261004_codex_remote_messaging/010_extraction_and_ownership.md
@@ -0,0 +1,44 @@
+# 010 — extraction and source ownership
+
+## Source identities
+
+Destination: upstream `dev` `0dd7451976e40f1e80e023e3c604e38d1966a68e`.
+Source reference: local `feat/claude-messaging`, including completed direct-RPC
+hardening at `a665128c3`. This is responsibility-based extraction and narrow
+rewriting, not a whole-commit cherry-pick or a copy of the deployed subsystem.
+
+## Extraction decisions
+
+| Responsibility | Contribution |
+| --- | --- |
+| Enrollment | Strict JSON state and SSH stdio control; explicit enable on both nodes, directional capabilities, recoverable transaction and local-first revocation. No mesh or auto-enable. |
+| SSH | Reuse unchanged Remote Link argv/fingerprint/environment policy; messaging owns separate known_hosts and -L/-R children. |
+| Bridge | Codex-only mutual-proof gateway, small RPC whitelist, projected metadata, bounded aggregate admission. No cross-harness/runtime feature imports. |
+| Runtime | New explicit foreground owner with shared cancellation and accounting; no supervisor, proxy startup activation or automatic retries. |
+| Send | Extend integrated same-connection direct queue RPC through a narrow authenticated socket attachment seam. Preserve the Unix-only local factory. |
+| Envelope | Optional machine metadata and exact remote reply host; descriptive sender identity does not grant authority. |
+| Fixtures | Standalone isolated native/SSH fixtures, not the broader experiment's fixtures. |
+
+## Current ownership and paths
+
+- `structure/remote-messaging.md` owns remote modules and CLI lifecycle/contracts.
+- `structure/local-messaging.md` preserves local-only default and queue semantics.
+- `structure/cli-management.md` accounts for explicit remote command dispatch.
+- `structure/decisions/ADR-6480-remote-messaging-owner.md` records provisional
+ foreground ownership. The manifest and generated structure index register it.
+- `src/link/ssh-argv.ts`, `fingerprint.ts` and SSH environment policy remain
+ unchanged and shared; messaging does not enroll Remote Link or reuse provider keys.
+- `src/messaging/remote-process.ts` adds cancellation/join behavior the existing
+ link runner does not expose. It does not silently change Remote Link behavior.
+- `src/messaging/rpc.ts` accepts a caller-authenticated connected socket only
+ through `attach`. Its local `connect` still refuses network URLs.
+- `src/cli/message-command.ts` imports remote runtime only after pure syntax
+ validation. Local commands without a host do not read remote state.
+- English public CLI reference, registry, capabilities, help and generated
+ operating surface describe implemented commands, not proposed HTTP routes.
+- New tests are registered in both layout manifests. The contained test-temp
+ directory now explicitly uses 0700 to honor private socket/store fixtures.
+
+No source dependency/process graph or exhaustive downstream audit is claimed.
+Callbacks, subprocesses, source-oracle guards and native contracts require explicit
+regression coverage rather than import-graph coverage alone.
diff --git a/devlog/_plan/261004_codex_remote_messaging/020_contract_and_test_matrix.md b/devlog/_plan/261004_codex_remote_messaging/020_contract_and_test_matrix.md
new file mode 100644
index 00000000000..525a067c72e
--- /dev/null
+++ b/devlog/_plan/261004_codex_remote_messaging/020_contract_and_test_matrix.md
@@ -0,0 +1,79 @@
+# 020 — contracts and qualification ledger
+
+Status: implemented contracts with focused evidence; final qualification is tracked
+in 030 and the local PR verification packet, not inferred from historical help.
+
+## Native contract
+
+The gateway uses the integrated experimental `thread/queue/add` RPC, with one
+connection for discovery, revalidation and submission. Validated native IDs and
+text echo establish queued; a correlated rejection establishes not_sent.
+Post-write cancellation, malformed/lost receipts and route loss remain unknown.
+Nothing retries a possibly submitted message.
+
+Stock `codex-cli 0.160.1` was exercised in a separate credential-free home with
+a synthetic local provider. Executable SHA-256:
+`f34a4d2301892ae96c90097786bfe5dc269f187b6f69faf42a7b357b8c081e35`.
+The real daemon accepted the gateway request, completed a synthetic turn and sent
+a return response through the authenticated route. This is Linux evidence,
+not macOS or production-node qualification.
+
+Separately, a disposable loopback OpenSSH server with generated keys proved
+fingerprint confirmation, private enrollment, separate -L/-R routes, request and
+response and complete owned cleanup. Native endpoints in that SSH fixture are
+strict offline Unix fixtures. These two receipts are complementary, not a claim
+that the combined real-SSH/real-native matrix ran.
+
+## Fixed aggregate contracts
+
+One foreground owner covers at most four peers, 16 admitted connections, 32 active
+requests, ten helpers (eight persistent tunnel children plus two transient control
+or inspection children) and 2 MiB accounted input/output. Connections admit at
+most four pending requests, frames at most 1 MiB, state at most 128 KiB and helper
+stdout/stderr at most 64 KiB each. There is no waiting queue. Full capacity refuses
+before allocation, and release is idempotent.
+
+Setup uses one 30-second budget across peers and stages. Authentication is bounded
+to five seconds, route refresh to ten seconds, leases to 25 seconds. Connections
+last at most 30 seconds. Owned cleanup has a three-second ceiling with TERM then
+KILL; concurrent shutdown joins the same flight. Escaped process groups are not
+claimed to be terminated; retained pipes are canceled. Logical helper operations
+settle after bounded cleanup; unresolved physical children retain their reservation.
+Withdrawn published routes remain part of the owner's final cleanup join.
+
+## Regression mapping
+
+| File in tests/codex-integration | Evidence covered |
+| --- | --- |
+| messaging-remote-contract.test.ts | Direction/nonce/identity proofs; exact RPC admission; metadata projection; no native attachment before auth; replacement listener gets no client proof/body; upgrade and capacity refusal; revocation. |
+| messaging-remote-store.test.ts | Absent reads stay absent, strict bounded schema, private ownership/mode/symlink/file guards, locking, secret omission. |
+| messaging-remote-enrollment.test.ts | Explicit receiver enable, immutable transaction, fingerprint mismatch, lost-reply reconciliation, generation-bound journal ownership, concurrent completion, removal and journal-failure ordering, full registry refusal. |
+| messaging-remote-send.test.ts | Same connection, machine-aware replies, return-only receiver, strict receipts, unload, source admission revalidation after awaits, lost/malformed receipts, no replay. |
+| messaging-remote-lifecycle.test.ts | Inert imports and pure parser, owned cancellation, shared cleanup/output limits; published and unpublished tunnel cleanup joins both helpers, including failure. |
+| messaging-remote-process.test.ts | Bounded logical settlement for unresolved exit and failed stdin, retained physical reservations, persistent/transient caps and injected Darwin listener inspection. |
+| messaging-remote-interop.test.ts | Opt-in generated-key loopback SSH enrollment, duplex delivery and zero owned reservations after cleanup. |
+| messaging-remote-native.test.ts | Opt-in explicit stock binary, real queue/turn contract in isolated home, synthetic provider and return delivery. |
+
+Existing local messaging and shared SSH policy regressions are part of integrated
+validation. Import-graph selection alone cannot cover source-oracle, subprocess or
+golden-file guards; run those explicitly.
+
+## Remaining gates
+
+Full-suite qualification remains incomplete: the existing runner stopped its
+parallel lane after 900 seconds, exit 124; serial lanes were not reached. The
+local PR packet records the resource exception, observed failures, focused
+coverage and commands instead of claiming a passing full suite. No ratchet,
+timeout or required check was relaxed.
+
+A later bounded `test:changed` attempt selected 979 of 2,222 files and exceeded its
+explicit 180-second runner ceiling, exit 124, without a final count receipt. It is
+also incomplete, not passing; final focused regressions cover subsequent changes.
+
+Required exact-head hosted CI once publication is authorized and macOS
+interoperability remain outstanding. Local checks and independent source review
+are recorded separately from maintainer acceptance and operational qualification.
+No receipt from an unrun or skipped tier is described as passing.
+
+Unreleased security assessment material belongs in ignored scratch or managed
+scan artifacts, never in this public planning ledger.
diff --git a/devlog/_plan/261004_codex_remote_messaging/030_implementation_sequence.md b/devlog/_plan/261004_codex_remote_messaging/030_implementation_sequence.md
new file mode 100644
index 00000000000..759b67c4ad1
--- /dev/null
+++ b/devlog/_plan/261004_codex_remote_messaging/030_implementation_sequence.md
@@ -0,0 +1,56 @@
+# 030 — implementation and handover
+
+Status: the agreed remote slice is implemented locally on
+`feat/codex-remote-contribution`; bounded qualification and source review are
+recorded in the local PR packet. Publication is user-authorized; deployment and
+maintainer acceptance remain separate.
+
+## Completed implementation sequence
+
+1. Reconstructed the contribution on current upstream dev containing merged
+ #6544, preserving the old prep worktree and deployed experiment.
+2. Pinned narrow gateway/native contracts, mutual connection proofs, strict
+ receipt semantics and numeric aggregate capacities.
+3. Added explicit private state/enrollment and recoverable private-stdio
+ transactions; no implicit remote enable or lifecycle changes.
+4. Reused shared SSH policy with messaging-owned -L/-R children and live return
+ leases. Added cancellation/join behavior without changing Remote Link.
+5. Added an explicit foreground owner and dynamically selected remote CLI path.
+ No listener, helper, timer or state creation occurs from imports/default startup.
+6. Added one-connection remote sends and machine-aware return replies, preserving
+ local-only Unix trust and queued/not_sent/unknown/no-replay behavior.
+7. Added registered isolated regressions, generated surfaces, public CLI docs,
+ source ownership and a foreground decision record.
+8. Qualified disposable loopback SSH and stock Codex 0.160.1 separately. Joined
+ unpublished tunnel cleanup has dedicated regressions and a narrow independent
+ review. The rebase carried the implementation unchanged, verified by range-diff.
+9. Recorded the full-suite 900-second timeout as incomplete evidence with a
+ proportionate resource exception. Hosted CI, macOS qualification and maintainer
+ acceptance remain outstanding; no unrun tier is reported as passing.
+10. Refined transaction ownership, source admission and published cleanup joins;
+ qualified bounded helper settlement and persistent/transient helper slots.
+ Final focused validation passed 283 tests with six skips and no failures;
+ broader changed-test selection remains incomplete at its bounded ceiling.
+11. Rebased onto upstream `dev` `0dd7451976e40f1e80e023e3c604e38d1966a68e`
+ before publication. All five contribution patches carried unchanged, verified
+ by range-diff. Rebased qualification is recorded separately in the PR packet.
+
+## Maintainer adjustment points
+
+Foreground lifetime is provisional. The individual-peer schema is intentionally
+independent of any future mesh. The protected gateway protocol is not a native
+WebSocket listener and does not claim raw queue CLI compatibility. Linux evidence
+must not be generalized to macOS. If the owner requests smaller contributions,
+split along enrollment/transport/delivery while retaining the tested contracts.
+
+## Safe handover
+
+Keep source and exact-head receipts together. Preserve the deployed local branch;
+do not install, restart, enroll production hosts or migrate skills from this PR
+preparation. Do not push or raise a PR without the user's go. Reference the broader
+proposal rather than auto-closing it.
+
+The local PR draft must distinguish implementation, tests actually run, remaining
+platform/CI/maintainer gates and security review. Any unrun suite or skipped opt-in
+fixture stays explicitly unqualified. Never include secrets, personal host paths,
+internal product names or unpublished security notes in public commits.
diff --git a/devlog/_plan/261021_fallback_off/000_design.md b/devlog/_plan/261021_fallback_off/000_design.md
new file mode 100644
index 00000000000..6357bc48de1
--- /dev/null
+++ b/devlog/_plan/261021_fallback_off/000_design.md
@@ -0,0 +1,145 @@
+# OCX-11 fallback off — design
+
+> Written 2026-10-21, revised 2026-10-22 after the supervisor decisions (§9) · implemented in PR #7482.
+> Consumer: GOAT hub design, goat-si/goat PR #684 (ADR-0021 §1.6, design doc §1.6 and DH3).
+> The present-tense contract is [structure/fallback-policy.md](../../../structure/fallback-policy.md).
+
+## 1. Goal
+
+Give the operator one clear way to say "do not fall back; fail with a clear error instead", for two families:
+
+- **Hub down.** A Remote Link Child or Hub client whose Home is unreachable never silently serves from
+ its own local providers, and the operator can see and set the per-device local fallback permission.
+- **Fallback chains.** Model and provider fallback (combos, subagent fallback, the main-account
+ hard-lock fallback, routing profiles) can be refused per scope: global, per provider, per model or
+ route, and per device.
+
+Defaults stay exactly as they are today (§7).
+
+## 2. Terms adopted from GOAT
+
+ADR-0021 §1.6 and its design doc define the hub-down policy. This note reuses these terms verbatim:
+
+- "a Child never silently serves its local providers when the Home is unreachable, and model, account and
+ provider fallback chains can be turned off per scope" (design §1.6, describing this package). The
+ account part is settled differently: see §9 C3.
+- "**local fallback** opt-in per device" (DH3: "pause plus decision item; local fallback opt-in per device").
+ GOAT's switch is `hub.devices[alias].localFallback`; the OpenCodex key is
+ `fallback.devices..localFallback`, so the leaf reads the same on both sides.
+- "goal work on that device **pauses** (health condition `HubUnreachable`), people's own threads see
+ OpenCodex's 503 as today". OpenCodex keeps that 503 and adds `code: "home_unreachable"` (§6).
+- "GOAT reads that state; it never changes OpenCodex fallback settings", shown as "**on, off, unknown**
+ when the Home predates OCX-11". The Home advertises `fallback-policy/1` in `/readyz` `features`;
+ GOAT reads its absence as unknown.
+- "**GOAT fallback**" (ADR-0018 chains) is GOAT's word for moving GOAT work to another provider instance.
+ OpenCodex never uses that phrase; its own hops are "model fallback", "provider fallback" and "local
+ fallback".
+
+## 3. Inventory
+
+A fallback is a hop that serves a request from a **different model** or a **different provider** than
+the one chosen or requested.
+
+| # | Fallback | Kind | Where | Governed |
+|---|---|---|---|---|
+| F1 | Combo failover | provider or model | `src/server/responses/core-combo.ts` `executeComboResponses`; decisions in `src/combos/failover.ts`, picker in `src/combos/resolve.ts` | yes: reactive hop at the dispatch loop, proactive for a cooling first `failover` target |
+| F2 | Subagent model fallback | model or provider | `src/codex/subagent-model-fallback.ts` `applySubagentModelFallback` | yes, proactive |
+| F3 | Main-account hard-lock fallback | provider or model | `src/server/responses/main-hard-lock-routing.ts` `applyMainHardLockFallbackOverride` | yes, proactive |
+| F4 | Routing-profile fallback | provider or model | `src/server/responses/policy-fallback.ts` `handleResponsesWithPolicyFallback` | yes, reactive |
+| F5 | Claude Code classifier fallbacks | — | `src/claude/inbound-model-options.ts` | not a hop: the first configured entry always wins, with no availability check |
+| F6–F9 | Codex pool account switch (including pool → signed-in main), Anthropic OAuth, generic OAuth and API-key pool rotation | — | `src/codex/routing/selection.ts`, `src/oauth/anthropic-routing.ts`, `src/oauth/generic-account-failover.ts`, `src/providers/key-failover.ts` | not a fallback: intra-pool rotation (§9 C3) |
+| F10 | Remote Link Child, Home unreachable | local | `src/client/link-relay.ts` | no local path exists; 503 gains `home_unreachable` |
+| F11 | Hub client, Home unreachable | local | Codex on a Hub client talks to the Hub URL directly; the local listener carries only the dashboard relay (`src/client/hub-relay.ts`, 502) | no local path exists; 502 gains `home_unreachable` |
+
+Neither client kind can serve from local providers: both run the relay-only client runtime
+(`src/client/runtime.ts`), `ocx start` refuses an invalid client state instead of starting standalone
+(`src/cli/index.ts`), and the standalone recycle runs only when the link itself ends, never on an outage.
+`tests/clients/client-home-unreachable.test.ts` pins this with a load-time import-graph check.
+
+Out of scope because the target does not change: same-target retries (`src/lib/upstream-retry.ts`),
+the Antigravity direct lane's bridge fallback (`src/protocols/settings.ts`), transport fallbacks.
+
+### Fallback versus intra-pool rotation
+
+A **fallback** changes *what* answers: another model or another provider, with different capabilities,
+pricing, privacy terms or quality. An operator can reasonably refuse that.
+
+**Intra-pool rotation** changes only *which credential* answers for the same provider and model: the
+next account of an OAuth pool or the next key of an API-key pool when one is rate-limited, or the Codex
+pool's move away from an account at its usage threshold. The request, model and provider are unchanged.
+The maintainer rule recorded in `src/types/config.ts` says rotating away from an account upstream has
+just rate-limited "only ever runs after a refusal, so stranding it while a second logged-in account sits
+idle is a defect rather than a configuration choice". OCX-11 keeps that rule: rotation is resilience
+inside one pool and these controls do not govern it. An operator who wants one account only configures
+one account, or pauses the others, with the existing pool controls.
+
+## 4. Configuration
+
+```jsonc
+"fallback": {
+ "model": true, // absent = true
+ "provider": true,
+ "providers": { "anthropic": { "model": false } },
+ "routes": { "combo/coding": { "provider": false } },
+ "devices": { "lnk_0123456789abcdef": { "localFallback": false, "model": false } }
+}
+```
+
+A hop's kind is decided by what changes: provider differs → `provider`; same provider, different model →
+`model`. A malformed leaf reads as absent and diagnostics name the section. Device keys are a link id
+or a Hub data key id; the resolver maps a request's admission key id to its link through the link store.
+
+CLI: `ocx fallback status|off|on|clear [--kind model|provider|all] [--provider|--route|--device]` and
+`ocx fallback local on|off --device `. Management: `GET|PUT /api/fallback`.
+
+## 5. Resolver
+
+`src/routing/fallback-policy.ts`: `resolveFallbackPolicy`, `fallbackHopKind`, `guardFallbackHop` (records
+and refuses) and a 20-entry event ring. Nothing else reads `config.fallback` on the request path.
+`src/routing/fallback-state.ts` builds the `ocx.fallback/1` view and applies the scoped write.
+
+## 6. Error contract
+
+A refused hop answers with the status of the failure that triggered it (with its `Retry-After`), or 429
+for proactive quota/cooldown/hard-lock refusals and 503 otherwise, and:
+
+```json
+{ "error": { "type": "fallback_refused", "code": "fallback_refused", "message": "… To allow it: ocx fallback on --kind provider --route combo/coding",
+ "fallback": { "kind": "provider", "scope": "route", "scopeKey": "combo/coding", "from": "a/m", "to": "b/n", "trigger": "rate_limited" } } }
+```
+
+plus `x-opencodex-fallback: refused`. A taken hop is recorded in status; no header is added to the
+answering response, because the combo and policy responses are built deep in child dispatch.
+
+Hub down keeps status and retry semantics: `{ "error": "link tunnel unavailable", "code": "home_unreachable", "message": "…" }`
+on a Child (503) and `{ "error": "hub relay unavailable", "code": "home_unreachable", … }` on a Hub
+client's dashboard relay (502).
+
+## 7. Defaults and migration
+
+No default changes and no migration: absent `fallback` keeps every chain as it was, and an absent
+`localFallback` reads false, which is what a Child already does.
+
+## 8. Visibility
+
+`ocx status --json` `fallback` (live, the Home's over the link on a Child, or the local policy), `GET
+/api/fallback` (also over the link relay, aliases omitted), `/readyz` `fallback-policy/1`, and the
+dashboard Fallback panel on the Dashboard overview and on Remote Link. Events carry time, site, route, kind, from, to,
+trigger, outcome, scope and device id; never bodies, keys or account identities.
+
+## 9. Decisions (supervisor, 2026-10-22)
+
+- **C1 precedence: accepted.** Route > provider > global, most specific first; a device set to off always refuses.
+- **C2 `localFallback: true`: accepted as permission only.** OpenCodex keeps answering 503 when the Home is
+ unreachable; GOAT reads the flag and moves the work itself (ADR-0021 §1.6, "pause + decision item").
+- **C3 account rotation: overridden.** Account-pool 429 rotation stays not switchable; the maintainer rule
+ in `src/types/config.ts` stands. Rotation is intra-pool resilience, not fallback, so the `account` kind
+ is out of OCX-11 (§3, "Fallback versus intra-pool rotation").
+
+## 10. Tests
+
+`tests/routing/fallback-policy.test.ts` (81-case precedence matrix, degrade, device identity, contract,
+privacy), `tests/responses/responses-combo-fallback-off.test.ts`, `tests/routing/routing-policy-fallback-off.test.ts`,
+`tests/routing/subagent-model-fallback-off.test.ts`, `tests/codex-integration/main-account-hard-lock-fallback-off.test.ts`,
+`tests/server/fallback-routes.test.ts`, `tests/clients/client-home-unreachable.test.ts`, `tests/cli/cli-fallback.test.ts`,
+`tests/server/readyz-features.test.ts` and `gui/tests/fallback-panel.test.tsx`.
diff --git a/devlog/_plan/261021_link_apply_parity/000_plan.md b/devlog/_plan/261021_link_apply_parity/000_plan.md
new file mode 100644
index 00000000000..ea02ac32019
--- /dev/null
+++ b/devlog/_plan/261021_link_apply_parity/000_plan.md
@@ -0,0 +1,220 @@
+# Remote Link parity on apply (OCX-6, #6957)
+
+Status: open. Branch `feat/link-apply-parity`, PR target `dev` (merges after 2.90.0).
+
+## Problem
+
+The hub design (goat-si/goat ADR-0021, devlog 261021_goatclaw_hub §1.4) checks two things on a
+Remote Link Child that OpenCodex cannot answer today:
+
+- **P5 runtime freshness**: the Child runtime must have started after the last apply of the Home's
+ settings. Nothing records when the Home's settings were applied, and `ocx sync` on a linked Child
+ re-applies them without restarting the runtime. `ocx restart` cannot restart a linked Child at
+ all: the client listener's `/healthz` answers no attestation challenge and advertises no
+ `restartCapability`, so `requestBoundSystemRestart` refuses it (`restart_attestation_failed`).
+ Step 2 of the hub repair ladder (`ocx restart` on the device) therefore cannot work.
+- **P6 Codex settings**: there is no stable digest of the applied settings to compare between
+ Home and Child.
+
+## Contract
+
+- `codexSettingsDigest(settings)`: `sha256:<64 hex>` of the canonical JSON (recursively sorted keys,
+ no whitespace) of the `HomeCodexSettings` object. Home name and version are excluded, so a Home
+ and its Child agree. Never the content.
+- Child record `/link/home-codex-settings.json` gains `appliedAt`. A re-apply of the
+ same digest for the same link keeps the earlier `appliedAt` (idempotent); a new digest or a
+ first apply stamps now. Older records without it read `appliedAt = fetchedAt`.
+- The client runtime publishes `startedAt` (ISO, its process start) on `/healthz`.
+- `ocx status --json` gains top-level `codexSettings`:
+ `{ source: "published" | "home" | "local", digest, appliedAt, runtimeStartedAt }`.
+ Home/standalone: `published` with the digest of what it serves Children. Link Child: `home` with
+ the record's digest and `appliedAt`, plus the live client runtime's `startedAt`. Hub client or a
+ Child with no record: `local`, nulls.
+- Home `GET /api/link/status` (and `ocx link status --json`) gains per link
+ `parity: null | { appliedAt, runtimeStartedAt, codexSettingsDigest, reportedAt }`, from the
+ Child's report. The CLI validator accepts the key absent (older runtime) and normalizes to null.
+
+## Restart (P5)
+
+Reuse the existing bound restart (`ocx restart` -> attested `/healthz` -> capability-signed
+`POST /api/system/restart`). The client listener gains:
+
+- `/healthz`: `restartCapability: "v1"`, `startedAt`, and the attestation proof for a challenge,
+ keyed by the same secret the runtime already writes to `runtime-port.json`.
+- `POST /api/system/restart`: admitted only with the HMAC restart capability bound to this pid and
+ port (no GUI session, no admin token). 202 with the standalone body shape; idempotent while one
+ restart is in flight (`alreadyDraining: true`).
+
+Client restart executor (`src/client/runtime-restart.ts`), same supervisor rules as the standalone
+restart and the client recycle:
+
+1. Desktop-supervised: exit 75, the app starts the replacement.
+2. `OCX_SERVICE=1` with a viable service: exit 1, the failure-only supervisor relaunches.
+3. Unsupervised: stop the tunnel supervisor, stop the listener gracefully (bounded by the 60 s
+ drain), spawn `ocx start` with `replacementStartEnvironment(true)` and `waitForHealth: true`
+ (`spawnReplacementStart`). Success: exit 0. Failure: rebind the listener on the same port,
+ restart the supervisor, keep serving, and log the failure code. The CLI sees the same pid at its
+ deadline and reports the restart as failed (existing fail-closed path); the old runtime stays up.
+
+`ocx sync` on a linked Child: after a successful apply, if a client runtime is live and its
+`startedAt` precedes the record's `appliedAt`, run the same `handleProxyRestart` as `ocx restart`
+(no start when nothing is running). Unchanged digest + fresh runtime = no restart (idempotent). A
+failed restart exits non-zero with a message naming the kept runtime; re-running `ocx sync` or
+`ocx restart` retries.
+
+Enrollment (`POST /api/link/apply`) already runs `connect --link` (which records the settings)
+then `ocx restart` once, so it gains `appliedAt` with no flow change.
+
+## Child -> Home report
+
+`POST /v1/link-management/child-parity` on the Home's hub-link ingress only, after link-key
+admission. Body exactly `{ linkId, appliedAt, runtimeStartedAt, codexSettingsDigest }` (<= 1 KiB,
+strict ISO timestamps or null, digest pattern or null, linkId must belong to the admitting key).
+Stored in memory per link id (lost on Home restart until the next report), display only, never
+used for admission or routing. 204 on accept, 400 on a malformed body, 404 for a key/link mismatch.
+
+The client runtime (link mode) sends it through the connection-bound relay
+(`fetchBoundLinkRelay`, same as the settings read): first after start with backoff
+(2 s, 5 s, 15 s, 30 s, 60 s), then every 5 minutes; a 404 from an older Home stops it. Unref'd
+timers, stopped on shutdown/restart.
+
+## Files
+
+- src/link/codex-settings.ts: digest.
+- src/link/child-parity.ts (new): report parse + Home in-memory store.
+- src/link/management-routes.ts: report path in the route table.
+- src/server/index/link-management.ts: report handler (needs the admitting key id).
+- src/server/index/serve-options.ts: pass the admission key id.
+- src/link/status-projection.ts, src/server/management/link-routes.ts: `parity` per link.
+- src/cli/link.ts: validator accepts `parity`.
+- src/client/link-home.ts: `appliedAt`, report send.
+- src/client/connect.ts: enrollment write goes through the appliedAt helper.
+- src/client/machine-listener.ts: healthz attestation/capability/startedAt, restart route.
+- src/client/runtime.ts, src/client/runtime-restart.ts (new), src/client/link-parity-reporter.ts (new).
+- src/cli/dispatch.ts, src/cli/link-apply-restart.ts (new): sync -> restart.
+- src/cli/status.ts: `codexSettings` block.
+- structure/remote-link.md, docs-site CLI reference (status, link, sync, restart).
+
+## Tests (isolated homes only)
+
+- digest: key order / whitespace independence, change detection, Home projection == Child parse.
+- record: first apply stamps, same digest keeps appliedAt, new digest restamps, legacy record.
+- client listener: healthz proof + capability; restart route refuses without/with wrong capability,
+ accepts a valid one once, idempotent second accept.
+- restart executor: unsupervised success exits 0; replacement failure rebinds and keeps serving
+ (old runtime kept, failure reported); desktop -> 75; service -> 1.
+- sync: stale runtime -> restart called; fresh runtime -> not called; restart failure -> exit 1.
+- report: Home route accepts a valid report, rejects malformed / foreign link; status shows it.
+- JSON shape: `ocx status --json` codexSettings (Home and Child) and `ocx link status --json` parity.
+
+## Verification
+
+typecheck, focused files, test:changed, privacy:scan, structure:check, full suite under the lock.
+
+
+## Audit round 1 (gpt-6.1-sol, FAIL) and the revision
+
+1. **Not-ready replacement counted as success.** Folded. `spawnReplacementStart` gains
+ `notReady: "handoff" | "terminate"` (default `handoff`, standalone unchanged). The client restart
+ passes `terminate`: a replacement still unready at the deadline is killed and the call rejects
+ with `not_ready`, so rollback starts only once no replacement survives. Test: a replacement that
+ stays alive without answering is killed and the old runtime serves again.
+2. **Rollback cannot restart the stopped tunnel supervisor; shutdown captured locals.** Folded.
+ `startClientRuntime` keeps its resources in module state (`activeServer`, `activeSupervisor`)
+ and the signal handlers read that state. Rollback is one transaction in `runtime.ts`: bind a
+ fresh listener (port wait capped at 10 s, inside the CLI's observation budget), create a fresh
+ supervisor with the same key source, republish pid + runtime record with the same attestation
+ secret, and clear the restart latch.
+3. **Supervised handoff exits before validating the replacement.** Rebutted, kept as a stated
+ residual. Under the desktop app (exit 75) or a viable service (exit 1) the supervisor owns the
+ process identity and the port; the old process cannot hand the port back, and both supervisors
+ retry the relaunch (desktop on 75, launchd KeepAlive on unsuccessful exit, systemd
+ `Restart=on-failure`). This is exactly the standalone `ocx restart` semantics today. Refusing
+ instead would leave P5 unrepairable on service-installed Children, which is the common fleet
+ shape. A non-viable service falls through to the unsupervised path with rollback, as the
+ standalone restart does. Documented in structure/remote-link.md.
+4. **Receipt best-effort and independently committed.** Partly folded. The record write now
+ returns the committed receipt or throws; `ocx sync` reports a failed receipt write and exits
+ non-zero, and enrollment's `connect --link` fails the same way instead of swallowing it. The write
+ runs under `withClientLifecycleSync` after `assertClientConnectionUnchanged`. Holding L across
+ the injection is not possible: the documented lock order is N -> L -> C and injection takes N
+ itself. Residual: two concurrent `ocx sync` runs on one Child that read two different Home
+ documents can leave a receipt naming the earlier one; the next sync corrects it, and P6 then
+ reports the mismatch rather than hiding it.
+
+Notes folded: the restart route requires the capability principal explicitly (GUI session, admin
+token and link key are refused, with tests); it is outside `linkRouteAllowed`. The report POST is
+handled before the passive-read dispatch without widening the GET/HEAD table, bound to the stored
+link id and its admitted `apiKeyId`, read with a 1 KiB streaming cap and a 5 s deadline; reports for
+links no longer in the store are pruned on every write and never projected. `ocx sync` passes a
+start callback that never starts a proxy and rechecks connection ownership before restarting; a
+live runtime without `startedAt` (older build) counts as stale. Wire names stay camelCase
+(`appliedAt`, `runtimeStartedAt`) like every other `ocx ... --json` field; the docs map them to the
+brief's `applied_at` / `runtime_started_at`.
+
+
+Sync restart callback: when discovery first reads absent, start nothing (`skipped`); when an accepted restart left no runtime (`recoveringLiveRestart`), delegate to the ordinary recovery start (`handleRestartStartWhenStopped(true)`) so a vanished Child is brought back.
+
+## Audit round 2 (FAIL) and the revision
+
+1. **Supervised handoff.** Folded by refusal. Under the desktop app or a service supervisor the
+ client runtime answers the bound restart with `409 restart_supervised` and keeps serving; the
+ CLI reports the restart as not performed and names the supervisor's own restart
+ (`ocx service restart` / the desktop app). No supervised exit path exists in the client restart,
+ so no code path can leave a supervised Child down. A non-viable service (`OCX_SERVICE=1` without
+ viable assets) is treated as unsupervised and uses the rollback path. Consequence: P5 on a
+ supervised Child is repaired by the supervisor's restart, not by `ocx sync`; `ocx sync` says so.
+ Reported upstream as the open design question (supervisor-coordinated handoff with old-runtime
+ fallback would be a separate change in the desktop app and service assets).
+2. **Rollback vs CLI recovery.** Folded with one budget. The client restart owns a single deadline
+ of 90 s from acceptance: listener drain at most 20 s, replacement readiness until 70 s, then
+ termination (SIGTERM, wait for the exit event up to 3 s, SIGKILL, wait up to 2 s; the exit event is
+ the confirmation), rebind with a 10 s port wait, publication. The CLI's replacement observation
+ runs to about 135 s, so the old pid is back before recovery can become eligible, and the CLI
+ then reads the same pid and fails closed. `spawnReplacementStart` takes the shared deadline via
+ `io.deadlineAt` and `notReady: "terminate"` returns only after the child's exit is observed.
+ Rollback keeps the original `startedAt`.
+3. **Concurrent-sync receipt.** Folded by serialization. A link apply (Child `ocx sync` and the
+ link branch of `ocx connect`) holds a dedicated apply lock, outermost (before N), from the Home
+ read through injection to the receipt write. It reuses the lifecycle lock primitive
+ (`withClientLifecycle` with its own `lockPath` under `/link/`), `busy_timeout = 0`, so
+ a second concurrent apply fails fast with "another apply is in progress" and changes nothing.
+4. **Enrollment receipt failure.** Folded as a post-commit warning. `connect --link` keeps the
+ committed connection; a failed receipt write is printed as a warning (`Codex settings applied;
+ apply record not written; run ocx sync`) and `ocx status` then reports `source: "local"` until a
+ sync records it, which P6 shows as missing rather than as parity.
+
+
+## Audit round 3 (FAIL, two blockers) and the revision
+
+1. **Late acceptance can still overlap rollback with CLI recovery.** Folded with a pre-POST guard.
+ `CLIENT_RESTART_BUDGET_MS = 90_000` lives in `src/lib/system-restart-contract.ts`. The client
+ `/healthz` advertises `role: "client"`; `requestBoundSystemRestart` refuses to POST to a client
+ target (`restart_deadline_expired`, no request sent, runtime untouched) unless
+ `deadlineAt - RESTART_REOBSERVE_RESERVE_MS - now >= CLIENT_RESTART_BUDGET_MS + 5_000`. With the
+ 145 s deadline and 10 s reserve, a client restart is only accepted within the first 40 s, so the
+ rollback always finishes before replacement observation ends. The client enforces its own 90 s
+ from acceptance. Test: delayed acceptance is refused before POST; an accepted restart with a
+ failed replacement is back on the old pid inside the observation window.
+2. **409 loses the guidance.** Folded. The client answers
+ `409 { error: { code: "restart_supervised", supervisor: "service" | "desktop" } }`.
+ `requestBoundSystemRestart` reads that bounded body and returns the validated code
+ `restart_supervised_service` / `restart_supervised_desktop`; `src/cli/restart-failure.ts` prints
+ "Restart not performed; the running Child runtime was kept. Restart it with `ocx service restart`
+ (or from the OpenCodex desktop app)." Any other 409 keeps the existing generic report.
+
+
+## Contract change from the manager (GOAT H2, goat-si/goat PR #696)
+
+Supersedes the field names and formats above where they differ:
+
+- **OCX-6a**: every link row of the Home's `ocx link status --json` carries `lastApplyAt`, epoch
+ **milliseconds** of the Child's last successful settings apply (with `runtimeStartedAt`,
+ `appliedCodexSettingsDigest` and `reportedAt` beside it). The Child report uses epoch ms too.
+- **OCX-6b**: `ocx status --json` on any device carries top-level `appliedCodexSettingsDigest` =
+ lowercase hex SHA-256 (no prefix) of the compact UTF-8 JSON
+ `{"version":1,"settings":{"fastMode":…,"multiAgentMode":…,"webSearchSidecarEnabled":…,"subagentDefaults":null|{"model":…,"reasoningEffort":…}}}`
+ in exactly that key order, from the values actually applied. Serialized by name, not by a sorting
+ canonicalizer. Golden digests for a null and a set `subagentDefaults` are pinned in
+ `tests/clients/link-codex-settings-digest.test.ts`. The Child's `lastApplyAt` and
+ `runtimeStartedAt` sit beside it as epoch ms.
diff --git a/devlog/_plan/261021_readyz_features/000_plan.md b/devlog/_plan/261021_readyz_features/000_plan.md
new file mode 100644
index 00000000000..745de5e35aa
--- /dev/null
+++ b/devlog/_plan/261021_readyz_features/000_plan.md
@@ -0,0 +1,50 @@
+# OCX-3: `/readyz` advertises peer features
+
+Producer side of the GOAT hub design (goat-si/goat#684, ADR-0021, `261021_goatclaw_hub/000_design.md` §5 and
+brief OCX-3). Peers decide compatibility from `features` instead of parsing versions.
+
+## Current state
+
+- `GET /readyz` (`src/server/index/serve-options.ts`) spreads `readyProtocolMetadata(config, req)` from
+ `src/remote/protocol.ts` into its body. `RemoteReadyMetadata.features?: string[]` already exists and
+ `parseRemoteReadyMetadata` validates it (at most 64 unique non-empty strings, each at most 80 chars, no control
+ characters), but no producer fills it.
+- Relay auth version: `LINK_RELAY_AUTH_VERSION = "2"` in `src/link/relay-auth.ts`.
+- Management routes are declared in `MANAGEMENT_ROUTES` (`src/server/management/route-registry.ts`), pure data
+ reconciled against dispatch source by `tests/server/management-route-registry.test.ts`.
+- The Remote Link management relay read table is `LINK_MANAGEMENT_READ_PATHS` (`src/link/management-routes.ts`).
+- No `x-opencodex-served-by` header is emitted yet (OCX-2).
+
+## Change
+
+1. New `src/remote/features.ts`: one ordered registry `PEER_FEATURES`, each entry an id plus a predicate over a
+ `PeerFeatureSources` snapshot read from the owning modules. `servedPeerFeatures(sources?)` returns the ids
+ whose predicate holds, in registry order.
+ - `quota-pool/1`: `MANAGEMENT_ROUTES` declares `GET /api/quota-pool` (OCX-1 turns it on by declaring the route,
+ which the registry reconciliation test already forces).
+ - `link-management-relay`: `LINK_MANAGEMENT_READ_PATHS` is non-empty.
+ - `served-by-header`: `SERVED_BY_HEADER_EMITTED`, the emitter's descriptor in a new
+ `src/remote/served-by.ts` (`false` until OCX-2, which flips it in the change that emits the header).
+ A live test on an isolated server keeps it honest: `x-opencodex-served-by` is present on the
+ `/readyz`, `/healthz` and unknown-`/v1/*` responses exactly when `/readyz` advertises the feature.
+ - `relay-auth/` (currently `relay-auth/2`): always.
+2. `readyProtocolMetadata` adds `features: servedPeerFeatures()` (computed once; the sources are build constants).
+3. Tests in a new `tests/server/readyz-features.test.ts` (registered in layout + expected fixture): registry
+ presence/absence follows each source, ids are unique and survive `parseRemoteReadyMetadata`, the live
+ `/readyz` body carries exactly the registry output and nothing account-shaped. Update the exact-shape
+ assertions line-neutrally in `tests/server/proxy-liveness.test.ts` (two `readyProtocolMetadata` `toEqual`s)
+ and `tests/server/server-live.test.ts:1442` (sorted key allowlist gains `features`; allowlist kept).
+4. Docs: `structure/runtime.md` (owns `src/remote/`), `structure/remote-link.md` pointer for relay-auth,
+ `docs-site` lifecycle `ocx ready` field list.
+
+## Out of scope / open question
+
+The Child machine listener (`src/client/machine-listener.ts`) answers its own `/readyz` with a different fixed
+shape (`protocolVersion: 1`, no `features`). Whether a Child should advertise features, and which (it serves no
+`/api/quota-pool` of its own), is a product decision reported back, not chosen here.
+
+## Verification
+
+typecheck; focused files (new test, proxy-liveness, management-route-registry, core-link-boundary,
+core-lab-boundary, client-machine-listener, server-live); `bun run test:changed`; `bun run structure:check`;
+`bun run privacy:scan`; full `bun run test` under `/usr/bin/lockf -t 3600 /tmp/ocx-heavy-test.lock`.
diff --git a/devlog/_plan/261021_service_status_json/000_plan.md b/devlog/_plan/261021_service_status_json/000_plan.md
new file mode 100644
index 00000000000..5ce225f7b75
--- /dev/null
+++ b/devlog/_plan/261021_service_status_json/000_plan.md
@@ -0,0 +1,30 @@
+# 000 — OCX-10: passive service status JSON and install-method verdict
+
+Producer side of the GOAT hub inventory (goat-si/goat#684, design §5 detection steps 3–4).
+GOAT reads an adopted OpenCodex install over SSH and must not change it.
+
+## Scope
+
+1. `ocx service status --json`: one stable document built from the same reads the text
+ status already performs — `diagnoseService()` (launchd / systemd user unit / Task Scheduler
+ / WinSW) and, when a registration exists, the loopback serving probe. It is answered before
+ `platformOps()` resolves, so no install, start, stop, repair or migration code is reachable.
+ The text output is unchanged.
+2. `ocx status --json` gains an additive `install` field mapped from the existing
+ `detectInstallOwnership()` (`src/update/install-detection.mjs`), with the evidence basis and
+ no new paths.
+
+## Shape decisions
+
+- `running` is the service manager's verdict (the value `startup.serviceRunning` already
+ carries); `serving` says separately whether a proxy answers on the installed port.
+- `backend` names the manager holding a registration: `launchd`, `systemd`, `schtasks`,
+ `winsw`, or `none` when nothing is registered or the platform is unsupported.
+- `install.method` adds `standalone` to the requested set because the existing detector already
+ distinguishes the compiled desktop sidecar; `unknown` is reserved for a detector failure.
+
+## Verification
+
+Platform shape snapshots with mocked managers, a passive guard over every spawn entry point on
+darwin/linux/win32, an install-method matrix, typecheck, test:changed, skill surface, structure,
+privacy and the full suite.
diff --git a/devlog/_plan/261023_sweep/010_wave_plan.md b/devlog/_plan/261023_sweep/010_wave_plan.md
new file mode 100644
index 00000000000..970c0f1b827
--- /dev/null
+++ b/devlog/_plan/261023_sweep/010_wave_plan.md
@@ -0,0 +1,127 @@
+# 2.92.0 sweep — wave plan v1
+
+> Written 2026-10-23 after the 2.91.0 release (main `83ec4f75a4`, npm/tag/GitHub release/updater verified).
+> Baseline: `dev` at `299e8b68b2` (after #7511, #7510 and #7458). Planner only: no lane here merges; the
+> sweep manager owns every merge. UI/UX changes are excluded from the sweep and get issues only.
+
+## 1. Safety gates (unchanged)
+
+- Combined-tree simulation on current `dev` before each merge.
+- Rule C: a Windows-sensitive change (path filter plus markers `spawnSync`, `SIGTERM`, `SIGKILL`,
+ `process.kill`, PowerShell, `.cmd`) needs a green `lane=all` run on the exact head.
+- No unresolved blocking review threads; dev-red stop rule.
+- Auth, credentials, dependency, GitHub Actions or release tooling changes carry a security summary in the
+ PR description; this applies to every lane below.
+
+## 2. Host placement
+
+- Remote hosts (mac-studio, macmini-ts) are mid GOAT update, and remote launches from the coordinating
+ MacBook are refused until the manager deploys the dev build there. **Start remote lanes only after that
+ deploy.**
+- Until then, run **at most two light lanes on the coordinating Mac** (its GOAT pressure is elevated):
+ W1-B and W1-C first, then W1-E when one finishes.
+- After the deploy: heavy and Windows-sensitive lanes on mac-studio (16 cores), light lanes on
+ macmini-ts (10 cores). mini (win32) and lidge-ts have no opencodex checkout.
+- Model: `anthropic/claude-opus-5-5` for auth and Windows-sensitive lanes;
+ `anthropic2/claude-opus-5-5` for plain carries and test-only lanes.
+
+## 3. Wave 1 (no file overlap between lanes)
+
+| Lane | Scope | Size | Windows | Host | Branch |
+|---|---|---|---|---|---|
+| B | #7505 honor upstream no-retry verdict | S | – | this Mac | `sweep/w1-retry-noretry` |
+| C | #7392 finish with `length` on Responses incomplete length; register test in both layout maps | S | – | this Mac | `sweep/w1-chat-length` |
+| E | #7497 + #7498 SSE carry performance | S | – | this Mac, after B or C | `sweep/w1-sse-carry` |
+| A | #7521 (#7517) → #7519 (#7512) → #7391 (#7390), sequential; #7519 and #7391 share `src/clients/config-export/qoder.ts`; #7391 must refuse an unowned legacy collision (review P2) | M | – | mac-studio | `sweep/w1-qoder` |
+| D | #7457 (#7403); #7404 finite client-connect deadline; loopback-listener `freePort`→bind race (macOS EADDRINUSE); tests only | M | – | mac-studio | `sweep/w1-flaky-tests` |
+| F | PowerShell effective-account SID lookup timeout at `src/codex/user-identity.ts:254` during service install | S | yes | mac-studio | `sweep/w1-win-sid-timeout` |
+| G | Upstream catalog refresh (section 6) | M | – | mac-studio | `sweep/w1-upstream-oct` |
+
+#7521 touches `structure/providers-and-adapters.md` and `docs-site/.../guides/providers.md`; #7471, #7444 and
+#7445 touch the same files, so they wait for wave 2.
+
+## 4. Wave 2 (after wave 1 merges)
+
+| Lane | Scope | Notes |
+|---|---|---|
+| H | #7471 command-code credit refusal rotation | blocker withdrawn by reviewer |
+| I | #7444 (#7440) Business Premium capacity weight | S |
+| J | #7445 deprecate mimo-free → #7439 (#7438) Alibaba Token Plan | sequential: `entries-extended.ts`, `derive.ts`; endpoint change needs primary-source review |
+| K | #7463 (#7456) fail unmet required tool completions | conflicting; `openai-chat.ts` after lane E |
+| L | #7375 message phase inference | review P2 on terminal snapshot phases |
+| M | #7450 + #7499 | share `src/codex/auth-api/account-list.ts` |
+| N | #7459 Hermes conversation id | unblocked by #7511; can run beside wave 1 |
+| O | #7425 `ocx sync` overwrites user edits in `config.toml` | no PR; implement |
+| P | #7442 (#7441) Antigravity family quota to JEV | open CHANGES_REQUESTED blocker; `core-combo.ts` |
+| Q | Anthropic API version `2026-10-21` (sampling parameters removed) | `src/adapters/anthropic.ts` |
+| – | #7523 replay state isolation by caller | after P (`core-combo.ts`) |
+| R | #7516 Qoder CN image input, allowed only for qoderclicn >= 1.1.70 (decision §9) | after lane A: shares `src/adapters/qoder/adapter.ts` with #7521; version probe must fail closed |
+| S | #7500 route an Antigravity model only to accounts whose discovered roster lists it; clear error when none is eligible (decision §9) | `src/oauth/generic-account-failover.ts`, `src/providers/antigravity-account-roster.ts`; `anthropic/` |
+| T | #7525 opt-in JEV per-session pin, narrower than #6665 (decision §9) | after P: `src/combos/jev-dispatch.ts`, `src/cli/combo.ts`; default off, reuse JEV dispatch/decision owners |
+| U | #7366 document how `spawn_agent` fills slots when fewer than five models are featured (decision §9) | docs only (`guides/sub-agent-surface.md`); the dashboard hint is UI and stays on #7366 for the UI track |
+
+## 5. Wave 3
+
+W3: contributor PR review queue, managed separately.
+
+## 6. Upstream provider changes (lane G / Q input)
+
+- Anthropic Claude Haiku 5.5 (`claude-haiku-5-5`, released 2026-10-07): 1M context, 128k output,
+ adaptive thinking and `effort`; sampling parameters must be omitted.
+- Anthropic API version `2026-10-21`: breaking removal of sampling parameters from the Messages API.
+- Gemini: `gemini-omni-flash-preview` shut down 2026-10-22 (replacement `gemini-omni-1.1-flash`);
+ `deep-research-pro-preview-12-2025` on 2026-10-23; Vertex retired `gemini-2.5-pro` on 2026-10-20,
+ with no Developer API date yet, so change Vertex presets only.
+- Codex CLI 0.163.0 stable (2026-10-15), 0.164.0-alpha.9 prerelease: app-server and catalog smoke.
+- xAI, DeepSeek, Kimi and Qwen were not checked; lane G verifies them against official docs.
+
+## 7. Lane brief template
+
+Remote lanes cannot be steered after launch (`goat_thread_send` to a remote thread is refused by the
+permission ceiling), so a remote brief must be self-contained: every decision pre-made, a stop rule for
+ambiguity (decide, record the decision in the PR description, continue), and a final report of PR URL, head
+SHA, verification commands with totals, and anything `NOT RUN`.
+
+> `$codexclaw:cxc-loop` (HOTL, scoped) + `$codexclaw:cxc-dev`. Lane : . Base: latest
+> `origin/dev`, branch . Carrying a contributor PR keeps a `Co-authored-by` trailer. Resolve every
+> CHANGES_REQUESTED, CodeRabbit and Codex thread, recording reasons for rejected findings. Register new tests
+> in both `layout.json` (or its shard) and a `test-layout-expected*.json` shard; never grow a file at its
+> size cap. Verify with focused tests, `bun run test:changed`, `typecheck`, `privacy:scan` and
+> `structure:check`. Windows-sensitive changes need exact-head `lane=all` green. Auth, credentials,
+> dependency, GitHub Actions or release tooling changes carry a security summary. Open a templated PR against `dev`, do not merge, and report
+> the head SHA and check state to the manager. File issues for UI changes instead of making them.
+
+## 8. Close proposals
+
+Done by the manager: #7448 (fixed by #7449), #7436 (duplicate of #7433), #7437 (duplicate of #7434),
+#7533 (superseded by the opt-in #7534).
+
+## 9. Needs a product decision
+
+Approved by the supervisor as recommended on 2026-10-23; a later user decision overrides.
+
+| Item | Decision |
+|---|---|
+| #7533 vs #7534 Flex tier | keep the opt-in variant (#7534) |
+| #7516 Qoder CN image input | allow, gated on qoderclicn >= 1.1.70 |
+| #7500 Antigravity paid vs trial Pro | route by discovered model roster; clear error when no account is eligible |
+| #7525 JEV per-session pin | opt-in, narrower than #6665 |
+| #7366 `spawn_agent` slot fill | document and hint first; policy change later |
+
+## 10. Deferred and excluded
+
+- Waiting for reporter data: #7443, #7382.
+- Upstream tracking only: #7524 (desktop capability gate; no repository change between 2.86.0 and 2.90.0).
+- UI/UX, excluded: #4649, #7529, #7318, #7275, #7292, #7347, #7526, #7368, #7406.
+- Large drafts parked for this sweep: #7531, #7485, #7344, #7308, #7270, #7267, #7311, #7428, #7376,
+ #7234, #7325, #7374, #7370.
+- Already in flight: #7527, #7528.
+
+## 11. State at plan revision (2026-10-23)
+
+- Merged: #7511 `8d6e124dd8`, #7510 `943f63ffc1`, #7458 `299e8b68b2`; #7520 and #7509 on exact-head `lane=all`.
+- Running on the coordinating Mac: W1-B, W1-C.
+- W1-A was started on mac-studio, then interrupted to honor the remote-after-deploy rule: branch
+ `sweep/w1-qoder` holds the #7521 and #7519 cherry-picks, unpushed, stopped mid-conflict on #7391.
+ Resume in the same worktree after the deploy.
+
diff --git a/devlog/_plan/261025_command_code_plan_denial/000_plan.md b/devlog/_plan/261025_command_code_plan_denial/000_plan.md
new file mode 100644
index 00000000000..d66592cab6a
--- /dev/null
+++ b/devlog/_plan/261025_command_code_plan_denial/000_plan.md
@@ -0,0 +1,120 @@
+# 261025 Command Code MODEL_NOT_IN_PLAN refusal (#7684)
+
+Command Code answers a model the account's plan excludes with HTTP 403
+`{"success":false,"error":{"code":"FORBIDDEN","status":403,"message":"MODEL_NOT_IN_PLAN: "}}`.
+OpenCodex forwards that 403, and Codex treats any 403 as a retryable `UnexpectedStatus`: a scratch
+probe against Codex CLI 0.163.0 counted 6 sends (`Reconnecting... 1/5` to `5/5`) for the 403 and
+exactly 1 send for an HTTP 400 (`.tmp/m5/codex-retry-probe.ts`, not committed). Inside a combo the
+same 403 cools the whole Command Code provider row (`comboFailureCooldownScope` returns
+`provider` for 401/402/403), so every other Command Code model in that combo is skipped. This unit
+makes the refusal a single, non-retryable, model-scoped answer.
+
+## Loop spec
+
+- Archetype: satisfy-spec, one work-phase (wp1). Trigger: lane M5 brief for issue #7684.
+- Goal: one upstream send per turn for a plan-excluded model, a clear client error naming the
+ model and the plan, combos hop to the next target and cool only the refused target.
+- Non-goals: catalog/discovery removal of plan-excluded models (unverifiable without an
+ authenticated account), sibling-account rotation on a plan refusal, a per-account denial cache
+ (generic OAuth has none; `src/codex/observed-model-denials.ts` is Codex-pool only and none is
+ added), the Command Code API-key `openai-chat` preset, the shared error formatter allowlist,
+ docs-site (guides/providers.md is owned by lane M4), the known failure at
+ `tests/claude-integration/claude-desktop-remote-hub.test.ts:249`.
+- Verifier: `bun test tests/providers/command-code-plan-denial.test.ts` red before / green after;
+ credit refusal suites stay green; typecheck, test:changed, privacy:scan, structure:check exit 0;
+ Codex probe through the real proxy route counts 1 send.
+- Stop: PR open on dev with independent review PASS at the exact head and CI state reported.
+- Memory artifact: this file plus the goalplan under `.codexclaw/goalplans/`.
+- Terminal outcomes: DONE as above; BLOCKED if push/PR/review surfaces fail repeatedly; UNSAFE if
+ the fix needs credential handling changes; NEEDS_HUMAN if review demands excluded scope.
+- Escalation: a reviewer asking for catalog removal or account rotation goes to the origin.
+- Resource bounds: none stated; host limits apply.
+
+## Diff-level plan
+
+1. `src/adapters/command-code.ts`: generalize the credit normalizer into
+ `normalizeCommandCodeRefusal(response, oauth, modelId, signal)`. Status 400 keeps the existing
+ OAuth-only credit rewrite byte-for-byte. Status 403, for every auth mode, reads at most 8 KiB
+ (fatal UTF-8, display-safe) and matches only `success === false`, `error.code === "FORBIDDEN"`
+ and a message starting with `MODEL_NOT_IN_PLAN` followed by `:` or the end. It returns HTTP 400 with
+ `{"error":{"type":"invalid_request_error","code":"model_not_in_plan","message":...}}`, headers
+ `x-opencodex-upstream-status: 403` and `x-opencodex-refusal: model-not-in-plan` (both diagnostic).
+ `commandCodePlanRefusalMessage` is fixed copy naming the canonical model; the upstream detail is
+ never quoted. The adapter marks the Response it builds in memory
+ (`markVerifiedModelNotInPlanResponse`); that object identity is the only provenance. Any other 403, a malformed or
+ oversized body, or malformed UTF-8 is returned untouched. The reasoning-effort retry send uses the
+ same normalizer. No account, key or provider cooldown is recorded; nothing is logged.
+2. `src/combos/failover.ts`: `comboFailureDecision` hops on `model_not_in_plan` only when the caller
+ passes `verifiedPlanRefusal` (combo loop from `consumeComboFailure`, policy fallback from the
+ envelope mark); a bare, passthrough, streamed or run-turn code is read like any unknown code.
+ `comboFailureCooldownScope` then falls through to `target` for the 400, so only the refused
+ provider/model target cools.
+3. `tests/providers/command-code-plan-denial.test.ts` (registered in
+ `scripts/test-layout/layout-adapters.json` and on an existing line of
+ `tests/fixtures/test-layout-expected.json`): OAuth two-account dispatch (one send, 400, active
+ account unchanged, the same account then serves another model), key row (one send, 400), an
+ unrelated FORBIDDEN 403 stays 403, combo `[plan-refused model, sibling model]` on the same
+ provider completes on the sibling, and the failover predicates for the code.
+4. `structure/providers-and-adapters.md`: extend the existing command-code row in place (net zero
+ lines; the doc is at 600/600).
+
+## Risks
+
+- A 400 is a status change for this one refusal; clients that branched on 403 for it now see
+ `invalid_request_error`. The upstream status is kept in `x-opencodex-upstream-status`.
+- The client body keeps the dispatch wrapper (`Provider error 400: {...}`); the formatter
+ keeps `model_not_in_plan` as the top-level code so clients and policy fallback can read it. The
+ wire code is not provenance; only the boundary that verified the refusal marks the envelope.
+
+## Audit r1 amendment (GOAT task m5-plan-audit-r1, codex/gpt-6.1-sol high: GO-WITH-FIXES)
+
+1. Accepted (P2 policy fallback): `policy-fallback.ts` hops on the final envelope's `error.code`,
+ which `adapter-dispatch.ts` never forwards. The constant `MODEL_NOT_IN_PLAN_CODE` lives in
+ `src/lib/errors.ts`; `formatErrorResponse` keeps it when a caller passes it (same rule as
+ `undeclared_tool_call`). `adapter-dispatch.ts` passes it only when `isModelNotInPlanRefusal`
+ accepts the response: a 400 with the canonical body code whose Response object the adapter
+ marked in memory. The header is diagnostic only and is never read as provenance.
+2. Accepted (P2 misleading detail): the message is fixed wording built from the request's
+ canonical model id (accepted only if it matches a conservative id pattern). Upstream detail is
+ never quoted, so effort-repair, opaque-recovery and subagent quota classifiers cannot fire on it.
+ The adapter also skips its effort-repair branch for a normalized plan refusal.
+3. Accepted (P1 logging): no upstream text reaches the client body or the combo warning. A test
+ puts synthetic account/key canaries plus effort and quota phrases in the upstream detail and
+ asserts their absence from the client body and captured console output.
+4. Accepted (P2 matrix): adapter-level negatives for malformed JSON, invalid UTF-8, oversized body,
+ wrong `success`/`code`, a `MODEL_NOT_IN_PLAN_OTHER` prefix lookalike, abort propagation,
+ key/forward/OAuth modes, normalization after a genuine effort-repair send; dispatch tests on the
+ Responses, Chat and Messages inbound paths; combo hop plus a second request that skips only the
+ cooled target; a policy-route hop.
+5. Noted: attribution moves from `credential-rejected` to `payload-rejected` by the status table;
+ documented in the PR, not pinned by a test that would restate the table.
+
+## Review rounds (PR #7696)
+
+Independent review via GOAT task (codex/gpt-6.1-sol, high), each round at the exact head:
+
+- r1 MUSTFIX: the combo trust gate matched only the lowercase code while failover folds case,
+ space and hyphens. Fixed with `namesModelNotInPlanCode` (`6d92c0657`).
+- r2 MUSTFIX: policy fallback trusted a passthrough envelope's code. Fixed with the explicit
+ `verifiedPlanRefusal` option on `comboFailureDecision` (`5d41c59f5`).
+- r3 MUSTFIX: status, code and marker header are all upstream-forgeable. Fixed by making
+ provenance in-memory object identity (`bbc39eab6`). r4 PASS at `bbc39eab6`.
+- CodeRabbit: `formatErrorResponse` minted the mark from a caller-supplied code, which run-turn
+ combo children take from an adapter event. Fixed by marking only at the verifying boundaries
+ (`adapter-dispatch.ts`, `consumeComboFailure`); this plan text was also corrected.
+- r5 MUSTFIX: the web-search and image sidecar loops rebuilt a failed first iteration as their own
+ envelope, dropping the fixed copy, code and mark, so a sidecar combo or policy stopped instead of
+ moving on. Fixed with `verifiedPlanRefusalMessage` in both loops and mark propagation across the
+ `sidecar-execution.ts` stream wrappers.
+- r6 MUSTFIX: a refusal on a later sidecar iteration lands inside an already committed 200 stream,
+ where `model_not_in_plan` made Codex CLI 0.163.0 reconnect (6 sends). It now ends as `invalid_prompt`
+ with `retryable: false` and the same copy (1 send), via `sidecarLoopTerminalEvent`.
+- CodeRabbit (tests): whole-body assertion and positive streamed combo/policy fallbacks.
+- r7 MUSTFIX: on Chat inbound the later-iteration `invalid_prompt` arrived without a numeric status,
+ and both the streamed projection and the buffered collector inferred 502 `server_error` from the
+ wording. `src/chat/outbound.ts` now treats every `isTerminalRefusalCode` code as a 400-class
+ verdict that keeps its code (the existing cyber-policy rule, generalized).
+- CodeRabbit (tests): the sidecar route test asserts the injected `web_search` function tool.
+- Manager Linux verification: `adapter-dispatch.ts` grew to 2,007 lines, failing the file-size ratchet
+ and the responses-core module cap. The plan-refusal branch moved into `formatUpstreamFailureResponse`
+ (`src/bridge/errors.ts`); `adapter-dispatch.ts` is back to 1,999 lines with a one-call change.
diff --git a/devlog/_plan/261025_hermes_profiles/000_plan.md b/devlog/_plan/261025_hermes_profiles/000_plan.md
new file mode 100644
index 00000000000..8cf5afbd0a5
--- /dev/null
+++ b/devlog/_plan/261025_hermes_profiles/000_plan.md
@@ -0,0 +1,50 @@
+# 000 — hermes_profiles: Plan
+
+## Objective
+
+Issue #7674: the Hermes integration writes only the default profile. Hermes keeps one
+home per profile: the default home is `HERMES_HOME` or the platform default
+(`%LOCALAPPDATA%\hermes` on Windows, `~/.hermes` elsewhere), and each named profile is
+`/profiles//` with its own `config.yaml`. OpenCodex resolves exactly one home
+(`src/clients/config-export.ts:266,276`, registry `src/integrations/registry.ts:320`), so
+named profiles get no provider block and no catalog.
+
+Evidence base (Hermes upstream, NousResearch/hermes-agent@4dcef215):
+
+- `hermes_constants.py` `PROFILE_ID_RE = ^[a-z0-9][a-z0-9_-]{0,63}$`,
+ `_PROFILE_IDENTITY_MARKERS = (config.yaml, .env, SOUL.md, profile.yaml, auth.json, state.db)`,
+ tombstone `profiles/.deleted/`, `_hermes_root_of` (HERMES_HOME under the native home
+ -> native home; `/profiles/` -> ``; otherwise HERMES_HOME itself),
+ `_platform_default_hermes_home` (Windows `%LOCALAPPDATA%\hermes`).
+- `hermes_cli/profiles.py` `_get_profiles_root` = root/profiles, `_iter_named_profile_dirs`.
+- Docs: https://hermes-agent.nousresearch.com/docs/user-guide/profiles ,
+ https://hermes-agent.nousresearch.com/docs/getting-started/installation (Windows home).
+
+## Loop-spec
+
+- Loop archetype: verifier-defined (route/uninstall regression tests, gates).
+- Write scope: new `src/integrations/hermes-profiles.ts`, new
+ `src/server/management/hermes-profile-routes.ts`, scoped store/path plumbing in
+ `src/server/management/integration-routes.ts`, `src/cli/uninstall-integrations.ts`,
+ GUI Hermes profile list + scope plumbing, docs-site integrations guide (EN),
+ `structure/clients/hermes.md` (new leaf; integrations.md net <= +1 line), tests.
+- Out of scope: CLI `ocx integrations` profile flags, Hermes catalog auto-refresh (Hermes is
+ not in any refresh list today, default included), "apply to all" batch, translated docs,
+ `structure/runtime.md`.
+- Budget: one work-phase; one PR.
+
+## Work-phase map
+
+| WP | Doc | Slice | Depends on |
+|----|-----|-------|------------|
+| wp1 | 010_phase1.md | discovery + scoped API + uninstall + GUI + docs + delivery | — |
+
+## Accept criteria
+
+- Named Hermes profiles are discovered and selectable; with no selection (or `profile=default`)
+ behavior is identical to dev.
+- Each named profile has its own ownership store `integrations/hermes-profiles//`
+ (records, journal, snapshots); apply/disable/overwrite/restore/delete-history are scoped.
+- Uninstall disables every owned Hermes profile before removing state.
+- Regression test fails on dev, passes after the fix; acceptance commands green.
+- PR to dev, exact-head CI dispatched, independent review PASS.
diff --git a/devlog/_plan/261025_hermes_profiles/010_phase1.md b/devlog/_plan/261025_hermes_profiles/010_phase1.md
new file mode 100644
index 00000000000..e0f8a3cdb99
--- /dev/null
+++ b/devlog/_plan/261025_hermes_profiles/010_phase1.md
@@ -0,0 +1,101 @@
+# 010 — Phase 1 (hermes_profiles)
+
+## MODIFY / NEW / DELETE map
+
+### NEW src/integrations/hermes-profiles.ts
+
+- `HERMES_PROFILE_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/` (Hermes PROFILE_ID_RE),
+ `HERMES_DEFAULT_PROFILE = "default"`, identity markers as Hermes.
+- `hermesRootDir(env, home)`: Hermes `_hermes_root_of` over OpenCodex's existing native home:
+ HERMES_HOME unset -> native; HERMES_HOME inside native -> native; parent named `profiles`
+ -> grandparent; else HERMES_HOME.
+- `listHermesProfiles(env, home)`: `{ name: "default", home: hermesHomeDir(env, home) }` followed by
+ named profiles under `/profiles`: real directory (lstat, never a symbolic link or
+ junction), name matches the id grammar and is not `default`, carries an identity marker, no
+ tombstone, and does not resolve to the default home (HERMES_HOME pointing at a profile already
+ makes it the default target). Sorted by name. Missing root -> default only.
+- `resolveHermesProfile(name, env, home)`: `HermesProfileError` 400 for an invalid name,
+ 404 when it is not a listed profile.
+- `hermesProfileStore(root, name)` at `/hermes-profiles/`;
+ `listHermesProfileStores(root)` enumerates persisted child stores (uninstall), refusing names
+ outside the grammar.
+- `hermesProfileEnv(profile, env)`: `{ ...env, HERMES_HOME: profile.home }` — how Hermes itself
+ selects a profile; registry/writer/state then resolve that profile's config.yaml.
+
+### NEW src/server/management/hermes-profile-routes.ts
+
+- `GET /api/client-integrations/hermes/profiles` -> `{ profiles: [IntegrationStatus & { hermesProfile }] }`.
+- `scopeHermesProfileRequest(ctx, ...)`: when `profile` is present and the request is
+ `/api/client-integrations/hermes` or a history path with `client=hermes` (`journal`, `preview`,
+ `restore`, `restore/preview`), validate and resolve the profile, register `{ profile, store, env }`
+ per Request, and return a ctx whose URL drops `profile`/`client` so the Aside handlers never
+ interpret a Hermes name. `profile=default` leaves the root scope. Errors -> 400/404 JSON.
+
+### MODIFY src/server/management/integration-routes.ts
+
+- `integrationStore(ctx?)` and `pathOverrides(ctx?)` consult the request scope; generic call sites
+ pass `ctx`. Aside options keep the root store.
+- Call the profile list route and the scope resolver at the top of `handleIntegrationRoutes`.
+- Scoped preview requires body `clientId: "hermes"`; scoped GET echoes `hermesProfile`.
+
+### MODIFY src/cli/uninstall-integrations.ts
+
+- Read every Hermes child store strictly before mutation; a child naming another client
+ refuses. `resolvedPaths` from the record (must be `.../profiles//config.yaml`),
+ disable through the same coordinated writer; final emptiness check includes Hermes children.
+
+### GUI
+
+- `integration-api.ts`: `hermesProfile?: string` scope on state, journal, preview, restore
+ preview, toggle, restore, delete; `loadHermesProfiles`.
+- NEW `HermesProfilesPage.tsx`: profile list with state badges; selecting one renders
+ `FileIntegrationPage` scoped to it (switch, overwrite, history, undo).
+- `FileIntegrationPage.tsx`, `RestoreDialog.tsx`: pass `hermesProfile` through.
+- `Integrations.tsx`: Hermes tab renders `HermesProfilesPage`. i18n keys in every locale.
+
+### Docs
+
+- `docs-site/src/content/docs/guides/integrations.md`: Hermes profiles paragraph.
+- NEW `structure/clients/hermes.md` registered in `structure/manifest.json`; one pointer line in
+ `structure/clients/integrations.md`.
+
+## TESTS
+
+- NEW `tests/clients/hermes-profiles.test.ts`: discovery (markers, tombstone, invalid names,
+ symlink refusal, HERMES_HOME-at-profile exclusion, LOCALAPPDATA root), root derivation.
+- NEW `tests/clients/hermes-profile-routes.test.ts`: the regression — apply with `?profile=coder`
+ writes `profiles/coder/config.yaml`, leaves default untouched, records ownership in
+ `hermes-profiles/coder`; journal scoping, restore (undo) and disable per profile; invalid or
+ missing profile 400/404; default selection unchanged; uninstall disables profile ownership.
+
+## Verification (C)
+
+- `bun test tests/clients/hermes-profiles.test.ts tests/clients/hermes-profile-routes.test.ts` (red on dev, then green)
+- `bun test tests/server/management-integration-routes.test.ts tests/cli/uninstall.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts`
+- `bun run typecheck`, `bun run test:changed`, `bun run privacy:scan`, `bun run structure:check`,
+ `bun run lint:gui`, GUI tests, docs-site build. Exit 0 each.
+
+## A-round 1 (FAIL, 4 blockers) — resolutions
+
+1. Flight join (accepted): `scopedFlightKey` prefixes named-profile flight keys with
+ `hermes-profile::`; default keeps `apply`/`disable`/`restore:...`. Named/named and
+ named/default concurrent writes return `integration_mutation_busy`. Test: "a second profile is busy...".
+2. Aside fallback (accepted): only `profile` is stripped; `client=hermes` stays. Every scoped request,
+ `profile=default` included, registers a scope, and the generic routes skip
+ `asideJournalResponse`, `asideRestoreResponse` and `asideJournalDeleteResponse` for it;
+ `outsideHermesScope` 404s any non-Hermes operation in restore, restore preview and delete, and a
+ scoped preview must name `clientId: "hermes"`. Test: foreign pi and Aside op ids, both scopes.
+3. Root move (accepted): `hermesProfileBoundary` refuses (`hermes_profile_owner_mismatch`, 409) when the
+ child store's record names another config.yaml — at scope creation and again in the writer's
+ synchronous `guard` (restore: last step of `revalidate`). The list reports such a profile `unsafe`.
+ Test: two roots, same profile name, apply/overwrite/disable all refused, original record retained.
+4. Filesystem boundary (accepted, proportionate): the boundary pins `profiles/` and the profile home by
+ `dev:ino` and refuses links/junctions, a tombstone appearing, and a linked store root or store
+ parent, immediately before the write. Discovery never lists links/junctions. Uninstall reads
+ records strictly and checks the `profiles//config.yaml` shape. Test: junctioned profile
+ not offered; profile swapped for a junction inside the flight is refused with no write.
+5. Unscoped history (contract, not aggregated): the unscoped journal lists the root store only;
+ named-profile history is read/undone from that profile's page. Documented in structure/clients/hermes.md.
+6. GUI keys (accepted): `hermesProfile` is part of the remount key, resource/session cache keys, callback
+ deps, and the response identity check (`status.hermesProfile === hermesProfile`).
+7. Gates (accepted): layout shard + expected fixture registered; structure INDEX regenerated.
diff --git a/devlog/_plan/261025_moonshot_k31_seed/000_plan.md b/devlog/_plan/261025_moonshot_k31_seed/000_plan.md
new file mode 100644
index 00000000000..324f56eaca0
--- /dev/null
+++ b/devlog/_plan/261025_moonshot_k31_seed/000_plan.md
@@ -0,0 +1,53 @@
+# 000 — moonshot_k31_seed: Plan
+
+Issue #7653 (lane M4). Single-cycle C3 change; docs-first roadmap cycle skipped (one work-phase).
+
+## Objective
+
+The `moonshot` preset (Kimi API, `https://api.moonshot.ai/v1`) seeds `kimi-k3` plus legacy K2 ids
+(`src/providers/registry/model-seeds.ts:772-773`) but not Kimi K3.1 Pro, so the picker never offers
+`kimi-k3.1-pro` / `kimi-k3.1-pro-highspeed`, and a routed request gets no 1M window or effort ladder.
+#7657 (46f23cab5e) already added the price rows for both ids (`src/usage/expected-prices.ts:467-468`)
+but deliberately left the seeds out.
+
+Evidence base (official Moonshot platform docs only, fetched 2026-10-25):
+
+- https://platform.kimi.ai/docs/models.md — both `kimi-k3.1-pro` and `kimi-k3.1-pro-highspeed` listed
+ as current multi-modal models ("native visual understanding and a 1M-token context window";
+ highspeed = "same intelligence, about 2-3x the speed").
+- https://platform.kimi.ai/docs/pricing/chat.md — "All K3 series models support a 1,048,576-token
+ context window"; price rows $2/$10/$0.20/$2(5m) and $4/$20/$0.20/$4(5m) (already in tree, unchanged).
+- https://platform.kimi.ai/docs/guide/kimi-k3-1-pro-quickstart.md — always thinking; top-level
+ `reasoning_effort` low/high/max, default max; temperature/top_p/n/penalties fixed; vision via base64
+ or ms:// file ids; return the complete assistant message (reasoning_content) on multi-turn.
+- https://platform.kimi.ai/docs/api/models-overview.md — "kimi-k3.1-pro and kimi-k3.1-pro-highspeed
+ also have the same parameter constraints as kimi-k3"; `tool_choice` auto/none/required supported.
+- https://platform.kimi.ai/docs/guide/use-reasoning-effort.md — low/high/max, default max.
+
+## Loop-spec
+
+- Loop archetype: verifier-defined (parity + wire tests, typecheck, gates).
+- Write scope: model-seeds.ts KIMI_API_* constants; entries-extended.ts moonshot entry wiring;
+ tests/providers/provider-registry-parity.test.ts; tests/codex-integration/reasoning-effort.test.ts;
+ docs-site/src/content/docs/guides/providers.md (English canonical); structure/providers-and-adapters.md
+ (in-place, file stays at 600 lines); this devlog unit.
+- Out of scope (owner decision, noted in PR): K2 retirement, moonshot defaultModel (kimi-k2.7-code,
+ entries-extended.ts:475), rename migrations, kimi-k3's own max-only ladder (docs now list low/high/max
+ for kimi-k3 too — follow-up), coding presets, prices, GUI, translated docs (English-only addition
+ does not contradict them).
+- Budget / bounds: none stated; host limits apply. Tool scope: local repo, gh (push/PR authorized by
+ lane brief), GOAT task.create for review.
+
+## Work-phase map (one phase = one full PABCD cycle)
+
+| WP | Doc | Slice | Depends on |
+|----|-----|-------|------------|
+| wp1 | 010_phase1.md | seed + tests + docs + gates + PR + review | — |
+
+## Accept criteria
+
+- c-1 moonshot seeds both ids with 1M window, text+image, low/high/max default max, K3 effort map,
+ fixed sampling, preserve reasoning, not auto-tool-choice-only; kimi-k3 / K2 / default unchanged.
+- c-2 regression tests red on base (0aeaf0cb6b) and green after the fix.
+- c-3 typecheck, test:changed, privacy:scan, structure:check, docs-site build green on final head.
+- c-4 PR open on dev, GOAT-linked, independent review PASS at exact head, CI state reported.
diff --git a/devlog/_plan/261025_moonshot_k31_seed/010_phase1.md b/devlog/_plan/261025_moonshot_k31_seed/010_phase1.md
new file mode 100644
index 00000000000..6f36afc9371
--- /dev/null
+++ b/devlog/_plan/261025_moonshot_k31_seed/010_phase1.md
@@ -0,0 +1,91 @@
+# 010 — Phase 1 (moonshot_k31_seed) — consolidated after audit rounds 1-2 and manager scope update
+
+## MODIFY / NEW / DELETE map
+
+### MODIFY src/providers/registry/model-seeds.ts (KIMI_API_* block, ~lines 772-809)
+
+```ts
+export const KIMI_LEGACY_API_MODELS = [...unchanged];
+// 261025 Kimi K3.1 Pro on the Moonshot API (#7653): evidence comment (models.md, pricing/chat.md,
+// kimi-k3-1-pro-quickstart.md, api/models-overview.md; China platform.kimi.com/docs/models.md), verified 2026-10-25.
+export const KIMI_API_K31_MODELS = ["kimi-k3.1-pro", "kimi-k3.1-pro-highspeed"];
+export const KIMI_API_K3_SERIES_MODELS = ["kimi-k3", ...KIMI_API_K31_MODELS];
+export const KIMI_API_MODELS = [...KIMI_API_K3_SERIES_MODELS, ...KIMI_LEGACY_API_MODELS];
+// Frozen previous seed for the saved-roster repair (literal, not derived).
+export const KIMI_API_MODELS_BEFORE_K31 = ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
+export const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => !KIMI_API_K3_SERIES_MODELS.includes(id));
+// none -> omit: thinking cannot be disabled; value mirrors REASONING_EFFORT_OMIT_SENTINEL (src/reasoning-effort.ts:30)
+export const KIMI_API_K31_REASONING_EFFORT_MAP = { ...KIMI_CODING_K3_REASONING_EFFORT_MAP, none: "__omit__" };
+export const KIMI_API_REASONING_EFFORTS = KIMI_API_MODELS -> K3.1: ["low","high","max"]; kimi-k3: ["max"] (unchanged); K2: [].
+export const KIMI_API_DEFAULT_REASONING_EFFORTS = { "kimi-k3.1-pro": "max", "kimi-k3.1-pro-highspeed": "max" };
+export const KIMI_API_REASONING_EFFORT_MAPS = { both K3.1 ids: KIMI_API_K31_REASONING_EFFORT_MAP };
+export const KIMI_API_MODEL_CONTEXT_WINDOWS = K3 series 1_048_576, K2 262_144;
+export const KIMI_API_MODEL_INPUT_MODALITIES = K3 series ["text","image"];
+```
+
+(The omit literal is used because model-seeds.ts imports nothing from src/reasoning-effort.ts and the
+registry must not depend on the request-path module; a test asserts the literal equals the exported
+constant.)
+
+### MODIFY src/providers/registry/entries-extended.ts (moonshot entry)
+
+Add `modelDefaultReasoningEfforts: KIMI_API_DEFAULT_REASONING_EFFORTS` and
+`modelReasoningEffortMap: KIMI_API_REASONING_EFFORT_MAPS` (+ imports). defaultModel stays kimi-k2.7-code.
+
+### MODIFY src/providers/stale-model-roster-migration.ts (STALE_MODEL_ROSTERS)
+
+```ts
+{
+ provider: "moonshot", from: KIMI_API_MODELS_BEFORE_K31, to: KIMI_API_MODELS,
+ contextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS, defaultReasoningEfforts: KIMI_API_DEFAULT_REASONING_EFFORTS,
+ destinationBaseUrls: [MOONSHOT_INTL_BASE_URL, MOONSHOT_CN_BASE_URL],
+}
+```
+
+### MODIFY docs-site/src/content/docs/guides/providers.md (English canonical, after the Kimi Coding price paragraph)
+
+One paragraph: moonshot seeds kimi-k3.1-pro / -highspeed next to kimi-k3 and the K2 ids; 1M context,
+text+image, always-on thinking, low/high/max (default max; medium->high, xhigh->max, none omits the field);
+sampling params omitted; untouched saved model lists on the International/China URLs gain both ids at
+startup; default stays kimi-k2.7-code; link the official model list (K2 closes to new accounts 2026-10-31,
+retirement planned end of December 2026).
+
+### Structure docs: no edit
+
+providers-and-adapters.md is owned by lane M5 (manager). Its line 87 (Kimi Coding seeds) and line 394
+(generic stale-roster contract) remain accurate: this change adds a data row to an existing mechanism and
+seeds; no contract changes. chat-compat.md (600/600) describes wire compat, not seeds. Per
+structure/AGENTS.md ("a reviewed document whose content remains accurate does not need copied unchanged
+prose") no structure edit is required. Recorded in the PR.
+
+## TESTS (all in tests/providers/)
+
+- provider-registry-parity.test.ts, moonshot section: exact models, defaultModel, exact context windows,
+ per-K3.1 metadata (no-reasoning absent, ladder, default max, modalities, fixed-sampling + preserve lists,
+ not auto-tool-choice-only, map == K3 map with none "__omit__"), kimi-k3 unchanged (["max"], no default/map),
+ omit literal === REASONING_EFFORT_OMIT_SENTINEL; catalog entry default_reasoning_level "max".
+ New test: persisted OLD moonshot row (old models list, old modelContextWindows/modelReasoningEfforts) routed
+ via routeModel to moonshot/kimi-k3.1-pro[-highspeed]: configuredReasoningEfforts low/high/max; wire none -> no
+ reasoning_effort, low->low, medium->high, high->high, xhigh->max, max->max, ultra->max; temperature/top_p/penalties stripped.
+- model-roster-seed-repair.test.ts: untouched moonshot seed on intl URL, cn URL, and no URL -> refreshed, existing
+ containers gain 1M windows + max defaults; custom URL and hand-edited list unchanged.
+
+Red run first on base 0aeaf0cb6b with test edits only.
+
+## Verification (C)
+
+| Command | Expected |
+|---|---|
+| bun test tests/providers/provider-registry-parity.test.ts tests/providers/model-roster-seed-repair.test.ts tests/usage/usage-cost.test.ts tests/codex-integration/reasoning-effort.test.ts tests/codex-integration/codex-catalog.test.ts | exit 0 |
+| bun run typecheck | exit 0 |
+| OCX_TEST_RUNNER_BUN= bun run test:changed | exit 0 except known claude-desktop-remote-hub.test.ts:249 |
+| bun run privacy:scan | exit 0 |
+| bun run structure:check | exit 0 |
+| cd docs-site && bun install --frozen-lockfile && bun run build | exit 0 |
+
+## Audit history
+
+- Round 1 FAIL: (1) none sent on wire for always-thinking K3.1 -> own map with omit; (2) saved rows keep old
+ roster -> STALE_MODEL_ROSTERS entry. Both folded.
+- Round 2 NEAR-PASS: chat-compat.md is 600/600 -> resolved by making no structure edit (rationale above);
+ stale superseded sections -> this consolidated rewrite.
diff --git a/devlog/_plan/261025_sync_expect_link/010_plan.md b/devlog/_plan/261025_sync_expect_link/010_plan.md
new file mode 100644
index 00000000000..9290d2127fc
--- /dev/null
+++ b/devlog/_plan/261025_sync_expect_link/010_plan.md
@@ -0,0 +1,121 @@
+# 10 — `ocx sync --expect-link` / `--expect-hub-key` (issue #7694)
+
+Class C3 (CLI contract + client lifecycle). Single work-phase `wp1`.
+
+## Problem
+
+`ocx sync` (`src/cli/dispatch.ts`, `sync` runner) reads `readClientConnectionState()` without a
+lock and picks a path from it: connected -> `syncConnectedClient`; disconnected -> local
+`syncModelsToCodex`. GOAT runs `ocx connect status --json` and then `ocx sync`; if the Child was
+disconnected or relinked in between, sync quietly applies local providers or another Home.
+
+## Contract
+
+- `ocx sync --expect-link `: proceed only when, under the lock that selects the path, the
+ machine is a connected Remote Link Child (`transport: "link"`) whose saved `link.linkId` equals
+ ``, with no pending key rotation and no pending/unsafe disconnect receipt.
+- `ocx sync --expect-hub-key `: same for a hub-mode client (transport absent or `hub`)
+ whose `apiKeyId` equals the value.
+- Match: exactly today's connected sync (catalog, Home Codex settings, apply record, runtime restart).
+- Mismatch: nothing written, no local proxy discovery, no `syncModelsToCodex`; exit **5** (the
+ documented "state is not what you expected; inspect first" code); stderr carries one human line
+ and a final JSON line `{"phase":"child_link_mismatch"|"hub_client_mismatch","reason":R}` with
+ R in `disconnected | invalid_state | disconnect_pending | pending_operation | hub_client |
+ link_client | other_link | other_key`. Identifiers are not echoed.
+- Usage: missing value, malformed link id (`^lnk_[0-9a-f]{16}$`), empty/oversized key id
+ (> 256 chars), a repeated flag, or both flags -> exit 2 before any state read.
+- Without either flag, behavior is unchanged.
+
+## Diff-level plan
+
+1. `src/client/sync-expectation.ts` (new): `ClientSyncExpectation`, `ClientSyncMismatchReason`,
+ `ClientSyncExpectationError` (`phase`, `reason`), pure `classifyClientSyncExpectation(state,
+ receipt, expectation)`, and `assertClientSyncExpectation(expectation)` which reads the disconnect
+ receipt and connection state. Read-only; called with L/C held.
+2. `src/client/connect.ts`: `syncConnectedClient(options.expect?)`; inside the first
+ `withClientLifecycleSync(withConfigMutationLockSync(...))` block (already inside `withLinkApply`)
+ call `assertClientSyncExpectation` before the existing `assertNoClientDisconnectPending`. Later
+ writes are already CAS-bound to that connection by `assertClientConnectionUnchanged`. Return the
+ connection the sync ran under (`connection`) so the CLI stops relying on its unlocked read.
+3. `src/cli/sync-expectation.ts` (new): `parseSyncExpectation(args)` and
+ `reportSyncExpectationMismatch(error, log)` (returns 5 or null).
+4. `src/cli/dispatch.ts` `sync`: parse first (exit 2 on usage); with an expectation, skip the
+ unlocked branch decision and always call `syncConnectedClient({ restartCodex, expect })`; map
+ `ClientSyncExpectationError` to exit 5; derive `link` and the restart owner from
+ `result.connection`.
+5. CLI surface: `src/cli/registry.ts` usage/details, `src/cli/help.ts` line, sync capability flags
+ in `src/cli/capabilities-base.ts`; `bun run skill:surface`; prose in
+ `skills/ocx/references/04_failure_semantics.md` and `05_remote_hub.md`.
+6. Docs: `docs-site/src/content/docs/reference/cli/lifecycle.md` (English) and
+ `structure/cli-management.md` (manager scope: `structure/runtime.md` is at its 600-line cap
+ and stays untouched).
+7. Test `tests/cli/cli-sync-expect-link.test.ts` (registered in the `layout-clients.json` shard
+ and `tests/fixtures/test-layout-expected.json`): in-process, `OPENCODEX_HOME` temp dir, config
+ fixtures written directly, the client lifecycle lock moved into the test directory; spies on
+ `syncModelsToCodex` and `findLiveProxy` prove no fallback; a match uses a Child selecting only
+ `claude` (the schema requires one client) and a stubbed Home catalog fetch, so the real
+ connected path runs end to end.
+8. `src/client/machine-api.ts`: `/api/machine/sync` spreads the sync result into its response,
+ so it drops the new `connection` field (token fingerprint, prior catalog) before replying.
+
+## Audit round 1 (codex/gpt-6.1-sol, NEAR-PASS) — decisions
+
+1. Record deletions after the Home settings await are not re-checked against a concurrent
+ disconnect. **Rebutted for this unit**: pre-existing in every connected sync, unchanged by the
+ expectation, and the record is one disconnect removes anyway. Left as a residual.
+2. Pending connect marker not inspected. **Accepted**: on a disconnected machine a valid
+ `client-connect-pending` marker is `pending_operation`, an unreadable one `invalid_state`. A
+ connect only starts from a disconnected machine, so a marker beside a connection is ignored.
+3. Test coverage. **Accepted in part**: added both expectation kinds end to end, invalid config,
+ pending connect (valid and malformed), pending/unsafe receipts in the classifier, a relink
+ during the catalog read, isolated lock path, restored `process.exitCode`. Selected-Codex
+ injection is unchanged code after the gate and stays covered by its existing suites.
+4–6. Notes adopted: lock files are coordination artifacts, not state; the JSON `phase` is the
+ discriminator, exit 5 the code; `result.connection` is `next`.
+
+Test hazard found while recording the red run: on an unfixed tree an argv containing
+`--restart-codex` is a real restart request, which ended the Codex app hosting the agent. The
+test carries no restart flag and stubs the restart helper.
+
+## PR review round 1 (codex/gpt-6.1-sol, FAIL) — decisions
+
+1. Blocker, **accepted**: `runCli` ran `maybeAutoRestoreCodexShim` before dispatch, so a pinned
+ sync could repair the shim on a mismatched machine, and a malformed one read shim state before
+ exiting 2. Fix: `src/cli/root.ts` validates the flags before any preflight (exit 2);
+ `skipsCodexShimAutoRestore` exempts a pinned sync (the `stop --expect-*` precedent); the sync
+ runner calls `runCodexShimAutoRestore` once the locked check passed, so a match still repairs
+ like an unpinned sync. Root-level tests added; without the fix 4 of 25 cases fail.
+2. Non-blocking, **accepted**: trailing blank lines at EOF removed (`git diff --check` clean).
+
+## Manager review at 7bfbd75a41 (MUST-FIX) — decisions
+
+1. P2, **accepted**: a link connect holds the apply lock across its network reads, so a pinned
+ sync failed acquiring it before its check and exited 1 without a phase. `withLinkApply` now
+ throws `LinkApplyBusyError` (`code: "link_apply_busy"`, same message); `syncConnectedClient`
+ maps that or `client_lifecycle_busy` raised before its check passed to
+ `ClientSyncExpectationError(..., "pending_operation")`. Tests hold a real link connect at its
+ readiness fetch (deferred promise) and a real lifecycle lock; without the fix both fail.
+2. P3, **accepted**: PR body regains a standalone `## Verification` heading and `Closes #7694`.
+
+## PR review round 4 (codex/gpt-6.1-sol at 21166e1be, FAIL) — decisions
+
+1. Blocker, **accepted**: the check also needs the config mutation lock; a concurrent config writer
+ made acquisition throw `CONFIG_MUTATION_LOCK_UNAVAILABLE` (cause `SQLITE_BUSY`), which the
+ mapper ignored (exit 1, no phase). `isClientSyncLockBusy` now accepts that code when its cause
+ is busy/locked, with the classification `src/config/mutation-lock.ts` already uses; other
+ acquisition or storage failures stay themselves. Test: a real SQLite `BEGIN IMMEDIATE` holder
+ for both expectation kinds, an unpinned control (exit 1), and a holder taken after the check
+ (exit 1, "Config mutation already in progress"). Without the fix the case fails.
+
+## Verification
+
+Red run of the new test on the unfixed tree, then: focused test files, `bun run typecheck`,
+`bun run test:changed`, `bun run privacy:scan`, `bun run structure:check`,
+`bun run skill:surface:check`, docs-site build. Full `bun run test` is optional (shared host).
+
+## Risks
+
+- Older `ocx` ignores unknown sync flags, so a caller must version-gate the flag (GOAT keeps its
+ status check for older versions, per the issue).
+- A connection that changes after the locked check but before a write still fails as today
+ (`client_connection_changed`, exit 1), not as a mismatch: the expectation held when checked.
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
index 8476dae8623..89ccd0728d6 100644
--- a/docs-site/astro.config.mjs
+++ b/docs-site/astro.config.mjs
@@ -103,6 +103,7 @@ export default defineConfig({
{ label: "Desktop App", translations: { fr: "Application de bureau", ko: "데스크톱 앱", "zh-CN": "桌面应用", "zh-TW": "桌面 App", ru: "Настольное приложение", ja: "デスクトップアプリ", tr: "Masaüstü Uygulaması" }, slug: "guides/desktop-app" },
{ label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" },
{ label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" },
+ { label: "Fallback off", translations: { fr: "Désactiver le repli", ko: "폴백 끄기", "zh-CN": "关闭回退", "zh-TW": "關閉備援", ru: "Отключение резерва", ja: "フォールバックをオフにする", tr: "Yedeği kapatma" }, slug: "guides/fallback-off" },
{ label: "Protocol Paths", translations: { fr: "Chemins de protocole", ko: "프로토콜 경로", "zh-CN": "协议路径", "zh-TW": "協定路徑", ru: "Пути протоколов", ja: "プロトコル経路", tr: "Protokol Yolları" }, slug: "guides/protocol-paths" },
{ label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" },
{ label: "Grok Build", translations: { fr: "Grok Build", ko: "Grok Build", "zh-CN": "Grok Build", "zh-TW": "Grok Build", ru: "Grok Build", ja: "Grok Build", tr: "Grok Build" }, slug: "guides/grok-build" },
@@ -162,6 +163,7 @@ export default defineConfig({
{ label: "Adapters", translations: { fr: "Adaptateurs", ko: "어댑터", "zh-CN": "适配器", "zh-TW": "適配器", ru: "Адаптеры", ja: "アダプター", tr: "Adaptörler" }, slug: "reference/adapters" },
{ label: "Architecture", translations: { fr: "Architecture", ko: "아키텍처", "zh-CN": "架构", "zh-TW": "架構", ru: "Архитектура", ja: "アーキテクチャ", tr: "Mimari" }, slug: "reference/architecture" },
{ label: "Platform Support", translations: { fr: "Prise en charge des plateformes", ko: "플랫폼 지원", "zh-CN": "平台支持", "zh-TW": "平台支援", ru: "Поддержка платформ", ja: "プラットフォーム対応", tr: "Platform Desteği" }, link: `${SITE_URL}/reference/platform-support` },
+ { label: "Windows Code Signing", translations: { fr: "Signature du code Windows", ko: "Windows 코드 서명", "zh-CN": "Windows 代码签名", "zh-TW": "Windows 程式碼簽署", ru: "Подпись кода Windows", ja: "Windows コード署名", tr: "Windows Kod İmzalama" }, slug: "reference/code-signing" },
{ label: "Proxy API Formats", translations: { fr: "Formats de l’API proxy", ko: "프록시 API 형식", "zh-CN": "代理 API 格式", "zh-TW": "代理 API 格式", ru: "Форматы API прокси", ja: "プロキシAPI形式", tr: "Proxy API Formatları" }, slug: "reference/proxy-formats" },
{ label: "Management API", translations: { fr: "API de gestion", ko: "관리 API", "zh-CN": "管理 API", "zh-TW": "管理 API", ru: "API управления", ja: "管理API", tr: "Yönetim API'si" }, slug: "reference/management-api" },
],
diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro
index 4048ae628be..8944272605e 100644
--- a/docs-site/src/components/Landing.astro
+++ b/docs-site/src/components/Landing.astro
@@ -111,6 +111,7 @@ const fr = {
'Installation guide': 'Guide d’installation',
'All releases and checksums': 'Toutes les versions et sommes de contrôle',
'Desktop app guide': 'Guide de l’app de bureau',
+ 'Code signing policy': 'Politique de signature du code',
'Desktop App': 'App de bureau',
'Latest release': 'Dernière version',
'Star on GitHub': 'Ajouter une étoile sur GitHub',
@@ -344,6 +345,12 @@ const docsMap = [
{t('All releases and checksums', '전체 릴리스와 체크섬', '所有版本与校验和', 'Все релизы и контрольные суммы', 'すべてのリリースとチェックサム', '所有版本與校驗碼', 'Tüm sürümler ve sağlama toplamları')}
{t('Desktop app guide', '데스크톱 앱 가이드', '桌面应用指南', 'Руководство по приложению', 'デスクトップアプリガイド', '桌面應用程式指南', 'Masaüstü uygulaması kılavuzu')}
+
+
diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md
index aa8668df8c5..d0021b7f12a 100644
--- a/docs-site/src/content/docs/contributing.md
+++ b/docs-site/src/content/docs/contributing.md
@@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI
bun run prepare:package # refresh package launchers/assets
```
-The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.2.
-`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
+The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4.
+`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
Install that version locally if it is missing; the resolver never downloads it.
+The roles remain independently checked even when their versions match. Promotion requires
+a successful full cross-platform `lane=all` run on the candidate head.
Run `bun run test` by default. If a full run is disproportionately expensive for the task size,
available machine resources, or concurrent worktrees, you must at least run focused regression tests
@@ -263,6 +265,11 @@ Use one of the versioned dispositions: `passthrough`, `translated`, `degraded`,
Every non-passthrough claim must state its limitation, and every fixture-backed claim must name the
exact assertion ids that prove it. Add the secret-free request vector under
`tests/fixtures/compatibility/` and execute it against the production adapter in a focused test.
+A `passthrough` claim may only cite `equals` assertions whose expected value is the caller's own
+value at the same place, on a selector bound to that feature in the manifest test, so a removed,
+rewritten, or unrelated field cannot be labeled lossless. Keep related
+aspects as separate claims when a route can preserve one and lose the other, such as a custom tool
+and its grammar.
Compatibility manifests are passive data: the ordinary router, Responses handler, and server
startup path must not import the manifest catalog or activate Compatibility Lab.
diff --git a/docs-site/src/content/docs/fr/getting-started/quickstart.md b/docs-site/src/content/docs/fr/getting-started/quickstart.md
index a3eb2f9fea0..fa96c775b52 100644
--- a/docs-site/src/content/docs/fr/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/fr/getting-started/quickstart.md
@@ -13,7 +13,7 @@ ocx init
`ocx init` vous accompagne dans les étapes suivantes :
-1. **Choix d’un fournisseur** — sélectionnez l’un des 104 préréglages intégrés au registre, ou `custom` pour saisir une
+1. **Choix d’un fournisseur** — sélectionnez l’un des 106 préréglages intégrés au registre, ou `custom` pour saisir une
URL de base et un adaptateur.
2. **Clé API** — collez une clé ou référencez une variable d’environnement telle que `${ANTHROPIC_API_KEY}`.
3. **Modèle par défaut** — pour les fournisseurs clés, locaux et personnalisés, acceptez le préréglage ou saisissez un identifiant de modèle.
diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md
index 49cd7d6113e..0405b2807d9 100644
--- a/docs-site/src/content/docs/fr/guides/claude-code.md
+++ b/docs-site/src/content/docs/fr/guides/claude-code.md
@@ -169,24 +169,24 @@ unknown signifie qu’opencodex ne peut pas déterminer si les réglages pointen
### Mode picker : modèles opencodex dans le sélecteur Code first-party
-Le mode picker fait partie du mode first-party. Sur macOS, il est activé par défaut lorsque first-party
+Le mode picker fait partie du mode first-party. Sur macOS et Windows, il est activé par défaut lorsque first-party
est sélectionné, sauf si `claudeCode.intercept.picker: false` est défini. Il modifie le sélecteur de
modèles de l'onglet Code de Desktop first-party pour y afficher les modèles opencodex disponibles par
leur nom. Lors de la première activation, macOS peut demander l'autorisation d'une autorité de certification
-locale dans le trousseau de connexion. Cette autorité est limitée à `claude.ai` et à ses sous-domaines.
+locale dans le trousseau de connexion, et Windows dans le magasin racine de l'utilisateur actuel. Cette autorité est limitée à `claude.ai` et à ses sous-domaines.
Son identité de signature exportable est protégée par le magasin d'identifiants du système et réutilisée
lors des redémarrages ordinaires. Aucune clé de signature picker en clair n'est enregistrée dans le
répertoire de configuration OpenCodex. La validation complète de l'autorité contrainte et la vérification
-de la confiance du système restent obligatoires. Avec la même identité approuvée et un magasin accessible,
-un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats. La restauration au démarrage
-n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente.
-Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder.
+de la confiance du système restent obligatoires, avec sur Windows la correspondance exacte de l'empreinte et une feuille vérifiée. Avec la même identité approuvée et un magasin accessible,
+un redémarrage n'ajoute ni ne supprime de réglages de confiance des certificats macOS. La restauration au démarrage
+ou la vérification d'état n'installe jamais la confiance : si elle manque, est révoquée ou inconnue, le picker reste en attente.
+Exécutez explicitement `ocx claude desktop picker on` ou `ocx claude desktop picker trust` pour l'accorder, y compris la confirmation Windows si nécessaire.
Une migration unique depuis une ancienne identité peut demander votre consentement pour supprimer sa
confiance. Si ce nettoyage échoue, le picker reste indisponible et le profil appliqué utilise un relais
sans déchiffrement jusqu'à sa réussite. macOS peut aussi demander de déverrouiller le trousseau ou
-d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour.
-Le picker reste non pris en charge sous Windows et Linux : aucun travail de CA, de magasin d'identifiants
+d'autoriser l'accès d'une application aux identifiants, y compris après un redémarrage ou une mise à jour. Sur Windows, `on` ou `trust` explicite peut afficher une confirmation d'ajout de la racine.
+Le picker reste non pris en charge sous Linux : aucun travail de CA, de magasin d'identifiants
ou de proxy picker n'y démarre. L'interception Claude principale reste disponible ; ses fichiers CA locaux
sont protégés par des vérifications du propriétaire, des liens symboliques, des permissions et des ACL Windows.
diff --git a/docs-site/src/content/docs/fr/guides/codex-app-models.md b/docs-site/src/content/docs/fr/guides/codex-app-models.md
index 0e69068a714..8e420079af1 100644
--- a/docs-site/src/content/docs/fr/guides/codex-app-models.md
+++ b/docs-site/src/content/docs/fr/guides/codex-app-models.md
@@ -138,11 +138,12 @@ approximation fondée sur un ancien modèle d'entrée.
| Route | Identifiants du sélecteur et métadonnées du catalogue |
| --- | --- |
-| Connexion Codex (lignes qualifiées par compte désactivées) | Identifiants natifs non qualifiés comme `gpt-5.6-sol`, `gpt-5.6-terra` et `gpt-5.6-luna` ; Pool ou Direct est choisi avec `codexAccountMode`. Les lignes GPT-5.6 utilisent une fenêtre de catalogue de 372 000 jetons. |
+| Connexion Codex (lignes qualifiées par compte désactivées) | Identifiants natifs non qualifiés comme `gpt-5.6-sol`, `gpt-5.6-terra` et `gpt-5.6-luna` ; Pool ou Direct est choisi avec `codexAccountMode`. Les lignes GPT-5.6 utilisent une fenêtre de catalogue de 372 000 jetons. `gpt-6.1-terra` : contexte par défaut 500,000 / étendu 872,000. |
| Connexion Codex (lignes qualifiées par compte activées avec des sélecteurs admissibles) | Une ligne `/` par sélecteur admissible et modèle natif pris en charge ; chaque ligne utilise exclusivement le compte associé, et les lignes natives non qualifiées sont masquées dans le sélecteur. Les métadonnées natives et les fenêtres de contexte sont préservées. |
| Connexion Codex (ligne Daybreak transférée explicitement) | `openai/gpt-daybreak-blue-latest` uniquement lorsque l'entrée `customModels` exacte est configurée sur le fournisseur canonique `openai`. Elle conserve l'identifiant Daybreak transmis et utilise l'instantané de capacités Sol épinglé (contexte de 372 000 jetons ; compactage automatique à 334 800 jetons). |
-| OpenAI (clé API) | Exactement dix lignes avec espace de noms : `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, les trois identifiants virtuels `*-pro` et les deux alias Daybreak (contexte de 1 050 000 jetons ; entrée maximale de 922 000 jetons pour les dix) |
+| OpenAI (clé API) | Les modèles avec espace de noms comprennent `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, les identifiants virtuels `*-pro` et les alias Daybreak (contexte de 1,050,000 ; entrée maximale de 922,000). `openai-apikey/gpt-6.1-terra` ajoute un contexte de 1,050,000 et une sortie maximale de 128,000. |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1 050 000) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) |
| Cursor | Le repli statique comprend `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` et `cursor/gpt-5.6-luna` (1 000 000), ainsi que des lignes ordinaires/rapides pour Grok 4.5, 4.6 et 4.7 (500 000) ; 4.6 et 4.7 ajoutent `xhigh`, et la découverte dynamique propre au compte détermine quelles lignes restent visibles. |
| xAI | La découverte dynamique fait autorité. Le catalogue de secours comprend `xai/grok-4.6` et `xai/grok-4.7` et utilise `xai/grok-4.5` par défaut ; les trois ont une fenêtre de 500 000 jetons. Grok 4.6 et 4.7 proposent `low` / `medium` / `high` / `xhigh` (valeur amont par défaut : `high`), tandis que Grok 4.5 s’arrête à `high`. |
diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md
index 4cb051fc727..8a9398988f6 100644
--- a/docs-site/src/content/docs/fr/guides/combos.md
+++ b/docs-site/src/content/docs/fr/guides/combos.md
@@ -201,7 +201,7 @@ Les échecs d’un combo se répartissent entre ceux qui entraînent un **bascul
| HTTP 401, 403, 404, 408, 429, ou n'importe quel 5xx | Refroidissez la cible et passez à la prochaine cible éligible. |
| Erreur classée comme erreur d’authentification, d’abonnement, de quota, de limitation de débit, de surcharge ou de serveur en amont | Place la cible en période de refroidissement et bascule, même si le statut seul ne suffit pas. |
| Annulation client (499), `origin_rejected`, refus de cyber-politique, débordement de contexte ou autre demande invalide | Arrêtez et renvoyez l'erreur ; une autre cible ne rendrait pas la demande valide. |
-| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, ou rejet d'entrée d'image propre à un modèle (`param: input`) | Bascule vers la cible admissible suivante avant le début de la sortie, sans délai de refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. |
+| Rejet structuré de `user`, valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, rejet d'entrée d'image propre à un modèle (`param: input`), ou refus d'un média d'entrée que la route ne peut pas lire (`unsupported_input_modality`) | Bascule vers la cible admissible suivante avant le début de la sortie, sans délai de refroidissement ; voir Compatibilité des paramètres facultatifs ci-dessous. |
| Premier appel d'outil d'un tour Responses exécuté par un adaptateur interne (`runTurn`) que la requête courante n'a pas déclaré, avant toute sortie et tout effet de bord non rejouable | Met la cible en refroidissement et bascule avec le même catalogue d'outils. Après une sortie visible ou un effet de bord non rejouable, le refus est définitif. Les requêtes Chat Completions et Anthropic Messages ne changent pas. |
| Toute autre erreur non classifiée | Arrêtez et renvoyez l'erreur. |
@@ -277,7 +277,7 @@ flux de travail des tâches.
### Tableau de bord
Ouvrez le tableau de bord local et choisissez **Modèles → Combos**. L'espace de travail crée, modifie, renomme et supprime
-combos, et son sélecteur de cible exclut les modèles désactivés et les combos imbriqués.
+les combos. Les sélecteurs de modèles incluent les modèles masqués : la visibilité ne concerne que les listes externes, pas la sélection interne des combos. Sélectionner un modèle masqué ne le rend pas visible. Le sélecteur de cible exclut toujours les fournisseurs désactivés, les combos imbriqués et les fournisseurs JEV réservés au service de décision.
Chaque cible affiche aussi un badge de quota en direct : **Disponible**, **Quota épuisé** ou **Quota inconnu**.
L’éditeur bloque Enregistrer et Créer pour une raison de quota uniquement lorsque chaque cible utilisable dispose d’une confirmation serveur encore valide indiquant que la limite d’inférence liée à ses identifiants configurés est épuisée. Les quotas de compte, de modèle, de recherche et de MCP fournis uniquement à titre d’affichage, ainsi que les informations de routage absentes ou expirées, ne déclenchent pas ce blocage. Le blocage expire à la réinitialisation applicable ou à l’expiration de la validité des données et fait l’objet d’une nouvelle vérification lorsque la page devient active ou visible ; Actualiser recharge à la fois les données des combos et les quotas.
@@ -294,8 +294,8 @@ ocx combo remove --yes
```
`set` accepte également `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`,
-`--display-name` et `--rename-from`. Utilisez `-` comme valeur de `--effort`, `--alias` ou
-`--display-name` pour effacer ce champ. `--native-alias` exige un alias de modèle natif non qualifié actuellement
+`--display-name`, `--description` et `--rename-from`. Utilisez `-` comme valeur de `--effort`, `--alias`,
+`--display-name` ou `--description` pour effacer ce champ. `--native-alias` exige un alias de modèle natif non qualifié actuellement
pris en charge et un nom d’affichage non vide. `create` et `update` sont des alias pour `set` ; `delete` est un alias pour
`remove` ; et les mêmes sous-commandes sont disponibles sous `ocx route combo`.
@@ -341,6 +341,7 @@ Les combos sont stockés dans l'objet `combos` de niveau supérieur, saisi par l
| `alias` | Non | aucun | Identifiant de modèle public tronqué facultatif ; utilisez les règles d'alias ci-dessus. Une valeur vide est stockée sans alias. |
| `nativeAlias` | Non | `false` | Autoriser explicitement un `alias` natif nu actuellement pris en charge à avoir la priorité sur le routage et le catalogue. Jamais déduit de l'alias. |
| `displayName` | Non | aucun | Étiquette de catalogue délimitée en affichage uniquement. Obligatoire et non vide lorsque `nativeAlias` est vrai. |
+| `description` | Non | aucun | Description de ligne de catalogue en affichage uniquement, avec les mêmes limites que `displayName`. Sans valeur, le texte généré `Routed via opencodex → combo (combo).` est conservé. |
## Dépannage
@@ -370,6 +371,6 @@ gérer un refus de politique ou corriger l’origine de la demande rejetée. Les
## Compatibilité des paramètres facultatifs
-Exception aux erreurs 400 terminales : un rejet structuré de `user`, une valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, ou un rejet d’entrée d’image propre à un modèle (`param: input`) peut faire passer le combo à la cible admissible suivante avant le début de la sortie, sans délai de refroidissement. Le refus de sécurité, l’annulation et une sortie déjà commencée restent non rejouables.
+Exception aux erreurs 400 terminales : un rejet structuré de `user`, une valeur non prise en charge pour `reasoning.effort`/`reasoning_effort`, un rejet d’entrée d’image propre à un modèle (`param: input`), ou le refus d’un média d’entrée que la route ne peut pas lire (`unsupported_input_modality`) peut faire passer le combo à la cible admissible suivante avant le début de la sortie, sans délai de refroidissement. Le refus de sécurité, l’annulation et une sortie déjà commencée restent non rejouables.
[Canonical compatibility details](/guides/combos/#request-local-target-compatibility).
diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md
index f820ce88b5a..91cc03aaabe 100644
--- a/docs-site/src/content/docs/fr/guides/providers.md
+++ b/docs-site/src/content/docs/fr/guides/providers.md
@@ -67,8 +67,9 @@ La relance d'une requête 429 avec la même clé, configurée par
[`retryOn429`](/fr/reference/configuration/), s'applique uniquement aux fournisseurs à clé API
(`authMode: "key"`). Les préréglages OAuth, de transfert et locaux sont exclus : leurs identifiants ne doivent
jamais être réutilisés sur le même jeton, et les environnements locaux ne possèdent aucune clé distante à
-préserver. Cette fonction est facultative : elle est désactivée lorsque l'option est absente ; la présence de
-l'objet l'active, sauf si `enabled: false`.
+préserver. Cette fonction est facultative, sauf pour les valeurs par défaut documentées propres à certaines destinations. Le Chat Mistral canonique utilise une politique de relance patiente (6 relances, intervalle de repli de 10 secondes), dans la limite de l’allocation d’envois existante pour l’ensemble de la requête. Les réglages explicites de `retryOn429`, y compris `enabled: false`, sont prioritaires. Le préréglage Mistral transmet aussi par défaut les clés de cache de prompt fournies ; `promptCacheKey: false` désactive cette transmission. L’affinité de cache augmente les chances de réutilisation, mais ne garantit pas un succès de cache et ne lève pas les limites de débit de jetons.
+
+Mistral utilise un seul fournisseur à clé API pour les clés Studio et Vibe. Les types de clés et les allocations des offres sont décrits dans la [documentation des clés API de Mistral](https://docs.mistral.ai/admin/identity-access/api-keys) ; le proxy ne déduit pas le niveau d’abonnement à partir de la clé. Pour tous les modèles, les échéances `Retry-After` explicites renvoyées en amont sont partagées entre les appels qui utilisent les mêmes identifiants et le même modèle. Un 429 sans délai de relance peut déclencher une attente de récupération prudente de 60 secondes lorsque des compteurs par minute valides indiquent une capacité épuisée, ou lorsque le coût mesuré de la requête refusée dépasse les jetons restants tout en tenant dans l’allocation complète annoncée. Il s’agit d’une politique de récupération locale, pas d’une prédiction exacte de la réinitialisation côté serveur. Le trafic sain ne prédit jamais le cache ni le coût de la requête suivante. Après l’attente, une seule sonde de récupération est envoyée ; si elle réussit, le trafic ordinaire reprend. Des indications absentes, mal formées, sans droit d’usage, mensuelles épuisées ou supérieures à l’allocation complète n’inventent ni réinitialisation ni compactage de l’historique. `requestPacing: { "enabled": false }` ou `retryOn429: { "respectRetryAfter": false }` désactive le cadencement partagé. Pour une relance avec la même clé, si le délai de relance de Mistral dépasse `retryOn429.maxIntervalMs`, le proxy renvoie le 429 et l’indication de relance au lieu de relancer trop tôt. Lorsque la sélection des clés en rotation est activée, les requêtes en attente revérifient le refroidissement des clés et acquièrent la capacité de cadencement d’une clé de remplacement, et un rejet de clé avant toute sortie peut être récupéré avec une clé saine dans la limite d’envois existante de la requête. Les fenêtres de contexte et les préférences de compactage par modèle restent indépendantes.
## 1. Connexion ChatGPT (transfert direct)
@@ -112,6 +113,7 @@ ocx login google-antigravity
ocx login cursor # standalone Cursor PKCE login
ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json)
ocx login devin # Cognition/Devin : import de l'identifiant du Devin CLI, sinon connexion navigateur Auth0
+ocx login dsh-account # DeepSeek Harness Desktop (import de l'autorisation ~/.dsh/.credentials.yaml)
ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business)
ocx login codex # pool de comptes Codex (alias : chatgpt, openai ; nécessite un proxy en cours d'exécution)
ocx logout
@@ -128,6 +130,7 @@ ocx logout
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. |
| `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. |
| `devin` | `devin` | `https://server.codeium.com` | Passerelle Cognition/Devin non officielle et expérimentale. La connexion importe d'abord l'identifiant que le Devin CLI installé détient déjà (`devin auth login` écrit un `devin-session-token` dans son propre `credentials.toml`) ; à défaut, elle ouvre l'authentification Auth0 dans le navigateur puis échange le jeton collé via `RegisterUser` contre une clé d'API durable. `ocx login devin-cli` reste accepté comme alias déprécié. Les modèles sont découverts par compte avec `GetCascadeModelConfigs` ; le streaming passe uniquement par `runTurn` sur Connect-RPC. Absente du préréglage du tableau de bord par défaut. |
+| `dsh-account` | `dsh-account` | `https://api.deepseek.com/anthropic` | Fournisseur de compte expérimental à importation prioritaire réutilisant un compte DeepSeek Harness (DSH) Desktop installé et connecté. Lit les identifiants en lecture seule stricte depuis `~/.dsh/.credentials.yaml` lors de l'importation explicite. Indépendant de la facturation par clé d'API DeepSeek. |
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. |
Les vérifications de quota Google Antigravity utilisent des points de terminaison Google fixes, y compris le repli vers la liste des modèles. Elles prennent en charge le DNS Fake-IP transparent pour ces destinations en conservant la vérification TLS, le refus des redirections et les contrôles des adresses privées. Une URL de base personnalisée ne modifie que les requêtes de modèles ; `NO_PROXY` conserve la politique de connexion directe.
@@ -293,9 +296,20 @@ reconnue. Réparez ou supprimez la base illisible dans le chemin de données nor
sélecteurs d'importation, puis réessayez. La connexion depuis une machine dépourvue de session `kiro-cli`
existante n'est pas concernée.
+### DeepSeek Account (DSH)
+
+`dsh-account` est un fournisseur de compte expérimental à importation prioritaire, conçu pour réutiliser les identifiants de compte authentifiés d'une application DeepSeek Harness (DSH) Desktop installée.
+
+- **Indépendant de la clé d'API DeepSeek :** `dsh-account` utilise le quota de session DeepSeek authentifiée de l'utilisateur (portefeuilles standard et bonus, dépenses totales), tandis que le fournisseur `deepseek` facture sur le solde de la clé d'API de la plateforme. Les deux domaines d'identifiants et de routage sont strictement distincts et ne basculent jamais l'un vers l'autre.
+- **Importation explicite uniquement :** OpenCodeX détecte si DeepSeek Harness est installé et connecté (`~/.dsh/.credentials.yaml`), mais ne copiera, n'importera ni n'utilisera jamais automatiquement le jeton sans une action explicite de l'utilisateur (`ocx login dsh-account` ou clic sur Importer dans le tableau de bord web).
+- **Magasin d'identifiants DSH en lecture seule :** OpenCodeX traite `~/.dsh/.credentials.yaml` comme strictement en lecture seule. OpenCodeX n'écrit, ne modifie, ne supprime ni ne change jamais les permissions des fichiers appartenant à DeepSeek Harness.
+- **Copie d'identifiant et déconnexion isolées :** Lors de l'importation, OpenCodeX valide le jeton auprès du point de terminaison d'identité officiel de DeepSeek (`/auth-api/v0/users/current`) et enregistre sa propre copie dans le magasin d'authentification OpenCodeX. Se déconnecter ou supprimer le compte dans OpenCodeX supprime uniquement la copie locale d'OpenCodeX ; cela ne déclenche jamais de déconnexion de session distante et ne vous déconnecte pas de DeepSeek Harness Desktop.
+- **Pas de rotation automatique en arrière-plan et réimportation en cas de 401 :** Les autorisations de session DSH ne disposent pas d'un point de terminaison d'actualisation automatique en arrière-plan. Si le serveur distant renvoie HTTP 401 (`ACCOUNT_TOKEN_INVALID`), OpenCodeX marque le compte comme `needsReauth` et interrompt le routage. Pour rétablir l'accès, l'utilisateur doit réimporter explicitement via `ocx login dsh-account`.
+- **Avertissement relatif aux tiers :** OpenCodeX ne revendique aucune approbation ni autorisation officielle de DeepSeek pour la réutilisation des autorisations de session par des clients tiers.
+
## 3. Catalogue des clés API
-opencodex fournit 104 préréglages intégrés : 85 à clé, 15 OAuth, trois locaux et un préréglage par défaut de
+opencodex fournit 106 préréglages intégrés : 85 à clé, 17 OAuth, trois locaux et un préréglage par défaut de
transfert ChatGPT. Dans le tableau de bord, le sélecteur **Ajouter un fournisseur** ouvre le tableau de bord du
fournisseur à clé, valide la clé et l'enregistre ; la validation dépend du fournisseur. Parmi les entrées notables :
@@ -351,7 +365,7 @@ promotionnels de Cline ne sont accessibles que dans l'IDE ou la CLI Cline, pas p
| Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` |
| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` |
| [BigModel Coding Plan — Responses (liste statique)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` |
-| Qwen Cloud | Forfait à jetons (par défaut) : `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Facturation à l'usage : `https://dashscope.aliyuncs.com/compatible-mode/v1` · ou personnalisé |
+| Qwen Cloud | Forfait à jetons (par défaut) : `https://token-plan.maas.qwencloudapi.com/compatible-mode/v1` · Facturation à l'usage : `https://dashscope.aliyuncs.com/compatible-mode/v1` · ou personnalisé |
| Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` |
| SiliconFlow | `https://api.siliconflow.cn/v1` |
| Volcengine Ark · Coding Plan · Agent Plan | `https://ark.cn-beijing.volces.com/api/v3` · `https://ark.cn-beijing.volces.com/api/coding/v3` · `https://ark.cn-beijing.volces.com/api/plan/v3` |
@@ -371,32 +385,20 @@ par Sionic, `deepseek/deepseek-v4.1-flash-ultrafast` et `z-ai/glm-5.3-flash-ultr
sont affichés en premier. Créez une clé dans le [tableau de bord OpenGateway](https://opengateway.ai/api-keys),
puis lancez `ocx provider add opengateway` ou sélectionnez **OpenGateway** dans le tableau de bord.
Les requêtes chat utilisent la clé Bearer configurée ; la liste publique ne valide pas cette clé.
+Sur le protocole Responses, OpenGateway refuse les appels d’outils personnalisés natifs rejoués ; le préréglage convertit donc les outils personnalisés Codex, comme `exec` en mode code, en outils de fonction avant l’envoi.
+Les tableaux de contenu des résultats d’outils sont envoyés sous forme de texte, et les images sont conservées après le groupe de résultats d’outils associé. Les parties de résultat non prises en charge, y compris les refus et les contenus opaques, sont rejetées explicitement par une erreur d’entrée client avant l’envoi.
+Le niveau de détail d’image `original` est converti en `high`, que la passerelle accepte ; les octets de l’image restent inchangés.
+La route Responses native vérifie aussi les complétions vides en texte seul avant de les présenter comme un succès.
+Un budget de sortie épuisé produit `incomplete` avec `max_output_tokens` ; le proxy n’augmente pas le budget demandé. Une complétion vide qui peut être relancée sans risque et qui dispose encore de budget peut être retentée une seule fois, dans la limite d’envois existante. `emptyCompletionRetry: false` explicite ou `OCX_EMPTY_COMPLETION_RETRY=0` désactive cette nouvelle tentative. Le texte, les refus, l’activité d’outils et les événements de flux inconnus empêchent la relance et conservent le flux d’origine. Cette protection ne modifie ni les autres fournisseurs ni leurs valeurs par défaut de nouvelle tentative.
+La relance exige `store: false` explicite, une entrée complète et uniquement des outils exécutés par le client ; un outil hébergé comme `web_search` empêche la relance automatique même si aucun événement d’outil n’a été observé.
+
+**OpenCode Zen** (`opencode-zen`) utilise `https://opencode.ai/zen/v1` avec une clé API obtenue sur [opencode.ai/auth](https://opencode.ai/auth). Les modèles gratuits peuvent subir une limite courte d’environ 15–20 requêtes/minute (mesure communautaire ; OpenCode ne publie pas de RPM). Si une réponse 429 de limitation omet `Retry-After`, opencodex ajoute des indications et un `Retry-After` synthétique ; les en-têtes valides en amont restent prioritaires. Les tentatives avec la même clé restent facultatives via [`retryOn429`](/fr/reference/configuration/).
+
+Sur les routes officielles `https://opencode.ai/zen/v1` et `https://opencode.ai/inference/v1` du fournisseur canonique `opencode-zen` avec authentification par clé API, Gemini utilise automatiquement l’API native Google en mode `googleMode: ai-studio` (par défaut). Les identifiants Zen sont conservés, y compris Flash sans suffixe `-tiered`. Claude utilise automatiquement Anthropic Messages, quel que soit `googleMode`. Au chargement des configurations enregistrées, seules les anciennes surcharges Chat/Responses admissibles de ces modèles natifs sont ignorées, ainsi que les préférences admissibles de l’outil hébergé `image_generation` propres à Responses. Les fournisseurs personnalisés, les routes sans authentification par clé et les autres préférences restent inchangés.
+
+**La prise en charge de `opencode-free` a été supprimée** : tous les modèles renvoient désormais `403 permission_denied` à cause du contrôle du niveau gratuit en amont chez OpenCode ([#7255](https://github.com/lidge-jun/opencodex/issues/7255)). Le chargement d’une configuration existante désactive en mémoire les entrées concernées et leurs références de modèles, avertit une fois par processus et ne modifie pas le fichier. Si ce fournisseur était le défaut, la route OpenAI OAuth standard devient le défaut. Utilisez `opencode-go`, `opencode-zen` avec clé ou un autre fournisseur. [Conditions OpenCode Zen](https://opencode.ai/docs/zen/).
-**OpenCode Zen** (`opencode-zen`) et le préréglage sans clé **OpenCode Free** utilisent tous deux
-`https://opencode.ai/zen/v1`. Sur cette passerelle, les modèles gratuits atteignent souvent une limite de
-rafale sur une courte fenêtre, d'environ 15 à 20 requêtes par minute (mesure de la communauté ; OpenCode ne publie
-pas de valeur RPM). Zen peut renvoyer des erreurs génériques 429 de limitation de débit sans en-têtes
-`Retry-After` / `X-RateLimit-*`. Cette limite est distincte du quota sans clé pour application de bureau
-annoncé par OpenCode (environ 200 requêtes Big Pickle ou vers des modèles gratuits toutes les 5 heures sur
-`opencode-free`). Lorsque Zen omet `Retry-After` sur une telle réponse 429, opencodex ajoute à l'erreur client
-des indications propres au fournisseur ainsi qu'un `Retry-After` synthétique ; un `Retry-After` reçu en amont
-reste prioritaire. L'attente et la nouvelle tentative avec la même clé restent facultatives et s'activent avec
-[`retryOn429`](/fr/reference/configuration/).
-
-**Le niveau sans clé `opencode-free` est actuellement fermé aux clients tiers.** Zen refuse toute requête
-qui arrive sans en-tête `x-opencode-session` et répond avec le type d'erreur `MissingSessionID` et le message
-« OpenCode's free tier can only be used in OpenCode ». Le contrôle porte uniquement sur la présence de
-l'en-tête : un proxy pourrait donc le franchir en inventant une valeur, ce que opencodex ne fait pas.
-Fabriquer un identifiant de session et un User-Agent versionné `opencode/` revient à se déclarer
-client OpenCode, alors qu'OpenCode ne publie aucun contrat d'intégration tierce pour ce niveau sans clé ;
-une réponse HTTP 200 obtenue ainsi est un contrôle d'admission contourné, pas une autorisation. opencodex
-signale donc la restriction au lieu de la contourner : une requête vers `opencode-free` renvoie une erreur
-qui explique le blocage en amont.
-
-La voie prise en charge vers les mêmes modèles est le fournisseur **`opencode-zen`** avec une clé d'API
-OpenCode Zen obtenue sur [opencode.ai/auth](https://opencode.ai/auth). Si OpenCode publie plus tard un accès
-tiers pour le niveau sans clé, opencodex pourra le suivre ; d'ici là, le préréglage sert à documenter la
-restriction. Conditions en amont : [opencode.ai/docs/zen](https://opencode.ai/docs/zen/).
+Les affectations et valeurs par défaut valides de Claude Desktop sont conservées. Les règles dont la liste de modèles sources autorisés devient vide sont désactivées, ainsi que la synchronisation native des sous-agents si son modèle d’injection est supprimé. Les sauvegardes suivantes ne rétablissent pas les références supprimées et préservent les modifications simultanées valides.
La plupart utilisent l'adaptateur `openai-chat` avec une clé Bearer ; les préréglages compatibles
Anthropic, comme **Xiaomi MiMo** (`xiaomi`), utilisent l'adaptateur `anthropic` (`x-api-key`). Xiaomi propose
@@ -622,6 +624,9 @@ maintenir leur visibilité même lorsque les catalogues en direct ne sont pas en
| Connexion Codex (Pool ou Direct) | `gpt-5.6-*` | 372 000 |
| OpenAI (clé API) | `openai-apikey/gpt-5.6-*` plus `*-pro` | 1 050 000 (entrée maximale de 922 000) |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 1 050 000 |
+| Codex | `gpt-6.1-terra` | 500,000 / 872,000 |
+| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 |
| Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1 000 000 |
Les entrées natives GPT-5.6 conservent les niveaux de raisonnement fixés en amont — Luna propose par exemple
@@ -642,7 +647,7 @@ reste une passerelle à clé ou jeton d'abonnement sur son point de terminaison
**Cloudflare AI Gateway** exige que les identifiants de votre compte et de votre passerelle figurent dans l'URL.
Copilot présente un catalogue qui utilise plusieurs protocoles : ces modèles (`gpt-5.3-codex`, `gpt-5.4`,
-`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) rejettent
+`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) rejettent
`/chat/completions` pour le trafic d'agent. opencodex route donc ces modèles sur l'API Responses par défaut,
tandis que tous les autres modèles Copilot restent sur Chat Completions. L'ordre de priorité est le suivant :
verrouillage explicite du protocole → entrée [`modelAdapters`](/fr/reference/configuration/providers/) définie
@@ -721,4 +726,4 @@ au fournisseur.
Les fournisseurs qui disposent d'une sonde en direct sont OpenAI/Codex, Anthropic, xAI, Cursor, Kimi,
Google Antigravity, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax, Moonshot, Venice, Synthetic, DeepInfra,
-Neuralwatt, Command Code, ainsi que tout fournisseur personnalisé reposant sur a6api.
+Neuralwatt, Command Code, ainsi que tout fournisseur personnalisé reposant sur a6api ou Abliteration.
diff --git a/docs-site/src/content/docs/fr/guides/remote-link.md b/docs-site/src/content/docs/fr/guides/remote-link.md
index 9503b02ad37..82d715db467 100644
--- a/docs-site/src/content/docs/fr/guides/remote-link.md
+++ b/docs-site/src/content/docs/fr/guides/remote-link.md
@@ -55,7 +55,7 @@ Le rôle **Child** exige également qu’OpenCodex fonctionne en mode autonome s
- **Connected** signifie que le tunnel SSH est prêt et que Child peut utiliser la liaison Home.
- **Reconnecting** signifie que le tunnel est réessayé. Les requêtes peuvent temporairement renvoyer `503` avec `Retry-After`. Sur un Child connecté depuis son propre tableau de bord, une requête attend d’abord jusqu’à 15 secondes le retour du tunnel.
-- **Failed** signifie que la liaison nécessite une intervention. Vérifiez l’authentification SSH, la clé d’hôte confirmée, la redirection ou le délai indiqué. Le côté qui ouvre le tunnel SSH réessaie de lui-même après une mise en veille, une panne ou un redémarrage (Home pour un Child qu’il a ajouté, le Child pour une liaison qu’il a rejointe depuis son propre tableau de bord) : environ une fois par minute après un délai dépassé ou une erreur de redirection, et toutes les cinq minutes après une erreur d’authentification. Une clé d’hôte modifiée n’est jamais réessayée. Pour réessayer tout de suite, choisissez **Retry** dans le tableau de bord du Home ou exécutez `ocx link reconnect --link-id ` sur le Home. Le tunnel de cette liaison redémarre sans redémarrer OpenCodex.
+- **Failed** signifie que la liaison nécessite une intervention. Vérifiez l’authentification SSH, la clé d’hôte confirmée, la redirection ou le délai indiqué. Le côté qui ouvre le tunnel SSH réessaie de lui-même après une mise en veille, une panne ou un redémarrage (Home pour un Child qu’il a ajouté, le Child pour une liaison qu’il a rejointe depuis son propre tableau de bord) : une minute après un délai dépassé ou une erreur de redirection, puis avec une attente qui double après chaque nouvel échec, jusqu’à cinq minutes, et toutes les cinq minutes après une erreur d’authentification. Une clé d’hôte modifiée n’est jamais réessayée. Pour réessayer tout de suite, choisissez **Retry** dans le tableau de bord du Home ou exécutez `ocx link reconnect --link-id ` sur le Home. Le tunnel de cette liaison redémarre sans redémarrer OpenCodex.
Une liaison en échec ne bascule pas silencieusement vers un fournisseur local.
diff --git a/docs-site/src/content/docs/fr/guides/sidecars.md b/docs-site/src/content/docs/fr/guides/sidecars.md
index 1fcc91cfec6..ee37d9ef37b 100644
--- a/docs-site/src/content/docs/fr/guides/sidecars.md
+++ b/docs-site/src/content/docs/fr/guides/sidecars.md
@@ -88,9 +88,12 @@ les échecs de génération postérieurs à l'envoi des en-têtes sont transmis
Lorsqu'un modèle routé figure dans le `noVisionModels` de son fournisseur — ou est déclaré texte seul pour ce modèle
via `modelInputModalities` — et qu'une requête porte une image, opencodex décrit chaque image **avant** l'appel principal
et la remplace par du texte, à condition qu'un plan de sidecar vision soit disponible. Sans plan disponible, l'image brute
-est supprimée au lieu d'être transmise à un backend texte seul. Le catalogue de modèles annonce l'entrée image pour chaque
-modèle couvert par le sidecar. Les combos annoncent l'entrée image seulement lorsque chaque membre accepte les images,
-nativement ou via un sidecar, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme
+est supprimée au lieu d'être transmise à un backend texte seul. Avec `visionSidecar.enabled: false`, le catalogue de modèles
+n'ajoute pas d'entrée image fournie par le sidecar aux modèles texte seuls, y compris les entrées personnalisées et les
+membres des combos. La prise en charge native des images et les modalités image explicitement configurées sont conservées.
+Si `enabled` est omis, l'annonce des capacités du sidecar reste activée ; la présence dans le catalogue ne garantit pas
+que les identifiants ou la capacité du sidecar sont disponibles. Les combos annoncent l'entrée image seulement lorsque
+chaque membre accepte les images, nativement ou via un sidecar activé, et que le paramètre `imageInput` du combo n'est pas désactivé, afin que des clients comme
l'application Codex autorisent les pièces jointes au lieu de les bloquer avant l'exécution du sidecar. Lorsque
`visionSidecar.model` est absent ou vide, le chemin d'exécution OpenAI, le tableau de bord et l'API de gestion
utilisent le modèle de repli `gpt-5.6-luna`. Au démarrage, une ancienne valeur `gpt-5.6-luna` explicitement enregistrée
@@ -164,6 +167,8 @@ le délai d'attente et la limite précédemment choisis.
les clés omises inchangées. `timeoutMs` utilise les limites entières de l'environnement d'exécution
(1–2147483647 ms).
+Un changement de l'état d'activation effectif de Vision via `PUT /api/sidecar-settings` actualise le catalogue et le cache gérés après l'enregistrement ; `catalogRefreshPending=true` signale que cette actualisation n'a pas abouti et ne constitue pas un accusé de réception du client.
+
La carte du service auxiliaire de recherche web reprend la même forme de contrôle : la première
ligne du sélecteur de modèle est **Désactivé (Off)**. La désactivation arrête l'interception de
`web_search` par OpenCodex et l'intégration Codex écrit `web_search = "disabled"` dans
diff --git a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md
index a5b172a4e48..be3e9d21f82 100644
--- a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md
+++ b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md
@@ -46,6 +46,42 @@ Pour une liste v2, l'éligibilité a trois états : une entrée estampillée `"
sans champ `multi_agent_version` est admissible. Une valeur explicite `"v1"` est exclue, car elle indique
que le modèle appartient à l’autre surface de collaboration.
+## Modèles annoncés par `spawn_agent`
+
+Codex liste au plus cinq modèles dans la description de l'outil `spawn_agent`, et il les choisit lui-même : il trie les lignes du catalogue visibles dans le sélecteur par `priority` croissante et garde les cinq premières. La liste de la page **Subagents** (`subagentModels`) n'agit sur cette sélection qu'en donnant à ses modèles les priorités les plus basses, dans l'ordre de la liste (`0` à `4` sans sélecteurs de compte). Elle ne limite pas la sélection à ces modèles.
+
+Si vous mettez en avant un à quatre modèles, Codex complète les emplacements restants avec les lignes suivantes par priorité. Vous n'avez pas choisi ces lignes, et le tableau de bord n'indique pas lesquelles sont retenues. Elles dépendent du catalogue :
+
+| Catalogue | Lignes qui complètent les emplacements restants |
+| --- | --- |
+| Sans sélecteurs de compte Codex | Les autres lignes routées `provider/model`. Elles partagent toutes la priorité `5` et apparaissent donc par nom de fournisseur, puis par identifiant de modèle, dans l'ordre alphabétique. Les lignes GPT natives non mises en avant ont une priorité d'au moins `100` plus le nombre d'identifiants mis en avant ; elles viennent donc après. |
+| Avec sélecteurs de compte Codex | Les lignes natives qualifiées par compte (`/`), placées juste après les lignes mises en avant. Les lignes routées non mises en avant passent à la priorité `1005` et viennent après elles. |
+
+Par exemple, sans sélecteurs de compte et avec cette liste :
+
+```json
+{ "subagentModels": ["xai/grok-4.5", "anthropic/claude-sonnet-5"] }
+```
+
+| Emplacement | Modèle | Priorité | Raison |
+| ---: | --- | ---: | --- |
+| 1 | `xai/grok-4.5` | `0` | Première entrée de la liste |
+| 2 | `anthropic/claude-sonnet-5` | `1` | Deuxième entrée de la liste |
+| 3 | `deepseek/deepseek-v4-flash` | `5` | Première ligne routée restante dans l'ordre alphabétique |
+| 4 | `google/gemini-3-pro` | `5` | Ligne routée suivante |
+| 5 | `moonshot/kimi-k3` | `5` | Ligne routée suivante |
+| 6+ | `zai/glm-5.2`, puis les lignes GPT natives | `5`, puis `102` | Hors des cinq |
+
+Avec une liste vide, opencodex ne fixe aucune priorité et les cinq suivent les priorités normales du catalogue.
+
+Les emplacements complétés sont des annonces ordinaires : l'agent principal peut choisir l'un de ces modèles. La liste n'est pas non plus une liste d'autorisation : Codex accepte un modèle du catalogue hors des cinq lorsque l'agent principal le nomme exactement. Pour contrôler ce que Codex annonce :
+
+- Mettez cinq modèles en avant. Une liste complète ne laisse aucun emplacement à compléter.
+- Désactivez un modèle que vous ne voulez jamais voir proposé, ou retirez-le des `selectedModels` de son fournisseur. Un modèle désactivé ou non sélectionné quitte le sélecteur et ne peut pas compléter un emplacement.
+- `modelPickerOrder` modifie les priorités que Codex utilise pour trier. Une liste uniquement routée déplace les lignes listées non mises en avant à `1000` et au-delà ; les lignes routées non listées complètent donc les emplacements en premier. Une liste qui contient un identifiant natif nu ordonne tout le sélecteur, lignes mises en avant comprises : les lignes listées passent en premier et occupent les emplacements annoncés avant toute ligne non listée. Voir [Model ordering](/guides/model-ordering/).
+
+En v2, Codex écarte les lignes épinglées `"disabled"` avant de prendre les cinq ; le reste fonctionne de la même façon. La liste des consignes v2 d'opencodex (`{{roster}}`) ne nomme que les modèles configurés présents parmi les cinq : les modèles de complément apparaissent dans la description de l'outil Codex, jamais dans les consignes d'opencodex. Codex lit le catalogue au démarrage d'une session ; une modification de la liste s'applique donc aux nouvelles sessions.
+
## Modèle et efforts de délégation
La **délégation de sous-agent** du tableau de bord contrôle trois paramètres associés :
@@ -239,6 +275,10 @@ un décompte partiel positif avant de passer un dépassement de modèle ou d'eff
Il peut être masqué par le sélecteur, en dehors de la limite d'affichage de cinq modèles, absent du catalogue ou épinglé
à la v1. Une valeur de surface `"v2"`, `null` ou absente est admissible ; une valeur explicite `"v1"` ne l’est pas.
+### Pourquoi `spawn_agent` liste-t-il des modèles que je n'ai pas mis en avant ?
+
+Codex remplit jusqu'à cinq emplacements annoncés à partir du catalogue, par priorité. Avec moins de cinq modèles mis en avant, les lignes suivantes occupent les emplacements restants ; voir la section « Modèles annoncés par `spawn_agent` » ci-dessus.
+
### Les changements de mode affectent-ils les sessions en cours ?
Non. Démarrez une nouvelle session Codex après avoir changé de mode. Si un hôte App de longue durée affiche toujours des informations obsolètes
diff --git a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md
index cafcd8b6a32..d93aa422837 100644
--- a/docs-site/src/content/docs/fr/guides/subagent-v1-default.md
+++ b/docs-site/src/content/docs/fr/guides/subagent-v1-default.md
@@ -148,3 +148,14 @@ ocx v2 mode v1
La CLI ne demande pas de confirmation. C'est le même paramètre : choisissez-le
en tenant compte de ce que décrit cette page.
+
+## Limite de sous-agents simultanés
+
+Dans **Models → Model settings**, le réglage de limite des sous-agents définit le nombre maximal
+de sous-agents simultanés, sans compter l'agent principal. Il reste disponible en v1, base et v2,
+y compris en mode hybride lorsque le drapeau v2 global est désactivé. Le choix du mode est
+indépendant de cette limite.
+
+Augmenter la limite autorise davantage de sous-agents, sans les créer automatiquement ni garantir
+une exécution plus rapide. Lancez une nouvelle session Codex après modification. Si un hôte App
+de longue durée utilise encore l'ancienne limite, redémarrez cet hôte.
diff --git a/docs-site/src/content/docs/fr/reference/cli.md b/docs-site/src/content/docs/fr/reference/cli.md
index 26211aae04d..2563cdc0de9 100644
--- a/docs-site/src/content/docs/fr/reference/cli.md
+++ b/docs-site/src/content/docs/fr/reference/cli.md
@@ -17,7 +17,7 @@ Exécutez `ocx help` (ou `ocx --help` / `ocx -h`) pour afficher l’aide génér
Les commandes de gestion communiquent avec l’API de gestion du proxy actif. Elles s’appuient sur le port d’exécution enregistré et sur des contrôles d’identité, plutôt que sur un second chemin de configuration. Un proxy arrêté ou inaccessible est représenté par une réponse HTTP 503 et entraîne un code de sortie CLI non nul. Les commandes explicitement documentées comme des opérations de configuration hors ligne peuvent, quant à elles, valider et modifier le fichier de configuration sans proxy actif.
-`ocx system codex-cli-update check` ne nécessite aucun proxy actif et n’interroge aucun registre de paquets. La commande inspecte, dans des limites strictes, les métadonnées de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sous POSIX ou `windows_inspection_deferred` sous Windows. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. La commande n’installe ni ne répare de logiciel, n’exécute ni Codex ni npm, ne contrôle aucun processus actif et n’écrit aucun état de configuration ou de cache.
+`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan).
Pour observer une installation Windows x64, consultez [`attest`](/fr/reference/cli/agents/). Sans chemins explicites, la commande observe le candidat sélectionné identifié par l’instantané du lanceur lié à la preuve ; aucune autorité de mise à jour ni sélection du runtime n’est attestée.
diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md
index 03019ca987d..1337a76d0ce 100644
--- a/docs-site/src/content/docs/fr/reference/cli/agents.md
+++ b/docs-site/src/content/docs/fr/reference/cli/agents.md
@@ -273,7 +273,9 @@ le CLI, l’API, et le GUI utilisent les mêmes octets.
Gérez les paramètres d'exécution sans tête, le démarrage, la synchronisation, les diagnostics et les mises à jour.
-`ocx system codex-restart --yes` redémarre les serveurs d'application Codex et quitte puis relance entièrement l'application Codex Desktop, via le même module que `ocx sync --restart-codex`. Lorsque le proxy lui-même s'exécute dans l'application Codex, la commande refuse avec un message actionnable au lieu de promettre un transfert qu'elle ne peut pas mener à bien.
+`ocx system codex-restart --yes` redémarre les serveurs d'application Codex via le même module que `ocx sync --restart-codex`. Elle quitte et relance l'application Codex Desktop si celle-ci a démarré avant l'écriture du catalogue de modèles actuel ou si cette requête a modifié sa liaison `model_catalog_json`, car le sélecteur de modèles est construit au lancement. Sinon l'application reste ouverte, relance son propre app-server et conserve les brouillons non envoyés et les approbations en attente. Si l'état de l'application ou du catalogue est illisible, elle effectue le redémarrage complet. Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop refuse avec un message actionnable au lieu de promettre un transfert qu'il ne peut pas mener à bien.
+
+Une liaison `model_catalog_json` modifiée par cette requête impose également le redémarrage Desktop, même si le processus a démarré après l’écriture du fichier du catalogue.
```bash
ocx system settings --stream-mode eager-relay
@@ -285,7 +287,7 @@ ocx system settings --stream-mode eager-relay
ocx system codex-cli-update check --json
```
-`check` n’interroge aucun registre de paquets et inspecte, dans des limites strictes, les éléments de provenance du candidat d’installation configuré, notamment l’emplacement expurgé de l’exécutable et les preuves de propriété. Le contexte de confiance du lanceur publié authentifie uniquement cet instantané du candidat, et non l’exécution réussie de Codex. Comme cette commande ponctuelle n’exécute jamais Codex, les candidats issus de l’environnement ou de l’état persistant restent purement informatifs (`managed: false`, normalement `selection_unattested`) et `selectionAttested` reste `false`. La sortie JSON contient `candidateAvailable`, `candidateVersion`, `candidateSource` et `selectionAttested: false`. Une exécution directe via Bun ou depuis les sources ne fournit pas la preuve du lanceur, ignore les candidats issus de l’environnement ou de l’état persistant et peut signaler `candidate_unavailable` sur les systèmes POSIX. Sous Windows, cette première étape n’effectue aucune E/S de système de fichiers sur les chemins du candidat ou de configuration. Seul un candidat d’environnement absolu capturé par le lanceur de confiance peut recevoir une étiquette lexicale de bundle d’application ou de gestionnaire de versions ; tous les autres candidats Windows échouent de manière fermée. Comme cette étape ne consulte jamais l’état persistant, une exécution Windows pour laquelle aucun candidat d’environnement n’a été capturé signale `windows_inspection_deferred` plutôt que `candidate_unavailable` : la commande ne peut pas observer si une CLI Codex est installée, elle signale donc le report de l’inspection au lieu d’affirmer qu’aucun candidat n’existe. La commande n’exécute ni Codex ni aucun gestionnaire de paquets, ne répare aucun shim, n’écrit ni dans la configuration ni dans le cache, n’arrête aucun processus et n’installe rien. Les candidats intégrés à une application, issus d’un gestionnaire de versions reconnu, autonomes mais non vérifiés, ou associés à un état de shim ambigu sont signalés comme non gérés ou inconnus et ne sont jamais classés comme gérés.
+`check` ne consulte aucun registre et n’exécute pas Codex. Sur POSIX, `selectionAttested` peut être `true` si le résolveur choisit le même chemin canonique que le candidat d’environnement capturé par le lanceur publié. Un manifeste npm-global lié peut alors établir `managed: true` s’il prouve la propriété et, lorsqu’une version candidate est signalée, si elle correspond à celle du manifeste ; les candidats persistants restent non attestés. Windows diffère l’inspection sans lire les fichiers du candidat ni la configuration. Une exécution directe via Bun ou les sources n’a pas de preuve du lanceur. `plan` consulte le registre officiel avec une configuration, un cache et des journaux npm temporaires isolés, et refuse les sessions actives ou une observation incomplète des processus. Il n’installe rien et n’envoie aucun signal. Voir [les commandes et motifs de refus complets (en anglais)](/reference/cli/agents/#read-only-codex-cli-update-plan).
Sous Windows, une commande simple capturée comme `CODEX_CLI_PATH=codex`, un chemin distant ou un chemin de périphérique produit plutôt `candidate_path_unavailable`. Le candidat a été capturé, mais son chemin ne convient pas à cette inspection.
diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md
index bf590b687a5..d5e247ccad0 100644
--- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md
@@ -68,7 +68,7 @@ Il s'agit d'un réétiquetage large et destructif : chaque fil contenant un mess
### `ocx recover-history --ocx-compaction --yes`
-Réparez l'historique d'une tâche compactée par un fournisseur routé avant de la reprendre avec Codex natif. La commande sélectionne exactement une tâche par UUID, enregistre d'abord une sauvegarde privée octet par octet, puis convertit uniquement l'état de compaction `ocx1:` propre à OpenCodeX en résumé ordinaire relisible par Codex natif. Le contenu chiffré natif et les autres tâches restent inchangés. Fermez la tâche sélectionnée avant d'exécuter la commande ; toute modification simultanée du rollout interrompt la récupération sans remplacer le fichier.
+Réparez l'historique d'une tâche compactée par un fournisseur routé avant de la reprendre avec Codex natif. La commande sélectionne exactement une tâche par UUID, enregistre d'abord une sauvegarde privée octet par octet, puis convertit uniquement l'état de compaction `ocx1:`/`ocx2:` propre à OpenCodeX en résumé ordinaire relisible par Codex natif. Le contenu chiffré natif et les autres tâches restent inchangés. Fermez la tâche sélectionnée avant d'exécuter la commande ; toute modification simultanée du rollout interrompt la récupération sans remplacer le fichier.
### `ocx uninstall` · `ocx remove`
@@ -141,7 +141,7 @@ Vérifie l’identité du proxy actif. La sortie destinée aux utilisateurs indi
### `ocx ready [--json] [--wait [--timeout ]]`
-Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation.
+Vérifie l’état de préparation après synchronisation au moyen du point de terminaison non authentifié `GET /readyz`. Il renvoie `200` lorsque le service est prêt, ou `503` avec `Retry-After: 1` pour les états `pending` et terminal `failed`. Son identité HTTP expurgée est `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`. `protocol` est la version courante du protocole distant du hub, `minimumClientProtocol` la plus ancienne version cliente compatible et `managementUrl` l’origine canonique de gestion visible par le navigateur. Les anciens proxys dépourvus de `/readyz` échouent de manière sûre avec l’état `unreachable` ; `/healthz` mesure la disponibilité du processus, et non son état de préparation.
Par défaut, la commande effectue une seule sonde. Avec `--wait`, elle interroge le service jusqu’à ce qu’il soit prêt ou jusqu’à l’expiration du délai, mais s’arrête immédiatement si elle observe l’état terminal `failed`. Le délai par défaut est de 45 secondes. `--timeout ` exige `--wait` et accepte un entier positif compris entre 1 et 300. La sortie JSON de la CLI est `{ready, status, pid, port}`, où `status` vaut `ready`, `pending`, `failed` ou `unreachable`. Les codes de sortie sont 0 si le service est prêt ; 1 s’il n’est pas prêt, reste en attente, échoue, dépasse le délai ou est inaccessible ; et 64 si les arguments sont invalides.
@@ -155,6 +155,10 @@ La section **OAuth reliability** indique si le stockage des identifiants est acc
### `ocx sync [--restart-codex] [--restart-app-server-only]`
+> **Enfant Remote Link :** `ocx sync` applique aussi les réglages Codex du Home et, si le runtime client a démarré avant cette application, le redémarre même sans option de redémarrage. Si le redémarrage échoue, l'ancien runtime continue de servir et la commande se termine avec un code non nul. Les champs correspondants de `ocx status --json` et `ocx link status --json` sont décrits dans [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity) (en anglais).
+
+Un redémarrage explicitement demandé a également lieu après une modification de `config.toml` validée et enregistrée, même si les octets du catalogue et du cache restent identiques. Une opération sans changement de configuration, un refus, une validation préalable ou une écriture annulée ne le déclenche pas. Sans option de redémarrage, sync ne redémarre jamais Codex automatiquement.
+
Récupère la liste active des modèles de chaque fournisseur configuré et réinjecte le catalogue fusionné dans Codex. Exécutez cette commande après l’ajout d’un fournisseur ou pour actualiser les modèles disponibles.
Avant la découverte des fournisseurs ou le remplacement du catalogue et du cache, `ocx sync` vérifie que la configuration Codex gérée peut recevoir l’injection. Si cette validation refuse la configuration, la commande renvoie un code non nul, affiche la cause précise sur stderr et laisse le catalogue ainsi que le cache existants inchangés. `ocx restore back` effectue la même vérification préalable sans écriture avant de réactiver le routage.
@@ -167,6 +171,8 @@ Si des processus Codex `app-server` de longue durée sont encore actifs, `ocx sy
Lorsque la commande s’exécute depuis l’application Codex, le redémarrage est confié à un assistant détaché et cette session se termine avec l’application.
+**Synchronisation épinglée.** `ocx sync --expect-link ` (enfant Remote Link) et `ocx sync --expect-hub-key ` (client hub) ne synchronisent que si cette machine est toujours connectée à ce lien ou avec cette clé. Sinon, rien n'est modifié, les fournisseurs locaux ne sont pas synchronisés, la commande quitte avec le code 5 et la dernière ligne de stderr est `{"phase":"child_link_mismatch"|"hub_client_mismatch","reason":...}`. Un drapeau mal formé ou répété quitte avec le code 2. Détails : [référence anglaise](/reference/cli/lifecycle/#sync-only-from-the-expected-home---expect-link-and---expect-hub-key).
+
### `ocx sync-cache [--restart-codex] [--restart-app-server-only]`
Invalide le cache local du sélecteur de modèles de Codex afin qu’il soit reconstruit à partir du catalogue opencodex actif. Le même avertissement concernant un `app-server` obsolète et les mêmes options de redémarrage que pour `ocx sync` s’appliquent.
diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md
index 89d1e6773f6..25e82d8de21 100644
--- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md
@@ -164,6 +164,15 @@ et la colonne plan/label recouvre le plan, l'e-mail masqué, l'étiquette et la
### `ocx account list [provider] [--json] [--all]`
+Les comptes ChatGPT enregistrés dans le pool restent consultables sur les hôtes sans répertoire
+Codex natif, y compris dans le tableau de bord et lors de l'actualisation des comptes. Cette consultation
+ne crée pas de répertoire Codex et ne déconnecte pas le compte principal natif. Désactiver l'intégration
+du client Codex ne désactive pas l'observation du quota d'une connexion native existante. Un chemin
+`CODEX_HOME` explicite invalide continue de produire une erreur de configuration. Un stockage
+`OPENCODEX_HOME` non sûr produit une erreur même si le répertoire Codex natif est absent. La résolution
+du chemin Codex par défaut renvoie également une erreur s'il désigne un fichier, un lien symbolique
+dont la cible n'existe pas, ou si son répertoire parent ne peut pas être traversé.
+
Sans fournisseur, répertorie le groupe de comptes Codex, les comptes OAuth et les groupes de clés API configurés. Les fournisseurs vides
sont ignorés à moins que `--all` soit présent. Avec un fournisseur, répertorie uniquement cette famille d’informations d’identification.
La sortie destinée aux utilisateurs utilise `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` ; une ligne Codex sélectionnée manuellement porte la mention
diff --git a/docs-site/src/content/docs/fr/reference/configuration/advisor.md b/docs-site/src/content/docs/fr/reference/configuration/advisor.md
index baf92fbb1db..9c48ca2b7fc 100644
--- a/docs-site/src/content/docs/fr/reference/configuration/advisor.md
+++ b/docs-site/src/content/docs/fr/reference/configuration/advisor.md
@@ -1,6 +1,6 @@
---
title: Conseiller
-description: Le sidecar de consultation experte d'OpenCodex — un modèle expert configuré conseille les workers routés, avec les politiques manual et preflight.
+description: Consultation indépendante avec les politiques manual, preflight et adaptive fondée sur des preuves.
---
Le conseiller est un modèle expert indépendant qui examine la tâche du worker et renvoie des
@@ -16,6 +16,8 @@ sidecar côté proxy invisible du client — même un worker qui ne spawn jamais
## Configuration
+Sur la page **Advisor**, choisissez le modèle dans le catalogue. Le sélecteur conserve exactement les identifiants natifs, de fournisseur, de compte, personnalisés et de combo. Les modèles désactivés ou en attente de sélection initiale ne sont pas proposés. Un identifiant enregistré absent du catalogue reste visible avec un avertissement, jusqu’à son remplacement ou sa suppression. Un échec de chargement conserve la sélection et propose de réessayer. Choisissez un modèle capable de fournir des conseils textuels : le catalogue ne garantit pas la compatibilité des consultations. La CLI et l’API acceptent toujours les chaînes de modèle prises en charge par le routeur.
+
```json
{
"advisor": {
@@ -33,12 +35,13 @@ sidecar côté proxy invisible du client — même un worker qui ne spawn jamais
| `enabled?` | `boolean` | `false` | Interrupteur principal. Désactivé : aucun comportement conseiller sur le chemin de requête. |
| `model?` | `string` | — | Le modèle expert. Toute chaîne de modèle acceptée par le routeur : modèle natif seul (`gpt-6-astra`), `provider/model` explicite (`anthropic/claude-sonnet-4-6`, `xai/grok-...`) ou modèle natif qualifié par compte. Inter-fournisseurs entièrement pris en charge. |
| `effort?` | `string` | `"max"` | Intensité de raisonnement de l'appel conseiller (`low`–`ultra`). |
-| `policy?` | `"manual" \| "preflight"` | `"manual"` | Quand consulter le conseiller. |
+| `policy?` | `"manual" \| "preflight" \| "adaptive"` | `"manual"` | Quand consulter le conseiller. |
+| `maxConsultationsPerTask?` | `integer` | `3` | Budget adaptatif par tâche (1 à 3 tentatives, appels manuels compris). |
| `timeoutMs?` | `number` | `120000` | Délai de la consultation en boucle locale. |
| `contextSharingConsent?` | `"v1"` | absent | Consentement de l'opérateur pour envoyer le contexte de la tâche au fournisseur conseiller configuré. Seul `"v1"` est courant. Une valeur absente, périmée ou autre n'autorise aucun envoi. `enabled: true` n'est pas ce consentement. |
Gérez-le via la page **Advisor** du tableau de bord ou
-`ocx advisor status|on|off|set --model --effort --policy `.
+`ocx advisor status|on|off|set --model --effort --policy `.
Sans consentement courant, `ocx advisor on` n'active pas le partage inter-fournisseurs : il affiche cette divulgation et s'arrête. `ocx advisor on --ack-context-sharing` et `ocx advisor consent` enregistrent `v1`. `ocx advisor consent --revoke` retire le consentement et arrête immédiatement l'envoi. `ocx advisor set` n'accorde pas le consentement. La case du tableau de bord n'est pas précochée.
@@ -105,7 +108,14 @@ codage, et une consultation ne change jamais le modèle principal de la session.
- Les tours natifs OpenAI en passthrough (workers du pool ChatGPT) ne reçoivent pas l'outil
synthétique ; le conseiller couvre les fournisseurs routés (traduits). La consultation
preflight s'applique aux adaptateurs run-turn ; l'outil non.
-- Pas de déclencheur adaptatif : pas de détection de blocage, d'analyse d'échecs répétés, de
- niveaux d'escalade, de conseillers multiples ni de vote. `manual` et `preflight` seulement.
+- Pas de détection sémantique de blocage, de revue finale, de niveaux d’escalade, de conseillers multiples ou de vote.
- Le registre de déduplication preflight vit dans le processus ; après un redémarrage du proxy,
une tâche en cours peut recevoir une tentative preflight de plus.
+
+## Consultation adaptative et activité
+
+`adaptive` ajoute à la consultation initiale une consultation lorsque la même vérification, dans le même répertoire, échoue, qu’une modification de fichier ultérieure réussit, puis qu’une nouvelle exécution échoue encore. Une réussite efface l’échec ; une vérification démarrée avant la modification ne compte pas comme une nouvelle tentative après réparation. Les étapes initiale et réparation ont des revendications distinctes. Une identité de conversation stable est requise pour l’automatisation. Les appels/résultats doivent être appariés avec un code de sortie explicite. Scripts complexes, lots, modifications shell et sorties ambiguës ne déclenchent rien.
+
+Configurez `ocx advisor set --policy adaptive --max-consultations 2`. Les tentatives manuelles et automatiques partagent un budget de 1 à 3 par tâche, réservé avant envoi dans `OPENCODEX_HOME/advisor-budget.sqlite`. Un redémarrage ou un changement de worker ne le remet pas à zéro. Seuls les condensats, compteurs et expirations sont conservés, pendant 24 heures après la première tentative (512 tâches au maximum). Si le budget est inaccessible ou épuisé, le worker continue. Les appels envoyés puis échoués ou annulés restent réservés car ils peuvent coûter. Sans identité stable, seuls les appels manuels limités par requête restent disponibles.
+
+La page Advisor et `ocx advisor activity --json` (`GET /api/advisor/activity`) montrent jusqu’à 100 entrées de métadonnées du processus sur 24 heures ; la page visible s’actualise toutes les cinq secondes. Elles incluent motif, état, modèles, durée, tokens d’entrée/sortie signalés et efforts demandé et traduit par l’adaptateur. Aucun texte, commande, conseil ou identifiant de conversation n’est conservé ; un redémarrage efface ces entrées. La traduction décrit les paramètres envoyés et ne garantit pas le raisonnement interne. Les données absentes restent inconnues ; adoption et coût exact ne sont pas déduits.
diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md
index 442c597f0db..a46a4f89add 100644
--- a/docs-site/src/content/docs/fr/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md
@@ -12,6 +12,16 @@ En l’absence de fenêtre déclarée ou configurée, OpenCodex utilise les mét
Cette estimation est désactivée par défaut : activez-la par fournisseur avec `inferContextWindow: true`. Sans ce réglage, une fenêtre absente utilise le Context cap activé, puis 128k.
+## GPT-6.1 Terra
+
+[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/), sorti le 26 octobre 2026, succède à GPT-5.6 Terra. `gpt-6.1-terra` reste disponible aux côtés de `gpt-5.6-terra`, sans changement de modèle par défaut ni de liste de sous-agents. GitHub Copilot utilise Responses ; OpenRouter propose `openai/gpt-6.1-terra` avec un contexte de 1,050,000 tokens et des tarifs de $1.00 en entrée / $5.00 en sortie par million de tokens.
+
+Codex natif : contexte par défaut de 500,000 tokens, plafond étendu de 872,000, effort par défaut `low` et niveaux `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Le niveau natif `ultra` est envoyé sous la forme `max` sur le réseau. La ligne native propose uniquement Fast.
+
+[API OpenAI](https://developers.openai.com/api/docs/models/gpt-6.1-terra) : contexte de 1,050,000 tokens, sortie maximale de 128,000 ; entrée texte/image, sortie texte ; connaissances arrêtées au 2026-06-01. Efforts : `none`, `low`, `medium` (par défaut), `high`, `xhigh`, `max`. Points de terminaison : `v1/responses` et `v1/chat/completions` ; les appels d’outils nécessitent Responses.
+
+Par million de tokens : $1.00 en entrée, $0.05 en lecture du cache, $1.25 en écriture du cache (30 minutes), $5.00 en sortie. Au-delà de 272,000 tokens d’entrée, toute la requête est facturée à 2× pour l’entrée et le cache, et 1.5× pour la sortie : respectivement $2.00 / $0.10 / $2.50 / $7.50. Le mode Fast coûte 2×.
+
## Sélection des modèles à l’inscription
Une nouvelle connexion sans OAuth attend une liste de modèles fiable avant de les exposer. Si l’onglet Models contient au moins 20 lignes distinctes, tous les interrupteurs de modèles sont initialement OFF ; le fournisseur reste ACTIVE. Les connexions utilisant effectivement OAuth ou la connexion ChatGPT conservent leurs valeurs par défaut.
@@ -55,7 +65,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de
| `codexWsReuseAcrossTurns?` | `boolean` | `false` | Réutilisation optionnelle du WebSocket Codex entre les tours d'un même compte et fil. Désactivé par défaut. Activé, un socket occupé est attendu au plus 750 ms et jusqu'à deux sockets restent lors d'un changement de modèle ou de palier. |
| `upstreamHostCircuitThreshold?` | `number` | `0` | Seuil facultatif du coupe-circuit pour les échecs DNS/TCP avérés avant connexion sur les requêtes Responses OpenAI natives en mode transfert et les envois compacts. `0` le désactive ; `1`–`20` ouvre, après ce nombre de requêtes logiques arrivées à leur terme, une temporisation de 30 secondes propre à l'origine du fournisseur. Tant que le circuit est ouvert, les requêtes reçoivent `503` avec `Retry-After` avant la sélection du compte ou l'envoi en amont ; après la temporisation, une requête est admise en état semi-ouvert. Les délais d'attente et les réponses HTTP ne sont jamais comptabilisés, et toute réponse HTTP ferme le circuit. Ce mécanisme s'applique uniquement au routage du pool Codex sans compte épinglé ; il reste inactif pour `codexAccountMode: "direct"` et les sélecteurs qualifiés par compte. |
| `modelCacheTtlMs?` | `number` | `300000` | Fenêtre de fraîcheur pour le cache `/models` par fournisseur. |
-| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Politique Anthropic de mise en cache des invites : désactivée, éphémère pendant 5 minutes ou étendue à 1 heure. |
+| `cacheRetention?` | `"none" \| "short" \| "long"` | Anthropic direct : `"long"` ; autres points de terminaison compatibles Anthropic : `"short"` | Politique Anthropic de mise en cache des invites : désactivée, éphémère pendant 5 minutes ou étendue à 1 heure. Sans valeur, les requêtes vers `api.anthropic.com` (clé API ou pools de comptes OAuth) utilisent un TTL d’une heure ; les passerelles et points de terminaison compatibles gardent 5 minutes. Une valeur explicite l’emporte toujours. |
| `tokenGuardian?` | `OcxTokenGuardianConfig` | désactivé | Politique facultative d'actualisation proactive OAuth et de préchauffage des comptes Codex. |
Pour ces stratégies, le seuil effectif de chaque compte est son entrée `codexAccountAutoSwitchThresholds` si elle existe, sinon le seuil global `autoSwitchThreshold`. Une valeur de zéro désactive uniquement le basculement proactif selon l'utilisation ; la liaison au démarrage, le verrouillage strict, le cooldown, les droits d'accès au modèle et la reprise après échec restent appliqués.
@@ -95,7 +105,9 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| --- | --- | --- |
| `adapter` | `string` | L'un des `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` (ou alias `azure`). |
| `baseUrl` | `string` | URL de base de l'API en amont. La plupart des points de terminaison fixes intégrés ignorent une valeur incompatible ; les préréglages de clés protégés contre les collisions préservent une ancienne destination personnalisée portant le même nom. |
-| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, models? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client, distinct de l’utilisation, de la facturation et des indicateurs de limitation en amont. `maxConcurrentRequests` est une limite entière positive des requêtes en cours ; une règle fournisseur ou modèle peut utiliser uniquement cette limite. Les limites du fournisseur s’appliquent à tous ses modèles, tandis que les entrées `models` ciblent les identifiants exacts des modèles en amont et peuvent ajouter du délai ou réduire la concurrence. L’attente dans la file ne consomme pas le délai d’expiration des en-têtes de réponse en amont. Les requêtes HTTP et les distributions explicites `fetchResponse`/`runTurn` des adaptateurs sont couvertes. Avec une limite de concurrence, un tour Responses WebSocket canonique utilise HTTP/SSE afin de libérer la capacité à la fin, en cas d’erreur ou d’annulation du corps de réponse. Pour les adaptateurs `runTurn`, dont Cursor, la limite compte les tours actifs plutôt que les envois physiques : RunSSE et BidiAppend peuvent se chevaucher dans un même tour, tandis qu’un autre tour attend. Les envois suivants respectent toujours les intervalles de démarrage. |
+| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, models?, keySelection?, keys? }` | Cadencement facultatif du démarrage des requêtes sortantes côté client, distinct de l’utilisation, de la facturation et des indicateurs de limitation en amont. `maxConcurrentRequests` est une limite entière positive des requêtes en cours ; une règle fournisseur ou modèle peut utiliser uniquement cette limite. Les limites du fournisseur s’appliquent à tous ses modèles, tandis que les entrées `models` ciblent les identifiants exacts des modèles en amont et peuvent ajouter du délai ou réduire la concurrence. L’attente dans la file ne consomme pas le délai d’expiration des en-têtes de réponse en amont. Les requêtes HTTP et les distributions explicites `fetchResponse`/`runTurn` des adaptateurs sont couvertes. Avec une limite de concurrence, un tour Responses WebSocket canonique utilise HTTP/SSE afin de libérer la capacité à la fin, en cas d’erreur ou d’annulation du corps de réponse. Pour les adaptateurs `runTurn`, dont Cursor, la limite compte les tours actifs plutôt que les envois physiques : RunSSE et BidiAppend peuvent se chevaucher dans un même tour, tandis qu’un autre tour attend. Les envois suivants respectent toujours les intervalles de démarrage. |
+| `requestPacing.keySelection?` | `"active"`, `"round-robin"` | Sélection des clés pour les requêtes normales : `active` par défaut conserve la clé enregistrée et la rotation sur erreur ; `round-robin` répartit les allocations entre les clés disponibles sans modifier la sélection enregistrée. Cela équilibre les allocations, pas les jetons ni les coûts. |
+| `requestPacing.keys?` | `{ requestsPerMinute?, minIntervalMs?, maxConcurrentRequests? }` | Limites uniformes par référence de clé : RPM, intervalle minimal et concurrence, partagées entre modèles et cumulées avec les plafonds fournisseur et modèle. Les références identiques partagent leur capacité ; plusieurs références du même projet ne garantissent pas des quotas indépendants. TPM et quotas quotidiens ne sont pas gérés. |
| `responsesPath?` | `string` | Chemin de ressource relatif pour les requêtes d'authentification par clé `openai-responses`. Il doit commencer par `/` et ne contenir aucun schéma, requête ou fragment. |
| `chatCompletionsPath?` | `string` | Chemin de ressource relatif pour les requêtes `openai-chat`, miroir de `responsesPath` et soumis aux mêmes règles de forme. Nécessaire lorsqu'un même service en amont sert Chat Completions et Responses sous des préfixes différents : un override wire par modèle change l'adaptateur sans toucher `baseUrl`, donc sans ce réglage une requête Chat activée serait envoyée vers la base Responses. L'exemple fourni est Z.AI. |
| `upstreamWebsocket?` | `boolean` | Active le transport Responses WebSocket en amont pour les requêtes `openai-responses` (désactivé par défaut). N'est honoré que pour l'amont first-party `https://api.openai.com/v1` ; les points de terminaison des fournisseurs personnalisés utilisent toujours HTTP/SSE borné, car Bun ne peut pas appliquer de limite de taille aux messages WebSocket entrants avant d'avoir alloué le message complet. Pour le fournisseur canonique ChatGPT `openai`, l'omettre conserve le WebSocket en amont sur les tours éligibles, `false` envoie les tours en streaming via HTTP/SSE, et `true` est refusé ; avec `false`, le pilotage et l'injection natifs en cours de tour sont indisponibles. Ce champ est indépendant du réglage `websockets` côté client et ne change ni le point de terminaison ni les identifiants. Une base HTTP reste en SSE ; les chemins qui ne sont pas Responses et les requêtes `openai-chat` restent en HTTP. |
@@ -119,7 +131,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| `modelAutoCompactTokenLimits?` | `Record` | Budgets souples de compactage automatique par modèle, sous forme d'entiers sûrs positifs. Ils peuvent uniquement abaisser l'enveloppe effective de 90 % du contexte ou de l'entrée maximale et sont omis lorsqu'aucune fenêtre de contexte effective n'est déterminée. Pour le fournisseur canonique `openai`, les clés doivent être les identifiants exacts de modèles natifs pris en charge, sans préfixe de fournisseur ni de sélecteur de compte. PATCH fusionne les entrées ; `null` supprime une clé, tandis que `null` pour le champ entier efface la table. Ces marqueurs `null` sont réservés à PATCH. |
| `defaultMaxOutputTokens?` | `number` | Solution de secours `openai-chat` à l’échelle du fournisseur lorsque le client omet `max_output_tokens`. |
| `modelMaxOutputTokens?` | `Record` | Budgets de repli `openai-chat` positifs par modèle ; les correspondances exactes ou par motif priment sur la valeur par défaut du fournisseur. |
-| `modelCosts?` | `Record` | Prix affichés par modèle (USD par 1M de jetons), indexés par l'identifiant exact du modèle en amont de ce fournisseur — et non par un identifiant de fournisseur ni par une étiquette routée `provider/model`, par exemple `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Tout identifiant de modèle constitue une clé valide : les fournisseurs personnalisés peuvent cibler n'importe quel point de terminaison compatible avec OpenAI au moyen de l'adaptateur `openai-chat`, et les identifiants de fournisseur locaux ou internes fonctionnent même s'ils sont absents des catalogues intégrés. Les prix configurés par l'utilisateur priment sur les catalogues intégrés dans les estimations des pages Journaux (`~$`) et Utilisation. Les entrées historiques sont recalculées à partir de la surcharge actuelle ; modifier un prix peut donc changer les totaux antérieurs. L'ordre de repli est le suivant : `modelCosts` défini par l'utilisateur → catalogue jawcode → surcharge des prix attendus → repli propre au fournisseur au niveau du modèle. Une surcharge utilisateur explicitement définie à zéro produit une estimation nulle connue ; supprimez cette entrée pour rétablir la tarification automatique. Les prix de catalogue entièrement nuls restent soumis au repli. Chaque tarif doit être un nombre fini positif ou nul, inférieur ou égal à 1 000 000 (USD par 1M de jetons) ; les lignes hors plage sont rejetées par l'interface de gestion et ignorées au chargement. Ces valeurs servent uniquement à l'estimation lors de l'affichage : les surcharges n'affectent jamais le routage, la sélection des comptes, les quotas ni la facturation. |
+| `modelCosts?` | `Record` | Prix affichés par modèle (USD par 1M de jetons), indexés par l'identifiant exact du modèle en amont de ce fournisseur — et non par un identifiant de fournisseur ni par une étiquette routée `provider/model`, par exemple `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Tout identifiant de modèle constitue une clé valide : les fournisseurs personnalisés peuvent cibler n'importe quel point de terminaison compatible avec OpenAI au moyen de l'adaptateur `openai-chat`, et les identifiants de fournisseur locaux ou internes fonctionnent même s'ils sont absents des catalogues intégrés. Les prix configurés par l'utilisateur priment sur les catalogues intégrés dans les estimations des pages Journaux (`~$`) et Utilisation. Les entrées historiques sont recalculées à partir de la surcharge actuelle ; modifier un prix peut donc changer les totaux antérieurs. L'ordre de repli est le suivant : `modelCosts` défini par l'utilisateur → catalogue jawcode → surcharge des prix attendus → repli propre au fournisseur au niveau du modèle. Une surcharge utilisateur explicitement définie à zéro produit une estimation nulle connue ; supprimez cette entrée pour rétablir la tarification automatique. Les prix de catalogue entièrement nuls restent soumis au repli. Chaque tarif doit être un nombre fini positif ou nul, inférieur ou égal à 1 000 000 (USD par 1M de jetons) ; les lignes hors plage sont rejetées par l'interface de gestion et ignorées au chargement. Ces valeurs servent uniquement à l'estimation lors de l'affichage : les surcharges n'affectent jamais le routage, la sélection des comptes, les quotas ni la facturation. |
| `headers?` | `Record` | En-têtes supplémentaires en amont. L'autorisation, les cookies, les en-têtes de clé API, les nouvelles lignes intégrées et les noms invalides sont rejetés. |
| `openRouterRouting?` | `OpenRouterProviderRouting` | Préférences OpenRouter `order`, `only` et `allowFallbacks` par défaut ; valable uniquement pour les OpenRouter canoniques avec `openai-chat`. |
| `modelOpenRouterRouting?` | `Record` | Remplacements exacts de l'ID de modèle qui remplacent la préférence OpenRouter à l'échelle du fournisseur. |
@@ -131,7 +143,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| `modelReasoningEfforts?` | `Record` | Libellés propres à chaque modèle. Une liste vide masque le contrôle de l'effort. Comme pour `reasoningEfforts`, chaque échelle configurée avec l'adaptateur `google` déclare la capacité `thinkingLevel` ; les requêtes directes et Vertex sans image utilisent le chemin Gemini à plat, tandis que Cloud Code Assist l'envoie dans son enveloppe de requête. |
| `modelSupportsReasoningSummaries?` | `Record` | Définissez un modèle sur `false` pour arrêter la publicité des résumés et supprimer les champs de livraison du résumé. |
| `modelReasoningSummaryDelivery?` | `Record` | Énumération de livraison des réponses par modèle ; réécrit un champ de livraison existant. |
-| `modelAdapters?` | `Record` | Remplacement du protocole `openai-chat` ou `openai-responses` par modèle pour les passerelles multiprotocoles. Les entrées explicites priment sur les valeurs par défaut du registre. Le préréglage OpenCode Go sélectionne Responses pour `gpt-5.6-luna` tout en laissant les modèles apparentés sur leurs protocoles documentés ; DeepSeek peut sélectionner Responses natif pour `deepseek-v4-flash` ; GitHub Copilot déclare des valeurs par défaut limitées à Responses pour ces modèles (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`), car ces modèles rejettent `/chat/completions` pour le trafic des agents. Les modèles sans valeur intégrée par défaut, comme `gpt-5.4-nano`, peuvent être activés ici. Les services en amont à protocole unique et le transfert canonique ChatGPT rejettent ces remplacements. |
+| `modelAdapters?` | `Record` | Remplacement du protocole `openai-chat` ou `openai-responses` par modèle pour les passerelles multiprotocoles. Les entrées explicites priment sur les valeurs par défaut du registre. Le préréglage OpenCode Go sélectionne Responses pour `gpt-5.6-luna` tout en laissant les modèles apparentés sur leurs protocoles documentés ; DeepSeek peut sélectionner Responses natif pour `deepseek-v4-flash` ; GitHub Copilot déclare des valeurs par défaut limitées à Responses pour ces modèles (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`), car ces modèles rejettent `/chat/completions` pour le trafic des agents. Les modèles sans valeur intégrée par défaut, comme `gpt-5.4-nano`, peuvent être activés ici. Les services en amont à protocole unique et le transfert canonique ChatGPT rejettent ces remplacements. |
| Activation Responses xAI (tableau de bord) | interrupteur | Pour `xai` uniquement, définit ou efface atomiquement les entrées `modelAdapters` de `grok-4.5` et `grok-4.6`. Une seule entrée apparaît comme un état mixte jusqu’à la prochaine écriture. Les autres remplacements et le comportement des tiers restent inchangés. Grok 4.7 utilise Responses par défaut sur OAuth via le registre et peut passer à Chat avec une entrée explicite `modelAdapters["grok-4.7"] = "openai-chat"`. |
| `xaiResponsesXSearch?` | `boolean` | Désactivé par défaut. Sur une destination xAI Responses, ajoute la déclaration `x_search` hébergée par le fournisseur uniquement lorsqu’un outil `web_search` actif subsiste après la normalisation finale de la requête. Les déclarations existantes ne sont pas dupliquées, les sélecteurs `tool_choice`/`allowed_tools` de l’appelant ne sont jamais élargis, et cette option est distincte des options `search.xSearch` du service auxiliaire de recherche web. |
| `modelPreferHostedTools?` | `Record` | Activation explicite par modèle exact pour les passerelles Responses hors transfert qui réservent un espace de noms aux outils hébergés. Seul `["image_generation"]` est actuellement accepté ; le modèle correspondant doit utiliser le protocole `openai-responses` et prendre en charge cet outil hébergé. Le proxy supprime les déclarations clientes `image_gen` en conflit et réécrit leurs sélecteurs afin de préserver le choix d'outil de l'appelant. Pour les modèles virtuels `-pro` de l'API OpenAI, l'identifiant public sélectionné est comparé en premier et l'identifiant résolu du modèle de base sur le protocole sert de repli. `modelAdapters` résout d'abord l'identifiant public, puis celui de base ; la seconde résolution détermine le protocole final. Les autres modèles conservent le comportement normal des alias. |
@@ -145,13 +157,14 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| `noStopModels?` | `string[]` | Modèles qui rejettent `stop` spécifié par l’appelant. L’adaptateur `openai-chat`, le passthrough Chat et le passthrough Responses omettent ce champ pour ces identifiants. Le préréglage `xai` intégré y liste ses modèles de raisonnement (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), que xAI documente comme le refusant ; `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast` conservent le `stop` de l’appelant. |
| `noPenaltyModels?` | `string[]` | Modèles qui rejettent les pénalités presence/frequency. Le préréglage `xai` intégré y liste ses modèles de raisonnement (`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`), que xAI documente comme les refusant ; les identifiants sans raisonnement conservent les pénalités de l’appelant. |
| `noStructuredOutputModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette `response_format`. Seule une correspondance exacte du modèle demandé omet le champ ; la traduction à sortie structurée reste activée pour tous les autres modèles `openai-chat`. |
-| `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go`, `opencode zen` et `opencode free` l’embarquent pour leurs routes DeepSeek. |
+| `noJsonSchemaModels?` | `string[]` | ID de modèle exact dont le point final `openai-chat` rejette un `response_format` `json_schema` mais accepte encore `json_object`. Une telle requête est rétrogradée vers `json_object` au lieu d’être supprimée, donc un appelant qui demande du JSON en reçoit toujours. `noStructuredOutputModels` l’emporte quand un modèle figure dans les deux listes. Les préréglages `opencode go` et `opencode zen` l’embarquent pour leurs routes DeepSeek. |
| `foldDeveloperRoleToSystem?` | `boolean` | Indique si une destination `openai-chat` accepte le rôle `developer`. `foldDeveloperRoleToSystem` non défini envoie `system`, `true` envoie `system` et `false` envoie `developer`. Non défini signifie que rien n'a été enregistré pour cette destination ; `true` enregistre un service en amont qui refuse le rôle ; `false` en enregistre un qui l'accepte. Dans tous les cas le message conserve sa position dans la conversation ; seul le rôle change. Une destination qui refuse le rôle répond `400 role 'developer' is not allowed` et le tour ne démarre pas, d'où l'état non enregistré replié par défaut. |
| `parallelToolCalls?` | `boolean` | Contrôler les appels d’outils parallèles. Pour `openai-chat`, ils sont activés par défaut ; `false` envoie explicitement `parallel_tool_calls: false`. Les autres adaptateurs ne les annoncent que lorsque la valeur vaut explicitement `true`. |
| `terminalContinuationGuard?` | `boolean` | Active, pour un fournisseur `openai-chat`, une relance interne bornée lorsqu’un tour exploitable annonce une action puis s’arrête proprement sans appel d’outil. La valeur par défaut est `false`, et une valeur explicite `false` équivaut à l’absence du champ. Les tentatives de combinaison et les tours de compactage routés sont exclus ; les autres adaptateurs ignorent cette option. |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | Réparation SSE en aval désactivée par défaut pour les identifiants d'espace réservé exacts, les identifiants de terminal manquants et (avec `repairInvalidIds`) les identifiants message/reasoning manquant du préfixe canonique `msg_`/`rs_`. Les identifiants d’appel de fonction ne sont jamais réécrits. Le DeepSeek intégré active les deux derniers par défaut. |
| `responsesSnapshotRepair?` | `boolean` | Réparation côté client désactivée par défaut pour les instantanés du cycle de vie des réponses clairsemés dans SSE et JSON. Remplit les métadonnées d'état canonique, de sortie et d'outil manquantes tandis que l'inspection brute et la persistance restent inchangées. |
-| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Fournisseurs à clé API uniquement (`authMode: "key"`). Nouvelle tentative facultative sur la même cible après un 429 : lorsque `retryOn429` est absent, la fonctionnalité est désactivée ; la présence d'un objet l'active, sauf avec `enabled: false`. Après un 429, le proxy attend selon `Retry-After` reçu en amont ou selon l'intervalle fixe, puis relit la requête à l'identique avec la même clé avant tout basculement de clé. Ce comportement couvre la boucle principale de récupération d'un tour textuel, le protocole de transfert Responses, le pont d'images et de vidéos, le service auxiliaire de recherche Web et les continuations du terminal. Seules les réponses HTTP 429 reçues avant le début de la diffusion peuvent être relues ; les transports `runTurn` personnalisés ne font pas partie de la boucle de nouvelle tentative HTTP. `attempts` compte les relectures avec la même clé après le premier 429, soit `attempts` + 1 envois au total, et constitue un budget commun à toute la requête, partagé entre la boucle principale de récupération, la continuation de la garde du terminal et les nouvelles tentatives du pont. L'épuisement de `attempts` arrête uniquement les relectures supplémentaires avec la même clé : le basculement normal de clé ou la gestion de l'erreur finale s'applique ensuite selon les cibles disponibles. Sur le protocole de transfert authentifié par clé, aucun basculement n'est possible ; le 429 final est donc renvoyé sans modification. Codex ne retente jamais lui-même une requête après un 429 : cette option constitue ainsi la seule protection pour les fournisseurs à clé unique. Valeurs par défaut : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (chaque attente est plafonnée à `maxIntervalMs`, lui-même plafonné à 600000), `respectRetryAfter: true`. |
+| `promptCacheKey?` | `boolean` | Active pour tout le fournisseur `openai-chat` la transmission de `prompt_cache_key`. L’adaptateur transmet la clé reçue sans en inventer ; la traduction Claude Messages peut toutefois la dériver de `metadata.user_id`, ou d’un groupe modèle/système/outils sans métadonnées. Désactivé par défaut sauf dans un préréglage ; Mistral l’active selon son [contrat de cache](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching). `false` explicite le désactive. N’activer que si l’amont documente ce champ, car une passerelle stricte peut répondre HTTP 400. |
+| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | Réservé aux fournisseurs à clé API (`authMode: "key"`). Facultatif : absent, désactivé, sauf pour OpenCode Go et Command Code sur leurs destinations canoniques et Mistral canonique avec `openai-chat` : 6 relances, intervalle de 10 s, plafond de 60 s. Un objet active la fonction ; `enabled: false` désactive aussi ces valeurs de repli. Après un HTTP 429 avant le flux, attend `Retry-After` ou l’intervalle fixe, puis renvoie la même requête avec la même clé avant le basculement. Couvre la récupération du tour texte, Responses passthrough, les ponts image/vidéo, le service web-search et les continuations terminales ; les transports `runTurn` personnalisés sont exclus. `attempts` compte les relances après le premier 429 (envois totaux = `attempts` + 1), dans un budget partagé à l’échelle de la requête. Son épuisement arrête les relances avec la même clé, puis laisse agir le basculement ou l’erreur finale ; le passthrough à clé n’a pas de basculement. Pour Mistral Chat canonique, un délai explicite supérieur à l’attente autorisée arrête la relance avec la même clé et conserve le `Retry-After` original ; une autre clé du pool peut encore servir. Les autres fournisseurs conservent le délai plafonné. Mistral partage les échéances par identifiant/modèle, sauf avec `requestPacing.enabled: false` ou `retryOn429.respectRetryAfter: false`. Un 429 sans échéance avec une pression validée sur le budget par minute déclenche une attente prudente de 60 s, puis une seule sonde ; son succès rétablit le trafic ordinaire. Le trafic sain ne prédit pas le coût suivant. La politique utilise les allocations annoncées, exclut les allocations mensuelles connues comme épuisées, ne prédit pas une réinitialisation exacte et ne déclenche pas de compactage. Codex ne relance pas lui-même les 429. Valeurs par défaut de l’objet : `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000` (attente plafonnée ; valeur maximale 600000), `respectRetryAfter: true`. |
| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | Fournisseurs `openai-chat` et `openai-responses` authentifiés par clé uniquement. Les fournisseurs `authMode: "forward"` (le pool de comptes ChatGPT) ne lisent jamais cette option et conservent l'échelle par défaut. Nouvelle tentative facultative pour les états transitoires reçus en amont avant le début de la diffusion (500, 502, 503, 504, 520, 521, 522) : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Ce comportement couvre la requête Responses initiale, la continuation de la garde du terminal, le point de terminaison natif `/v1/chat/completions` et les réémissions liées à la récupération après un 429 ou à la récupération de compte. `attempts` représente le nombre TOTAL d'envois en amont autorisés pour une requête, premier envoi compris (de 1 à 10, valeur par défaut : 3). Il constitue un budget commun à la requête, partagé avec la récupération après une réinitialisation de connexion ; ainsi, `3` signifie qu'au plus trois requêtes réelles atteignent le fournisseur. Les attentes utilisent une temporisation exponentielle à base fixe de 400 ms, plafonnée à 5 s, et respectent `Retry-After`. Cette option est distincte de `retryOn429`, qui traite la limitation de débit ; les échecs en cours de diffusion ne sont jamais relus. Chaque cible Combo compte son premier envoi prépayé dans son propre total configuré : `attempts: 1` autorise un envoi, sans rembourser la réservation ni élargir le plafond partagé. Un refus local ou une annulation avant l'envoi libère la réservation inutilisée. |
| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | Fournisseurs `openai-responses` natifs, `authMode: "forward"` compris, ainsi que les requêtes Responses traduites envoyées par la voie générique avant les en-têtes reçus en amont. Remplacement facultatif d'un envoi qui a échoué alors que l'appelant n'avait rien observé : l'absence de l'option la désactive ; la présence d'un objet l'active, sauf avec `enabled: false`. Couvre les deux étapes ambiguës — une connexion rompue avant tout en-tête de réponse, et un corps SSE rompu après l'en-tête alors qu'il ne portait que des événements de contrôle. Une WebSocket canonique ChatGPT en amont qui s'est fermée ou a échoué après l'envoi de sa trame de création, avant tout événement Responses, est couverte de la même façon ; son remplacement est envoyé en HTTP. Seule une requête autonome est remplacée : `store: false`, `input` complet, ni `previous_response_id`, ni `conversation`, ni `stream_id`, et uniquement des outils exécutés par le client. `replacements` est le nombre d'envois de remplacement qu'UNE requête logique peut effectuer, toutes étapes et tous enfants de combo confondus (de 1 à 2, valeur par défaut : 1). Ce n'est ni un nombre de tentatives par étape ni un budget d'envoi : un remplacement doit toujours tenir dans l'allocation d'envois dont l'étape disposait déjà. Une requête qui a déjà émis une sortie ou un appel d'outil n'est jamais remplacée, quelle que soit cette valeur. L'inférence de remplacement peut tout de même être facturée si l'origine avait déjà démarré la première, d'où la désactivation par défaut. La voie générique de traduction prend en charge le remplacement après une rupture avant les en-têtes pour les envois initiaux et reconstruits, avec la même autorisation et le même budget d’envois. Les transports gérés par les adaptateurs et les erreurs des flux traduits après les en-têtes reçus en amont sont exclus. |
| `autoToolChoiceOnlyModels?` | `string[]` | Modèles dont `tool_choice` accepte uniquement `auto` ou `none` ; les choix forcés sont dévalorisés. |
@@ -165,8 +178,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| `anthropicEofTolerance?` | `boolean` | Laissez une passerelle compatible Anthropic compléter un flux qui se termine avant `message_stop`, uniquement lorsque du texte visible ou une entrée complète d'outil d'objet JSON a été reçue. Désactivé par défaut. |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Mode Google transport/auth. Par défaut `ai-studio`. |
| `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google uniquement. Une valeur absente ou `compatible` conserve le schéma compatible et la réparation 400 existante hors mode direct. `reject-lossy` refuse une perte initiale ou une comparaison bornée indéterminée avant l’envoi et bloque une réparation Vertex ou Cloud Code Assist qui ouvrirait des contraintes. AI Studio direct n’effectue jamais cette réparation. |
-| `project?` | `string` | ID du projet Vertex ou Antigravity Cloud Code Assist. |
-| `location?` | `string` | Région Vertex ; la valeur de repli de l'environnement est `GOOGLE_CLOUD_LOCATION`. |
+| `project?` | `string` | ID du projet Vertex ou Antigravity Cloud Code Assist. Lorsqu'il est défini, les clés API Vertex utilisent le point de terminaison du projet ; sinon elles utilisent le mode express, même si `GOOGLE_CLOUD_PROJECT` est défini. ADC se replie sur `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. Avec `"auto"`, chaque clé API Vertex résout son propre projet (une résolution réussie est mise en cache par clé ; une clé dont la résolution échoue est de nouveau sondée lors d'une requête ultérieure), de sorte qu'un pool de clés peut couvrir plusieurs projets ; ADC refuse `"auto"`. |
+| `location?` | `string` | Région Vertex. Les points de terminaison de projet avec clé API utilisent `global` par défaut et ignorent `GOOGLE_CLOUD_LOCATION` ; ADC exige ce champ ou le repli `GOOGLE_CLOUD_LOCATION`. |
| `mcpServers?` | `Record` | Cursor uniquement : serveurs MCP sur entrée-sortie standard ou HTTP diffusé en continu. |
| `desktopExecutor?` | `DesktopExecutorConfig` | Cursor uniquement : commandes externes d'utilisation d'un ordinateur et d'enregistrement de l'écran. |
| `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. |
@@ -631,3 +644,8 @@ le nom actuel avant d'effectuer une autre modification.
### `anthropicAccountPool.routes`
Les routes `anthropicAccountPool.routes` associent un modèle à des identifiants de comptes OAuth Anthropic enregistrés. La première règle correspondante (`match`, glob sensible à la casse) limite la sélection initiale et les reprises 429 lorsque le pool est activé. `fallback: true` élargit la sélection seulement si aucun compte de la règle n’est éligible.
+
+
+### `promptLengthPricing`
+
+Le champ facultatif `promptLengthPricing` complète les quatre tarifs de base. `mode: "automatic"` (ou champ absent) conserve les règles de contexte du catalogue ; `mode: "flat"` les désactive. Avec `mode: "custom"`, `thresholdTokens` est un entier positif sûr, `comparison` vaut `"gt"` (>) ou `"gte"` (≥), et `rates` contient les quatre tarifs alternatifs absolus. Le seuil compte tous les jetons du prompt, y compris les lectures et écritures du cache, sans la sortie. Les tarifs sélectionnés couvrent toute la requête, sortie comprise, et remplacent la règle de contexte du catalogue des deux côtés du seuil. Les ajustements Fast/Priority s'appliquent une seule fois après la sélection des tarifs et suivent la relation Priority publiée dans le catalogue pour ce modèle ; l'estimation n'est indiquée comme borne inférieure que lorsque le catalogue déclare la relation `lower-bound` (actuellement xAI Grok) ; un modèle sans règle de catalogue applique le multiplicateur Priority une seule fois. Configurez ces options dans **Models → Price**. **Reset to automatic** supprime tous les tarifs et la règle personnalisés. Les anciennes configurations restent compatibles ; ces paramètres concernent les estimations affichées.
diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md
index fb6a70a30aa..25f81a99073 100644
--- a/docs-site/src/content/docs/fr/reference/management-api.md
+++ b/docs-site/src/content/docs/fr/reference/management-api.md
@@ -353,7 +353,7 @@ lui-même s'il souhaite ajouter une étoile au dépôt.
| `POST /api/system/restart` | Amorcer un redémarrage du processus qui attend l'évacuation des requêtes, sans retirer l'injection du client | Renvoie 202 ; les appels répétés signalent l'évacuation déjà en cours |
| `POST /api/stop` | Arrêter le service, restaurer Codex en mode natif, retirer l'injection Grok gérée et évacuer les requêtes du proxy | 409 conflit de propriété du service; 409 `respawnable_service` lorsqu'un wrapper du Planificateur de tâches Windows pourrait relancer le proxy et que l'appelant n'est pas `ocx stop` (rien n'est modifié) ; 409 lorsque le gestionnaire installé refuse de s'arrêter ; 409 `service_state_unknown` lorsque l'état du Planificateur de tâches ne peut pas être lu (rien n'est modifié ; réparez la requête puis réessayez) |
| `GET /api/system/codex-app-server` | Indiquer si les serveurs d'application Codex en cours d'exécution sont antérieurs au catalogue de modèles actuel | — |
-| `POST /api/system/codex-restart` | Actualiser le catalogue, puis redémarrer les serveurs d'application Codex obsolètes et quitter puis relancer entièrement l'application Codex Desktop afin que le sélecteur de modèles se recharge. Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop est refusé plutôt que transféré. | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas |
+| `POST /api/system/codex-restart` | Actualiser le catalogue, puis redémarrer les serveurs d'application Codex. Une application Codex Desktop en cours reçoit une tentative de redémarrage afin que le sélecteur de modèles puisse se recharger ; les champs `desktopApp` de la réponse signalent un refus, des cibles survivantes ou un échec de relance. Un processus plus récent que le catalogue est tout de même signalé avec `desktopApp.pickerState: "unknown"`, car son heure de démarrage ne prouve pas quelle liaison `model_catalog_json` il a chargée ; une synchronisation antérieure peut avoir relié un catalogue déjà existant. Un processus qui n'est pas plus récent que le catalogue, ou dont cette requête a changé la liaison, est `stale`. Une application fermée renvoie `desktopApp.reason: "no_targets"` ; son `pickerState` peut être `not_running`, ou `stale` si cette requête a changé la liaison au catalogue. Lorsque le proxy lui-même s'exécute dans l'application Codex, le redémarrage Desktop est refusé plutôt que transféré. | Renvoie 200 avec `code: partially_stopped` lorsqu'une cible ne s'arrête pas |
### Délégation de l'authentification Codex
diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md
index 5535c90ade6..ae904510a0e 100644
--- a/docs-site/src/content/docs/fr/reference/proxy-formats.md
+++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md
@@ -41,6 +41,8 @@ Les points de terminaison Chat Completions et Messages honorent les catalogues d
Par ailleurs, sur chaque voie entrante, un appel dans l’espace de noms `default.` d’un fournisseur est normalisé vers le nom d’outil nu déclaré par la requête (`default.Read` → `Read`).
+Messages natif reconnaît aussi les définitions d’outils en ligne dans les messages système avec `tools: []`. Ces noms restent appelables jusqu’à leur retrait permanent, sans autoriser d’autres outils clients. Les appels MCP hébergés et les flux d’arguments des outils serveur conservent leur format natif.
+
## Présentation du point de terminaison
| Espace client | Point de terminaison | Résultat non-stream réussi | Résultat de flux ou de socket réussi |
diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md
index 020b0c2bc4a..3ffc2f3effd 100644
--- a/docs-site/src/content/docs/getting-started/installation.md
+++ b/docs-site/src/content/docs/getting-started/installation.md
@@ -38,8 +38,8 @@ Recent npm versions may block bun's postinstall script (`npm warn
install-scripts ... blocked because they are not covered by allowScripts`),
which leaves the bundled Bun runtime unprepared. After trying permitted bundled-runtime
recovery, the launcher can use an executable Bun from an absolute PATH directory if its
-stable version has the pinned major and a minor at least the pinned minor. With the current
-1.4.2 dependency, that means 1.4.0 or newer within major 1. Bounded version-policy and identity
+stable version has the pinned major and a version at least the pinned version. With the current
+1.4.4 dependency, that means 1.4.4 or newer within major 1. Bounded version-policy and identity
checks require `bun --version` to match `bun -e`'s `Bun.version`. On POSIX, the launcher checks
the whole candidate path, including ancestor directories and components reached through
symlinks. Each traversed directory and symlink, and the resolved executable, must be owned by
@@ -99,6 +99,9 @@ are not required. Download the archive for your platform, extract it, and run:
The extracted `gui/dist` and `keyring` directories must stay beside the binary. The first serves
the dashboard; the second carries the platform-native OS credential-store binding.
+On Windows, [Windows Code Signing](/reference/code-signing/) explains how `ocx.exe` is signed and
+how to verify it.
+
### Release channels
The stable `latest` channel already includes GPT-5.6 Sol/Terra/Luna catalog support for ChatGPT,
diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md
index 99ff1a1d5d7..ed39e7005e5 100644
--- a/docs-site/src/content/docs/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/getting-started/quickstart.md
@@ -18,7 +18,7 @@ ocx init
`ocx init` walks you through:
-1. **Pick a provider** — choose one of the 104 built-in registry presets or `custom` to type a base
+1. **Pick a provider** — choose one of the 106 built-in registry presets or `custom` to type a base
URL and adapter.
2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`.
3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id.
@@ -146,6 +146,18 @@ ocx restore # restore native Codex without stopping (alias: ocx eject)
ocx restore back # route Codex through the still-running proxy again
```
+## Startup ownership could not be verified
+
+If startup reports `Shared-client owner could not be verified` and `refusing startup`,
+OpenCodex could not safely decide whether another proxy owns the shared client settings.
+It stops before publishing a new runtime or changing those settings. Retry startup after
+local connectivity recovers; an installed background service retries through its service manager.
+A timeout does not permanently turn the new process into a secondary instance.
+
+IPv6 does not need to be enabled on loopback. Startup checks whether each loopback
+address can be bound locally and skips an unavailable family. Timeouts on an available
+family still stop startup until ownership can be verified.
+
## Next
- [How It Works](/getting-started/how-it-works/) — what happens to each request.
diff --git a/docs-site/src/content/docs/guides/chatgpt-desktop.md b/docs-site/src/content/docs/guides/chatgpt-desktop.md
index 887493f1935..458acdfbedf 100644
--- a/docs-site/src/content/docs/guides/chatgpt-desktop.md
+++ b/docs-site/src/content/docs/guides/chatgpt-desktop.md
@@ -126,6 +126,17 @@ app-server until you run one of them. A single output line longer than 8 MiB is
passed through unparsed rather than buffered.
This standalone shim does not rewrite conversation metadata or route model calls.
-Other app gates or upstream refusals can still prevent sending. Evidence reported
-on an exhausted Plus account also used an intercept, so it does not establish
-that this shim alone resolves every desktop send lock.
+Other app gates or upstream refusals can still prevent sending. An attached launcher
+or a passing filter self-test does not prove that the app's gate data crosses this pipe.
+
+Reporter observations in [#6196](https://github.com/lidge-jun/opencodex/issues/6196)
+on October 2 and 13, 2026 describe an attached shim with Send still disabled at
+natural quota exhaustion. On Desktop 26.1007.30912 with OpenCodex 2.84.0, the
+reporter observed no eligible rate-limit RPC messages while the renderer's gate
+used a separate usage transport. This shim cannot rewrite data that never reaches
+its stdout filter. These are build-specific field reports, not a new live
+verification or proof that every Desktop build uses the same transport.
+
+The remaining signed-in composer behavior stays tracked in #6196. Earlier success
+with a combined shim/intercept configuration does not establish shim-only
+sufficiency, and the closed, unmerged #6365 intercept is not a shipped remedy.
diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md
index f6a457ebcec..4a815b3c6d1 100644
--- a/docs-site/src/content/docs/guides/claude-code.md
+++ b/docs-site/src/content/docs/guides/claude-code.md
@@ -254,6 +254,12 @@ Claude Desktop can use OpenCodex in one of two mutually exclusive modes. Pick it
**Connect → Claude Desktop → Connection mode** in the dashboard or with `ocx claude desktop apply
--first-party|--gateway`.
+> Desktop library profiles and `_meta.json` must be regular files of at most
+> 4 MiB. OpenCodex also refuses generated profile or metadata output above that
+> limit before replacing files. Back up and reduce oversized configuration
+> manually; a size refusal does not truncate the existing data. Connection-owned
+> stores retain their existing tighter limits.
+
### Gateway (default)
New installs use **gateway** by default. Its third-party profile, described below, switches the
@@ -271,6 +277,15 @@ not advertise an authoritative per-provider ladder, and the selected provider's
normal effort normalization still applies. This remains a client-version-specific
compatibility mechanism.
+Gateway apply refuses unreadable or non-regular configuration files, including dangling metadata
+links, before creating a new profile. If the metadata update fails, apply restores the profile's
+previous contents, or removes a profile it had just created, but only while the profile still holds
+what apply wrote; if another program changed it in the meantime, apply keeps that version and
+reports "profile rollback skipped". Apply rechecks the profile before
+backup and replacement; if it detects a concurrent edit, it refuses with `desktop_profile_changed`.
+Review the edit and reapply. If restoring the profile also fails after a metadata error, the
+reported reason includes both failures; reconcile the profile and metadata before retrying.
+
### First-party (opt-in)
:::caution[Account risk]
@@ -306,10 +321,15 @@ entries, is kept.
Claude Desktop first-party routes its Code tab and subagents through OpenCodex. The standalone Claude Code CLI has a separate first-party switch. Both clients read the same `~/.claude/settings.json` proxy and CA settings: if only one switch is on, the other client still transits the local proxy, where TLS terminates, but its Messages requests relay to Anthropic unchanged. Other Anthropic paths relay unchanged and unrelated hosts remain blind tunnels. With the CLI switch on, the standalone `claude` also lists routed models in `/model`; see [Model picker in the CLI](#model-picker-in-the-cli).
:::note[Windows system proxy (Clash, v2rayN, corporate proxies)]
-When a Windows system proxy is on, Claude Desktop hands it to the Code tab as `HTTPS_PROXY`, and
+When picker intent is off at OpenCodex startup, its Windows relay stays unbound. Turning picker
+mode on later starts it within the running service; OpenCodex does not need a restart.
+For picker-off installs or pending installs with no applied picker profile, a Windows system proxy
+is handed to the Code tab as `HTTPS_PROXY`, and
that value takes precedence over the OpenCodex proxy in `~/.claude/settings.json`. The Code tab
then goes around OpenCodex and routed models fail there, while the standalone CLI keeps working.
-Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge,
+An applied selected picker profile uses Desktop's fixed egress proxy while it is available, even when
+missing trust leaves the picker pending and `claude.ai` relays blindly. System-proxy bypass advice
+applies when there is no selected available picker profile. Add `api.anthropic.com` to your proxy client's system-proxy bypass list (in Clash Verge,
`system_proxy_bypass`), then fully quit and reopen Claude Desktop. `ocx doctor` reports this
under "Claude Desktop first-party vs Windows system proxy". It cannot evaluate a PAC script or
automatic proxy detection (WPAD, "Automatically detect settings"), so it reports those as unknown;
@@ -340,17 +360,17 @@ hub, so `ocx claude desktop apply` there uses the gateway profile.
### Picker mode: opencodex models in the first-party Code-tab picker
-Picker mode is part of first-party mode. On macOS it is on by default when first-party is selected,
+Picker mode is part of first-party mode. On macOS and Windows it is on by default when first-party is selected,
unless `claudeCode.intercept.picker: false` is set. It changes the first-party Desktop Code-tab picker
so it lists available opencodex models by name. The first time it is enabled, macOS may ask you to
-trust a local certificate authority in the login keychain. That authority is constrained to `claude.ai`
+trust a local certificate authority in the login keychain, and Windows may ask you to trust it in the current-user Root store. That authority is constrained to `claude.ai`
and its subdomains. OpenCodex protects its exportable signing identity in the OS credential store
and reuses the same validated certificate and key across normal restarts. No plaintext picker signing
key is stored in the OpenCodex config directory. The full constrained CA validation and OS trust
-verification still apply. With the same approved identity and an available credential store, restarting
-OpenCodex does not add or remove Certificate Trust Settings. Startup restore never installs trust:
+verification still apply, including the exact fingerprint with a verified leaf on Windows. With the same approved identity and an available credential store, restarting
+OpenCodex does not add or remove macOS Certificate Trust Settings. Restart and status never install trust:
if trust is missing, revoked or unknown, the picker stays pending. Run `ocx claude desktop picker on`
-or `ocx claude desktop picker trust` explicitly to grant trust.
+or `ocx claude desktop picker trust` explicitly to grant trust, including the Windows confirmation when needed.
Startup also attempts to remove a legacy on-disk picker signing key before checking whether
interception is enabled. Cleanup is best-effort and does not enable interception or block startup.
@@ -358,9 +378,9 @@ One-time migration from an older picker identity may require consent to remove i
If cleanup cannot finish, the picker stays unavailable and the applied profile uses a blind relay
until cleanup succeeds; `ocx claude desktop picker status` reports that state. macOS may separately
ask you to unlock the keychain or approve an application's access to stored credentials. These native
-access prompts can still occur on restart or upgrade.
+access prompts can still occur on restart or upgrade. On Windows, explicit `on` or `trust` may show a confirmation to add the root.
-Picker mode remains unsupported on Windows and Linux: OpenCodex starts no picker CA, credential-store
+Picker mode remains unsupported on Linux: OpenCodex starts no picker CA, credential-store
or proxy work there. The main Claude intercept remains available with ownership, symlink, file-permission
and Windows ACL checks protecting its local CA files.
@@ -418,7 +438,7 @@ next request; Desktop does not need a restart.
Turn on the CLI switch in Connect → Claude, or run `ocx claude config set --first-party on`; use `off` to disable it. The switch is immediate and refuses `{enabled:false, cliFirstParty:true}` before any field is saved; it may also refuse to turn on if the local intercept is unavailable, the CA cannot be prepared, settings cannot be read, or a foreign proxy setting owns the keys. Off persists even when the intercept is unavailable; disabling Claude routing alone leaves an owned settings env untouched. For fully native terminal traffic with only Desktop first-party on, set `NO_PROXY='*'` in the shell. This still carries the first-party account risk stated above.
Disabling Claude routing leaves the owned settings env untouched. While the bound listener still runs, every Messages request relays unchanged; after it stops, plain `claude` cannot connect until OpenCodex runs or Desktop/CLI first-party is turned off. Native `ocx claude` sets `NO_PROXY=*` only for an owned env without a foreign inherited `HTTPS_PROXY`/`https_proxy`. With a foreign proxy it preserves that value and warns that the settings-owned intercept still applies; turn Desktop/CLI first-party off or unset the setting.
-The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged while it is bound; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. When interception is stopped, use **Start interception** or `ocx claude intercept start` to retry within the running OpenCodex service. Saving first-party settings also starts it automatically. A refusal reports disabled routing, client role, an ephemeral public port, an occupied port, or a startup failure. For an occupied port, free it or set `claudeCode.intercept.port`. If a pair is already bound on a different port, the response names both ports; use the bound port or restore the configured value. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence.
+The UI distinguishes uncertainty about whether settings still point at its proxy (unknown), a token-bearing opencodex proxy with a foreign CA (foreign: fix HTTPS_PROXY / NODE_EXTRA_CA_CERTS manually), and a tokenless loopback proxy beside a foreign CA (local: ownership is unconfirmed; remove HTTPS_PROXY if unused). With matching applied settings and a bound listener but Claude routing off, disabled means requests relay unchanged while it is bound; turn first-party off to remove settings. An owned URL with no listener is stopped; a bound listener with an owned CA but mismatched port or token is broken even when routing is off. With an intent on, stopped or broken plus ineligible interception displays routingOff: Claude routing or the intercept is off, or this machine is a client of another opencodex hub; enable interception on this machine or turn first-party off to remove the settings. When interception is stopped, use **Start interception** or `ocx claude intercept start` to retry within the running OpenCodex service. Saving first-party settings also starts it automatically. A refusal reports disabled routing, client role, an ephemeral public port, an occupied port, or a startup failure. For an occupied port, free it or set `claudeCode.intercept.port`. Startup refreshes an existing managed Claude proxy environment only after the intercept proxy binds successfully, using its bound port; an occupied port leaves Claude settings unchanged. If a pair is already bound on a different port, the response names both ports; use the bound port or restore the configured value. CLI intent with no proxy is not applied; one intent with a live proxy gets the shared-relay notice; any remaining proxy with neither intent is residual, unless unknown, foreign, or local takes precedence.
- Gateway discovery (`/model` → "From gateway") is not used, because Claude Code only queries
`GET /v1/models` on a configured gateway. Routed models reach the picker another way; see
@@ -948,7 +968,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons
| User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) |
| `tool_reference` in a tool result | Text `Tool loaded: ` in the paired result; preserves loaded-tool names without adding declarations or enabling translated server-side deferral |
| `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads |
-| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) |
+| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. A search or fetch with a matching call and complete input comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result`, verbatim. Orphan results are dropped without synthesizing a call, while the surrounding text is preserved. When the client sends a complete pair back in its history, the next `api.anthropic.com` request carries it in place only if the following block uniquely identifies its original turn. Repeated anchors, unmatched or duplicate call/result ids, malformed call input, or a result appearing before its call drop the exchange. Call input must be an object with a nonempty search query or fetch URL string; other destinations never see the exchange |
| `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` |
| `max_tokens` | `max_output_tokens` |
| `stop_sequences` | `stop` |
diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md
index cd39beb0418..4da93918113 100644
--- a/docs-site/src/content/docs/guides/codex-app-models.md
+++ b/docs-site/src/content/docs/guides/codex-app-models.md
@@ -221,11 +221,12 @@ metadata instead of an older-template approximation.
| Route | Picker ids and catalog metadata |
| --- | --- |
-| Codex login (account-qualified rows disabled) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 922,000-token catalog window. |
+| Codex login (account-qualified rows disabled) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 922,000-token catalog window. `gpt-6.1-terra`: 500,000 default / 872,000 long context. |
| Codex login (account-qualified rows enabled with eligible selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. |
| Codex login (explicit Daybreak forward row) | `openai/gpt-daybreak-blue-latest` only when the exact `customModels` row is configured on the canonical `openai` provider. It keeps the Daybreak wire id and uses the pinned Sol capability snapshot (922,000 context; 829,800 automatic compaction). |
-| OpenAI (API key) | Exactly ten namespaced rows: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, the three `*-pro` virtual ids, and the two Daybreak aliases (1,050,000 context; 922,000 max input for all ten) |
+| OpenAI (API key) | Namespaced models include `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, the `*-pro` virtual ids and the Daybreak aliases (1,050,000 context; 922,000 max input). `openai-apikey/gpt-6.1-terra` adds 1,050,000 context and 128,000 max output. |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) |
| Cursor | Static fallback includes `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, and `cursor/gpt-5.6-luna` (1,000,000), plus regular/Fast rows for Grok 4.5, 4.6, and 4.7 (500,000); 4.6 and 4.7 add `xhigh`, and live account discovery decides which rows remain visible. |
| xAI | Live discovery is authoritative. The fallback catalog includes `xai/grok-4.6` and `xai/grok-4.7` and defaults to `xai/grok-4.5`; all three have 500,000-token windows. Grok 4.6 and 4.7 expose `low` / `medium` / `high` / `xhigh` (upstream default: `high`), while Grok 4.5 stops at `high`. |
@@ -278,6 +279,12 @@ preserved, so Luna has `max` but no `ultra`.
On the wire, routed adapters map or clamp unsupported tiers. For older native models whose real
ladder stops at `xhigh`, `nativeEffortClamp` maps a direct `max` or an `ultra` selection to `xhigh`
(for example, GPT-5.5). Sol, Terra, and Luna have a real `max` rung.
+The ChatGPT backend itself accepts `none` through `max`, so a native ChatGPT request never forwards `ultra` as-is:
+native models with a real `max` rung receive `max`, including Astra, GPT-6.1 Sol and GPT-6.1 Terra whose ladders
+list `ultra`, while older models such as GPT-5.5 receive `xhigh` as above. This applies to
+`/v1/responses` and `/v1/chat/completions` alike. The native clamp does not apply to routed model IDs: a routed
+OpenAI Chat passthrough can copy a declared `reasoning_effort: "ultra"` to its provider unchanged,
+while the ordinary translated adapter maps `ultra` to `max`.
Catalog advertisement of the two top tiers is unconditional: `ocx sync` no longer removes `max` or
`ultra` when the installed Codex binary is too old to offer them — Codex versions without those
@@ -314,6 +321,45 @@ advertised where it cannot be honored. An `openai-chat` destination can authoriz
eligible model with `chatServiceTier: true`, or authorize only exact models with
`modelSupportsServiceTier`; Responses routes do not need that extra Chat wire authorization.
+### Selecting Flex in Codex
+
+For a routed provider and model verified to accept `service_tier: "flex"`, explicitly
+declare Flex support alongside the existing Fast wire. Merge these fields into the
+**existing** `providers.nanogpt` entry (do not replace its URL or credentials):
+
+```json
+{
+ "modelSupportsServiceTier": { "openai/gpt-6-luna": true },
+ "fastWire": {
+ "kind": "service-tier",
+ "canonicalToWire": { "priority": "priority", "flex": "flex" },
+ "foreignCallerTiers": "verbatim"
+ }
+}
+```
+
+With an eligible `openai-responses` route, the Luna row advertises Standard,
+Fast and Flex in Codex's speed selector. For an `openai-chat` route, also set
+`chatServiceTier: true` **only if** the upstream Chat Completions endpoint supports
+caller-provided service tiers. The `fastWire` mapping is **provider-wide**:
+other service-tier-eligible routed models in the same `providers.nanogpt`
+entry can also advertise Flex when the catalog publishes them as ordinary routed
+rows, even when they are not listed in `modelSupportsServiceTier`. That map is not
+an allowlist excluding other eligible models. Combo-namespace and native-alias
+rows are **not** published with Flex. Verify Flex support for **every** eligible
+ordinary routed model;
+if only Luna supports Flex, configure it in a separate provider entry with
+no other Fast-eligible models. Do not infer Flex support from Fast alone.
+
+Keep OpenCodex's global Fast mode on **Auto** (`fastMode` unset), as forcing
+Fast or Default overrides the caller's Flex choice. After `ocx sync`, refresh the
+Codex model list. Select **Flex** from the speed selector, or use
+`/model --speed=flex` in Codex CLI. This is a **selectable tier**,
+not a per-model forced default: switching models does not force Flex automatically.
+The tier is forwarded as `service_tier: "flex"` and can still be declined by
+the upstream; availability, cost and latency depend on the provider. Neither
+the menu nor a request alone proves which tier was billed.
+
### Selecting Ultra Fast in Codex Desktop
For a provider that supports `service_tier: "ultrafast"`, declare that wire mapping once.
@@ -341,10 +387,18 @@ Desktop if it still has the old list, select the routed model, then choose **Ult
in its speed menu. Standard remains the default.
Only an eligible `service-tier` wire with an explicit `ultrafast → ultrafast` mapping adds
-this choice. Ordinary Fast support, a GPT-like name, native/account rows and combo aliases
-do not enable it. Removing the mapping or disabling that model's service-tier support
-removes the generated choice from both the catalog and cache on the next successful sync.
-The separate `ultraFastTier` setting still only retains hand-supplied catalog tiers.
+this choice to routed rows; ordinary Fast support or a GPT-like name is insufficient.
+That routed mapping does not apply to native/account rows or combo aliases. Removing the
+mapping or disabling that model's service-tier support removes the generated routed choice
+from both the catalog and cache on the next successful sync.
+The active declaration also controls the Ultra Fast choice when Codex loads its model list over HTTP.
+
+For routed rows, `ultraFastTier` retains independently supplied Ultra Fast tiers from the
+same exact persisted row; generated routed tiers are not treated as operator-supplied.
+For bare native `gpt-6-astra` / `gpt-6.1-sol` rows and their trusted account-bound clones,
+enabling `ultraFastTier` also generates a missing Ultrafast choice without changing the
+native default tier. Disabling it withdraws only native tier metadata tracked as generated;
+independently supplied or edited native tier descriptors are preserved.
[OpenAI's API guide](https://developers.openai.com/api/docs/guides/ultrafast-mode) documents
Ultrafast for GPT-6 Astra and GPT-6.1 Sol. API access and
diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md
index acf3da827d7..eec26ef3049 100644
--- a/docs-site/src/content/docs/guides/codex-integration.md
+++ b/docs-site/src/content/docs/guides/codex-integration.md
@@ -31,7 +31,24 @@ selected stored account is cooling down and no eligible stored alternative or re
available. This also covers a new request blocked before sending, following the same caller
validation used after an upstream rejection. Existing model-permission and main-account policy
checks still apply. The fallback preserves the stored account's cooldown and does not persist the
-caller credential as the Pool selection. An exact account binding remains bound to that account.
+caller credential as the Pool selection. An exact account binding remains bound to that account. A manual Pool
+pin is a preference, not an exact account binding: it can still use this fallback. Use an
+account-qualified model when a request must remain on one account. Pausing the observed native
+main blocks fallback for its matching caller credential; it does not identify or pause unrelated
+caller-owned logins.
+
+Adding or reauthenticating a pool account rejects a duplicate of the native main login with
+`Account is already configured as the native Codex main account.` The check compares ChatGPT
+account ID, personal/workspace subscription bucket, and email; a missing email does not prove
+a different identity. Different workspaces, distinct workspace members, and separate personal
+and workspace subscriptions remain supported. A failed usage lookup does not discard the
+subscription plan carried by the login token.
+
+Existing duplicates remain stored but cannot be selected as a second Pool account. The account
+list exposes `selectionExcludedReason: "duplicate_main"` and an actionable `selectionExcludedAction`. Use native main explicitly,
+or sign native Codex into a different account before reusing the stored entry. Detection follows
+the latest owned native-login observation, including a later native relogin; passive account
+listing and caller-owned requests do not open native credentials to refresh that observation.
## Config injection
@@ -125,6 +142,17 @@ to restore the default upstream WebSocket selection. This setting does not chang
client-facing `websockets` switch or the ChatGPT account used for the request. Native
mid-turn steering and injection need upstream WebSocket and are unavailable while it is off.
+**Reasoning usage metadata (2.90.0 and later).** For HTTP/SSE clients on the canonical
+ChatGPT route, the upstream WebSocket relay preserves an actual `x-reasoning-included`
+connection-response header when the Bun runtime exposes it, including on a reused connection.
+This tells Codex that the server's reported usage already includes earlier reasoning. Metadata
+sent before the first Responses event takes precedence; later metadata remains in the stream
+but cannot change headers already committed. The relay does not invent this signal or alter
+usage numbers. A missing upstream signal and Codex's own accounting lifecycle remain separate
+issues, so this change alone does not establish that early compaction is fixed. Client-facing
+WebSocket connections have already completed their upgrade before the upstream response and
+cannot receive the signal retroactively as an upgrade header.
+
Local provider pacing can also hold a request before it is dispatched at all. So a slow first
output has several possible contributors, and upstream queueing is only one of them. `ocx doctor`
classifies configuration and measures none of these: compare actual transport, pacing, network,
@@ -379,6 +407,10 @@ Root keys such as `openai_base_url`, `model_provider`, and `model_catalog_json`
first `[table]` header. The injector guarantees that placement, removes its own stale/duplicate
copies, and never overwrites a user-owned root `openai_base_url`; if one exists, sync updates the
catalog but reports that routing was not injected.
+
+Sync and restore also leave a root `model_context_window` in place, since OpenCodex never writes
+that key. Codex uses it instead of each model's catalog window, capped at that model's maximum
+window, so delete it yourself if you want each model to report its own window.
:::
## Shared model catalog
@@ -427,6 +459,14 @@ provider advertises `supports_websockets = true` only when `"websockets": true`;
built-in provider may try WebSocket first, and a disabled proxy returns `426` so Codex falls back to
HTTP/SSE.
+The proxy admits up to 128 client-facing Responses WebSocket connections. When a new connection
+finds all 128 slots occupied, it closes the least recently used idle connection to make room. Connections
+with an active response, pending client tool or approval results, a native steering/injection
+session, or buffered output are protected. If no idle connection can be released, the new
+connection receives the existing `503` capacity response. A reclaimed client can reconnect for
+later requests using the existing continuation handling. This happens only under connection
+pressure, needs no setting, and does not change voice connections or upstream connection reuse.
+
If a canonical ChatGPT forward continuation references expired or missing local replay state,
opencodex returns `previous_response_not_found` before sending anything upstream. Codex's
WebSocket client recognizes this error and can reconnect with its full retained context,
@@ -464,10 +504,10 @@ other providers keep their defaults.
Authenticated loopback routing normally keeps Codex on its built-in `openai` provider identity.
That preserves native thread identity, but it also makes Codex request native remote compaction.
When a routed provider cannot return a native compaction blob, OpenCodeX stores the summary in its
-own `ocx1:` envelope. Native ChatGPT cannot verify that envelope if OpenCodeX is later removed from
+own `ocx1:`/`ocx2:` envelope. Native ChatGPT cannot verify that envelope if OpenCodeX is later removed from
the request path.
-On an authenticated loopback route, enable client-side compaction to keep V2 sub-agent routing while preventing new `ocx1:` compaction summaries. Non-loopback and API-key routes retain their existing provider and authentication behavior:
+On an authenticated loopback route, enable client-side compaction to keep V2 sub-agent routing while preventing new `ocx1:`/`ocx2:` compaction summaries. Non-loopback and API-key routes retain their existing provider and authentication behavior:
```bash
ocx system settings --client-compaction on # or "codexClientCompaction": true in config.json
@@ -495,7 +535,7 @@ selected provider. V2 sub-agent requests keep their existing provider selection
accounting. Client-side compaction does not change plaintext delivery, encrypted task passthrough
through `allowEncryptedV2AgentTasks`, or configured recovery and fallback behavior.
-This preference affects future compactions only, and it rewrites no existing `ocx1:` payload in
+This preference affects future compactions only, and it rewrites no existing `ocx1:`/`ocx2:` payload in
any configuration, so use the explicit history recovery workflow for a thread that needs one.
Whether resume-history metadata is re-tagged depends on which form the injection takes. On its
@@ -976,6 +1016,22 @@ has reached Codex the turn is never sent again. If every attempt is declined, Co
the same error it would have received without the retries. Native compact requests move to
another Pool account on a server error in the same way.
+### Runtime agent instances (Codex 0.164+)
+
+Codex 0.164 can sign each request with a runtime agent assertion when
+`features.runtime_agent_instances` is enabled (it is off by default). opencodex supports one of
+its two modes:
+
+- `mode = "dual_header"` keeps the normal `Authorization: Bearer` credential and adds an
+ `x-openai-agent-assertion` header. opencodex treats the request exactly as before. The assertion
+ is forwarded only when the turn goes to ChatGPT Direct with your own credential. It is removed
+ when a Pool account, a stored main credential, a routed provider, or a sidecar serves the request,
+ because it belongs to the credential Codex sent.
+- `mode = "assertion_only"` replaces the bearer with `Authorization: AgentAssertion `.
+ opencodex refuses these requests with HTTP 400 and error code `agent_assertion_unsupported`.
+ It never substitutes a Pool or provider credential for the assertion, and it never forwards or
+ logs the assertion. Use `dual_header`, or turn the feature off, when Codex runs through opencodex.
+
### Codex autostart shim
Run `ocx codex-shim install` to install the optional launcher wrapper. It runs
@@ -1088,6 +1144,13 @@ an app updater can omit the managed connection argument. Save drafts, close Code
After a Codex update, an unassessed app version refuses correction until its compatibility is
reviewed; reinstalling the certificate does not make an unassessed build supported.
+The panel can also report the last completed build check as diagnostic metadata: the package
+version this build was assessed against, the package version observed at that check, whether
+they matched, and when the check finished. It is a past observation, not a live reading of the
+installed package. After the service stops, the app updates, or a check is still running or was
+interrupted, it can lag the actual installation; only a completed probe refreshes it, and a
+`verified` result describes the package observed at that check, not whatever is installed now.
+
On Windows, an explicit OpenCodex full-app restart preserves an already active loopback
compatibility PAC argument only when the current OpenCodex process owns the serving compatibility
runtime. A stale or foreign endpoint is refused before stopping Codex; replacement of the runtime
@@ -1139,7 +1202,8 @@ Pool quota responses can authorize a pause only with valid usage and a captured
that is still current. A response without that proof may refresh displayed usage without pausing an account.
Check this server’s authenticated `GET /api/codex-auth/low-quota-events` history for `logged`
alerts or save failures. Manual resume remains in force for the current quota episode. The
-default alert reaches only the log and API; it does not produce a desktop or OS notification.
+default alert reaches only the log and API. Add `actions.desktop: true` to also show a macOS or
+Linux notification on the machine running the proxy; Windows stays log-only.
When the ChatGPT 5-hour quota is exhausted, Codex may offer a reserve fallback model
(`gpt-reserve` / Luna Reserve). While that state is active, the Codex model picker can make
@@ -1214,6 +1278,8 @@ An allowed response with primary and secondary windows can omit the optional ter
This recovery uses your included plan allowance and does not require enabling **Use credits**.
Partial or invalid usage data retains the previous block until recovery is confirmed.
+Stored accounts with **Use credits after limit** enabled renew their credit evidence on the existing background cycle once an included usage window is exhausted and the credit observation is at least three minutes old. In **Direct** mode this also covers stored accounts explicitly bound in `codexAccountNamespaces`, such as a sub-account selector. You can keep Direct mode and separate main/sub identities; this does not enable Pool routing or refresh unbound accounts. Paused and reauthentication-required accounts are skipped. Metadata reads do not validate pending accounts or send model requests. Failed reads retain backoff and requests still need fresh spendable credits; ordinary response usage headers do not renew the credit balance timestamp.
+
### Cancelling main-account device reauthentication
When cancelling main-account device reauthentication, a temporary DELETE or network failure, or a response with an unknown or nonterminal status, keeps the active flow and the cancellation-failure indication so cancellation can be retried. Status polling normally continues so a login that completes can still be detected. If a retryable cancellation failure overlaps a non-2xx GET status response while the flow is `pending` or `committing`, either response order preserves cancellation retry on that same flow, with its last server-provided device code, verification URL, and phase. The GET HTTP failure still stops polling, but cancellation can be retried without starting a second login POST. A terminal `failed` response releases the flow and displays the normalized failure reason; only `succeeded` reports login success. A confirmed `cancelled` response releases the flow so a new device login can be started. A definitive HTTP 404 response with code `unknown_flow` also releases the expired flow ID so a new device login can be started, but does not report a successful login or confirmed cancellation. Late POST, GET, or DELETE responses from an earlier flow cannot change the new flow or report login success for it.
@@ -1614,3 +1680,50 @@ The shared SSE decoder accepts LF, CRLF and standalone CR line endings, even whe
Choosing an account-qualified model does not enable credit spending. Stored accounts obey **Use credits after limit** during authentication, when credentials are prepared, and before Responses HTTP or WebSocket dispatch after pacing or retry waits. A held request reports the credit policy, not an authentication failure; wait for the reset, choose another account, or explicitly enable that account’s credit spending.
Stored-account vision and web-search helpers also recheck this policy before sending, including retries. If consent changes or a limit is reached after helper selection, the helper reports the policy refusal without sending that attempt. The standalone search relay returns a reset-bound 429. Caller-owned Direct credentials retain their existing behavior.
+
+## Opt in a custom provider to managed local routing
+
+A custom provider such as `chatgpt-http` may use `supports_websockets = false`
+to keep HTTP transport. OpenCodex leaves custom providers untouched by default.
+To delegate only that provider's URL to OpenCodex, run:
+
+```sh
+ocx codex-provider allow chatgpt-http
+ocx start
+```
+
+The first command explains the scope and asks for confirmation, defaulting to No.
+For an explicitly approved scripted operation, use `allow chatgpt-http --yes`.
+Consent applies to this provider ID in the selected `CODEX_HOME`; it does not
+approve other providers or other homes. Start and sync update only its `base_url`
+to the local proxy. Its identity, WebSocket setting, other TOML fields and history
+remain unchanged. Keep the proxy running while using this route.
+Turning the Codex integration OFF also prevents approved-provider URL updates on
+start or sync; the saved provider consent remains available when you turn it back on.
+
+This mode supports explicit Responses provider tables with `requires_openai_auth = true`
+and no custom credentials or headers. Non-loopback targets, authless Desktop mode
+and client compaction require the existing dedicated integration instead.
+
+`ocx stop` and `ocx restore` restore recorded URLs while keeping consent for a later
+start. Run `ocx codex-provider status` to list approvals, or
+`ocx codex-provider revoke chatgpt-http` to restore its managed URL and remove consent.
+A URL changed outside OpenCodex is preserved and requires revoke followed by a new
+approval; OpenCodex does not silently adopt the replacement as a new baseline.
+The original URL assignment is retained in private recovery metadata under
+`OPENCODEX_HOME`, separately from the native config journal.
+
+Startup safety recognizes an approved provider's recorded managed URL as OpenCodex routing.
+This does not install restart protection: a manually started proxy still needs a viable
+background service to recover automatically after a restart.
+
+## Native Desktop relay CONNECT validation
+
+The Desktop relay's existing HTTP/HTTPS CONNECT transport now rejects malformed
+proxy response headers and ambiguous HTTP Basic usernames containing colons.
+Decoded credential pairs are limited to 4096 UTF-8 bytes. If your selected proxy
+is rejected, correct its configuration or response; the relay does not silently
+switch to a direct connection.
+
+Proxy selection, SOCKS5/SOCKS5h support, certificate verification, and connection
+lifecycle are unchanged. Both SOCKS spellings retain remote target DNS resolution.
diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md
index 60e3db08e6b..d32a8dcf122 100644
--- a/docs-site/src/content/docs/guides/combos.md
+++ b/docs-site/src/content/docs/guides/combos.md
@@ -15,6 +15,17 @@ This is useful when you want either:
Combos sit in front of normal provider routing. Read [Model Routing](/guides/model-routing/) first
if `provider/model` selectors are new to you.
+## Metered fallback consent
+
+Mark a pay-per-use combo target with `"metered": true` when it follows subscription targets. Omitted or false consent denies metered dispatch. Choose consent for your deployment:
+
+- **Local Claude Code or Codex:** on a loopback-only proxy, set `"allowMeteredTargets": true` on the combo, or run `ocx combo set --allow-metered-targets on`. This explicitly permits local callers to use its marked targets. The default is false; `off` clears consent. Loopback authentication does not read API keys.
+- **Network or linked callers:** the caller's configured `apiKeys[]` entry must have `"allowMeteredComboTargets": true`. A proxy with a non-loopback listener ignores the combo flag, including on its secondary loopback listener. Linked callers also retain their configured-key consent requirement when the primary listener is loopback. The environment token never qualifies.
+
+Request headers cannot grant consent. Model/provider scopes still apply. This covers initial selection, failover and recovery across combo strategies, compaction, Messages, Chat Completions and WebSocket requests, and a subagent model fallback naming a combo alias. A collapsed fallback tries a usable unmetered target before refusing. Unmarked targets retain existing behavior; metering is declared by the operator rather than detected automatically.
+
+For example, a failover chain can target Anthropic OAuth, then Cursor, then `{ "provider": "anthropic-apikey", "model": "claude-opus-5-5", "metered": true }`. Without consent, exhaustion returns the last unmetered error, with one exception: an earlier 429 or 402 quota refusal takes precedence over a later 400, 401 or 403. If the subscription target returns 429 and Cursor then refuses with 401 or 403, the caller receives the subscription's 429, because the spent subscription explains the failure and Cursor's credential refusal does not. Dashboard saves retain omitted target metering and combo consent; explicitly setting either to false clears it. These consent fields are configured through the config file or API; the combo flag is also available through the CLI, while the dashboard does not expose it.
+
## 60-second quickstart
This example creates `combo/main` with Anthropic first and OpenAI second. Both providers must
@@ -207,6 +218,115 @@ order. Weights and `stickyLimit` do not affect this strategy.
This ranking and provider exclusion before dispatch require fresh model-inference limits that apply to the current single API key as a whole. OAuth/current-account summaries, caller-forward routes, multiple keys, and snapshots with changed credentials or destinations are display-only for this early decision. The same applies when `Authorization`, `x-api-key`, or `x-goog-api-key` headers override credentials; search-only and MCP-only windows are excluded. If no eligible target has an applicable reset, configuration order wins. Account selection and retries still enforce their normal limits.
+### Advisory quota signals for JEV
+
+Quota evidence is separately opt-in **per JEV Combo**, for all three decision methods:
+
+```bash
+ocx combo set coding --decision-quota-signals on --json
+ocx combo set coding --decision-quota-tiers '{"moderate":40}' --json
+```
+
+Only enable this on an existing `strategy: "jev"` Combo. `decisionQuotaSignals: true` adds
+subscription quota tiers to the decision request, not a hard eligibility rule. The decision
+backend is asked to prefer healthier quota among adequate options; it still chooses only from
+configured eligible targets and effort allowlists. Unknown is not exhausted, and ordinary
+fallback, cooldown, account selection and provider limits remain unchanged.
+
+Defaults are `limited: 70` and `nearlyExhausted: 90`, inclusive percentages used. There is no
+moderate tier unless you set it explicitly (for example, `moderate: 40`). Partial overrides merge
+with those defaults, then all thresholds must be finite, within 0–100 and strictly ascending.
+
+Evidence comes only from memory snapshots published by existing quota collectors, accepted
+response headers and credential/account owners, and only while at least one JEV Combo has this
+switch on: until then opencodex collects nothing for it, so evidence starts accumulating (and reads
+as unknown) from the moment you enable it. Reading advisory quota evidence never resolves
+credentials, loads auth stores or quota files, reads a keychain, probes upstream, or starts a
+refresher. Normal decision-backend credential resolution is unchanged. Cold, missing, stale (over
+30 minutes), future, invalid or reset-expired evidence is unknown. Each account uses its worst valid
+relevant five-hour, weekly, monthly or structurally model-scoped window. An enabled usable
+Anthropic/Codex pool uses its healthiest account tier; unknown ties healthy. A pool is nearly
+exhausted only if every usable account has known nearly-exhausted evidence. Reserve models
+without separately attributable bars remain unknown. Active-account summaries, multiple-key
+display reports, credits and API/Spark/USD meters are not quota evidence for this decision.
+The separate Anthropic · Pool 2 (`anthropic2`) account pool is not a source of this evidence, so its targets stay unknown.
+
+Antigravity contributes Gem/Cla five-hour and weekly buckets only after a fresh authenticated probe
+for its sole active, usable OAuth account, and only to the `google-antigravity` provider that signs in
+with that account. A renamed provider row reads unknown and never reuses that account's quota. Quota
+follows the resolved native `gemini-*` or `claude-*` model, not its display alias. Account, credential, project or destination
+changes withdraw the old observation. A display-only refresh can retain matching family counters
+only with their original timestamps; per-model and Argon rows never become decision evidence.
+
+:::caution[Additional decision data]
+Opting in sends quota tiers, and available used percentage/window/reset delay, to your selected
+decision backend, including hosted TypeSafe. No account ids, tokens or credential fingerprints
+are sent or logged by this feature. Local decision records retain only bounded tier counts and
+the selected tier. If quota would exceed the 64 KiB request cap, it is removed before retrying
+serialization; an ordinary oversized decision still fails open.
+:::
+
+`--decision-quota-signals off` disables evidence while retaining configured tiers; `-` clears
+the switch. `--decision-quota-tiers -` clears overrides, restoring defaults. API `null` clears;
+API `false` disables the switch. Omitted fields survive JEV updates, target replacement and
+rename, including saves through the existing editor. Switching away from JEV removes both
+quota settings. No new editor controls or background quota work are enabled.
+
+### Session pin for JEV
+
+A JEV Combo decides a target for every request, so consecutive turns of one conversation can move
+between providers, and each move starts with a cold provider prompt cache. `sessionPin` is an
+opt-in setting per JEV Combo, off by default, that keeps a client session on the target JEV chose
+for it while the session stays active:
+
+In the dashboard, open the JEV Combo editor's **Decision method** section and enable
+**Keep a session on its decided target**, then set **Idle window (ms)** and save. The range is
+60000–86400000 ms (1 minute–24 hours); newly enabled pins start at 60000 ms. Disable the checkbox
+and save to turn pinning off. Invalid or empty enabled windows block saving; failed saves keep
+your edits for retry. Older responses without this setting leave it omitted until you change it.
+
+```bash
+ocx combo set jev-auto --session-pin 20m --json
+```
+
+```json
+{ "strategy": "jev", "sessionPin": { "ttlMs": 1200000 } }
+```
+
+The first request of a session is decided normally. Once the target JEV applied for it completes a
+response, later requests of the same session reuse that target and effort without another decision
+call. `ttlMs` (1 minute to 24 hours) is an idle window: every completed turn on the pinned target,
+tool-call continuations included, starts it again, and a session that stays quiet longer is decided
+afresh.
+
+A session is identified by the client's own session headers (`thread-id`, `session_id` or
+`session-id`, `x-codex-parent-thread-id`, or a promoted `x-session-id`) together with the API key
+that admitted the request and the Combo. Requests without a session header are never pinned,
+`prompt_cache_key` is never used as a session identity, and two API keys never share a pin.
+
+JEV decides again, and the new decision replaces the pin, when:
+
+- the pinned target is not offered for this request: disabled, removed from the Combo, cooling
+ down after a 429 or another failure, out of cached quota, metered, or otherwise ineligible;
+- the pinned effort is no longer offered for that target;
+- `decisionQuotaSignals` is on and the target's quota evidence reads nearly exhausted;
+- any send of a pinned session fails (that request still fails over as usual);
+- the configuration has changed since the pin was written.
+
+A cancelled request keeps the pin. A fail-open decision (timeout, missing key, invalid answer)
+never creates one. Pins live in the proxy's memory, at most 512 sessions, and do not survive a
+restart.
+
+Usage records mark a reused turn with `jevDecision.pinned: true` and `pinReason: "session"`. A
+decision that replaced a released pin records why in `pinReason` (`expired`, `config_changed`,
+`target_unavailable`, `effort_unavailable` or `quota_nearly_exhausted`). `ocx combo stats`
+counts pinned requests apart from decisions; they still count toward successful requests, model
+fallbacks and per-model tokens.
+
+A pin makes provider cache reuse possible; whether the provider actually serves cached tokens still
+depends on the provider, the account and a stable prompt prefix. `--session-pin -` (API `null`)
+turns it off, switching away from JEV removes it, and saves from the dashboard editor keep it.
+
### Decision method
`strategy: "jev"` asks a decision backend to choose the first eligible target and a compatible
@@ -335,10 +455,21 @@ Combo's `decisionProvider`:
- Self-hosted services receive each target/effort option as a plain description string (for
example `Target openai/gpt-5.6-sol (provider openai, model gpt-5.6-sol) with low reasoning
effort.`), because Ollama accepts only string or `null` option descriptions. TypeSafe keeps
- receiving the structured option objects.
-- OpenCodex offers 2–26 options to a System One-compatible row. Each target contributes one option
- per offered reasoning effort; use per-target `reasoningEfforts` to trim them. Fewer than 2 or
- more than 26 options fail open without a request.
+ receiving the structured option objects. When targeting Vercel AI Gateway (`ai-gateway.vercel.sh`),
+ route options include both `value` and detailed `description` fields. Other self-hosted endpoints,
+ including a custom `/decisions` path, keep the System One request format.
+- OpenCodex offers 2–26 options to a System One-compatible row (`SELF_HOSTED_MIN_OPTIONS = 2`, `SELF_HOSTED_MAX_OPTIONS = 26`).
+ Each target contributes one option per offered reasoning effort; use per-target `reasoningEfforts` to trim them.
+ Fewer than 2 or more than 26 options fail open without a request. For Vercel AI Gateway, whose
+ [OpenAI-compatible Decisions reference](https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-decisions)
+ supports 1..255 choices, the upper bound expands to 255 options (`DECISIONS_MAX_OPTIONS = 255`); OpenCodex
+ enforces the 2-choice minimum as local routing policy to select among candidate targets.
+- Vercel AI Gateway endpoints (`https://ai-gateway.vercel.sh/v1`) automatically normalize to `/decisions`.
+ OpenCodex projects structured conversation context into a formatted plain-text `input` string
+ (`Task:`, `Signals:`, `Recent Assistant:`, `Recent Tool Call:`, and `Tool Output:`). Route answers are accepted
+ from object (`answers.route`) or choice-array formats, resolving from either `choice` or `value`. Probability
+ distributions are accepted as key-value maps or probability records; duplicate choice keys or invalid distributions
+ strictly fail open to the first eligible target.
- A cold model load can take tens of seconds, and an aborted decision request makes Ollama abandon the
load. Pre-warm the model and keep it resident (`OLLAMA_KEEP_ALIVE=-1`, or `keep_alive`), and raise
`decisionTimeoutMs` (1000–120000 ms, default 4000) when the service is slower than four seconds.
@@ -481,8 +612,9 @@ are ignored. Operator notes are evidence for the decision, not commands, and can
target allowlist or reasoning-effort limits. Only put information there that may be disclosed to
the selected decision backend.
-Each logical model call is decided on its own; there is no per-conversation pin. Consecutive turns of
-one session can therefore land on different targets, and every switch starts a cold provider prompt
+By default, each logical model call is decided on its own. Unless you enable the opt-in
+[session pin](#session-pin-for-jev), consecutive turns of one session can therefore land on
+different targets, and every switch starts a cold provider prompt
cache, so a mix of very different targets can cost more input tokens than it saves. Keep the
allowlist to targets you are content to alternate between. Targets marked `lastResort` are withheld
from JEV under `cooldownWaitPolicy: "before-last-resort"` while any normal target is offered, and
@@ -532,6 +664,8 @@ rows show decision count, applied count, and average latency for `typesafe`, `sy
## What happens when a target fails
Combo failures are divided into **hop** failures and **terminal** failures.
+To make a combo fail with a clear `fallback_refused` error instead of hopping to another model or
+provider, see [Fallback off](/guides/fallback-off/).
| Result | Behavior |
| --- | --- |
@@ -540,7 +674,7 @@ Combo failures are divided into **hop** failures and **terminal** failures.
| Classified authentication, subscription, quota, rate-limit, overload, or upstream-server error | Cool the target and hop, even when the status alone is not sufficient. |
| Client cancellation (499), `origin_rejected`, cyber-policy refusal, context overflow, or other invalid request | Stop and return the error; another target would not make the request valid. |
| Exact HTTP 400 saying a model is not supported when using Codex with a ChatGPT account | Try the next declared target before output commitment. The message must be the whole refusal in `detail`, `error.message`, or bare text; competing `detail` and `error` envelopes stay terminal. This does not quarantine the account or cool every model on the provider. |
-| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, or model-scoped image input | Hop before output commitment without cooling; see request-local target compatibility below. |
+| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, model-scoped image input, or input media the route cannot read (`unsupported_input_modality`) | Hop before output commitment without cooling; see request-local target compatibility below. |
| First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. |
| Any other unclassified error | Stop and return the error. |
@@ -640,7 +774,11 @@ classify the original HTTP 403 as a hop.
For streaming requests, the upstream HTTP status is not the final decision. OpenCodex buffers a
bounded pre-output prefix of the selected child's Responses SSE. If the stream reports a retryable
`response.failed` terminal before any text, reasoning, tool call, or other output event, the child
-is recorded as failed and the combo may try its next eligible target. Once any output event begins,
+is recorded as failed and the combo may try its next eligible target. A zero-output
+`response.incomplete` follows the same path when its reason is missing or is `adapter_eof`,
+`missing_terminal_event`, or `upstream_stall_timeout`. Explicit semantic reasons such as
+`max_output_tokens` and `content_filter` remain terminal, as does an explicit null reason.
+A reasonless final snapshot that already contains output items is also kept. Once any output event begins,
the target is committed: a later stream failure is returned to the client and is never replayed on
another provider, which prevents duplicate text and tool execution. If the pre-output buffer reaches
its safety cap without a terminal or output boundary, OpenCodex also commits the current target
@@ -650,7 +788,23 @@ instead of growing memory without a bound.
When routing Claude Code to the canonical ChatGPT Codex backend, OpenCodex removes the unsupported top-level `user` metadata field without changing the session/cache key, input messages, tool schemas, or safety identifiers. Public Responses API and noncanonical forward gateways keep that field.
-A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. This is a mismatch for that request, not evidence that the target is unhealthy, so it records no cooldown. This compatibility recovery does not silently change `none` into a different effort or broaden this exception to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection.
+A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. This is a mismatch for that request, not evidence that the target is unhealthy, so it records no cooldown. This compatibility recovery does not silently change `none` into a different effort or broaden this exception to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection.
+
+## Tool calls stay on one target
+
+When a model asks your client to run a tool, the request that returns the tool result finishes the
+same assistant turn. OpenCodex sends that request to the target that issued the tool call, as long
+as the target is still enabled, still in the combo, and out of cooldown. This holds for every
+strategy, including JEV, which skips its decision for that request. The next user message chooses a
+target normally.
+
+Tracking uses the client's thread or session id, so a client that sends neither gets a normal pick.
+If the original target is unavailable or fails, the combo fails over as usual, and the new target
+receives the earlier tool calls in a form it accepts. DeepSeek thinking models get a one-space
+reasoning placeholder for a tool call that another model issued without reasoning. Gemini gets
+Google's documented signature placeholder. Anthropic gets a plain tool call and result. Earlier
+versions could send DeepSeek a bare tool call here and fail with `The reasoning_text in the
+thinking mode must be passed back to the API`.
## Default reasoning effort
@@ -702,6 +856,25 @@ even when every target supports images — the catalog drops `image` from `input
image-bearing requests are rejected with HTTP 400 before any target is called. `"auto"` (or
omitting the field) keeps the automatic intersection.
+In the dashboard, the **Image / multimodal** switch can be enabled whenever every target has
+known image or text input capabilities. Native image members stay unchanged; text-only members are
+automatically declared text-only on save (`modelCapabilities[model].inputModalities = ["text"]`)
+so the [Vision Sidecar](/guides/sidecars/) describes their images. The switch's hint names the
+members that will be enrolled. A member whose modalities are unknown or have no text input (for
+example an audio-only model) cannot be covered by the sidecar; it keeps the switch unavailable and
+is named in the hint. If the [Vision Sidecar](/guides/sidecars/) is disabled globally, enabling the
+switch shows a warning linking to the dashboard: enrollment still saves, but images are replaced
+with an omission note and requests continue without image descriptions until the sidecar is
+re-enabled. Turning the switch off disables image input for the combo
+but keeps those provider declarations, and `PUT /api/combos` accepts the same enrollment as an
+optional top-level `visionSidecarTargets` array of exact `{ provider, model }` targets
+(request-only; it is never stored on the combo and is rejected while `imageInput` is
+`"disabled"` when the enrollment list is non-empty). Entries that already accept image input or
+are declared without text are rejected with HTTP 400 rather than overwritten, entries the sidecar
+already covers are skipped, and a save refused before publication restores the combo and every declaration together.
+Removing a member from the combo does not remove its text-only declaration; clear it in the
+provider editor if you no longer want it.
+
## Encrypted v2 sub-agent tasks
There is one important limitation for Codex v2 sub-agents ([issue #92](https://github.com/lidge-jun/opencodex/issues/92)).
@@ -739,8 +912,9 @@ task workflow.
### Dashboard
Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes
-combos, and its target picker excludes disabled models, nested combos, and the credential-only JEV
-provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template;
+combos. Its model selectors include hidden models: catalog visibility only controls external model
+lists, not internal Combo selection, and selecting a hidden model does not make it visible externally.
+The target picker still excludes disabled providers, nested combos, and the credential-only JEV provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template;
an existing `jev-auto` id or alias is reported instead of creating a duplicate. A JEV Combo also shows
**Decision service** (TypeSafe JEV or a configured `jev-decision` provider) and **Decision timeout
(ms)**, and the Combos overview lists each JEV Combo's decision service, endpoint, and timeout.
@@ -761,9 +935,10 @@ ocx combo remove --yes
```
`set` also accepts `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`,
-`--display-name`, `--decision-provider`, `--decision-timeout`, and `--rename-from`. Use `-` as the
-value of `--effort`, `--alias`, `--display-name`, `--decision-provider`, or `--decision-timeout` to
-clear that field. The two decision flags apply only to `--strategy jev`. `--native-alias` requires a currently supported bare native
+`--allow-metered-targets on|off`, `--display-name`, `--description`, `--decision-provider`, `--decision-timeout`, and `--rename-from`. Use `-` as the
+value of `--effort`, `--alias`, `--display-name`, `--description`, `--decision-provider`, or `--decision-timeout` to
+clear that field. The two decision flags apply only when the effective strategy is JEV: `--strategy jev`, or a stored JEV combo when `--strategy` is omitted. On an existing combo, `--targets` replaces only
+the target list: without `--strategy` the stored strategy and its JEV settings stay as they are. `--native-alias` requires a currently supported bare native
model alias and a non-empty display name. `create` and `update` are aliases for `set`; `delete` is an alias for
`remove`; and the same subcommands are available under `ocx route combo`.
@@ -828,6 +1003,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id:
| `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. |
| `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. |
| `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. |
+| `description` | No | none | Display-only catalog row description, bounded like `displayName` (at most 128 characters, no control characters). Unset keeps the generated `Routed via opencodex → combo (combo).` text. Management API writes that omit it keep the saved value; an empty string clears it. |
| `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a full HTTPS decision URL or a local HTTP `/systemone` URL, such as a self-hosted Ollama `tev1`. |
| `decisionTimeoutMs` | No | `4000` | JEV only. Integer from 1000 to 120000: the decision deadline before failing open to the first eligible target. |
@@ -843,7 +1019,10 @@ running opencodex instance that receives model requests.
Every target is currently ineligible: for example, its provider is disabled, it is cooling down,
it has already been attempted for this request, or an encrypted v2 task excludes it. Check target
-provider state and recent upstream errors. For cooldowns, follow an observed `Retry-After` value first;
+provider state and recent upstream errors. When no target was attempted, the error message lists
+why each target was skipped, for example
+`No available targets for combo: fast (openai/gpt-6: quota exhausted; anthropic/claude-opus-5-5: cooldown 42s)`.
+For cooldowns, follow an observed `Retry-After` value first;
Codex reset headers also take precedence over `cooldownMs`.
If neither upstream signal is usable, the configured `cooldownMs` applies, or the upstream fallback applies
when it is unset (5 seconds for request-rate codes `1302`/`1305`, 10 minutes for a spent usage
@@ -861,3 +1040,31 @@ validation message.
The error was terminal rather than target-specific. Fix invalid input, reduce an oversized context,
handle a policy refusal, or correct the rejected request origin. Combos do not hop for those cases.
+
+## Retry all-cooled targets (opt-in)
+
+Combo cooldown recovery is opt-in and disabled by default. To allow one new
+failover pass when every otherwise eligible target has a saved combo cooldown:
+
+```sh
+ocx combo set review --cooldown-reprobe on --cooldown-reprobe-interval 300000
+ocx combo set review --cooldown-reprobe off
+```
+
+The corresponding per-combo configuration is:
+
+```json
+{ "cooldownReprobe": { "enabled": true, "intervalMs": 300000 } }
+```
+
+The default interval is five minutes; accepted values are integer milliseconds
+from 1000 to 86400000. Setting the interval alone does not enable recovery.
+Recovery is demand-driven: the first eligible request can reserve a pass immediately,
+and later passes are separated by at least the configured interval. There is no timer
+or background traffic. Each target can be attempted once in the request's existing
+failover budget; failures record fresh cooldowns. Account quota checks, disabled
+providers, request compatibility, metered-spend permissions and cancellation remain
+enforced. Explicit provider Retry-After values may therefore be retried early only
+when the operator enables this option. Preview/list operations never clear cooldowns.
+The interval is tracked per combo in memory and resets on proxy restart or combo
+selection-state reset. Existing cooldown-wait behavior is unchanged when disabled.
diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md
index 4ecad314138..73e9367061f 100644
--- a/docs-site/src/content/docs/guides/desktop-app.md
+++ b/docs-site/src/content/docs/guides/desktop-app.md
@@ -23,9 +23,10 @@ still blocks it, use **System Settings → Privacy & Security → Open Anyway**.
### Windows
-Download `OpenCodex--windows-x64.msi` and run the installer. Windows SmartScreen may
-warn because the installer is not yet code-signed; choose **More info → Run anyway** after
-confirming that you downloaded it from the release page.
+Download `OpenCodex--windows-x64.msi` and run the installer. Code signing through
+SignPath Foundation is rolling out ([Windows Code Signing](/reference/code-signing/)). Until a
+release carries the signature, Windows SmartScreen may warn about an unsigned installer; choose
+**More info → Run anyway** after confirming that you downloaded it from the release page.
### Linux
@@ -116,8 +117,12 @@ means the registration could not be read. While the app runs the proxy, the Star
background service installer disabled; to use it instead, quit OpenCodex and run
`ocx service install` in a terminal. The Codex launcher shim can still be installed there.
When a Codex launch finds no proxy, the shim waits for OpenCodex to restart it, opens
-OpenCodex on macOS if it quit, and otherwise reports an error instead of starting a second
-proxy. If the shim reads "activation not confirmed", add
+OpenCodex on macOS if it quit and its recorded ownership matches a valid **Start at Login**
+registration, and otherwise reports an error instead of starting a second proxy. Automatic
+reopening selects that registration's app bundle path and checks that its declared main
+executable matches the registered Desktop executable. If registration is missing, stale or
+inconsistent, open OpenCodex manually; another copy with the same bundle identifier is not
+selected as a fallback. If the shim reads "activation not confirmed", add
`. ~/.opencodex/codex-shell-env.sh` to your shell startup file after other PATH setup.
The bundled CLI is `/Applications/OpenCodex.app/Contents/MacOS/ocx` on macOS and
diff --git a/docs-site/src/content/docs/guides/fallback-off.md b/docs-site/src/content/docs/guides/fallback-off.md
new file mode 100644
index 00000000000..424b158e01f
--- /dev/null
+++ b/docs-site/src/content/docs/guides/fallback-off.md
@@ -0,0 +1,132 @@
+---
+title: "Fallback off"
+description: Turn model and provider fallback off per scope, and see what happens on a Remote Link Child when its Home is unreachable.
+---
+
+OpenCodex can serve a request from a different model or provider when the one you chose fails: a
+[combo](/guides/combos/) moves to its next target, a [routing profile](/guides/routing-profile-editor/)
+tries its next candidate, a spawned subagent moves down its model fallback chain, and the main-account
+hard lock can reroute to its configured fallback model. All of these stay on by default.
+
+Turn a kind of fallback **off** when you would rather get a clear error than an answer from somewhere
+you did not pick.
+
+## What counts as a fallback
+
+A fallback is a hop to a **different model** or a **different provider**:
+
+| Kind | Example | Switch |
+|---|---|---|
+| Model fallback | `anthropic/claude-opus` fails, `anthropic/claude-sonnet` would answer | `model` |
+| Provider fallback | `anthropic/claude-opus` fails, `openai/gpt-6-sol` would answer | `provider` |
+
+Rotation among the accounts or API keys of **one provider pool** for the same model is not a fallback.
+It is how a pool absorbs a rate-limited account, and it keeps working whatever you set here. Retrying
+the same target, and transport fallbacks that keep the same target, are not fallbacks either.
+
+When a main-account hard lock routes to a combo or profile, it refuses early only when every declared
+destination is known to be policy-denied. Otherwise normal selection proceeds without filtering, and
+its actual selected destination still follows these switches. Allowing provider fallback does not allow
+a different model on the same provider when model fallback is off, even if another candidate is allowed.
+
+## Turn fallback off
+
+```bash
+ocx fallback off # model and provider fallback, everywhere
+ocx fallback off --kind provider # only cross-provider hops
+ocx fallback off --route combo/coding # one combo, routing profile or model selector
+ocx fallback off --provider anthropic # hops that leave one provider
+ocx fallback on --route combo/coding # an exception under a global off
+ocx fallback clear --route combo/coding # remove an override
+ocx fallback status # current settings and the last fallback
+```
+
+The same switches are on the dashboard: the **Fallback** card at the bottom of the **Dashboard** holds the
+global switches, the overrides and the last fallback taken or refused.
+
+### Precedence
+
+1. A **device** set to off always refuses (see [per device](#per-device-settings)). A device setting can
+ only refuse; turning it on never reopens a hop another scope refuses.
+2. Otherwise the most specific setting wins: **route**, then **provider**, then **global**.
+3. Nothing set means allowed, which is today's behavior.
+
+The settings live in `config.json` under `fallback`:
+
+```json
+{
+ "fallback": {
+ "provider": false,
+ "routes": { "combo/coding": { "provider": true } },
+ "providers": { "anthropic": { "model": false } },
+ "devices": { "lnk_0123456789abcdef": { "localFallback": false, "model": false } }
+ }
+}
+```
+
+A malformed leaf is ignored, so policy resolution uses the next applicable scope, and `ocx config`
+diagnostics name it. A whole-config write that contains one is refused instead.
+
+## The error you get
+
+A refused fallback answers with the **status of the failure that triggered it**, so a refused 429 is
+still a 429 with its `Retry-After`, and with this body:
+
+```json
+{
+ "error": {
+ "type": "fallback_refused",
+ "code": "fallback_refused",
+ "message": "combo/coding: anthropic/claude-opus failed (rate_limited). Provider fallback to openai/gpt-6-sol is off for route combo/coding, so OpenCodex did not send the request there. To allow it: ocx fallback on --kind provider --route combo/coding",
+ "fallback": { "kind": "provider", "scope": "route", "scopeKey": "combo/coding",
+ "from": "anthropic/claude-opus", "to": "openai/gpt-6-sol", "trigger": "rate_limited" }
+ }
+}
+```
+
+The response also carries `x-opencodex-fallback: refused`. When the first target is already known to be
+unavailable, the refusal happens before anything is sent, and the fallback target is never contacted.
+The status depends on why the first target was skipped:
+
+- A combo's first target cooling down, or with its quota spent: 429.
+- A combo's first target disabled or not usable for the request: 503.
+- A blocked main account: 429.
+- An unavailable subagent model: 503.
+
+After a real upstream failure, the refusal keeps that failure's status.
+
+## Remote Link Children and Hub clients
+
+A Remote Link Child and a Hub client never serve requests from their own local providers. When the
+Home is unreachable, a Child answers a retryable `503` (and a Hub client's dashboard relay a `502`) with:
+
+```json
+{ "error": "link tunnel unavailable", "code": "home_unreachable", "message": "The Home is unreachable, and OpenCodex did not serve this request from this device's local providers. …" }
+```
+
+Nothing is sent to a local provider, and the link key and the request body never leave the device.
+
+### Per-device settings
+
+On the Home, each linked device has two switches, on **Remote Link** or with the CLI:
+
+```bash
+ocx fallback local on --device mac-studio # permission for local fallback while the Home is down
+ocx fallback off --device mac-studio # refuse model and provider fallback for this device's requests
+```
+
+`--device` takes a link alias or id from `ocx link status`, or a Hub data key id.
+
+**Local fallback** is a permission that GOAT reads: with it on, GOAT may move that device's work to the
+device's own sign-ins while the Home is unreachable. OpenCodex itself still answers `home_unreachable`
+and never serves the device locally. It is off unless you turn it on.
+
+## Status
+
+`ocx status --json` includes a `fallback` object (on a Child it is the Home's, read over the link), and
+`GET /api/fallback` returns the same `ocx.fallback/1` document: the effective global switches, the
+overrides, every device with its local fallback permission, and the last 20 fallbacks taken or refused
+with time, route, from, to, trigger and the scope that decided. The list is kept in memory and resets
+when the proxy restarts. It never contains request bodies, keys or account identities.
+
+A Home that supports this advertises `fallback-policy/1` in `GET /readyz` `features`.
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index 5bd17d8072e..fdb813c12b6 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -1,15 +1,16 @@
---
title: Integrations
-description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code and Factory Droid from the dashboard — one switch per client, with a backup taken before every write.
+description: Connect opencodex to OpenCode, Pi, Gentle Shell, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code and Factory Droid from the dashboard — one switch per client, with a backup taken before every write.
---
The **Connect** page writes opencodex's provider block into a client's own config
-file, and removes it again. Nineteen clients work this way, each with a switch:
+file, and removes it again. Each client below has its own switch:
| Client | Config file | Format | When the change takes effect | Credential |
|---|---|---|---|---|
| OpenCode | `~/.config/opencode/opencode.json` | JSON | next direct launch | `OPENCODEX_OPENCODE_API_KEY` |
| Pi | `~/.pi/agent/models.json` | JSON | new sessions | loopback placeholder |
+| Gentle Shell | `~/.gentle-shell/agent/models.json` (persistent selected home) | JSON | after restarting Gentle Shell | loopback placeholder |
| OMP | `~/.omp/agent/models.yml` | YAML | after restarting OMP | `opencodex-loopback` placeholder |
| Hermes | `~/.hermes/config.yaml` | YAML | new sessions | `OPENCODEX_HERMES_API_KEY` |
| OpenClaw | `~/.openclaw/openclaw.json` | JSON5 | immediately, on a running gateway | `OPENCODEX_OPENCLAW_API_KEY` |
@@ -23,17 +24,84 @@ file, and removes it again. Nineteen clients work this way, each with a switch:
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only |
| omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder |
| Qoder | `settings.json` in `$QODER_CONFIG_DIR`, default `~/.qoder` | JSON | new sessions | loopback placeholder |
+| Qoder CN | `settings.json` in `$QODERCN_CONFIG_DIR`, default `~/.qoder-cn` | JSON | new sessions | loopback placeholder |
+| WorkBuddy (CN) | `~/.workbuddy/models.json` | JSON | auto reload / new sessions | loopback placeholder |
+| WorkBuddy AI | `~/.workbuddy-ai/models.json` | JSON | auto reload / new sessions | loopback placeholder |
| Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder |
| Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` |
| Command Code | `~/.commandcode/providers.json` (on Windows with the default home: use `HOME ?? USERPROFILE` only if nonblank and Windows-absolute, otherwise `homedir()`; a separately supplied home is preserved; append `.commandcode\providers.json`) | JSON | next launch of Command Code | none — keyless loopback (`apiKey: false`) |
| Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback |
+## Gentle Shell
+
+Choose **Connect → Gentle Shell**, or use the same managed-integration commands:
+
+```bash
+ocx export --client gentle-shell --json
+ocx integration client enable --client gentle-shell
+ocx integration client disable --client gentle-shell
+```
+
+Gentle Shell has a separate destination and rollback history from Pi. Only
+`providers.opencodex` is managed; other providers and preferences stay intact.
+Catalog changes refresh an already connected, unchanged block. An existing manually
+copied block is a conflict, not permission to overwrite it: inspect the preview and
+use the explicit overwrite confirmation only if you intend to replace it.
+
+The saved `home` in `~/.gentle-shell/config.json` selects the destination. A saved
+custom path wins; isolated/default mode uses `GENTLE_SHELL_HOME` or
+`~/.gentle-shell/agent`. `GENTLE_SHELL_CONFIG` selects an alternate launcher config.
+These environment variables must be set on the **OpenCodex process**, not just in a
+later shell. Selectors must be absolute paths; literal `~` and relative paths refuse.
+One-off `gentle-shell --home` flags cannot be discovered by a background proxy.
+
+Link mode or a home aliasing Pi refuses with a Pi-integration remedy; use **Connect → Pi**
+for that shared file instead. OpenCodex does not change launcher preferences. An
+unreadable/unsafe launcher config refuses; malformed JSON falls back to isolated,
+matching the launcher. If you move a connected home, disable it using its original
+selectors before reconnecting the new one. Restart Gentle Shell after a catalog update.
+This integration retains Pi’s loopback-only export policy and stores no real API key.
+
"omo" names three products that share the `~/.omo` folder. The **omo** tab manages Pi-based omo
(the senpi engine) through `~/.omo/agent/models.json`, as in the table above. Codex-based omo
(LazyCodex) gets its own section on the same tab, shown only when LazyCodex is detected, described in
[omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo
(oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it.
+### Qoder CN and WorkBuddy editions
+
+In **Connect**, enable **Qoder CN**, **WorkBuddy (CN)** or **WorkBuddy AI**, then choose
+an OpenCodex model in the client's custom-model picker. The proxy must stay running.
+The first enable saves a snapshot; disable removes only the generated contribution,
+and Undo restores the snapshot. Existing models and unrelated settings remain intact.
+These are separate connections from Global Qoder and from each other.
+
+The same operations are available through the CLI (replace `workbuddy` with
+`workbuddy-ai` or `qoder-cn` for the other editions):
+
+```bash
+ocx integration client enable --client workbuddy
+ocx export --client workbuddy --json
+ocx integration client disable --client workbuddy
+```
+
+Qoder CN honors `QODERCN_CONFIG_DIR`. Both WorkBuddy editions honor their native
+`WORKBUDDY_CONFIG_DIR`, then `CODEBUDDY_CONFIG_DIR`; unset overrides keep the regional
+roots in the table. Overrides must be absolute, `~`, or start with `~/` or `~\`. WorkBuddy's legacy
+array configuration and its object-form `models` list are both supported. If an existing
+`availableModels` restriction excludes OpenCodex models, remove that restriction in
+WorkBuddy before enabling. Project-level settings can override user-level models.
+
+Connections require an unauthenticated loopback listener; no upstream credentials are
+copied into these clients. WorkBuddy receives the full Chat Completions URL and the
+capabilities declared by the OpenCodex catalog. Restart the client or start a new session
+if its model picker has not reloaded yet. Configuration/lifecycle tests do not establish
+end-to-end compatibility with every upstream model or every client release.
+
+Doubao Desktop and Devin currently have no verified arbitrary-provider configuration
+contract in this integration. They have no enable switch; adding a local JSON file alone
+would not make their model pickers consume it.
+
Qoder IDE / qodercli supports export and managed configuration through the Dashboard and CLI:
```bash
@@ -43,15 +111,22 @@ ocx integration client disable --client qoder
```
Qoder uses `settings.json` in `QODER_CONFIG_DIR` when that variable is set, and
-`~/.qoder/settings.json` otherwise; a relative `QODER_CONFIG_DIR` is refused. Enable merges `providers.opencodex` and
-OpenCodex entries in `modelConfigs.customModels`, preserving other providers, custom models,
-and settings. Disable removes the recorded OpenCodex entries; restore uses the existing
+`~/.qoder/settings.json` otherwise; a relative `QODER_CONFIG_DIR` is refused. Enable merges only `providers.opencodex`, preserving other providers, custom models,
+and settings. Models appear once in the provider catalog; duplicating them in
+`modelConfigs.customModels` causes Qoder to reject the provider. Refreshing an older managed
+integration removes its recorded duplicate rows; user-edited rows remain conflicts. Disable removes the recorded OpenCodex entries; restore uses the existing
snapshot workflow. The provider's default model is the first model in the sorted export.
-Custom models use their catalog context window, falling back to 128000 input tokens.
+If `modelConfigs.customModels` already holds an `opencodex` row for an exported model that
+OpenCodex did not write, enable, refresh, overwrite and disable report a conflict and leave the
+file unchanged; remove or rename that row in Qoder's settings, then try again. Rows for other
+providers or for models outside the export are left alone.
+Provider models use their catalog `contextWindow`, falling back to 128000 input tokens.
+Restart Qoder after refreshing an existing integration to reload the provider registry.
The generated API key is a non-secret placeholder: Qoder integration requires loopback
access and refuses an authenticated non-loopback bind. When the dedicated unauthenticated
loopback listener is enabled, exports point Qoder at that listener. `ocx sync` and catalog
changes refresh an already enabled Qoder integration like the other managed clients.
+Model IDs containing `,` or `]` (for example `kimi/k3[1m]`) are exported like any other model.
Generated catalogs include only enabled models from each provider selection. This applies to both
downloads and managed integrations, including Pi and Aside. The management model list still shows
@@ -268,7 +343,12 @@ parse, or one whose structure we cannot reason about, still refuses.
## Hermes session affinity
The generated `providers.opencodex` block includes `session_affinity_header: session-id` for all
-models. This names a header; Hermes supplies its dynamic conversation identifier. OpenCodex does
+models. Retain this generated default unless you deliberately choose log-only correlation:
+changing `session_affinity_header` from `session-id` to `x-hermes-conversation-id` replaces the
+header sent by Hermes. Because OpenCodex treats `x-hermes-conversation-id` strictly as a request-log
+correlation fallback and not as a native routing or turn-termination identity, switching to
+`x-hermes-conversation-id` removes native session affinity and lane assignment while correlating
+agent loops in request logs. This names a header; Hermes supplies its dynamic conversation identifier. OpenCodex does
not write a shared static identifier or change `api_mode` to enable affinity.
Use a Hermes version supporting [per-provider request options](https://hermes-agent.nousresearch.com/docs/user-guide/configuring-models#per-provider-request-options).
@@ -287,6 +367,28 @@ exception to the conflict rule above: other edits, a different header name, or a
matching ownership record still require conflict resolution. Unrelated YAML settings and comments
remain untouched, and the existing snapshot and Restore workflow applies to the upgrade.
+## Hermes profiles
+
+Hermes keeps a separate home for each bot profile. The default profile uses
+`~/.hermes/config.yaml` (`%LOCALAPPDATA%\hermes\config.yaml` on Windows, or `$HERMES_HOME/config.yaml`),
+and each named profile created with `hermes profile create ` uses
+`/profiles//config.yaml`. See Hermes'
+[profiles guide](https://hermes-agent.nousresearch.com/docs/user-guide/profiles).
+
+When named profiles exist, **Connect → Hermes** lists every profile with its own status. Select a
+profile to apply, disable, replace, or restore OpenCodex for that profile alone; each profile keeps its
+own backups and rollback history, and the other profiles stay unchanged. Every profile keeps its own
+`model.default` and other providers: OpenCodex adds only `providers.opencodex`. With no named profiles,
+the page works as before and changes the default profile.
+
+The management API selects a profile with `profile=` on `/api/client-integrations/hermes` and with
+`client=hermes&profile=` on the preview, journal, and restore endpoints;
+`GET /api/client-integrations/hermes/profiles` lists the profiles. A directory under `profiles/` counts
+only when Hermes would list it: a valid name, a Hermes identity file such as `config.yaml` or `SOUL.md`,
+and no deletion marker. A profile reached through a symbolic link or junction is not offered. If
+`HERMES_HOME` later points at a different Hermes home, a profile with the same name there is refused
+until you disconnect the original one, so OpenCodex never loses track of a file it changed.
+
## Preview and confirm changes
In the dashboard, Apply, Replace, Disable, and Restore begin with a preview. The dialog shows exactly which
@@ -307,6 +409,8 @@ profiles** remains a separate bulk action and is not bound to one combined previ
## What to expect, honestly
+If a generated managed path cannot be represented safely, that client reports an unsafe status and its preview refuses the operation. Attempts to apply, overwrite or disable return HTTP 409 without changing the configuration. Other clients remain available.
+
**Managed configuration files are limited to 4 MiB (4,194,304 UTF-8 bytes).**
The shared integration reader refuses larger files as unsafe to read, and the writer
refuses a merged configuration that exceeds the same limit with `write_failed`, leaving
@@ -715,7 +819,7 @@ every Codex agent role found in `$CODEX_HOME/agents/*.toml`, with the model each
to. Codex runs a role on that pin no matter which model the parent asks for, so this is where a
role's model is actually decided. LazyCodex counts as installed when the `omo@sisyphuslabs`
Codex plugin is enabled in `$CODEX_HOME/config.toml` and an installed copy under
-`$CODEX_HOME/plugins/cache/sisyphuslabs/omo/` carries its `lazycodex-install.json`. A `~/.omo`
+`$CODEX_HOME/plugins/cache/sisyphuslabs/omo/` carries its plugin manifest or `lazycodex-install.json`. A `~/.omo`
folder on its own does not count, because Pi-based omo creates it too. Without LazyCodex the
section is hidden and the command line reports it as not installed. Pick a model and a reasoning
effort on a row and press Save. A row whose role has no model pin can save an effort on its own;
diff --git a/docs-site/src/content/docs/guides/minimax.md b/docs-site/src/content/docs/guides/minimax.md
index db01d782d7a..121bf88c9c1 100644
--- a/docs-site/src/content/docs/guides/minimax.md
+++ b/docs-site/src/content/docs/guides/minimax.md
@@ -13,7 +13,7 @@ the protocol boundary it actually exposes:
## MiniMax Code
In the OpenCodex Models page, both MiniMax Coding Plan regions advertise image input for
-`MiniMax-M3` and `MiniMax-M3.1-Flash-Preview`. A Combo's **Image / multimodal** switch is
+`MiniMax-M3` and `MiniMax-M3.1-Flash`. A Combo's **Image / multimodal** switch is
available only when every selected model supports image input. The switch controls image
attachments; it does not enable video uploads.
diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md
index 4ccfde4b97a..772a078eb74 100644
--- a/docs-site/src/content/docs/guides/model-routing.md
+++ b/docs-site/src/content/docs/guides/model-routing.md
@@ -117,6 +117,8 @@ Routing and catalog visibility are separate controls:
It raises the advertised window only when that exact model has a known per-model window
in `modelContextWindows` (for example, `1000000`); an unknown model keeps its live window.
An explicit per-model window wins, then the existing provider cap applies.
+ Saved windows and tier selections survive startup and OAuth re-login. OAuth presets fill
+ missing per-model defaults while keeping your configured entries, including on xAI.
The `"default"` tier retains normal live context metadata.
The dashboard control is planned separately.
diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md
index 03b1886a8e0..741b56d4298 100644
--- a/docs-site/src/content/docs/guides/pi.md
+++ b/docs-site/src/content/docs/guides/pi.md
@@ -147,12 +147,14 @@ An explicit reasoning effort of `none` survives Chat conversion. Output limits a
controls are preserved for generic API-key Responses targets; the canonical ChatGPT target
still applies its own restrictions. This does not make all providers' controls equivalent.
-**Audio and most file attachments need a native input wire that supports them.** A document
-that carries its own base64 bytes in a user message is the exception: it survives translation
-and reaches the Anthropic, OpenAI Chat and Google wires as a native document, file part and
-inline data part. Everything else still returns an explicit error rather than succeeding
-without the attachment — audio, a file-ID or remote reference the proxy cannot dereference, an
-attachment in a tool output or a system message, and a document routed to a wire with no byte
+Inline WAV/MP3 audio in a user message or tool result survives translation to OpenAI Chat as an
+`input_audio` content part; tool-result audio is promoted into a follow-up user message after
+the paired tool results because Chat tool messages are text-only. A document that carries its
+own base64 bytes in a user message is also carried: it reaches the Anthropic, OpenAI Chat and
+Google wires as a native document, file part and inline data part. Everything else still
+returns an explicit error rather than succeeding without the attachment — remote or
+unsupported audio, a file-ID or remote reference the proxy cannot dereference, an attachment
+in a system message, a document in a tool result, and a document routed to a wire with no byte
carrier. File-ID-only images have the same restriction because translated adapters cannot
resolve those IDs. Convert the attachment to text first, or use a native wire and model that
support it. Native Chat and raw Responses (including Azure) retain their existing behavior;
diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md
index 0c0f5c93fb9..2e5ac6de19c 100644
--- a/docs-site/src/content/docs/guides/protocol-paths.md
+++ b/docs-site/src/content/docs/guides/protocol-paths.md
@@ -102,7 +102,7 @@ These switches stage the new paths. Each defaults to off except `antigravityDire
| `managedMessagesNative` | A Messages request whose route is a direct, key-authenticated Anthropic provider is sent as Messages instead of through the bridge. Routes that need bridge-only behaviour (a pinned effort, blocked-skill elision, the web-search sidecar, vision preprocessing, synthetic rows) stay on the bridge. |
| `managedMessagesNativeOAuth` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Effective only together with `managedMessagesNative`; a pooled Anthropic OAuth account set stays on the bridge. |
| `directEncoders` | For a non-Responses upstream, the answer to a Chat or Messages client is encoded directly from the adapter's events instead of through the internal Responses stream. The request side is unchanged. |
-| `antigravityDirect` | **On by default**; set it to `false` to turn it off. A Chat or Messages request whose route is one Google Antigravity provider skips the internal Responses body in both directions: the conversation is decoded from the client body straight into what the Google adapter compiles from, and the answer is encoded directly as with `directEncoders`. The path reads `chat -> ir -> other` or `messages -> ir -> other`. Request settings (model, effort, limits, tool choice) still pass through the usual policies, so account rotation, effort rules and usage are unchanged. Combos, policies, a hosted web-search tool, images for a model marked text-only, a model the shadow-call intercept could rewrite, and text the proxy rewrites (a skills catalog, a generated-image link) stay on the bridge, and the log row names the reason. |
+| `antigravityDirect` | **On by default**; set it to `false` to turn it off. A Chat or Messages request whose route is one Google Antigravity provider skips the internal Responses body in both directions: the conversation is decoded from the client body straight into what the Google adapter compiles from, and the answer is encoded directly as with `directEncoders`. The path reads `chat -> ir -> other` or `messages -> ir -> other`. Request settings (model, effort, limits, tool choice) still pass through the usual policies, so account rotation, effort rules and usage are unchanged. Combos, policies, a hosted web-search tool, images for a model marked text-only, a model the shadow-call intercept could rewrite, and text the proxy rewrites (a skills catalog, a generated-image link) stay on the bridge, and the log row names the reason. On a Chat request the decoded conversation counts toward the shared translation budget, so a request whose retained IR exceeds it is refused with HTTP 413 and `translation_buffer_limit` before anything goes upstream. |
| `shadowPlan` | At the end of each Chat or Messages request, the plan a preview would have predicted is compared with the path the request took; a disagreement adds `planMismatch: true` to the log row's path record. No second request is sent. |
Change them with the CLI or by editing `protocols` in `config.json`
@@ -160,5 +160,11 @@ These paths still use the internal Responses bridge or are not covered:
top-level fields outside its allowlist without a feature effect, and does not apply
`claudeCode.stabilizePromptCache`.
A recognized Claude Code client keeps `context-management-2025-06-27` together with its
- `context_management` body field. Dropped beta names appear once in the proxy log as
- `anthropic-beta-dropped=`.
+ `context_management` body field. For first-party Anthropic key and OAuth requests, any native
+ Messages client can send `thinking-binding-controls-2026-08-01` with `thinking.block_binding`
+ on adaptive or enabled thinking. The proxy strips that nested field when the beta is absent or
+ the destination does not support it, preserving the rest of the thinking configuration. Cloud
+ vendor endpoints and third-party compatible providers do not inherit first-party beta support.
+ Pairing uses the final header, including operator-configured betas; a beta alone is valid for
+ [thinking transformation diagnostics](https://platform.claude.com/docs/en/build-with-claude/preserved-thinking).
+ Dropped beta names appear once in the proxy log as `anthropic-beta-dropped=`.
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 520161b511b..f2d5f813a86 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -52,6 +52,8 @@ reset is expected to recover as `+N% pool capacity`. **Incomplete coverage** mea
accounts could not safely contribute to the estimate, for example because their plan or quota is
unknown, their reading is stale, or the account is paused or needs reauthentication.
+Business Premium seats reported as `self_serve_business_prolite` count as five Standard-seat units in this estimate. This display weight does not select accounts or create a five-hour window when the upstream reports only weekly usage. The calibration follows the [official Business usage limits](https://help.openai.com/en/articles/12003714-chatgpt-business-models-and-limits), checked 2026-10-20; unknown plans continue to count at the baseline with the unknown-plan count disclosed.
+
A **partial window coverage** warning means some included accounts reported one quota window but
not another. The overview keeps those windows separate and marks each affected window incomplete
instead of treating the missing reading as usage for that window.
@@ -64,6 +66,17 @@ Shipped v1 configs migrate automatically to marker 2 and one option-aware row. T
is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with
`cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`.
+## Claude Opus 5.5 progress updates
+
+When the `anthropic` adapter sends Claude Opus 5.5 to `api.anthropic.com` (OAuth or API key),
+OpenCodex requests Anthropic's [progress-update display mode](https://platform.claude.com/docs/en/build-with-claude/thinking#progress-updates).
+Progress updates and mid-turn acknowledgements appear as visible assistant commentary in Codex,
+even when thinking summaries are hidden. Reasoning summaries are omitted in this mode because
+Anthropic cannot distinguish progress updates from reasoning under `display: "summarized"`.
+The original signed thinking blocks are preserved for tool continuation; no signature is decoded
+or rewritten. Updates appear once their signed block completes. Other models and compatible
+third-party endpoints retain their existing thinking display behavior.
+
## Anthropic image input
The built-in Claude model seeds advertise text and image input for both `anthropic` (OAuth) and
@@ -76,6 +89,26 @@ modalities are declared; Kimi omits `capabilities` for unknown or text-only mode
a supported capability field keep their existing configuration shape. After updating opencodex,
regenerate or refresh the client configuration managed by opencodex to receive the updated metadata.
+## Replayed Anthropic thinking signatures
+
+On the Responses endpoint, an Anthropic-compatible upstream can reject a replayed thinking
+signature because it belongs to a different conversation prefix. When the error explicitly
+identifies this signature-binding problem, opencodex can remove replayed signed/redacted thinking
+and retry once within the request's send budget. This covers HTTP 400 refusals and initial stream
+errors before output or tool activity. Hosted web search and image/video bridges share one recovery
+limit across their model iterations: only the rejected inference is resent, while completed sidecar
+calls and tool results are retained. An empty-completion retry shares its request's allowance;
+ordinary continuations after output cannot use it. A failed replacement is
+returned without another signature retry. Other errors keep their existing handling.
+
+Sidecar recovery is disabled when enforced spend accounting cannot establish receipts for earlier
+ordinary sidecar sends. It never increases a caller or Combo send allowance.
+
+Only a successful, nonempty completion remembers the rejection for that conversation and serving
+identity for five minutes. Only the refused opaque payloads are suppressed when they can be
+identified; newly issued thinking remains replayable. Plaintext history remains available; a failed or cancelled retry does
+not disable thinking replay on later requests.
+
## Auth modes
Provider configs accept three `authMode` values (`key` is the default). The built-in registry also
@@ -90,8 +123,29 @@ labels local presets separately; those normally omit both `authMode` and `apiKey
The [`retryOn429`](/reference/configuration/) same-key 429 replay applies only to API-key
providers (`authMode: "key"`). OAuth, forward, and local presets are excluded — their
credentials must never be replayed on the same token, and local runtimes have no remote key to
-preserve. It is opt-in: when the option is absent the feature is off; object presence enables
-it unless `enabled: false`.
+preserve. It is opt-in except for documented destination-specific defaults. Canonical Mistral
+Chat uses a patient retry policy (6 replays, 10-second fallback interval), within the
+existing request-wide send allowance. Explicit `retryOn429` settings, including
+`enabled: false`, take precedence. The Mistral preset also forwards supplied prompt-cache
+keys by default; `promptCacheKey: false` disables forwarding. Cache affinity improves the
+chance of reuse, but does not guarantee a cache hit or remove token-throughput limits.
+
+Mistral uses one API-key provider for both Studio and Vibe keys. Key types and plan allowances
+are described in [Mistral's API-key documentation](https://docs.mistral.ai/admin/identity-access/api-keys);
+the proxy does not infer a subscription tier from the key. For every model, explicit upstream
+`Retry-After` deadlines are shared between calls on the same credential and model. A 429
+without a retry time can activate a conservative 60-second recovery wait when valid minute
+counters show exhausted capacity, or the refused query's measured cost exceeds the remaining
+tokens while fitting the full advertised allowance. This is a local recovery policy, not an
+exact server-reset prediction. Healthy traffic never predicts the next query's cache or cost.
+After the wait, one recovery probe runs; success restores ordinary traffic. Missing, malformed,
+zero-entitlement, exhausted-monthly, or above-full-allowance evidence does not invent a reset or compact history.
+`requestPacing: { "enabled": false }` or `retryOn429: { "respectRetryAfter": false }`
+disables shared pacing. If Mistral's
+retry time exceeds `retryOn429.maxIntervalMs`, the proxy returns the 429 and retry hint rather
+than replaying early. Context windows and per-model compaction preferences remain independent.
+
+When round-robin key selection is enabled, queued requests recheck key cooldowns before sending and acquire pacing capacity for any replacement key. This applies to native Chat, Messages, Responses and compact. Waiting for replacement-key capacity does not consume the upstream response-header timeout. Per-key pacing and round-robin use guarded HTTP/SSE even when upstream WebSockets are enabled; WebSocket-only steering and injection require a route without those key policies. This does not change the saved active key. An established upstream turn keeps its credential; later sends are refused if that key cools. Pre-output key rejection can recover to a healthy key within the existing request limit. Direct Images and compact requests record genuine 401/429 cooldowns without replaying that paid POST.
### Anthropic API-key credit exhaustion
@@ -145,6 +199,9 @@ Cursor, Kiro, Zed Hosted AI and Nous Portal are login-only and have no API-key e
login-only too: `ocx login google-antigravity` signs in with your Google account over the Cloud Code
Assist wire, and the `google` preset beside it is the AI Studio Gemini API — a different product
reached with its own key, not a key mode for the same login.
+The Antigravity Accounts tab shows an observed membership tier as a badge beside the account
+label, using the upstream tier ID (for example `free-tier` or `gemini-pro`). No badge appears
+when the plan is unknown; it does not affect routing or quota.
Gemini 4 Argon (`gemini-4-argon`) is listed on the `google` preset with low, medium (the
default), and high thinking levels. Google offers it on the paid Gemini API tier only, not the free
@@ -154,6 +211,28 @@ eligible Enterprise accounts. Cloud Code Assist serves Argon only to the Antigra
OpenCodex sends Argon requests and model discovery with the CLI User-Agent
(`antigravity/cli/1.3.4`) and keeps the IDE User-Agent for every other model.
+On Vertex AI, `gemini-4-argon` receives images returned by tools, such as screenshots, inside
+the matching function response. This avoids the upstream 400 error "Requests ending with a
+model turn are not supported" after an image-producing tool call while preserving the images.
+Images from duplicate results or results without a matching call are preserved in a separate
+message after the tool responses, with a marker explaining that they could not be paired.
+No configuration change is needed; this handling is specific to Vertex Argon.
+
+For Gemini on Antigravity, including Argon, provider-marked thought summaries stay separate
+from the answer: Responses uses reasoning summary items, Chat Completions uses
+`reasoning_content`, and Messages uses `thinking` blocks. Clients decide how to display those
+fields. A Responses client can request `reasoning.summary: "none"` to suppress summaries;
+this does not turn off the model's internal reasoning. A thought signature is continuation
+metadata and can accompany ordinary text, so its presence alone does not hide that text.
+OpenCodex preserves unmarked answer text even when its wording sounds like planning or analysis.
+
+Gemini models, Argon in particular, sometimes skip thinking on a tool-calling turn and write their
+planning into the visible progress text, for example a progress line followed by "Let's read
+google.ts next." Once that text is sent back as history, the model tends to repeat the pattern
+on every later turn. To stop it from spreading, OpenCodex sends Gemini only the first sentence of
+the text that came before a tool call in earlier turns. Final answers are sent back in full, and
+what your client displays is not changed.
+
The built-in `google-antigravity` model list is Gemini 3.8 Flash (`gemini-3.8-flash`), Gemini 4 Argon
(`gemini-4-argon`), Claude Opus 5.5 (`claude-opus-5-5`), and Claude Sonnet 5.5
(`claude-sonnet-5-5`), each with low, medium, and high efforts. What you actually see is your
@@ -161,9 +240,25 @@ account's own model discovery, because Google enables models per account: some a
Claude Sonnet 4.6 and Claude Opus 4.6 (Thinking), others only Claude Opus and Sonnet 5.5 (directly
billed, non-trial Pro or Ultra plans), and Gemini 4 Argon only on Ultra and Enterprise. Both Claude
generations map: 4.6 takes its effort as a thinking level, 5.5 as its `-low`, `-medium`, or
-`-high` model. With several Antigravity accounts and `oauthAccountFailover.enabled`, a request
-prefers an account whose discovery lists the requested model. Saved selections of models that left
-the list keep routing as before.
+`-high` model.
+
+Antigravity requests go only to accounts that may serve the requested model. OpenCodex remembers
+each account's model list from that account's own discovery, in memory, for six hours. An account
+whose list includes the model is preferred; an account with no current list (not yet discovered,
+expired, or forgotten by a restart) is still used, because nothing proves it lacks the model. An
+account whose current list omits the model is never sent the request, including on 429 and
+authentication failover. If the selected account omits it and `oauthAccountFailover.enabled` is on,
+the request moves to another account that is not paused or awaiting reauthentication. Otherwise
+OpenCodex refuses the request locally with HTTP 403 `permission_denied` and sends nothing upstream:
+
+- `The selected Google Antigravity account does not list the model "" in its model discovery. Select an account that lists it, or enable oauthAccountFailover so OpenCodex can choose one.`
+ Another usable account may serve the model, but failover is off.
+- `The selected Google Antigravity account does not list the model "" in its model discovery, and no account that may serve it could be used for this request. Retry the request.`
+ Failover is on and chose another account, but that account could not be used for this request.
+- `No usable Google Antigravity account lists the model "" in its model discovery. Choose a model your accounts list, or add an account that has access to it.`
+ Every usable account's current list omits the model.
+
+A saved selection of a model that left the list follows the same rule.
To check which mode a provider is actually using, open it on the Providers page: the **Connection**
block's **Authentication** row reads `OAuth`, `API key`, `ChatGPT passthrough`, `Local`, or
@@ -213,6 +308,7 @@ ocx login zed # Zed native-app callback login (experimental)
ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json)
ocx login orcarouter-oauth # OrcaRouter browser consent + PKCE
ocx login devin # Cognition/Devin: import Devin CLI credential, else Auth0 browser sign-in
+ocx login dsh-account # DeepSeek Harness Desktop (import ~/.dsh/.credentials.yaml grant)
ocx login github-copilot # GitHub device flow → Copilot token (Copilot account)
ocx login codex # Codex account pool (aliases: chatgpt, openai; needs a running proxy)
ocx logout
@@ -222,7 +318,7 @@ ocx logout
| --- | --- | --- | --- |
| `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. |
| `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. |
-| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. Retired `kimi-k2.x` selections are migrated to the alias on upgrade. |
+| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. `k3.1-pro` and `k3.1-pro-highspeed` (K3.1 Pro) are also listed: 1M-token context, thinking-only on the same ladder (default `max`). Retired `kimi-k2.x` selections are migrated to the alias on upgrade. |
| `kimi-responses` | `openai-responses` | `https://api.kimi.com/coding/v1` | Same Kimi account login (reuses the `kimi` OAuth credential) over the OpenAI Responses wire. Same model roster and capabilities as `kimi`; thinking content stays encrypted server-side, tool calls and results stay visible. |
| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. |
| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | The dashboard Login and Add account buttons offer Builder ID, Google, GitHub device login, or Kiro CLI. Native device login adds an account without signing `kiro-cli` out. The Kiro CLI choice imports or starts a CLI session; its Add account workflow temporarily switches the CLI account and restores it on cancellation or failure. |
@@ -231,6 +327,7 @@ ocx logout
| `zed` | `zed` | `https://cloud.zed.dev` | Experimental Zed Hosted AI bridge. Uses Zed's native-app RSA callback, exchanges the account token for a short-lived hosted-inference token, and discovers the account's live roster. |
| `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. |
| `devin` | `devin` | `https://server.codeium.com` | Experimental unofficial Cognition/Devin bridge. Login first imports the credential the installed Devin CLI already holds (`devin auth login` writes a `devin-session-token` to its own `credentials.toml`); when none is present it opens Auth0 browser sign-in and exchanges the pasted token via Cognition's `RegisterUser` for a long-lived API key. `ocx login devin-cli` remains as a deprecated alias. Models are discovered per account with `GetCascadeModelConfigs`. Account quota comes from `GetUserStatus` under one eight-second request and body deadline: dated daily and weekly windows, plus the monthly prompt and flex credit pool for a credit-billed plan or an unknown strategy with both reset dates absent when a balance field is present. Negative used credits omit the monthly window; valid zero available credits mark it exhausted. A timed-out probe keeps the last good quota. Not shown in the dashboard preset by default. Chat and usage reporting are verified against a live account across three models. |
+| `dsh-account` | `dsh-account` | `https://api.deepseek.com/anthropic` | Experimental import-first account provider reusing an installed and signed-in DeepSeek Harness (DSH) Desktop account. Reads credentials strictly read-only from `~/.dsh/.credentials.yaml` upon explicit import. Independent from DeepSeek API key billing. |
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. |
Google Antigravity groups a model's complete `low` / `medium` / `high` variants into one entry with an effort selector. This includes Claude Opus and Sonnet 5.5 and future versions discovered during automatic refresh. Partial sets remain separate. Existing enabled/disabled choices carry into the grouped entry; genuinely new models still follow your New model policy. Saved suffix IDs continue to request their original tier. Grouped effort routing survives a proxy restart when model discovery is temporarily unavailable. A running Codex session may need to reload its model list after catalog refresh. Usage groups known Claude Sonnet and Opus 5.5 tier IDs under their base model, including historical tier records. Their displayed costs are derived Anthropic reference estimates, not Antigravity subscription charges.
@@ -278,6 +375,8 @@ provided, and the bridge infers the hosted backend family from the live row or t
model discovery is not an allowlist. The Zed access token has no refresh endpoint, so when Zed
revokes it, run `ocx login zed` again.
+For Zed-hosted OpenAI models, OpenCodex translates `developer` instructions to `system` messages and removes OpenAI-private fields and unsupported built-in tools such as `web_search`. Function and custom tools remain available through the delegate; a tool choice with no supported tools left becomes `none`.
+
For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key),
opencodex forwards only a caller-supplied stable `prompt_cache_key` to the Chat Completions request
(the Responses wire accepts the same field, but opencodex does not send it there today);
@@ -291,15 +390,28 @@ to avoid its `assistant must not be empty` rejection. Nonempty text, whitespace,
reasoning and tool calls/results are retained; stored client history is not rewritten.
An empty message referenced by an `item_reference` in the same input is retained rather than leaving a dangling reference.
-Kimi Coding usage prices for `k3`, `k3[1m]`, and `k3-256k` are **API-reference estimates** across
-`kimi`, `kimi-code`, and `kimi-responses`, using the published default 5-minute cache-write rate.
-They do not reproduce Code Plan billing or quota: K3's 1M variant consumes about twice the quota
-of `k3-256k`. `kimi-for-coding` now points to K2.8 Preview, so its former K2.7 price is no longer
+Kimi Coding usage prices for `k3`, `k3[1m]`, `k3-256k`, `k3.1-pro`, and `k3.1-pro-highspeed` are
+**API-reference estimates** across `kimi`, `kimi-code`, and `kimi-responses`, using the published
+default 5-minute cache-write rate (the `moonshot` API preset prices the same models as `kimi-k3`,
+`kimi-k3.1-pro`, and `kimi-k3.1-pro-highspeed`). They do not reproduce Code Plan billing or quota:
+K3's 1M variant consumes about twice the quota of `k3-256k`, and K3.1 Pro Highspeed about twice
+that of `k3.1-pro`. `kimi-for-coding` now points to K2.8 Preview, so its former K2.7 price is no longer
used; its estimate remains unavailable unless the operator supplies `modelCosts`. Unknown-price
routing policies and cost caps that exclude unknown estimates can therefore exclude this alias.
See [Kimi's model configuration](https://www.kimi.com/code/docs/en/kimi-code/models.html) and
[API pricing](https://platform.kimi.ai/docs/pricing/chat).
+The `moonshot` (Kimi API) preset lists `kimi-k3.1-pro` and `kimi-k3.1-pro-highspeed` next to
+`kimi-k3` and the K2 ids. Both have a 1M-token context window and text + image input, and always
+think: the picker offers `low` / `high` / `max` (default `max`), Codex `medium` is sent as `high`
+and `xhigh` as `max`, and an explicit `none` leaves `reasoning_effort` out so the upstream default
+applies. Fixed sampling parameters (`temperature`, `top_p`, penalties) are not sent. A saved
+`moonshot` model list that is still the previous built-in list gains both ids at startup when it
+points at the International or China endpoint; an edited list or a Custom URL is left as it is.
+The preset default stays `kimi-k2.7-code`. Moonshot closes the K2 series to new accounts on
+2026-10-31 and plans to retire it by the end of December 2026; see the
+[Kimi API model list](https://platform.kimi.ai/docs/models).
+
A custom `openai-chat` provider can opt in when its upstream documents support for
`prompt_cache_key`:
@@ -370,6 +482,29 @@ Two cases behave differently, and it is worth knowing which you are in:
Device-code providers never open a browser from the proxy in either case: they show a code and a
verification URL to open wherever you are signed in.
+### Headless Claude login (code-display mode)
+
+For Anthropic there is a cleaner path than pasting a dead redirect: the **code-display** login.
+Anthropic redirects to its own page and shows a `code#state` string instead of calling back to the
+proxy, so nothing needs to reach `127.0.0.1` on the proxy's host.
+
+```bash
+ocx login anthropic --headless
+```
+
+Over the management API, start with `POST /api/oauth/login` and `{"provider":"anthropic","mode":"code"}`;
+the response carries a `flowId` and an `expiresAt` (10 minutes). Submit the pasted string to
+`POST /api/oauth/login/code` with that `flowId`. The answer is the result of the token exchange —
+`{"ok":true,"account":…}` or `{"ok":false,"error":…}` — so a hosted caller with a single paste box
+knows whether the login actually worked. A paste with the wrong state, or with no code in it, is
+refused before any token request and leaves the flow open for another try; a paste that reaches the
+token exchange uses the flow up, so start a new login if that exchange fails.
+
+Only `anthropic` and `anthropic2` support this mode; other providers answer `code_mode_unsupported`. Several code
+logins can be in flight at once, each pinned to its `flowId`, but they all write the same account
+store: the most recent successful one becomes the active Claude account. This is meant for one
+operator on a headless host, not for isolating unrelated users on a shared instance.
+
### Multiple OAuth accounts
OAuth providers whose credentials include a stable account id or email can keep more than one
@@ -393,6 +528,8 @@ cat accounts.json | ocx account import google-antigravity --format cockpit-tools
Inline JSON and extra positional arguments are rejected. Keep exported files private and delete or store them securely after import.
+Each record may carry an optional `project_id` for an account whose Cloud Code Assist project is not discovered automatically. It must be a Google Cloud project id (6–30 lowercase letters, digits or hyphens). OpenCodex uses it only when discovery finds no project, and only after a quota check with that account's own token succeeds for it; otherwise the record fails with `missing_project` and nothing is saved. A discovered project always takes precedence.
+
### OAuth reliability
opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the
@@ -538,9 +675,26 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses
The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. Closing the dialog sends cancellation and leaves a bounded background status check to reconcile a commit already in progress. A stalled status response is retried; exhausting the flow deadline produces the neutral ended outcome rather than claiming success. Google and GitHub approval completes when Kiro reports `authorized` (or the legacy `approved` status) with valid tokens and a profile ARN.
The account list marks Kiro accounts excluded from automatic selection with a reason, when available.
+### DeepSeek API
+
+The `deepseek` API-key provider includes `deepseek-flash`, its `deepseek-v4-flash` alias, and `deepseek-v4-pro`. DeepSeek [withdrew the Pro retirement plan](https://api-docs.deepseek.com/updates/) and continues serving it; opencodex retains Pro in the catalog with a 1,048,576-token context window, text-only input, and `low`/`high`/`max` reasoning effort. Responses requests use its native Responses endpoint; Chat Completions requests retain the Chat wire. Flash remains the default. Third-party providers have independent model availability.
+
+On upgrade, startup adds Pro to a saved model list only when it exactly matches the previous built-in DeepSeek seed and still uses the official endpoint and adapter. Customized lists, including empty lists, remain unchanged; live discovery need not be enabled for this repair.
+
+### DeepSeek Account (DSH)
+
+`dsh-account` is an experimental, import-first account provider designed to reuse the authenticated account credentials of an installed DeepSeek Harness (DSH) Desktop application.
+
+- **Independent from DeepSeek API Key:** `dsh-account` runs on the DeepSeek account signed in to DeepSeek Harness, whereas the `deepseek` provider bills against your platform API key balance. The two credential domains and routes are strictly separate and never fall through into one another.
+- **Explicit import only:** opencodex reads `~/.dsh/.credentials.yaml` (or `$DSH_HOME/.credentials.yaml`) only when you run `ocx login dsh-account` or select **Log in** on the DeepSeek Account (DSH) row of the dashboard's **Add provider** picker. It never copies, imports, or uses the token on its own.
+- **Read-only DSH credential store:** OpenCodeX treats `~/.dsh/.credentials.yaml` as strictly read-only. OpenCodeX never writes, modifies, removes, or alters the permissions of any files belonging to DeepSeek Harness.
+- **Isolated credential copy & logout:** Upon import, OpenCodeX validates the token against DeepSeek's authoritative identity endpoint (`/auth-api/v0/users/current`) and saves its own copy to the OpenCodeX auth store. Logging out or removing the account in OpenCodeX only deletes the OpenCodeX local copy; it never triggers upstream remote session logout and will not sign you out of DeepSeek Harness Desktop.
+- **No background token rotation & 401 re-import:** DSH session grants do not feature an automatic background refresh endpoint. If upstream returns HTTP 401 (`ACCOUNT_TOKEN_INVALID`), OpenCodeX marks the account `needsReauth` and halts routing. To restore access, the user must explicitly re-import via `ocx login dsh-account`.
+- **Third-party disclaimer:** OpenCodeX makes no claim of official endorsement or authorization by DeepSeek for third-party clients reusing session grants.
+
## 3. API-key catalog
-opencodex ships 104 built-in presets: 85 key-based, 15 OAuth, three local, and one default
+opencodex ships 106 built-in presets: 85 key-based, 17 OAuth, three local, and one default
ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard,
validates the key, and stores it; validation is provider-specific. Notable entries:
@@ -622,7 +776,7 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper
| Z.AI (GLM Coding) | `https://api.z.ai` — Responses at `/api/v1/responses` by default; Chat Completions at `/api/coding/paas/v4/chat/completions` per model through `modelAdapters` |
| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` |
| BigModel Coding Plan (Responses, static roster) | `https://open.bigmodel.cn/api/v1` |
-| Qwen Cloud | Token plan (default): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · or Custom |
+| Qwen Cloud | Token plan (default): `https://token-plan.maas.qwencloudapi.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · or Custom |
| Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` |
| SiliconFlow | `https://api.siliconflow.cn/v1` |
| Volcengine Ark · Coding Plan · Agent Plan | `https://ark.cn-beijing.volces.com/api/v3` · `https://ark.cn-beijing.volces.com/api/coding/v3` · `https://ark.cn-beijing.volces.com/api/plan/v3` |
@@ -632,11 +786,33 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper
| Opper | `https://api.opper.ai/v3/compat` |
| TokenLab | `https://api.tokenlab.sh/v1` |
| Modelflare | `https://modelflare.dev/v1` |
+| AI Stupid Level | `https://aistupidlevel.info/v1` |
| OpenGateway | `https://apis.opengateway.ai/v1` |
| GitLab Duo | `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` |
| Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` |
| …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM |
+**Alibaba Token Plan** provides Beijing Personal Edition and International Team Edition
+presets. Both regions offer both subscription tiers. The presets keep static chat-model
+lists because upstream discovery also includes non-chat models. Existing selections and
+explicit International endpoint overrides are preserved.
+
+Both presets include `deepseek-v4.1-flash-1017` with text/image input, text output,
+1,000,000 context and `low`/`high`/`max` reasoning (default `high`). Its 393,216 output
+budget follows the existing published-budget normalization, not a measured boundary;
+the lab lists 384,000. Strict schemas reject and `json_object` is unverified for this
+exact ID, so Chat requests omit `response_format`. This is best-effort text, not
+guaranteed JSON. The bare model's verified JSON downgrade and existing Responses
+pins are unchanged. Beijing also includes now-listed `deepseek-v4-pro-0813`;
+International omits retired `deepseek-v3.2` and bare `deepseek-v4-flash` but retains
+`deepseek-v4-flash-0731`. Both presets keep the hosts their official quick starts
+document: Beijing `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` and
+International `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1`.
+The Qwen Cloud preset uses the separate Token Plan host that Qwen Cloud documents above.
+See the [1017 model card](https://www.qianwenai.com/models/deepseek-v4.1-flash-1017),
+[Chat reference](https://docs.qwencloud.com/api-reference/chat/openai-chat) and
+[Personal overview](https://help.aliyun.com/en/model-studio/token-plan-personal-overview).
+
**OpenGateway** is an OpenAI-compatible gateway operated by Sionic AI at
`https://apis.opengateway.ai/v1`. Its public catalog contains about 80 active models
(as verified on 2026-10-02); the preset automatically refreshes the live model list from
@@ -647,6 +823,16 @@ Create a key in the [OpenGateway dashboard](https://opengateway.ai/api-keys), th
use the configured Bearer key; the public model list does not validate that key.
On the Responses wire OpenGateway rejects replayed native custom tool calls, so the preset
lowers Codex custom tools such as code-mode `exec` to function tools before sending them.
+Tool-result content arrays are sent as text, with images preserved after the paired tool-result group. Unsupported result parts, including refusals and opaque content, are rejected explicitly with a client input error before sending.
+Image detail `original` maps to `high`, which the gateway accepts; image bytes remain unchanged.
+The native Responses route also checks text-only empty completions before exposing them as success.
+Exhausted output budgets produce `incomplete` with `max_output_tokens`; the proxy does not increase
+the requested budget. A replay-safe empty completion with budget remaining may retry once, within
+the existing send limit. Explicit `emptyCompletionRetry: false` or `OCX_EMPTY_COMPLETION_RETRY=0`
+disables that retry. Text, refusals, tool activity, and unknown stream events prevent replay and
+retain the original stream. The protection does not change other providers or their retry defaults.
+Replay requires explicit `store: false`, complete input, and client-executed tools only; a hosted
+tool such as `web_search` prevents automatic replay even if no tool event was observed.
**TokenLab** ([sponsor](https://github.com/lidge-jun/opencodex/blob/main/SPONSORS.md)) is an
OpenAI-compatible API gateway at [tokenlab.sh](https://tokenlab.sh/r/OPENCODEX),
@@ -664,7 +850,7 @@ Each model then uses the request format TokenLab declares for it
| Models | Codex (Responses clients) | Chat clients | Claude Code (Anthropic clients) |
| --- | --- | --- | --- |
-| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://tokenlab.sh/docs/en/api-reference/responses/create-response) | Chat Completions | Chat Completions |
+| `gpt-6-astra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-sol`, `gpt-6-luna`, `grok-4.7`, `deepseek-v4.1-flash`, `deepseek-v4-pro`, `kimi-k3`, `glm-5.3` | [Responses](https://tokenlab.sh/docs/en/api-reference/responses/create-response) | Chat Completions | Chat Completions |
| `claude-*` | [Messages](https://tokenlab.sh/docs/en/api-reference/messages/create-message) | Messages | Messages |
| Every other model, including `gemini-3.8-flash` | Chat Completions | Chat Completions | Chat Completions |
@@ -685,9 +871,11 @@ picker and marks it as a sponsor, and nothing else about routing or defaults cha
**Modelflare** is an OpenAI-compatible relay at `https://modelflare.dev/v1`. Select **Modelflare**
in `ocx init` or the dashboard's **Add provider** picker, or run `ocx provider add modelflare`, and
give it your key directly or as the reference `${MODELFLARE_API_KEY}` so the key stays in your
-environment. The preset uses Chat Completions and seeds four models that
+environment. The preset uses Chat Completions. The following existing models
were verified with chat, streaming and tool calls: `glm-5.3-flash` (the default),
-`claude-sonnet-5-5`, `gpt-6.1-sol` and `deepseek-v4-flash`. You can add other Chat-compatible
+`claude-sonnet-5-5`, `gpt-6.1-sol` and `deepseek-v4-flash`.
+`gpt-6.1-terra` is a preemptive seed mirroring Sol; it has not been inference-verified here.
+You can add other Chat-compatible
Modelflare models as custom models. Image and video models use separate Modelflare endpoints and
do not work through this Chat preset. An ordinary Modelflare key is bound to one routing group and
can call only that group's models, so use a Smart key for the full seeded set, or pass
@@ -695,6 +883,31 @@ can call only that group's models, so use a Smart key for the full seeded set, o
`deepseek-v4.1-flash` is listed upstream but currently answers 503, so the preset leaves it out.
Upstream latency varies from a few seconds to about 30 seconds, with occasional transient 503s.
+**AI Stupid Level** is a Smart Router at `https://aistupidlevel.info/v1`, operated by S.C. Studio
+Platforms S.R.L. Create a router key at [aistupidlevel.info/router/keys](https://aistupidlevel.info/router/keys)
+and add your own OpenAI, Anthropic, Google, DeepSeek, Kimi or GLM keys to the same account. Then select
+**AI Stupid Level** in `ocx init` or the dashboard's **Add provider** picker, or run
+`ocx provider add aistupidlevel`, and give it the router key directly or as the reference
+`${AISTUPIDLEVEL_API_KEY}`. The router calls each model with your own provider key, so the provider
+bills you directly and AI Stupid Level adds no markup ([terms](https://aistupidlevel.info/terms#router)).
+The preset uses Chat Completions for every client, Claude Code included, and discovers models from the
+authenticated `GET /v1/models`. Its seeded models are the
+[routing modes](https://aistupidlevel.info/router/docs#strategies): `auto-coding` (the default) sends
+each request to the model with the best measured score on AI Stupid Level's coding benchmark,
+`auto-tooling` and `auto-reasoning` do the same for tool use and reasoning, and `auto-value-coding` or
+`auto-fastest-quality` pick the best-value or fastest model within 5 points of the best. A real model id
+from the live list, such as `aistupidlevel/claude-opus-5-5`, pins one model, and the `X-AISM-Model`
+response header names the model that answered. Routing modes declare a 262,144-token window and 64,000
+output tokens, which hold for any model the router picks. The router accepts text only, so for a
+routing mode or pinned model at the preset destination the proxy hands images to the vision sidecar instead of sending them. Its Anthropic
+Messages endpoint is a passthrough to Anthropic without routing, so the preset does not use it.
+
+The provider editor also accepts an optional `inputModalities` fallback: a nonempty array containing
+`text`, `image`, `audio`, or `video`. Exact per-model declarations take precedence. Invalid values
+are rejected on save; a malformed value in a hand-edited config loads as `["text"]` with a warning.
+The preset's text-only fallback is applied at runtime, including when saved under another provider
+name at the same URL and Chat adapter. It does not follow a custom URL or another adapter.
+
The MiniMax and MiniMax (CN) provider cards can also show Coding Plan quota when the configured
key has an active plan. The dashboard reads the plan's 5-hour window and, when present, weekly
window; these are display observations and do not change model routing.
@@ -706,19 +919,32 @@ attempt fails, while transient failures keep it. An unreadable or oversized body
further attempts. Dollar credits show the included allowance only, excluding purchased credits,
so an exhausted allowance stops routing to Ollama Cloud only when no purchased credit remains.
-`MiniMax-M3.1-Flash-Preview` (1M context) is listed on both MiniMax presets. MiniMax serves it
-only to Token Plan subscription keys and MiniMax Code for now, so a pay-as-you-go API key gets an
-error for it. Thinking is always on: the effort picker offers `low` through `max` and defaults to
-`max`, and there is no way to turn thinking off. MiniMax has not published a per-token price
-for the preview; usage is drawn from your
-[Token Plan quota](https://platform.minimax.io/docs/guides/pricing-token-plan), so OpenCodex
-shows no estimated cost for it. MiniMax's `/models` endpoint does not list
-the preview yet, so OpenCodex keeps it in the catalog from the preset. An install whose saved
-MiniMax model list is still the previous default receives it on the next start; a list you edited
-is left as it is; register the preview by hand with
-`ocx models add minimax MiniMax-M3.1-Flash-Preview --context-window 1000000`.
-
-**OpenCode Go** requires a stable session identifier for routing. OpenCodex sends
+`MiniMax-M3.1-Flash` (1M context, up to 524,288 output tokens) is the default model on both MiniMax
+presets. MiniMax released it on 2026-10-19 and retired the `MiniMax-M3.1-Flash-Preview` id, so a
+saved configuration that still names the preview is renamed to `MiniMax-M3.1-Flash` on the next
+start. Thinking is always on: the effort picker offers `low` through `max` and defaults to `max`,
+and there is no way to turn thinking off. MiniMax's `/models` endpoint does not list Flash, so
+OpenCodex keeps it in the catalog from the preset. An install whose saved MiniMax model list is
+still the previous default receives it on the next start; a list you edited is left as it is;
+register it by hand with
+`ocx models add minimax MiniMax-M3.1-Flash --context-window 1000000`.
+
+**OpenCode Go** routes `gemini-4-argon-contributor` through its Gemini API, including
+streaming and tool-result continuations. The existing `opencode-go` preset keeps its
+historical URL pinning: saved custom URLs, including `https://opencode.ai/inference/go/v1`,
+route through the canonical `https://opencode.ai/zen/go/v1` endpoint and inherit Go defaults.
+Gemini then uses Go's native model endpoint with key authentication. A differently named
+custom provider keeps its configured URL and does not receive this Gemini pin.
+Go selects protocols by exact model ID: GPT 6 Luna, GPT 5.6 Luna, Grok 4.6/4.7 and
+Muse Spark Contributor use Responses; Claude Haiku 5.5, MiniMax M2.7/M3 and
+Qwen3.7 Plus/3.8 Max/3.8 Flash use Anthropic Messages. Legacy MiniMax M2.5 and
+Union Alpha also retain Messages. Models without a listed wire retain Chat.
+New native pins apply to the pinned Go destination with key authentication.
+Responses models still accept an explicit `modelAdapters` override.
+When reading an older saved configuration, OpenCodex ignores obsolete Chat/Responses overrides for the newly pinned Gemini and Qwen models, along with their formerly valid Responses-only hosted-tool preferences. Other providers, accounts and routing remain available. Reads leave the file unchanged; a later explicit configuration edit saves the upgraded values. New overrides for these native models are still rejected.
+Gemini uses `low`, `medium` and `high` thinking levels; higher requested efforts clamp to `high`.
+
+OpenCode Go requires a stable session identifier for routing. OpenCodex sends
`claude-haiku-5-5`, like the MiniMax models, over the Anthropic Messages wire, matching
[OpenCode Go's endpoint table](https://opencode.ai/docs/go/#endpoints). OpenCodex derives
its Go session header from Codex thread/session headers, or from a client's
@@ -746,46 +972,37 @@ when a stable upstream session is required.
**MiMo tool-call echoes.** On OpenCode Go and other Chat Completions routes, a bare
`` block is hidden when it duplicates one structured call to the same tool
-with the same effective input. If the input differs or several calls could explain
+with the same effective input. This includes MiMo wrappers such as
+`` with matching closing IDs, including echoes appended to ordinary prose.
+Code examples remain visible. If the input differs or several calls could explain
the block, the markup remains visible. For `mimo-v2` and dotted MiMo V2 model IDs such as
`mimo-v2.6-pro`, if the gateway instead sends exactly one empty `{}` call for a declared
-freeform tool and puts its input in a standalone bare block, opencodex restores that
+freeform tool and puts its input in a standalone bare or identified block, opencodex restores that
input to the call and hides the block. Hyphenated IDs such as `mimo-v2-pro` and
`mimo-v2-omni` are outside this recovery rule. Prose, quoted
examples, ordinary functions, and ambiguous responses remain unchanged. This also handles
MiMo's malformed `` User-Agent is a claim to
-*be* the OpenCode client, and OpenCode publishes no third-party integration contract for this
-keyless tier; an HTTP 200 obtained that way is a bypassed admission check rather than
-permission. opencodex therefore reports the restriction instead of working around it: a request
-to `opencode-free` returns an error explaining the upstream gate and pointing here.
-
-The supported route to the same models is the keyed **`opencode-zen`** provider with an OpenCode
-Zen API key from [opencode.ai/auth](https://opencode.ai/auth). If OpenCode later publishes a
-supported third-party path for the keyless tier, opencodex can follow it; until then the preset
-stays as documentation of the restriction. Upstream terms:
-[opencode.ai/docs/zen](https://opencode.ai/docs/zen/).
+**OpenCode Zen** (`opencode-zen`) uses `https://opencode.ai/zen/v1` with an API key from [opencode.ai/auth](https://opencode.ai/auth). Free-model traffic can hit short-window rate limits around 15–20 requests/minute (community-measured; OpenCode does not publish RPM). When a rate-limit 429 omits `Retry-After`, opencodex adds guidance and a synthetic `Retry-After`; valid upstream headers take precedence. Same-key retries remain opt-in via [`retryOn429`](/reference/configuration/).
+
+Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Claude models on these API-key routes use Anthropic Messages automatically, regardless of `googleMode`. When loading saved configurations, OpenCodex ignores eligible older Gemini and Claude Chat/Responses overrides only for native models in the canonical keyed `opencode-zen` provider on these official Zen routes. It also ignores eligible Responses-only `image_generation` hosted-tool preferences; custom providers, non-key routes, and other preferences remain unchanged.
+
+**`opencode-free` has been removed** because every model now returns `403 permission_denied` under OpenCode's upstream free-tier gate ([#7255](https://github.com/lidge-jun/opencodex/issues/7255)). Loading an existing configuration disables its entries and dependent model references in memory, warns once per process, and leaves the file unchanged. If it was the default provider, the standard OpenAI OAuth route becomes the default. Use `opencode-go`, keyed `opencode-zen`, or another provider. [OpenCode Zen terms](https://opencode.ai/docs/zen/).
+
+Healthy Claude Desktop assignments and defaults are preserved. Rules whose source-model allowlist becomes empty are disabled, along with native subagent sync when its injection model is removed. Later config saves keep removed references out of the file while preserving healthy concurrent edits.
Most use the `openai-chat` adapter with a bearer key; Anthropic-compatible presets such as
**Xiaomi MiMo** (`xiaomi`) use the `anthropic` adapter (`x-api-key`). Xiaomi also has an OpenAI Chat
preset, `xiaomi-mimo`, and a token-plan preset, `mimo`. All three default to MiMo V2.6 (`mimo-v2.6-pro`,
or `mimo-v2.6-flash` on `xiaomi-mimo`). Xiaomi retires `mimo-v2.5` and `mimo-v2.5-pro` on 2026-10-21
with no redirect, so switch a saved V2.5 default before then; opencodex does not rewrite it for you.
+
+**Reasoning capabilities.** Model metadata describes three independent capabilities: `supportsReasoning`, `supportsThinkingToggle`, and `supportsReasoningEffort`. An omitted value is unknown; `false` is an explicit declaration. `defaultThinkingEnabled` records the default switch state, while `reasoningEfforts` lists actual strength choices. Per-model declarations live in the provider's `modelCapabilities` map; reasoning axes merge over registry defaults using case-insensitive model IDs, with operator values taking precedence. `/v1/models` exposes the corresponding `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort`, and `default_thinking_enabled` fields under `capabilities`, without inventing an effort list for fixed-strength models.
+
+Legacy configurations with an explicit empty effort list and no reasoning capability declarations retain `capabilities.supports_reasoning: false` in `/v1/models` discovery. An explicit `supportsReasoning: true` or `supportsThinkingToggle: true` still advertises thinking when the effort list is empty.
+
+Public MiMo V2.6 is one instance: its three models support reasoning and an on/off switch, but no adjustable intensity; thinking defaults to enabled. The wire adapter sends `thinking.type: enabled/disabled`. OCX's Responses ingress also accepts `reasoning.enabled` independently of `reasoning.effort`. For clients that only offer effort enums, Codex catalogs and OpenCode/Kilo exports represent this switch as `none/high`; those values are client compatibility aliases, not MiMo strength levels. Client-owned labels may still say “High.” Fixed reasoning models without a switch get no fabricated control. Xiaomi's [deep-thinking guide](https://mimo.mi.com/docs/en-US/quick-start/usage-guide/other/deep-thinking) describes the public MiMo switch; free, token-plan, third-party routes and older MiMo models keep their existing declarations.
+
Volcengine Coding Plan and Agent Plan use their native Responses endpoints through `openai-responses`.
During validated Ark Coding Plan tool continuations, replaying the returned Responses `reasoning` item
answered `400 InvalidParameter`, so the Coding Plan preset drops replayed reasoning items before
@@ -940,6 +1157,10 @@ ladder, and `meta-model/muse-wonder-1.0` (Muse Wonder, preview) with `minimal` t
Muse Wonder is Standard mode only on the Model API: it has no Contributor tier and costs
$4 per 1M input and $20 per 1M output tokens. Muse Wonder Full is available only inside Muse
Code subscriptions, so it is not seeded.
+On startup, an untouched saved two-Spark model list gains Muse Wonder when `meta-model` uses
+`https://api.meta.ai/v1` or has no saved endpoint override. Custom gateway destinations and
+hand-edited model lists stay unchanged. Existing per-model context-window overrides and output
+limits are preserved; Wonder's context window is filled only into an existing window map.
Discovery stays off until an authenticated roster is verified, because Meta serves image and
voice models on the same host.
@@ -956,7 +1177,10 @@ stored by `muse login`. With no local credential, or on another platform, it sta
browser device-approval flow. Add-account and reauthentication skip local import to avoid
reusing the account being replaced. OpenCodex never launches the Muse CLI. If device login
fails without cancellation, an available manual-input surface can accept a pasted key;
-that key faces the same format and Model API validation as an imported key. See
+that key faces the same format and Model API validation as an imported key. On macOS,
+approve the Keychain prompt on the proxy host within 120 seconds. Import refusals
+show the failure reason and offer an existing-key paste field without starting another
+login. Add account on an already configured provider bypasses local import. See
[Platform support](/reference/platform-support/) for the full per-platform picture.
Starting any Meta Muse login through the management API requires a dashboard session,
@@ -1064,6 +1288,35 @@ Create a key in [Novita's key manager](https://novita.ai/settings/key-management
> hosts and schemas and are not routed by this preset.
> Live discovery for this preset is capped at a 1 MiB response and 256 raw model rows.
+### WorkBuddy AI subscription
+
+`workbuddy-ai` connects your international WorkBuddy AI subscription as a model provider.
+Choose **WorkBuddy AI** in Providers and log in, or run:
+
+```bash
+ocx login workbuddy-ai
+```
+
+Complete the browser sign-in; OpenCodex polls for authorization for up to five minutes.
+Tokens are stored in OpenCodex and refreshed on demand. Adding accounts uses the existing
+OAuth account list; team identity stays attached to its own token. No WorkBuddy desktop
+installation or credential-file import is required.
+
+Models and capabilities come from your account's live product catalog. Select a
+`workbuddy-ai/` model after login; `workbuddy-ai/default-model` is the fallback
+selector when discovery is unavailable. Streaming, tool calls and buffered responses use
+WorkBuddy's Chat endpoint. The Overview and Accounts tabs show remaining credits, used/total credits, base and gift
+balances, and the next expiring non-daily credits when available. Refresh reads the signed-in
+account's billing summary; an unavailable read is not a zero balance. These credits are
+informational and do not disable free models when paid credits run out.
+
+This uses an unofficial subscription protocol based on
+[Magpie's WorkBuddy plugin](https://github.com/magpie-community/plugins/tree/e7f65641df820dfd715c0d2241f99f2092e4efb1/packages/workbuddy).
+Upstream client-channel or account restrictions are returned to the caller. OpenCodex does
+not rewrite agent identity prompts to bypass them. Availability depends on your account,
+region, model entitlement and WorkBuddy's current API. This is separate from the CodeBuddy
+CLI API-key providers and does not connect the WorkBuddy desktop client to OpenCodex.
+
### Official CodeBuddy Code CLI (Global & CN)
OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code CLI via the `codebuddy` (Global) and `codebuddy-cn` (China) presets.
@@ -1129,8 +1382,11 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa
- **Authentication:** Create a PAT in the account integrations page
([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment.
- **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL.
+- **Windows executables:** Each preset launches its CLI's native executable (`qoder.exe` or `qodercli.exe` for Global, `qodercn.exe` or `qoderclicn.exe` for CN) when one is on PATH. Within one PATH folder the `.exe` wins over a same-name `.cmd`, and a later candidate name such as `qoderclicn.exe` anywhere on PATH wins over `qodercn.cmd`; an earlier PATH folder that holds only `qodercn.cmd` still shadows a later folder's `qodercn.exe`. Qoder turns pass empty `--tools` and `--setting-sources` values to keep the CLI's built-in tools and local settings out, and a dispatcher that forwards through PowerShell drops those empty values. When only such a dispatcher is found, the turn fails before the CLI starts with `cli_dispatcher_drops_empty_args`; add the folder holding the native executable to PATH (the official CN installer places it in `%USERPROFILE%\.qoder-cn\bin\qoderclicn`) and retry. A dispatcher that passes arguments straight through, such as an npm shim, keeps working.
- **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed.
+- **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix.
- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server.
+- **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD.
- **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied.
- **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI.
- **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred.
@@ -1183,15 +1439,28 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn:
variable (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry,
feedback and the auto-updater disabled, and `--tools ""`, `--strict-mcp-config` plus
`--setting-sources ""`. The harness loads no CLAUDE.md, skill, hook, plugin or MCP server from the
- machine and can neither read, write, exec nor browse. No session is persisted between turns.
+ machine and can neither read, write, exec nor browse. No session is persisted between turns. The
+ official CLI is found on `PATH`, or at the native install location (`~/.local/bin/claude`) when
+ a service starts OpenCodex with an older `PATH`.
- **System prompt:** the caller's system and developer prompts replace the Claude Code preset
(`--system-prompt-file`), so the turn answers the client's contract rather than the harness
persona. The folded prompt is staged in a private per-turn file (mode `0600`) and passed by path,
because process arguments are world-readable through process listing; a request that carries
- neither a system nor a developer prompt gets an empty file, which replaces the preset with nothing.
-- **Tool ownership:** v1 is text and reasoning only, exactly like the CodeBuddy and Qoder presets:
- with no tool channel, approval, sandboxing and execution stay with the client. The shared
- capture-only tool bridge is the documented follow-up.
+ neither a system nor a developer prompt gets only the fixed conversation-replay instructions.
+- **Conversation caching:** each message is replayed as a separate stable text block, with the
+ current message formatted identically to its later history representation. Long history is cut
+ only at whole-message checkpoints. Tool catalogs serialize deterministically. Claude Code
+ controls cache breakpoints, so this makes prompt-cache reuse possible without guaranteeing it.
+- **Tool ownership:** when the request carries tools, an isolated capture-only MCP catalog exposes
+ only the client's selected tools (the same bridge CodeBuddy uses). The CLI captures the call and
+ the turn ends there; approval, sandboxing, execution and the returned result stay with Codex,
+ Pi, or another client. Built-in tools and every other MCP server remain disabled.
+- **Subscription limits:** before each launch OpenCodex asks Anthropic's usage endpoint, with the
+ CLI's own sign-in read in memory, whether the plan's 5-hour, weekly or model-specific window is
+ full. An exhausted plan is refused locally with HTTP 429 `claude_subscription_cooldown` and a
+ `Retry-After` for the reset, instead of starting a CLI that would fail. The answer is cached for
+ a minute in `claude-usage-admission.json` under the OpenCodex home (percentages, reset times and
+ a hashed account key only). If the check cannot run, the turn proceeds.
- **Destination:** the canonical row names `https://api.anthropic.com` because that is where the
subscription's traffic lands. OpenCodex never sends that request itself, and overriding the base
URL fails closed rather than handing the turn to another environment.
@@ -1317,6 +1586,9 @@ visible even while live catalogs lag:
| Codex login (Pool or Direct) | `gpt-5.6-*` | 922,000 |
| OpenAI (API key) | `openai-apikey/gpt-5.6-*` plus `*-pro` | 922,000 (922,000 max input) |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 922,000 |
+| Codex | `gpt-6.1-terra` | 500,000 / 872,000 |
+| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 |
| Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1,000,000 |
The native GPT-5.6 entries preserve the pinned upstream reasoning ladders (for example, Luna has
@@ -1363,7 +1635,7 @@ seconds per call, including pacing and body reads. These are OpenCodex safeguard
GitHub limits; oversized catalogs, slow negotiation or a truncated intent excerpt can affect compatibility.
For named selections, Copilot fronts a mixed-wire catalog: the following models (`gpt-5.3-codex`, `gpt-5.4`,
-`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) reject
+`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) reject
`/chat/completions` for agent traffic, so opencodex routes those models over the
Responses API by built-in default while every other Copilot model stays on chat
completions. The precedence is: hard wire pin → your explicit
@@ -1439,8 +1711,17 @@ dashboard or `custom` in `ocx init` and enter the base URL. See the
Codex asks a second model to review approval requests, and takes that reviewer from
`auto_review_model_override` on the catalog row of the current turn's model. The root
-`auto_review_model` in `$CODEX_HOME/config.toml` applies one reviewer to every row. To give a
-routed provider its own — usually cheaper — reviewer, set the selector on that provider row in
+`auto_review_model` in `$CODEX_HOME/config.toml` is Codex's fallback. The dashboard's global
+**Auto-review override** in Model settings applies one reviewer to all catalog rows that do not
+have a provider-scoped reviewer. It stores `autoReviewOverride` in `~/.opencodex/config.json`; the
+setting must be enabled and name a model. An enabled setting with no model stays unapplied and
+leaves the Codex fallback in effect. A catalog refresh is a separate state: after a save it can
+stay pending, and the Models page reports it so you can retry with `ocx sync`. From the CLI, read
+the setting with `ocx models auto-review status` or
+change it with `ocx models auto-review set [--enabled ]`; use `-` to clear the
+model. CLI output recommends `ocx sync` when a refresh can be retried. If a refresh fails, the
+setting remains saved and the command exits 1. Resolve the failure before syncing. To give a routed
+provider its own — usually cheaper — reviewer, set the selector on that provider row in
`~/.opencodex/config.json`:
```json
@@ -1472,9 +1753,10 @@ no per-model override, leaving a valid provider-wide target as fallback. Whateve
keep the root selector, or upstream behavior when that is unset. Removing the root selector leaves
provider stamps alone, and removing a provider selector clears only that provider's stamps.
-These fields are available through configuration, `PATCH /api/providers?name=`, and
-the dashboard raw JSON provider editor; dedicated form controls are not present. The canonical `openai` provider
-rejects them. Field-by-field rules live in the
+Provider fields are available through configuration, `PATCH /api/providers?name=`, and
+the dashboard raw JSON provider editor; dedicated provider form controls are not present. The
+global override is available in Model settings. The canonical `openai` provider rejects provider
+reviewer fields. Field-by-field rules live in the
[provider configuration reference](/reference/configuration/providers/#auto-review-approval-model-selection).
## Rate limits in the providers overview
@@ -1487,7 +1769,14 @@ provider-specific) is already consumed.
Providers with a live probe: OpenAI/Codex, Anthropic, xAI, Cursor, Kimi,
Google Antigravity, OpenCode Go, OpenRouter, DeepSeek, ClinePass, Z.AI, MiniMax,
Moonshot, Venice, Synthetic, DeepInfra, Neuralwatt, Command Code, and any a6api-backed
-custom provider.
+or Abliteration custom provider.
+
+**Abliteration credits.** A provider of any name whose `baseUrl` is
+`https://api.abliteration.ai` or `https://api.abliteration.ai/v1` reads
+`GET https://api.abliteration.ai/v1/credits` with its active key as a Bearer token and does not
+follow redirects. `total_credits` minus `total_usage` is the remaining USD balance, shown as a
+credits bar of consumed spend. An account with no credits shows no bar. A rejected key clears the
+row; `503 credit_balance_unavailable`, 408, and 429 keep the last reading.
**OpenCode Go quota.** The canonical `opencode-go` preset reads
`GET https://opencode.ai/zen/go/v1/usage` with the configured key as a Bearer token and
diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md
index ab4e06cd75e..8dfd7f0723a 100644
--- a/docs-site/src/content/docs/guides/remote-hub.md
+++ b/docs-site/src/content/docs/guides/remote-hub.md
@@ -562,7 +562,7 @@ input checks.
opencodex does not publish an official container image. The repository does maintain a source-build
[`Dockerfile`](https://github.com/lidge-jun/opencodex/blob/main/Dockerfile),
[`compose.yaml`](https://github.com/lidge-jun/opencodex/blob/main/compose.yaml), and a narrow
-`.dockerignore`. The build pins the multi-platform Bun 1.4.2 image index by digest, runs the proxy as
+`.dockerignore`. The build pins the multi-platform Bun 1.4.4 image index by digest, runs the proxy as
the non-root `bun` user, keeps the root filesystem read-only, drops Linux capabilities, and publishes
only the data listener on the host's `127.0.0.1:10100` by default. The foreground process uses
`OCX_SERVICE=1`, so stopping or recreating the container preserves routed Codex state instead
diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md
index e3dbbf673e3..5fd0d404030 100644
--- a/docs-site/src/content/docs/guides/remote-link.md
+++ b/docs-site/src/content/docs/guides/remote-link.md
@@ -27,6 +27,8 @@ The dashboard does not ask you to enter a token. It probes the host first, and i
The Add Child sheet explains that the Child uses this Home's providers over SSH and lists the prerequisites above. Host aliases come from `~/.ssh/config` on the Home running OpenCodex, not necessarily the computer displaying the browser. If discovery succeeds with no hosts, add a `Host` entry like this, then choose **Rescan hosts**. You can also enter an existing alias manually; selecting or entering an alias enables **Test connection**. The sheet links to this guide.
+Host discovery also follows top-level `Include` directives. Parentheses and braces in an included filename stay literal when combined with wildcards: for example, `Include conf.d/*(1).conf` finds `backup(1).conf`, and `Include conf.d/shard-{1..3}-*.conf` looks for filenames containing `{1..3}`. An `Include` inside a `Host` or `Match` block is conditional and is not used for discovery.
+
```sshconfig
Host devbox
HostName devbox.example.com
@@ -49,6 +51,8 @@ Connecting restarts OpenCodex on this computer. Codex turns that are already run
The Child waits for its configured port while the old process releases it. If a CLI-managed restart still fails, run `ocx start` on the Child and check `~/.opencodex/restart-handoff.log`. In the desktop app, the app starts and supervises the replacement automatically.
+`ocx stop` on a Child stops its proxy and leaves Codex routing, the catalog and the Grok config in place, so `ocx start` (or `ocx service start`) brings the Child back unchanged. If `ocx status` on the Child reports that Codex routing is native, run `ocx sync --restart-codex`; `ocx start` does not rewrite a Child's Codex routing.
+
If **Child** says to pair this machine first, open this computer's configured literal-loopback HTTP dashboard, for example `http://127.0.0.1:`. The local pairing form appears only when pairing is missing and the dashboard and API use the same loopback origin. Copy the form's `ocx gui pair --origin "http://127.0.0.1:"` command, run it in a terminal on this computer, and paste the one-use code into the form. Use the exact origin shown in the form; a provider API key or admin token is not a pairing code. Missing pairing is separate from a configured-port mismatch.
On a Hub dashboard opened through a non-loopback address, the browser may have no loopback session or may have a shared session that cannot manage machine links. When Remote Link reports that session or permission problem, choose **Connect this dashboard to the hub** in the Remote Link page to open the one-time pairing form. Run the displayed command on the Hub and enter its code. The page validates the new session's Remote Link access before it closes the form.
@@ -59,12 +63,16 @@ The **Child** role also requires a standalone OpenCodex runtime running on its c
- **Connected** means the SSH tunnel is ready and the Child can use the Home link.
- **Reconnecting** means the tunnel is being retried. Requests can temporarily return `503` with `Retry-After` while the retry is in progress. On a Child that connected from its own dashboard, a request first waits up to 15 seconds for the tunnel to come back.
-- **Failed** means the link needs attention. Check SSH authentication, the confirmed host key, forwarding, or the timeout reason shown in the dashboard. The side that runs the SSH tunnel keeps retrying by itself, after sleep, an outage or a restart: Home for a Child it added, and the Child for a link it joined from its own dashboard. It retries about once a minute after a timeout or forwarding error, and every five minutes after an authentication error. A changed host key is never retried. To try again at once, select **Retry** in the Home's dashboard or run `ocx link reconnect --link-id ` on the Home. It restarts that link's tunnel without restarting OpenCodex.
+- **Failed** means the link needs attention. Check SSH authentication, the confirmed host key, forwarding, or the timeout reason shown in the dashboard. The side that runs the SSH tunnel keeps retrying by itself, after sleep, an outage or a restart: Home for a Child it added, and the Child for a link it joined from its own dashboard. After a timeout or forwarding error it retries after one minute and doubles the wait after each retry that fails again, up to five minutes; after an authentication error it retries every five minutes. A changed host key is never retried. To try again at once, select **Retry** in the Home's dashboard or run `ocx link reconnect --link-id ` on the Home. It restarts that link's tunnel without restarting OpenCodex.
A failed link does not silently switch to a local provider.
The Child returns a retryable `503` without forwarding the link key or request when its tunnel is failed, stopped, or not supervised.
It also keeps the key local when another process owns the tunnel port; free that port so the Child's SSH tunnel can bind it.
+On a Child, `GET /healthz` only says that OpenCodex is running. `GET /readyz` (and `ocx ready`) also says whether the link can carry a request right now: while it cannot, it answers `503` with `Retry-After: 1`, `"status": "pending"` and a `link` object such as `{ "state": "unavailable", "reason": "home_unreachable" }`, and while it can, `200` with `"status": "ready"` and `{ "state": "available" }`. The reasons are `tunnel_connecting`, `tunnel_failed`, `tunnel_idle`, `tunnel_unavailable`, `sidecar_invalid`, `home_unreachable`, `home_unauthorized`, `link_auth_failed`, `link_credential_unavailable` and `link_unverified`. A Child that joined from its own dashboard answers from its tunnel. A Child that its Home added has no tunnel of its own to look at, so it asks the Home through the tunnel with the same credential-free check every relayed request starts with; it repeats that check at most once every five seconds, and answers `link_unverified` when the Home has not replied within half a second.
+
+To confirm which Home is serving a Child, compare the `x-opencodex-served-by` header on the Child's `GET /v1/models` with the `instanceId` in the Home's `GET /healthz`. The Child passes the Home's header through unchanged, so the two match while the link works. The value is an opaque id, never a hostname; see [Home identity and the served-by header](/reference/management-api/#home-identity-and-the-served-by-header).
+
## What a Child shows and manages
A Child runs on the Home's providers, accounts and settings, so the Home is where you change them.
@@ -95,6 +103,20 @@ When a step fails, the dashboard shows the reason and, when SSH reported one, a
- **ocx was not found on the remote computer**: OpenCodex looks for `ocx` on the PATH of a non-interactive SSH session first, then in `~/.bun/bin`, `~/.local/bin`, `/opt/homebrew/bin` and `/usr/local/bin`. If it is installed elsewhere, add that directory to PATH in a file the remote shell reads for non-interactive sessions, such as `~/.zshenv` for zsh.
- **OpenCodex on the remote computer is too old**: run `ocx update` on that computer. Remote Link needs 2.66.0 or later.
- **The remote computer did not report an OpenCodex version**: `ocx --version` on that computer printed something else, for example the usage text of an unsupported Windows install.
+- **Requests fail with `home_unreachable` while the Home is down**: a Child never serves requests from its own providers. When the tunnel is down before a request is forwarded, the Child answers a retryable 503 and sends nothing to the Home. If the tunnel drops after forwarding started, the answer is also `home_unreachable`, but the Home may already have received the request. A failed link relay authentication answers `link_relay_auth_failed` instead: upgrade the Home and Child or re-link. Whether GOAT may move that device's work to its own sign-ins is the per-device local fallback permission on the Home's **Remote Link** page, or `ocx fallback local on --device `; see [Fallback off](/guides/fallback-off/).
+- **A Home link stays Failed with a forwarding error after the Home slept or changed networks**: run `ocx link status --json` on the Home. Its `tunnel` field shows the attempts since the link was last connected, `nextRetryAt`, and the last failure. A `lastFailure.cause` of `remote_forward_conflict` means the Child's SSH server still holds the tunnel port for the session that died with the Home's old connection, so every new tunnel is refused until it lets go. OpenCodex keeps retrying and connects on its own once the port is free; it never stops processes on the Child. The Child's SSH server notices a dead session only when it probes it. Without `ClientAliveInterval` in the Child's `sshd_config`, that can take as long as the operating system's TCP keepalive (two hours by default on macOS). Setting `ClientAliveInterval 15` and `ClientAliveCountMax 3` there makes it drop such a session within about a minute.
+
+### Retryable 503 diagnostics
+
+A relay-generated `503` includes a server-generated `x-opencodex-link-request-id`. Use it to correlate a failure with local evidence under the Child's OpenCodex configuration home: `link/diagnostics.jsonl` and one rotated backup, `link/diagnostics.jsonl.1`. Each file is capped at 256 KiB, with private permissions (directory 0700 and files 0600 on POSIX; private ACLs on Windows). Records contain only fixed event/outcome/state categories, generated correlation IDs, numeric timing/generation/attempt metadata and timestamps, never secrets, request bodies, headers, URLs, account identifiers or raw errors. Each event/outcome pair is limited to one record per second; the next record includes a count of suppressed events, so an absent record does not prove an absent failure. A backward wall-clock adjustment starts a new rate-limit window, including when the writer queue is full, and preserves the suppression count. An asynchronous writer keeps at most 56 pending records, counts overflow as suppression, and yields while Windows ACL work runs. Logging is best effort and cannot change whether a request is admitted. Graceful signal shutdown and standalone recycling wait up to one second for queued diagnostics before exiting; stalled storage, forced termination and power loss can still lose records.
+
+`owner_lookup` records refused ownership checks; routine successful checks are silent. `owner_lookup.elapsedMs` measures the owner lookup itself. For `auth_timeout`, `relay_retry` and `relay_failure` report the actual transport-attempt duration in `elapsedMs` (normally about five seconds). A `relay_recovered` event with outcome `recovered` is emitted only after a retry receives an upstream response with `upstream.ok` (HTTP 2xx); it does not prove the streamed body completed. Rate limiting makes request-ID correlation sampled evidence, not a guaranteed trace of every failed request.
+
+The Home writes the same kind of file under its own configuration home for the tunnels it runs. Each change of a Home-initiated tunnel's state is a `tunnel_transition` record with `previousState`, `state`, the cause as `outcome` (`auth`, `hostkey`, `forward`, `remote_forward_conflict`, `timeout`, `network`, `unknown`, `spawn_failed` or `listener_unavailable`; `connected`, `spawned` or `stopped` when nothing failed), the `attempt` count since the link was last connected, the link's `tunnelPort` and, when a retry is scheduled, `retryAfterMs`. A retry of a failed link that fails again is recorded too, so an outage leaves one record per attempt. The same rate limit applies, across all links.
+
+Before sending data, OpenCodex allows one shared preflight retry for either an owner-lookup deadline or a specifically identified five-second authentication deadline. The retry obtains fresh owner proof where required and a fresh physical socket, then authenticates that socket before sending the link credential or body. It does not reuse cached owner approval, bypass security checks, restart SSH or OpenCodex, or replay a dispatched request body. The two-second owner-lookup and five-second authentication deadlines are unchanged for each attempt; other failures do not qualify for this preflight retry.
+
+This can recover these transient deadline paths and helps diagnose future failures. It does not establish the historical cause of [#7297](https://github.com/lidge-jun/opencodex/issues/7297).
## Security
@@ -123,6 +145,25 @@ Update both the Home and Child when upgrading to connection-bound relay authenti
Removing the final Home link drains pending authenticated relay requests before releasing its listener. Stopping the process still cancels active connections. This does not change which caller credentials are stripped or which routes can be relayed, and it does not replace SSH's host-key verification.
+### Home and Child versions
+
+On the Home, `ocx link status --json` reports the Home's own `local` version, relay authentication version and features, and gives every link a `peer` block. Nothing is read over SSH: the Home learns about a Child from the relay authentication check the Child sends before each relayed request. From 2.91.0 that check also carries the Child's version and feature list. The Home keeps what it learned in memory, so after a Home restart a link reads `unknown` until its Child relays a request again.
+
+| Part of the link | First release |
+|---|---|
+| `relay-auth/2`, connection-bound relay authentication | 2.69.0 |
+| `link-management-relay`, read-only Home management on the Child | 2.84.0 |
+| `link-peer-report/1`, the Child reports its version and features | 2.91.0 |
+
+`peer.compatibility` is one of:
+
+- `compatible`: the relay authentication versions match, and the Home serves every feature the Child reports. A Child older than 2.91.0 reports no version; `peer.version` is then `null` and `peer.versionRange` gives the range its relay authentication implies, for example `{ "min": "2.69.0", "below": "2.91.0" }`.
+- `degraded`: the Child reports a feature this Home does not serve. Upgrade the Home.
+- `incompatible`: the Child asked for a relay authentication version this Home does not speak, so its relayed requests are refused. `peer.reasons` names the side to upgrade.
+- `unknown`: the Child has not relayed a request since the Home started, or it sent a version or feature list the Home could not read, so its features cannot be checked.
+
+The version a Child reports is its own claim, bound to its link key. OpenCodex uses it for display and never for admission. Older `ocx` installations keep receiving the previous status document; only a 2.91.0 or newer CLI asks for these fields.
+
### Standalone pairing authorization
@@ -142,6 +183,12 @@ On the Home, run `ocx link probe child-a --json` to inspect the offered SSH fing
Home-initiated connections now restart the Child after saving its connection, including capable older Child installations. The Child must already serve standalone OpenCodex on its configured port, expose bound restart support, and have curl installed. Update or disconnect a Child that fails this preflight. Desktop and system OpenCodex installations take precedence over generic PATH shims; broken candidates are skipped. Windows Children remain unsupported.
+### Windows computers
+
+`ocx link add` and the dashboard refuse a Windows computer during the probe or host confirmation, before any link key is issued or tunnel started. The refusal covers Windows OpenSSH with Git Bash, MSYS2 or Cygwin as its shell (`uname -s` reports MINGW, MSYS or CYGWIN) and with cmd or PowerShell as its shell. Connect a Windows computer in Hub mode instead: run `ocx hub invite` on a Hub and run the printed `ocx connect` command on the Windows computer.
+
+A Windows Child linked by an older release keeps its link. Run `ocx link cleanup` on the Home to find such links; add `--link-id ` to check one, or `--json` for a machine-readable report. It asks each Home-initiated Child for its platform over the link's pinned SSH trust and changes nothing. For each Windows Child it prints the recovery order: run `ocx disconnect` and `ocx restart` on the Windows computer, then `ocx link revoke --link-id --force --yes` on the Home, then connect it in Hub mode. A plain revoke fails on these Children because its remote disconnect runs a POSIX command that Windows refuses.
+
Restart waits for running turns and may take several minutes. Success requires the restarted Child to serve the client role and relay a fresh complete catalog to this Home. SSH setup has a bounded allowance derived from measured latency; relay verification starts after restart with its own 15-second window. Errors name the failing phase. If an error reports residual Child configuration, run `ocx disconnect` and `ocx restart` on that Child before retrying. Home cleanup alone does not restore Child configuration. A CLI timeout reports an uncertain outcome; inspect Home `ocx link status` and Child `ocx connect status` first.
On a Home-initiated Child, `ocx link status` reports role `child`, link identity and tunnel port from its saved connection even though its Home-side link store is empty. Locally unobserved tunnel state is `idle`. `ocx status` calls the peer a Remote Link Home. When Home runs in Hub mode, status reads its supported projection through the local Child relay. A standalone Home reports that it does not publish Hub state. Child-initiated tunnel identity comes from its valid sidecar; unreadable evidence does not claim a direction.
diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md
index 939ea624de9..ade9bc5ed6b 100644
--- a/docs-site/src/content/docs/guides/remote-workspace.md
+++ b/docs-site/src/content/docs/guides/remote-workspace.md
@@ -167,10 +167,35 @@ to another computer or folder silently. If the Executor disconnects, the session
Prompt submission acknowledges acceptance immediately; the dashboard polls the session for progress and completion. If the acknowledgement is lost, the draft remains visible with an unknown-submission notice. Check session progress before sending it again; the dashboard never retries a prompt automatically.
+If the Pi process exits or its extension fails while a turn is starting, the session reports the failure even if Pi has not acknowledged that turn yet.
+
**Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn,
cancels an active Executor command, and prevents a late response from reopening the stopped
session.
+## Work with sessions side by side
+
+The session list on the left opens a session in the focused pane. Drag a session to the left,
+right, top, or bottom edge of another pane to split the conversation area. You can show up to
+four different sessions at once. An already visible session moves between panes instead of
+opening a duplicate composer.
+
+Use **Split right** or **Split below** next to a session for the keyboard-accessible alternative.
+Drag a divider to resize the panes, or focus the divider and use its arrow keys. **Home** and
+**End** set the divider to its minimum and maximum size. Each session
+keeps its own draft, submission state, and Stop action, so a pending turn in one pane does not
+block a different session. **Close pane** changes the layout only; it does not stop the session.
+
+The pane layout is restored for this Hub in the same browser. Only session identifiers and pane
+positions and divider sizes are saved; draft text and model output are not written to browser storage. Reloading
+the page clears unsent drafts. On screens up to 760px wide, panes stack vertically without
+resize dividers; the saved split positions and sizes return when the screen is wider.
+
+This view is shared by the browser dashboard and the OpenCodex desktop dashboard. CLI users
+can open the dashboard with `ocx gui` and select **Remote Workspace** after its normal Hub setup.
+It does not change the terminal interface of `codex` or the conversation layout inside the
+separate official ChatGPT/Codex desktop app.
+
## Restart and reconnect behavior
The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart,
diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md
index 3d35989872b..e25d49dbfe7 100644
--- a/docs-site/src/content/docs/guides/sidecars.md
+++ b/docs-site/src/content/docs/guides/sidecars.md
@@ -18,6 +18,11 @@ closed. Explicit `xai` requires a usable stored Grok OAuth account and does not
requires both ChatGPT login auth and an enabled `forward` provider.
:::
+On file load, a retired OpenCode Free model target in global or `claudeCode` overrides of
+`visionSidecar` or `webSearchSidecar` is removed and that sidecar is explicitly disabled, retaining
+its other settings. Select a supported target before re-enabling it. Healthy targets and the defaults
+for absent sidecar sections are unchanged.
+
### Additional web-search backends (explicit-only)
Three more web-search backends exist beyond the ChatGPT and Claude paths. Each is
@@ -138,16 +143,39 @@ failures after response headers have started are delivered as `response.failed`
## Vision sidecar
+Mistral's `zai-glm-5-3` is enrolled by the provider preset as text-only. With the
+vision sidecar enabled and available, OpenCodex describes attached images and
+sends their text descriptions to GLM. The Codex catalog advertises image
+attachments for this route. After an update, run `ocx sync`, then fully quit and
+relaunch the Codex desktop app or reload the VS Code window. Sync without a
+restart flag does not restart an existing Codex app-server, which can retain
+the old model list. Alternatively, use `ocx sync --restart-codex` to sync and
+restart matching Codex processes and the desktop app. Finish active work first:
+this restart ends live conversations. See the [sync and restart reference](/reference/cli/lifecycle/#ocx-sync---restart-codex---restart-app-server-only).
+After relaunch, select `mistral/zai-glm-5-3` and verify that image attachments
+are available. No model modality override is needed. Mistral documents native input in its
+[GLM 5.3 reference](https://docs.mistral.ai/models/zai-glm-5-3).
+
Image routing is capability-aware. Before an image-bearing upstream send, opencodex resolves the selected model's effective input modalities from runtime provider evidence, explicit operator declarations, backend/registry metadata, and generated vendor metadata. A target positively known to be text-only goes through the Vision Sidecar first; the image is described **before** the main call and replaced inline with text. A target positively known to support images receives the image directly. Unknown custom models keep the existing compatibility behavior rather than being guessed text-only.
For the canonical ChatGPT Codex route, opencodex uses the `openai-codex` metadata bundle rather than public OpenAI API metadata, so backend-specific modality differences are respected. The native Chat fast path uses the same gate and cannot bypass a known text-only verdict. Without an available sidecar plan, raw images are stripped before a proven text-only backend.
-Combos advertise image input only when every member accepts images, either natively or through a
+With `visionSidecar.enabled: false`, the model catalog does not add sidecar-backed image input to
+text-only models, including custom rows and combo members. Native image support and explicitly
+configured image modalities are preserved. Omitting `enabled` keeps sidecar advertisement on;
+catalog visibility does not establish that sidecar credentials or capacity are available.
+Combos advertise image input only when every member accepts images, either natively or through an enabled
sidecar, and the combo's `imageInput` setting is not disabled, so clients such as the Codex app
allow attachments instead of blocking them before the sidecar runs. When
`visionSidecar.model` is absent or blank, the OpenAI execution path, Dashboard, and management API
use the `gpt-5.6-luna` fallback. Startup still migrates an explicitly persisted legacy
`gpt-5.4-mini` value to `gpt-5.6-luna`; that migration applies to a stored value, not to an absent
model field.
+Saving a multimodal combo from the dashboard enrolls its non-image members automatically: the
+Combos page sends them as `visionSidecarTargets` on `PUT /api/combos`, which writes the exact
+text-only declaration on the member's provider, so no manual config edit and reload is needed.
+Enrollment never overwrites an existing image-capable declaration, and members whose modalities are
+unknown or have no text input are not enrolled — the dashboard names them and keeps the combo's
+image switch unavailable.
The first-party DeepSeek `deepseek-flash` model is native multimodal (`text` and `image`) and does
not use this sidecar by default. Explicit `noVisionModels` or text-only declarations remain
authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` remain
@@ -222,6 +250,10 @@ timeout, and limit.
omitted keys unchanged. `timeoutMs` uses the runtime integer bounds
(1–2147483647 ms).
+When the effective Vision switch changes, saving also refreshes the managed Codex catalog and
+model cache. The response reports `catalogRefreshPending: true` if that refresh did not complete;
+the saved switch is retained, and catalog publication does not confirm that a running client has reloaded it.
+
The web-search sidecar card carries the same control shape: the model picker's first row is
**Off**. Off does two things, and the second one is the reason the row exists. OpenCodex stops
intercepting `web_search`, and the Codex integration writes Codex's own
diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md
index 67ec9f1a8ad..d0d52fee5ba 100644
--- a/docs-site/src/content/docs/guides/sub-agent-surface.md
+++ b/docs-site/src/content/docs/guides/sub-agent-surface.md
@@ -79,6 +79,60 @@ For a v2 roster, eligibility has three states: an entry stamped `"v2"`, explicit
with no `multi_agent_version` field is eligible. A genuine `"v1"` pin is excluded because it states
that the model belongs to the other collaboration surface.
+## Which models `spawn_agent` advertises
+
+Codex lists at most five models in the `spawn_agent` tool description, and it chooses them
+itself: it sorts the picker-visible catalog rows by `priority`, lowest first, and takes the first
+five. The roster on the **Subagents** page (`subagentModels`) shapes that list only by giving its
+models the lowest priorities in roster order (`0` through `4` without account selectors). It
+does not limit the list to the roster.
+
+When you feature one to four models, Codex fills the remaining slots with the next rows by
+priority. You did not choose those rows, and the dashboard does not show which ones they are. They
+depend on the catalog:
+
+| Catalog | Rows that fill the remaining slots |
+| --- | --- |
+| No Codex account selectors | Other routed `provider/model` rows. They all share priority `5`, so they appear by provider name and then model id, alphabetically. Unfeatured native GPT rows sit at priority `100` plus the number of featured ids or higher, so they come later. |
+| Codex account selectors | Account-qualified native rows (`/`), which directly follow the featured rows. Unfeatured routed rows move to priority `1005` and come after them. |
+
+For example, with no account selectors and this roster:
+
+```json
+{ "subagentModels": ["xai/grok-4.5", "anthropic/claude-sonnet-5"] }
+```
+
+| Slot | Model | Priority | Why |
+| ---: | --- | ---: | --- |
+| 1 | `xai/grok-4.5` | `0` | First roster entry |
+| 2 | `anthropic/claude-sonnet-5` | `1` | Second roster entry |
+| 3 | `deepseek/deepseek-v4-flash` | `5` | First remaining routed row alphabetically |
+| 4 | `google/gemini-3-pro` | `5` | Next routed row |
+| 5 | `moonshot/kimi-k3` | `5` | Next routed row |
+| 6+ | `zai/glm-5.2`, then native GPT rows | `5`, then `102` | Outside the five |
+
+With an empty roster, opencodex pins nothing and the five follow the catalog's normal
+priorities.
+
+The filled slots are ordinary advertisements, so the main agent may pick one of those models.
+The list is also not an allowlist: Codex accepts a catalog model outside the five when the main
+agent names it exactly. To control what Codex advertises:
+
+- Feature five models. With a full roster, no slot is left to fill.
+- Disable a model you never want offered, or leave it out of its provider's `selectedModels`.
+ A disabled or unselected model leaves the picker and cannot fill a slot.
+- `modelPickerOrder` changes the priorities Codex sorts by. A routed-only list moves the listed
+ unfeatured rows to `1000` and up, so the unlisted routed rows fill the slots first. A list that
+ contains a bare native id orders the complete picker, featured rows included: listed rows come
+ first and take the advertised slots before any unlisted row. See
+ [Model ordering](/guides/model-ordering/).
+
+On v2, Codex skips rows pinned `"disabled"` before it takes the five; otherwise the window works
+the same way. The opencodex v2 guidance roster (`{{roster}}`) names only configured roster models
+inside the five, so filler models appear in Codex's tool description and never in opencodex
+guidance. Codex reads the catalog when a session starts, so a roster change applies to new
+sessions.
+
## Delegation model and effort
The dashboard's **Sub-agent delegation** controls three related settings:
@@ -137,9 +191,12 @@ opencodex no sign that v1 is available, so the guidance is not added there.
The default-off `syncCodexSubagentDefaults` option is separate from guidance. When opencodex owns
active Codex routing, sync or restart can write the selected values as marker-owned
`[agents] default_subagent_model` and `default_subagent_reasoning_effort` entries in Codex TOML.
-opencodex updates or removes only fields bearing its markers. If either target field is user-owned,
-the pair is left unchanged rather than partially written; ambiguous TOML is rejected without a
-write. External provider managers and user-owned root routing also remain authoritative.
+opencodex updates or removes only fields bearing its markers. If you edit a managed value in place,
+the next sync keeps your value, drops the marker, and treats the field as yours from then on.
+Restore also keeps those edits when they recreate previously injected file contents. If
+either target field is user-owned, the pair is left unchanged rather than partially written;
+ambiguous TOML is rejected without a write. External provider managers and user-owned root
+routing also remain authoritative.
## Fallback chains
@@ -306,6 +363,12 @@ a positive partial count before passing a model or effort override.
It may be picker-hidden, outside the five-model display limit, missing from the catalog, or pinned
to v1. A `"v2"`, `null`, or absent surface value is eligible; a real `"v1"` pin is not.
+### Why does `spawn_agent` list models I did not feature?
+
+Codex fills up to five advertised slots from the catalog by priority. With fewer than five
+featured models, the next rows take the remaining slots; see
+[Which models `spawn_agent` advertises](#which-models-spawn_agent-advertises).
+
### Do mode changes affect running sessions?
No. Start a new Codex session after changing the mode. If a long-running App host still shows stale
diff --git a/docs-site/src/content/docs/guides/subagent-v1-default.md b/docs-site/src/content/docs/guides/subagent-v1-default.md
index 5f4fd79b58f..ab8c6654c6f 100644
--- a/docs-site/src/content/docs/guides/subagent-v1-default.md
+++ b/docs-site/src/content/docs/guides/subagent-v1-default.md
@@ -130,3 +130,13 @@ ocx v2 mode v1
```
The CLI does not prompt. It is the same setting, so choose it knowing what this page describes.
+
+## Concurrent child limit
+
+In **Models → Model settings**, the subagent limit control sets the maximum number of concurrent
+child agents, excluding the primary agent. It remains available in v1, base and v2, including
+hybrid mode with the global v2 flag disabled. Choose the surface mode separately from this limit.
+
+Raising the cap permits more children; it does not create them automatically or guarantee faster
+execution. Start a new Codex session after changing the limit. If a long-running App host still
+uses the previous limit, restart that host.
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index d808779cd34..0792854aa24 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -71,7 +71,7 @@ host and port over a LAN IP or an alias.
## Dashboard layout
-The sidebar has eight rows, in order: **Dashboard**, **Connect**, **Codex**, **Providers**, **Models**, **Subagents**, **Usage & Logs**, and **Remote Link**. **Connect** opens the page headed **Connect**, with its tab strip ordered **Codex**, **Claude**, **Claude Desktop**, **Grok Build**, then the remaining integrations. Claude has no sub-tabs; it shows the Claude Code settings directly. Claude Desktop is a separate Connect tab at `#claude/desktop`. Connect has no section switcher. **Usage & Logs** opens Usage and groups Usage, Logs & Debug, and Storage with a pill-shaped section switcher. The last row, **Remote Link**, groups Remote Link (`#remote`) and Remote Workspace (`#remote-workspace`, when available) with the same switcher. **Codex** is the renamed **Codex Set** row; the preserved URLs include `#codex-set`, `#claude`, `#claude/code`, and `#claude/desktop`; the former `#claude/settings` bookmark opens Code. The former `#claude/account` bookmark redirects to `#providers?provider=anthropic&tab=accounts`. Startup (`#startup`) stays outside the sidebar.
+The sidebar has eight rows, in order: **Dashboard**, **Connect**, **Codex**, **Providers**, **Models**, **Subagents**, **Usage & Logs**, and **Remote Link**. **Connect** opens the page headed **Connect**, with its tab strip ordered **Codex**, **Claude**, **Claude Desktop**, **Grok Build**, then the remaining integrations. Claude has no sub-tabs; it shows the Claude Code settings directly. Claude Desktop is a separate Connect tab at `#claude/desktop`. Connect has no section switcher. **Subagents** opens Subagents and groups Subagents and Advisor (`#advisor`) with a pill-shaped section switcher. **Usage & Logs** opens Usage and groups Usage, Logs & Debug, and Storage with a pill-shaped section switcher. The last row, **Remote Link**, groups Remote Link (`#remote`) and Remote Workspace (`#remote-workspace`, when available) with the same switcher. **Codex** is the renamed **Codex Set** row; the preserved URLs include `#codex-set`, `#claude`, `#claude/code`, and `#claude/desktop`; the former `#claude/settings` bookmark opens Code. The former `#claude/account` bookmark redirects to `#providers?provider=anthropic&tab=accounts`. Startup (`#startup`) stays outside the sidebar.
Responses first-output timing includes streamed function arguments and custom-tool input, as well
as text and reasoning. A tool-only turn can therefore have a first-output time even without prose.
@@ -111,9 +111,10 @@ is visible) and never forces an upstream refresh.
| Area | What it does |
| --- | --- |
| **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, active providers, and available native/routed models. |
+| **Native reasoning retention** | Independently opt into reasoning ciphertext forwarding on native model or account changes. Both default off; account retention is experimental. See [the configuration scope](/reference/configuration/server/#native-reasoning-retention). |
| **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by OpenCodex delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. |
| **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. |
-| **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. In the desktop shell, its update entry opens the native app update page instead of running the package updater. |
+| **Maintenance** | Resync the Codex model catalog, inspect project-local config bypass warnings, check the latest or preview release, and run an update with optional proxy restart. With restart on, Codex routing and its model catalog stay in place while the package is replaced, so a Codex app that loads them during the update still sees your routed models; the restarted proxy takes them over, and `ocx stop` restores native Codex if it never comes back. In the desktop shell, its update entry opens the native app update page instead of running the package updater. |
| **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. |
| **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. |
| **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. |
@@ -148,6 +149,9 @@ its request and attempt failure cause is `transport-ambiguous`, with resend perm
`refused-ambiguous`: the upstream may already have processed the turn. A genuine provider
429 remains `rate-limit` with resend permission `permitted`.
+A Codex move to another account after HTTP 402 appears as **Account quota exhausted (402)**
+in the attempt's recovery details and counts as a quota recovery in exported metrics.
+
Logs filters combine surface, intercepted requests, provider, exact model, status, time,
speed, and conversation ID over the currently loaded request ring. Provider and model
choices also include fallback attempts; model matching ignores case and surrounding spaces
@@ -203,7 +207,13 @@ The **Usage** summary and Models/Providers tables show end-to-end output through
The **Usage** Models and Providers tables show the estimated priced portion for each row. Requests
without a matching price or usable usage are counted as excluded beside that amount when the proxy
-reports pricing coverage fields. A row with only excluded requests shows an em dash with that count
+reports pricing coverage fields. Usage that was only partly measured (`unreported` or `unsupported`)
+is not priced, and one excluded part excludes the whole request from that row's priced count. A
+leg that reached no upstream, or that upstream refused with an HTTP error before any output, spent
+nothing and does not; a client cancellation (499), or a leg whose response began or ended without
+a status but reported no usage, may have been billed, so it does. A refusal followed by a measured
+successful send contributes that send's cost to the summary even when the aggregate usage status
+is `unreported`. A row with only excluded requests shows an em dash with that count
instead of a zero-dollar estimate, and a displayed zero-dollar value is a confirmed priced estimate.
An older proxy that reports no coverage fields keeps a bare em dash with no count, so missing
coverage stays distinct from an all-excluded row and from a confirmed zero estimate.
@@ -470,3 +480,30 @@ them. Both switches apply immediately; **Save** never changes them. When interce
Claude/Anthropic accounts are managed only on **Providers > Anthropic > Accounts**: login and the browser option, the account roster with switch, pause, remove, and reauthentication, the paste-code field, account pool settings, and quota. Claude no longer has an Account sub-tab. Provider-level controls such as the default provider, removal, and the enabled switch stay on Providers.
The bookmarks `#claude` and `#claude/code` open this page, and the former `#claude/settings` redirects to `#claude/code`; `#claude/desktop` selects the separate Claude Desktop tab. The old `#claude/account` bookmark redirects to `#providers?provider=anthropic&tab=accounts`. Legacy `#integrations/claude` and `#integrations/claude/desktop` bookmarks still redirect to `#claude/code` and `#claude/desktop`, respectively. Arrow keys move between tabs; Home and End select the first and last tab.
+
+
+## Test one provider model
+
+In **Providers**, **Test connection** checks discovery connectivity. Use the
+separate **Test completion** control to check whether a selected model answers.
+Choose a model and accept the confirmation that the request may incur provider
+charges. Cancelling the confirmation sends nothing. Changing the provider or
+model, or leaving the page, cancels the test and discards a stale result.
+
+The completion test sends `Reply exactly OK.` once with a 32-token output cap,
+a 20-second deadline and a 64 KiB stream limit. It reports TTFT at the first
+non-empty text delta, total latency, the upstream-reported model and a match,
+mismatch or unknown comparison against the wire model id. Missing or conflicting
+metadata is unknown; upstream metadata does not verify model identity. The panel
+shows the applicability reason when authentication, adapter or transport settings
+cannot be tested. No generated text is displayed, and the diagnostic does not
+update routing health, request history or the provider catalog.
+
+The authenticated management endpoint is
+`POST /api/providers/test-completion?name=` with exactly
+`{"model":"","confirm":true}`. Dashboard sessions require their usual CSRF
+token. Invalid input returns 400, unknown providers return 404 and a concurrent
+test for the same provider returns 409. Completed diagnostics and applicability
+results return 200, including a structured error when inference fails. Destination
+policy and provider egress settings apply; redirects, retries and failover are disabled.
+For the CLI workflow, see [Providers and accounts](/reference/cli/providers-accounts/).
diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md
index 5a82e0e6073..9ad672176ec 100644
--- a/docs-site/src/content/docs/ja/contributing.md
+++ b/docs-site/src/content/docs/ja/contributing.md
@@ -175,6 +175,10 @@ versioned disposition は `passthrough`、`translated`、`degraded`、`unsupport
`passthrough` 以外のクレームには具体的な制限を記載し、fixture に基づくクレームでは根拠となる正確な
assertion id を指定してください。秘密情報を含まない request vector を `tests/fixtures/compatibility/` に
追加し、production adapter に対して実行する集中テストを用意します。
+`passthrough` claim が引用できるのは、manifest test でその feature に紐付けられた selector 上の
+`equals` assertion で、期待値が同じ位置の呼び出し元の値そのものであるものだけです。これにより、削除・
+書き換え・無関係なフィールドを lossless と表示できません。custom tool とその grammar のように、片方だけ
+保たれうる側面は別々の claim にしてください。
compatibility manifest は受動的なデータです。通常の router、Responses handler、server startup path から
manifest catalog を import したり、Compatibility Lab を有効化したりしてはいけません。
diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md
index 2294fc1d541..216e3891dac 100644
--- a/docs-site/src/content/docs/ja/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md
@@ -18,7 +18,7 @@ ocx init
`ocx init` では次の手順を説明します。
-1. **プロバイダーを選択してください** — 104 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。
+1. **プロバイダーを選択してください** — 106 個の組み込みレジストリプリセットのいずれか、または `custom` を選択してベース URL とアダプターを入力します。
2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。
3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。
4. **プロキシ ポート** — デフォルトは `10100` です。
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 4d1cd5938b4..8476e7eb932 100644
--- a/docs-site/src/content/docs/ja/guides/claude-code.md
+++ b/docs-site/src/content/docs/ja/guides/claude-code.md
@@ -166,21 +166,21 @@ unknown は設定がまだ opencodex のプロキシを指すか判断できな
### Picker モード: 1P の Code タブで opencodex モデルを表示する
-Picker モードは 1P モードの一部です。macOS で 1P を選ぶとデフォルトで有効になりますが、
+Picker モードは 1P モードの一部です。macOS と Windows で 1P を選ぶとデフォルトで有効になりますが、
`claudeCode.intercept.picker: false` を設定した場合は無効です。1P の Desktop の Code タブにある
モデルピッカーを書き換え、利用できる opencodex モデルを名前付きで表示します。初回の有効化時は、
-macOS がログインキーチェーン内のローカル証明書認証局を信頼するよう求めることがあります。この認証局の
+macOS がログインキーチェーン内、Windows が現在のユーザーのルートストアでローカル証明書認証局を信頼するよう求めることがあります。この認証局の
制約は `claude.ai` とそのサブドメインに限られます。エクスポート可能な署名用 ID は OS の資格情報ストアで
保護され、通常の再起動でも同じ証明書と鍵を再利用します。OpenCodex の設定ディレクトリには Picker の
-署名鍵を平文で保存しません。制約付き CA の完全な検証と OS の信頼確認は引き続き必要です。
-承認済みの同じ ID と利用可能な資格情報ストアがあれば、再起動で証明書の信頼設定を追加・削除しません。
-起動時の復元は信頼をインストールしません。信頼が未設定、取り消し済み、不明の場合は保留となり、
-`ocx claude desktop picker on` または `ocx claude desktop picker trust` を明示的に実行して信頼を付与します。
+署名鍵を平文で保存しません。制約付き CA の完全な検証と OS の信頼確認は引き続き必要です(Windows では正確なフィンガープリントと検証済みリーフで確認します)。
+承認済みの同じ ID と利用可能な資格情報ストアがあれば、再起動で macOS の証明書の信頼設定を追加・削除しません。
+起動時の復元や状態確認は信頼をインストールしません。信頼が未設定、取り消し済み、不明の場合は保留となり、
+`ocx claude desktop picker on` または `ocx claude desktop picker trust` を明示的に実行して信頼を付与します(Windows では確認が表示されることがあります)。
旧 ID からの一度限りの移行では、以前の信頼を削除するために同意が必要になる場合があります。
削除が完了するまでは Picker は利用できず、適用済みプロファイルは復号しない中継を使います。
キーチェーンのロック解除やアプリの資格情報へのアクセス許可は macOS 独自の動作で、再起動や更新時にも
-表示される場合があります。Windows と Linux は Picker 非対応で、Picker の CA、資格情報ストア、
+表示される場合があります。Windows では `on` や `trust` の明示的な実行時にルート追加の確認が表示されることがあります。Linux は Picker 非対応で、Picker の CA、資格情報ストア、
プロキシの処理を開始しません。通常の Claude インターセプトは利用でき、ローカル CA ファイルには
所有者、シンボリックリンク、ファイル権限、Windows ACL の検査が適用されます。
@@ -535,7 +535,7 @@ Messages → Responses に変換されるリクエストでは、Claude Code の
| Assistant `tool_use` | `function_call`(`input` → JSON 文字列に変換した `arguments`) |
| ユーザー `tool_result` | `function_call_output`(`is_error` → `[tool error]` 接頭辞) |
| `thinking` / `redacted_thinking` 再生 | シグネチャと秘匿ペイロードを境界付き `ocxr1` エンベロープに保持した `reasoning` 項目 |
-| Function ツール | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) |
+| Function ツール | `{type: "function"}`(`web_search*` → `{type: "web_search"}`). `api.anthropic.com` への翻訳ルートでは、型付き `web_search_*` と `web_fetch_*` 宣言も Anthropic に送信され、サーバー側で実行されます。ただし Web 検索サイドカーが `web_search` を提供する場合はそちらが優先されます。検索と取得の結果は `server_tool_use` と対応する `web_search_tool_result` / `web_fetch_tool_result` としてクライアントに返されます |
| `tool_choice` | `auto`→`auto`、`none`→`none`、`any`→`required`、名前指定関数→`{type:"function",name}`、ホスト型 WebSearch/web_search→`{type:"web_search"}` |
| `max_tokens` | `max_output_tokens` |
| `stop_sequences` | `stop` |
diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md
index 63d62a27364..ef99c820f3a 100644
--- a/docs-site/src/content/docs/ja/guides/codex-app-models.md
+++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md
@@ -52,10 +52,11 @@ visibility = "list"
|ルート |ピッカー ID とカタログのメタデータ |
| --- | --- |
-| Codex ログイン (account-qualified 行が無効) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 922,000 トークンです。 |
+| Codex ログイン (account-qualified 行が無効) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 922,000 トークンです。 `gpt-6.1-terra`:既定 500,000 / 長文 872,000。 |
| Codex ログイン (account-qualified 行が有効で、有効な selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 |
-| OpenAI (API キー) |正確に 8 つの名前空間行: `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、および 3 つの `*-pro` 仮想 ID (コンテキスト 922,000、8 つすべての最大入力 922,000) |
+| OpenAI (API キー) | 名前空間付きモデルには `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、`*-pro` 仮想 ID、Daybreak エイリアスが含まれます(コンテキスト 1,050,000、最大入力 922,000)。`openai-apikey/gpt-6.1-terra` はコンテキスト 1,050,000、最大出力 128,000 です。 |
|オープンルーター | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` (922,000) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) |
| Cursor | 静的フォールバックには `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` (1,000,000) と、Grok 4.5 / 4.6 / 4.7 の通常・Fast 行 (500,000) が含まれます。4.6 と 4.7 は `xhigh` も公開し、ライブアカウントの検出によって表示される行が決まります。 |
| xAI | xAI のライブ検出が優先されます。フォールバックには `xai/grok-4.6` と `xai/grok-4.7` が含まれ、デフォルトは `xai/grok-4.5` です。3 モデルともコンテキストは 500,000 トークンです。Grok 4.6 と 4.7 は `low` / `medium` / `high` / `xhigh`(上流のデフォルトは `high`)を提供し、Grok 4.5 は `high` までです。 |
diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md
index 1a03b3e6a07..cb31f349e60 100644
--- a/docs-site/src/content/docs/ja/guides/combos.md
+++ b/docs-site/src/content/docs/ja/guides/combos.md
@@ -126,7 +126,7 @@ ocx combo set balanced \
|機密認証、サブスクリプション、クォータ、レート制限、過負荷、またはアップストリーム サーバー エラー |ステータスだけでは物足りない場合でもターゲットを冷やしてホップさせましょう。 |
|クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、コンテキスト オーバーフロー、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 |
| ChatGPT アカウントで Codex を使う際に、そのモデルをサポートしないことを示す正確な HTTP 400 の拒否 | 出力を確定する前に、次の設定済みターゲットを試します。拒否文全体が `detail`、`error.message`、または本文そのものにある場合に限ります。`detail` と `error` が競合する応答は終了扱いです。この拒否だけでアカウントを再認証待ちにしたり、プロバイダーの全モデルをクールダウンしたりしません。 |
-|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、またはモデル固有の画像入力拒否(`param: input`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 |
+|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 |
|インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 |
|その他の未分類のエラー |停止してエラーを返します。 |
@@ -184,7 +184,7 @@ v1/base/v2 モードと完全な暗号化タスクのワークフローについ
### ダッシュボード
-ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースはコンボを作成、編集、名前変更、削除し、そのターゲット ピッカーは無効なモデルとネストされたコンボを除外します。
+ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースではコンボの作成、編集、名前変更、削除ができます。モデル選択には非表示モデルも含まれます。表示設定は外部のモデル一覧にのみ適用され、コンボ内部の選択には影響しません。非表示モデルを選択しても外部には表示されません。ターゲット選択では、無効なプロバイダー、ネストされたコンボ、決定サービス専用の JEV プロバイダーは引き続き除外されます。
各ターゲットには **利用可能**、**クォータを使い切りました**、**クォータ不明** のライブバッジも表示されます。
エディターがクォータを理由に保存と作成をブロックするのは、使用可能なすべてのターゲットについて、設定された認証情報の推論上限に達したことを示す、サーバーによる確認が現在も有効な場合だけです。表示専用のアカウント・モデル・検索・MCP クォータや、ルーティングの根拠情報の欠落・期限切れによって、このブロックが発生することはありません。ブロックは該当するリセット時刻またはデータの有効期限に解除され、ページがアクティブになるか表示状態になると再確認されます。「更新」はコンボデータとクォータの両方を再読み込みします。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。
@@ -201,9 +201,9 @@ ocx combo remove --yes
```
`set` は、`--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、
-`--display-name`、および `--rename-from` も受け入れます。`--native-alias` には現在
+`--display-name`、`--description`、および `--rename-from` も受け入れます。`--native-alias` には現在
サポートされている bare native alias と空でない display name が必要です。`--effort`、`--alias`、
-`--display-name` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の
+`--display-name`、`--description` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の
エイリアス、`delete` は `remove` のエイリアスです。すべてのコンボ サブコマンドは `ocx route combo ...` からも利用できます。
### 管理 API
@@ -248,6 +248,7 @@ ocx combo remove --yes
| `alias` |いいえ |なし |オプションのトリミングされたパブリック モデル ID。上記のエイリアス ルールを使用します。空の値はエイリアスなしで保存されます。 |
| `nativeAlias` |いいえ | `false` | 現在サポートされている bare native alias に routing/catalog の優先権を明示的に与えます。 |
| `displayName` |いいえ |なし | catalog 表示専用ラベル。`nativeAlias` が true の場合は必須です。 |
+| `description` |いいえ |なし | catalog 行の表示専用の説明。制限は `displayName` と同じです。未設定の場合は生成された `Routed via opencodex → combo (combo).` が使われます。 |
## トラブルシューティング
@@ -269,6 +270,6 @@ ocx combo remove --yes
## 任意パラメーターの互換性
-通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、またはモデル固有の画像入力拒否(`param: input`)を示す構造化エラーでは、出力開始前に次の適格なターゲットへ進めます。この不一致ではクールダウンを記録しません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。
+通常の 400 エラーは終了扱いですが、`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、モデル固有の画像入力拒否(`param: input`)、またはルートが読み取れない入力メディアの拒否(`unsupported_input_modality`)を示す構造化エラーでは、出力開始前に次の適格なターゲットへ進めます。この不一致ではクールダウンを記録しません。安全ポリシーによる拒否、キャンセル、出力開始後の再実行は禁止のままです。
[Canonical compatibility details](/guides/combos/#request-local-target-compatibility).
diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md
index 1cecbcb394f..3a952318d4e 100644
--- a/docs-site/src/content/docs/ja/guides/integrations.md
+++ b/docs-site/src/content/docs/ja/guides/integrations.md
@@ -3,7 +3,7 @@ title: クライアント統合
description: ダッシュボードから opencodex を OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast、omo、Cline CLI、Kilo、Command Code、Factory Droid に接続します。クライアントごとにスイッチがあり、書き込み前には必ずバックアップを取ります。
---
-**接続** タブは、各クライアントの設定ファイルに opencodex のプロバイダーブロックを書き込み、必要に応じて削除します。次の 18 クライアントは、それぞれのスイッチで管理できます。
+**接続** タブは、各クライアントの設定ファイルに opencodex のプロバイダーブロックを書き込み、必要に応じて削除します。以下のクライアントは、それぞれのスイッチで管理できます。
| クライアント | 設定ファイル | 形式 | 変更が反映される時点 | 認証情報 |
|---|---|---|---|---|
diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md
index 114fa62773f..2c5920a8c38 100644
--- a/docs-site/src/content/docs/ja/guides/providers.md
+++ b/docs-site/src/content/docs/ja/guides/providers.md
@@ -57,7 +57,9 @@ Codex login を Pool モードで使うと、Providers の概要には任意の
[`retryOn429`](/ja/reference/configuration/)(同一キーでの 429 リトライ)は API キー プロバイダー
(`authMode: "key"`)のみに適用されます。OAuth・forward・ローカル プリセットは除外されます —
同じトークンを再送すべきではなく、ローカルランタイムには保存すべきリモートキーがありません。
-オプトインです: オプションが無ければ無効、オブジェクトがあれば `enabled: false` でない限り有効です。
+ドキュメントに記載された宛先固有のデフォルトを除き、オプトインです。正規の Mistral Chat は、既存のリクエスト全体の送信枠の範囲内で、粘り強いリトライポリシー(再送 6 回、フォールバック間隔 10 秒)を使います。`enabled: false` を含む明示的な `retryOn429` 設定が優先されます。Mistral プリセットは、渡されたプロンプトキャッシュキーもデフォルトで転送します。`promptCacheKey: false` で転送を無効にできます。キャッシュアフィニティは再利用の可能性を高めますが、キャッシュヒットを保証せず、トークンスループットの制限もなくしません。
+
+Mistral は Studio キーと Vibe キーの両方に 1 つの API キー プロバイダーを使います。キーの種類とプランの許容量は [Mistral の API キーのドキュメント](https://docs.mistral.ai/admin/identity-access/api-keys)に記載されています。プロキシはキーからサブスクリプションのティアを推測しません。すべてのモデルで、上流が明示した `Retry-After` の期限は、同じ認証情報とモデルでの呼び出し間で共有されます。リトライ時刻のない 429 は、有効な分単位カウンターが容量の枯渇を示す場合、または拒否されたクエリの実測コストが残りトークンを超えつつ公表された全許容量には収まる場合に、控えめな 60 秒の回復待機を開始することがあります。これはローカルの回復ポリシーであり、サーバーのリセット時刻を正確に予測するものではありません。正常なトラフィックでは、次のクエリのキャッシュやコストを予測しません。待機後に回復プローブを 1 回だけ送り、成功すれば通常のトラフィックに戻ります。欠落、不正な形式、利用権ゼロ、月間枠の枯渇、全許容量超過といった根拠からは、リセットを作り出したり履歴をコンパクトしたりしません。`requestPacing: { "enabled": false }` または `retryOn429: { "respectRetryAfter": false }` で共有ペーシングを無効にします。同じキーでの再送について、Mistral のリトライ時刻が `retryOn429.maxIntervalMs` を超える場合、プロキシは早すぎる再送をせず、429 とリトライのヒントを返します。ラウンドロビンのキー選択が有効な場合、待機中のリクエストはキーのクールダウンを再確認して代替キーのペーシング容量を確保し、出力前のキー拒否はリクエストの既存の送信上限内で正常なキーに切り替えて回復できます。コンテキストウィンドウとモデルごとのコンパクション設定は独立したままです。
## 1. ChatGPT ログイン(forward / パススルー)
@@ -101,6 +103,7 @@ ocx login google-antigravity
ocx login cursor # Cursor 専用 PKCE ログイン
ocx login command-code # Command Code のブラウザ OAuth (または ~/.commandcode/auth.json を取り込み)
ocx login devin # Cognition/Devin: Devin CLI の資格情報を優先インポート、なければ Auth0 ブラウザサインイン
+ocx login dsh-account # DeepSeek Harness Desktop (~/.dsh/.credentials.yaml の許可を取り込み)
ocx login github-copilot # GitHub デバイスフロー → Copilot トークン (Copilot Pro/Business)
ocx login codex # Codex アカウントプール (別名: chatgpt, openai / プロキシの起動が必要)
ocx logout
@@ -117,6 +120,7 @@ ocx logout
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 |
| `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 |
| `devin` | `devin` | `https://server.codeium.com` | 実験的な非公式 Cognition/Devin ブリッジ。ログインはまず、インストール済み Devin CLI が保持する認証情報を取り込みます(`devin auth login` が `devin-session-token` を自身の `credentials.toml` に書き込みます)。なければ Auth0 のブラウザサインインを開き、貼り付けたトークンを `RegisterUser` で長期 API キーに交換します。`ocx login devin-cli` は非推奨エイリアスとして引き続き使えます。モデル一覧は `GetCascadeModelConfigs` でアカウントごとに取得し、ストリーミングは Connect-RPC 上の `runTurn` 経路のみを使います。ダッシュボードのプリセットには既定で含まれません。 |
+| `dsh-account` | `dsh-account` | `https://api.deepseek.com/anthropic` | 実験的なインポート優先アカウントプロバイダー。インストールおよびサインイン済みの DeepSeek Harness (DSH) Desktop アカウントを再利用します。明示的なインポート時に `~/.dsh/.credentials.yaml` から厳格に読み取り専用で資格情報を読み込みます。DeepSeek API キー課金とは完全に独立しています。 |
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 |
Google Antigravity のアカウント・プロバイダーのクォータ確認は、モデル一覧へのフォールバックも含め、固定の Google エンドポイントを使用します。その宛先では透過 Fake-IP DNS に対応し、TLS 検証、リダイレクト拒否、プライベートアドレス検査を維持します。カスタム base URL はモデル要求にのみ適用されます。`NO_PROXY` は直接接続のポリシーを維持します。
@@ -191,9 +195,20 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht
ロールバックはスナップショットがある場合にのみ可能なため、セッションストアが存在するのに取得できない場合(ファイルが読めない、スキーマの不一致、トークン選択があいまい)、`KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` が実際の CLI ストアと異なるインポート先を指す場合、またはプライマリ CLI データベースに認識できるトークン行がない場合、**アカウントを追加**は `kiro-cli` のログアウトを拒否します。通常の `kiro-cli` データパス上の壊れたデータベースを修復または削除し、インポート専用セレクタが設定されていれば解除してから再試行してください。既存の `kiro-cli` セッションがまったくない環境には影響しません。
+### DeepSeek Account (DSH)
+
+`dsh-account` は、インストールおよびサインイン済みの DeepSeek Harness (DSH) Desktop アプリケーションの認証済みアカウント資格情報を再利用する、実験的なインポート優先アカウントプロバイダーです。
+
+- **DeepSeek API キーから独立:** `dsh-account` はユーザーの認証済み DeepSeek セッションクォータ(通常/ボーナスウォレット残高および総支出)を使用しますが、`deepseek` プロバイダーはプラットフォーム API キー残高から課金されます。この2つの資格情報ドメインおよびルーティングは厳格に分離されており、相互にフォールバックすることはありません。
+- **明示的インポートのみ:** OpenCodeX は DeepSeek Harness がインストールされサインインされているか(`~/.dsh/.credentials.yaml`)を検出しますが、ユーザーの明示的な操作(`ocx login dsh-account` または Web ダッシュボードでのインポートクリック)なしにトークンを自動的にコピー、インポート、または使用することはありません。
+- **DSH 資格情報ストアへの読み取り専用:** OpenCodeX は `~/.dsh/.credentials.yaml` を厳格に読み取り専用として扱います。OpenCodeX が DeepSeek Harness に属するファイルの書き込み、変更、削除、またはアクセス権の変更を行うことは決してありません。
+- **隔離された資格情報の複製とログアウト:** インポート時、OpenCodeX は DeepSeek の信頼できる認証エンドポイント(`/auth-api/v0/users/current`)に対してトークンを検証し、OpenCodeX 認証ストアに独自の複製を保存します。OpenCodeX でアカウントをログアウトまたは削除しても、OpenCodeX のローカル複製が削除されるだけであり、上流のリモートセッションログアウトがトリガーされたり、DeepSeek Harness Desktop からサインアウトされたりすることはありません。
+- **バックグラウンドトークン自動ローテーションなし・401 時の再インポート:** DSH セッショングラントには自動バックグラウンド更新エンドポイントがありません。上流から HTTP 401(`ACCOUNT_TOKEN_INVALID`)が返された場合、OpenCodeX はアカウントを `needsReauth` としてマークし、ルーティングを停止します。アクセスを再開するには、ユーザーが `ocx login dsh-account` で明示的に再インポートする必要があります。
+- **サードパーティ免責事項:** OpenCodeX は、サードパーティクライアントによるセッショングラントの再利用について、DeepSeek 公式からの承認や保証を主張するものではありません。
+
## 3. API キーカタログ
-opencodex には組み込みプリセットが 104 個含まれています。キー方式 85、OAuth 15、ローカル 3、
+opencodex には組み込みプリセットが 106 個含まれています。キー方式 85、OAuth 17、ローカル 3、
デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、
入力したキーを検証した後保存します(検証はプロバイダー固有です)。主な項目は以下のとおりです:
@@ -247,7 +262,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5`
| Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` |
| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` |
| [BigModel Coding Plan — Responses (静的モデル一覧)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` |
-| Qwen Cloud | トークンプラン(デフォルト): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 従量課金: `https://dashscope.aliyuncs.com/compatible-mode/v1` · またはカスタム |
+| Qwen Cloud | トークンプラン(デフォルト): `https://token-plan.maas.qwencloudapi.com/compatible-mode/v1` · 従量課金: `https://dashscope.aliyuncs.com/compatible-mode/v1` · またはカスタム |
| Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` |
| SiliconFlow | `https://api.siliconflow.cn/v1` |
| Volcengine Ark · Coding Plan · Agent Plan | `https://ark.cn-beijing.volces.com/api/v3` · `https://ark.cn-beijing.volces.com/api/coding/v3` · `https://ark.cn-beijing.volces.com/api/plan/v3` |
@@ -267,13 +282,20 @@ Chat Completions モデル(および Responses 専用で Responses にルー
[OpenGateway ダッシュボード](https://opengateway.ai/api-keys)でキーを作成し、
`ocx provider add opengateway` を実行するか、ダッシュボードで **OpenGateway** を選択してください。
Chat リクエストは設定済みの Bearer キーを使います。公開一覧はキーの有効性を検証しません。
+Responses では OpenGateway がネイティブのカスタムツール呼び出しの再送を拒否するため、プリセットはコードモードの `exec` など Codex のカスタムツールを送信前に関数ツールへ変換します。
+ツール結果のコンテンツ配列はテキストとして送り、画像は対応するツール結果グループの後に保持します。refusal や不透明なコンテンツなど、サポートされない結果パートは送信前に明示的なクライアント入力エラーとして拒否します。
+画像の詳細度 `original` はゲートウェイが受け付ける `high` に変換し、画像のバイトはそのまま保ちます。
+ネイティブ Responses ルートは、テキストのみの空の完了を成功として返す前に検査します。
+出力予算を使い切った場合は `max_output_tokens` を伴う `incomplete` になり、プロキシは要求された予算を増やしません。予算が残っていて安全に再送できる空の完了は、既存の送信上限の範囲内で 1 回だけ再試行することがあります。明示的な `emptyCompletionRetry: false` または `OCX_EMPTY_COMPLETION_RETRY=0` でこの再試行を無効にします。テキスト、refusal、ツールの動作、未知のストリームイベントがあると再送せず、元のストリームを保持します。この保護は他のプロバイダーやそのリトライのデフォルトを変更しません。
+再送には明示的な `store: false`、完全な入力、クライアント実行のツールのみが必要です。`web_search` などのホスト型ツールがあると、ツールイベントが観測されていなくても自動再送しません。
+
+**OpenCode Zen**(`opencode-zen`)は [opencode.ai/auth](https://opencode.ai/auth) の API キーで `https://opencode.ai/zen/v1` を使用します。無料モデルは毎分約 15–20 リクエストの短時間レート制限に当たることがあります(コミュニティ計測。OpenCode は RPM を公表していません)。レート制限 429 に `Retry-After` がない場合、opencodex は案内と合成 `Retry-After` を追加し、有効な上流ヘッダーを優先します。同一キーの再試行は [`retryOn429`](/ja/reference/configuration/) で有効にします。
-**OpenCode Zen**(`opencode-zen`)とキー不要の **OpenCode Free** プリセットは
-`https://opencode.ai/zen/v1` を共有します。このゲートウェイ上の無料モデルは、しばしばおおよそ毎分 15–20 リクエストの短時間レート制限に当たります(コミュニティ計測。OpenCode は RPM を公表しません)。Zen は `Retry-After` / `X-RateLimit-*` ヘッダーなしの汎用 429 を返すことがあります。これはキー不要デスクトップ枠(`opencode-free` で Big Pickle/無料モデル約 200 回 / 5 時間)とは別です。Zen がそのような 429 で `Retry-After` を省略した場合、opencodex はクライアント向けエラーに案内を足し、合成 `Retry-After` を付けます(上流の `Retry-After` があればそれが優先されます)。同一キーの待機再試行は [`retryOn429`](/ja/reference/configuration/) でオプトインします。
+正規の `opencode-zen` プロバイダーで API キー認証と公式ルート `https://opencode.ai/zen/v1` または `https://opencode.ai/inference/v1` を使用すると、Gemini は既定の `googleMode: ai-studio` で Google ネイティブ API を自動的に使用します。Zen のモデル ID は、`-tiered` 接尾辞のない Flash ID もそのまま保持します。Claude は `googleMode` に関係なく Anthropic Messages を使用します。保存済み設定の読み込み時、これらのネイティブモデルの適格な旧 Chat/Responses オーバーライドと、適格な Responses 専用 `image_generation` ホストツール設定のみを無視します。カスタムプロバイダー、キー認証以外のルート、その他の設定は変更しません。
-**キー不要の `opencode-free` 枠は、現在サードパーティのクライアントに閉じられています。** Zen は `x-opencode-session` ヘッダーを伴わないリクエストをすべて拒否し、エラータイプ `MissingSessionID` と "OpenCode's free tier can only be used in OpenCode" というメッセージを返します。関門はヘッダーの有無だけを見るため、プロキシは値をでっち上げれば通過できますが、opencodex はそうしません。セッション識別子とバージョン付きの `opencode/` User-Agent を作って送ることは、自分が OpenCode クライアントであると主張することであり、OpenCode はこのキー不要の枠についてサードパーティ連携の契約を公開していません。その方法で得た HTTP 200 は許可ではなく、突破された関門にすぎません。そこで opencodex は回避せずに制限を報告します。`opencode-free` へのリクエストは、上流の関門を説明するエラーを返します。
+**`opencode-free` のサポートは削除されました。** OpenCode の上流無料枠の制限により、すべてのモデルが `403 permission_denied` を返すためです([#7255](https://github.com/lidge-jun/opencodex/issues/7255))。既存設定の読み込み時は該当項目と依存するモデル参照をメモリ上で無効にし、プロセスごとに一度警告します。ファイルは変更しません。既定のプロバイダーだった場合、標準の OpenAI OAuth 経路を既定にします。`opencode-go`、API キー付きの `opencode-zen`、または別のプロバイダーを使用してください。[OpenCode Zen 規約](https://opencode.ai/docs/zen/)。
-同じモデルに至るサポートされた経路は、[opencode.ai/auth](https://opencode.ai/auth) で発行した OpenCode Zen API キーを使う **`opencode-zen`** プリセットです。OpenCode が後にキー不要の枠へのサードパーティ経路を公開すれば、opencodex もそれに従えます。それまでこのプリセットは制限を記録する役割を担います。上流の規約: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/)。
+正常な Claude Desktop の割り当てと既定値は保持されます。ソースモデルの許可リストが空になるルールは無効になり、注入モデルが削除された場合はネイティブサブエージェント同期も無効になります。その後の設定保存では、削除済み参照を復元せず、正常な同時編集を保持します。
大半は bearer キーと共に `openai-chat` アダプターを使い、**Xiaomi MiMo**(`xiaomi`)などの
Anthropic 互換プリセットは `anthropic` アダプター(`x-api-key`)を使います。Xiaomi には
@@ -443,6 +465,9 @@ Sol/Terra/Luna をフォールバックリストに入れています。
| Codex ログイン(Pool または Direct) | `gpt-5.6-*` | 922,000 |
| OpenAI (API キー) | `openai-apikey/gpt-5.6-*` と `*-pro` | 922,000 (max input 922,000) |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` | 922,000 |
+| Codex | `gpt-6.1-terra` | 500,000 / 872,000 |
+| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 |
| Cursor | `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` | 1,000,000 |
ネイティブ GPT-5.6 項目は固定の上流推論段階に従います。例えば Luna には
@@ -462,7 +487,7 @@ Amazon Bedrock ネイティブ API のような、これらの実装のいずれ
Gateway** は URL にアカウント + ゲートウェイ ID を埋める必要があります。
Copilot は混在 wire カタログを提供します。モデル(`gpt-5.3-codex`、`gpt-5.4`、
-`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)はエージェント
+`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`、 `gpt-6.1-sol`、 `gpt-6.1-terra`、 `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)はエージェント
通信の `/chat/completions` を拒否するため、opencodex はこれらのモデルを組み込みデフォルトで
Responses API 経由にルーティングし、他の Copilot モデルはすべて chat completions のままです。
優先順位は次のとおりです: ハード wire ピン → 明示的な
diff --git a/docs-site/src/content/docs/ja/guides/remote-link.md b/docs-site/src/content/docs/ja/guides/remote-link.md
index 4ae8b634195..905cfb3db49 100644
--- a/docs-site/src/content/docs/ja/guides/remote-link.md
+++ b/docs-site/src/content/docs/ja/guides/remote-link.md
@@ -55,7 +55,7 @@ Home のプロバイダーを使うコンピューターで次の操作を行い
- **Connected** は SSH トンネルが準備でき、Child が Home のリンクを使える状態です。
- **Reconnecting** はトンネルを再試行している状態です。再試行中はリクエストが `Retry-After` 付きの `503` を一時的に返すことがあります。自分のダッシュボードから接続した Child では、リクエストはまずトンネルの復帰を最大 15 秒待ちます。
-- **Failed** は対応が必要な状態です。SSH 認証、確認済みのホストキー、転送、タイムアウトの理由を確認してください。SSH トンネルを張る側が、スリープ、障害、再起動の後も自動で再試行します。Home が追加した Child では Home が、自分のダッシュボードから接続した Child では Child が再試行します。タイムアウトや転送エラーの後は約 1 分ごと、認証エラーの後は 5 分ごとです。ホストキーが変わった場合は再試行しません。すぐに再接続するには、Home のダッシュボードで **Retry** を選ぶか、Home で `ocx link reconnect --link-id ` を実行します。OpenCodex を再起動せずに、そのリンクのトンネルだけを張り直します。
+- **Failed** は対応が必要な状態です。SSH 認証、確認済みのホストキー、転送、タイムアウトの理由を確認してください。SSH トンネルを張る側が、スリープ、障害、再起動の後も自動で再試行します。Home が追加した Child では Home が、自分のダッシュボードから接続した Child では Child が再試行します。タイムアウトや転送エラーの後は 1 分後に再試行し、失敗が続くたびに間隔を倍にして最大 5 分ごとになります。認証エラーの後は 5 分ごとです。ホストキーが変わった場合は再試行しません。すぐに再接続するには、Home のダッシュボードで **Retry** を選ぶか、Home で `ocx link reconnect --link-id ` を実行します。OpenCodex を再起動せずに、そのリンクのトンネルだけを張り直します。
リンクが失敗しても、ローカルプロバイダーへ自動的に切り替わることはありません。
diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md
index 4d623877c98..0ffc670f9a8 100644
--- a/docs-site/src/content/docs/ja/guides/sidecars.md
+++ b/docs-site/src/content/docs/ja/guides/sidecars.md
@@ -72,8 +72,11 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は
ルーティングモデルが該当プロバイダーの `noVisionModels` にある、またはそのモデルが
`modelInputModalities` でテキスト専用と宣言され、リクエストに画像が来る場合、opencodex は利用可能な
ビジョンサイドカー計画があるときに限り、メイン呼び出し**前に**各画像を説明したテキストに差し替えます。
-計画が利用できない場合は、生の画像をテキスト専用バックエンドへ転送せず削除します。モデルカタログは
-サイドカー対象の各モデルに画像入力を広告します。コンボは、すべてのメンバーがネイティブまたはサイドカーを
+計画が利用できない場合は、生の画像をテキスト専用バックエンドへ転送せず削除します。`visionSidecar.enabled: false` の場合、
+モデルカタログはカスタムの項目やコンボのメンバーを含むテキスト専用モデルに、サイドカーによる画像入力を追加しません。
+ネイティブの画像対応と明示的に設定された画像モダリティは維持されます。`enabled` を省略した場合、サイドカーによる
+画像入力の広告は引き続き有効です。カタログに表示されることは、サイドカーの認証情報や処理容量が利用可能であることを
+保証しません。コンボは、すべてのメンバーがネイティブまたは有効化されたサイドカーを
通じて画像を受け入れ、かつコンボの `imageInput` 設定が無効でない場合にのみ画像入力を広告します。これにより
Codex アプリなどのクライアントは、サイドカー実行前に添付をブロックせず許可できます。`visionSidecar.model` が未設定または空の場合、
OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` をフォールバックとして使います。起動時には
@@ -137,6 +140,8 @@ OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.6-luna` を
`PUT /api/sidecar-settings` は同じフィールドを受け付けます。部分更新では省略したキーをそのまま残します。`timeoutMs` はランタイムの整数範囲(1–2147483647 ms)を使います。
+`PUT /api/sidecar-settings` によりビジョンの実効的なオン/オフ状態が変わると、保存後に管理対象のカタログとキャッシュが更新されますが、`catalogRefreshPending=true` は更新が完了しなかったことを示し、クライアントによる受領確認を意味しません。
+
Web 検索サイドカーのカードも同じ構成です。モデルピッカーの先頭行が **オフ (Off)** です。オフにすると
OpenCodex は `web_search` への介入をやめ、Codex 統合は `~/.codex/config.toml` に
`web_search = "disabled"` を書き込みます。Codex は自身のモードがそうなるまでネイティブの
diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md
index c8d4e06b544..af233ba13d0 100644
--- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md
+++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md
@@ -33,6 +33,42 @@ opencodex は、これを最終パスとしてライブ `/v1/models` カタロ
v2 ロスターの場合、適格性には 3 つの状態があります。`"v2"` スタンプが付いているエントリー、明示的に `null` に設定されているエントリー、または `multi_agent_version` フィールドのないエントリーが適格です。純正の `"v1"` ピンは、モデルが他のコラボレーション サーフェスに属していると記載されているため、除外されます。
+## `spawn_agent` が提示するモデル
+
+Codex は `spawn_agent` ツールの説明に最大 5 つのモデルを列挙し、その選択は Codex 自身が行います。ピッカーに表示されるカタログ行を `priority` の昇順に並べ、先頭の 5 つを取ります。**Subagents** ページのロスター (`subagentModels`) がこのリストに影響するのは、ロスター順に最も低い priority (アカウントセレクターがない場合は `0`〜`4`) を割り当てることだけです。リストをロスターに限定するわけではありません。
+
+モデルを 1〜4 個だけ指定すると、Codex は残りの枠を priority 順に次の行で埋めます。これらの行はユーザーが選んだものではなく、どのモデルが入ったかはダッシュボードにも表示されません。どの行が入るかはカタログによって決まります。
+
+| カタログ | 残りの枠を埋める行 |
+| --- | --- |
+| Codex アカウントセレクターなし | その他のルーティング済み `provider/model` 行。すべて priority `5` を共有するため、プロバイダー名、次にモデル ID のアルファベット順に並びます。指定されていないネイティブ GPT 行の priority は `100` に指定した ID の数を足した値以上なので、その後に来ます。 |
+| Codex アカウントセレクターあり | アカウント指定のネイティブ行 (`/`)。指定した行の直後に来ます。指定されていないルーティング済み行は priority `1005` に移り、その後に来ます。 |
+
+たとえば、アカウントセレクターがなく、ロスターが次のとおりの場合:
+
+```json
+{ "subagentModels": ["xai/grok-4.5", "anthropic/claude-sonnet-5"] }
+```
+
+| 枠 | モデル | Priority | 理由 |
+| ---: | --- | ---: | --- |
+| 1 | `xai/grok-4.5` | `0` | ロスターの 1 番目 |
+| 2 | `anthropic/claude-sonnet-5` | `1` | ロスターの 2 番目 |
+| 3 | `deepseek/deepseek-v4-flash` | `5` | アルファベット順で最初の残りのルーティング済み行 |
+| 4 | `google/gemini-3-pro` | `5` | 次のルーティング済み行 |
+| 5 | `moonshot/kimi-k3` | `5` | 次のルーティング済み行 |
+| 6+ | `zai/glm-5.2`、続いてネイティブ GPT 行 | `5`、続いて `102` | 5 つの枠外 |
+
+ロスターが空の場合、opencodex は何も固定せず、5 つの枠はカタログ本来の priority に従います。
+
+埋められた枠も通常の提示と同じなので、メインエージェントがそのモデルを選ぶことがあります。また、このリストは許可リストではありません。メインエージェントが正確な名前を指定すれば、Codex は 5 つの枠外のカタログモデルも受け付けます。Codex が提示するリストを制御するには:
+
+- モデルを 5 つ指定します。ロスターが埋まっていれば、補充される枠は残りません。
+- 提示させたくないモデルは無効にするか、そのプロバイダーの `selectedModels` から外します。無効または未選択のモデルはピッカーから消えるため、枠を埋めることはありません。
+- `modelPickerOrder` は Codex が並べ替えに使う priority を変更します。ルーティング済み行だけのリストでは、列挙された未指定の行が `1000` 以上に移るため、列挙されていないルーティング済み行が先に枠を埋めます。ベアのネイティブ ID を含むリストは、指定した行も含めてピッカー全体を並べ替えます。列挙された行が先頭に来るため、列挙されていないどの行よりも先に提示枠を占めます。[Model ordering](/guides/model-ordering/) を参照してください。
+
+v2 では、Codex は 5 つを取る前に `"disabled"` に固定された行を除外します。それ以外は同じ仕組みです。opencodex の v2 ガイダンスのロスター (`{{roster}}`) は、5 つの枠内にある設定済みのロスターモデルだけを挙げます。そのため、補充されたモデルは Codex のツール説明にだけ現れ、opencodex のガイダンスには現れません。Codex はセッション開始時にカタログを読むため、ロスターの変更は新しいセッションから反映されます。
+
## 委任モデルと推論負荷
ダッシュボードの **サブエージェント委任** は、次の 3 つの関連設定を制御します。
@@ -163,6 +199,10 @@ curl -X PUT http://localhost:10100/api/injection-model \
ピッカーで非表示になっているか、5 つのモデルの表示制限を超えているか、カタログから欠落しているか、v1 に固定されている可能性があります。 `"v2"`、`null`、または表面値が存在しない場合は対象となります。実際の `"v1"` ピンはそうではありません。
+### 指定していないモデルが `spawn_agent` に表示されるのはなぜですか?
+
+Codex は最大 5 つの提示枠を、カタログから priority 順に埋めます。指定したモデルが 5 つ未満の場合、残りの枠には次の行が入ります。上の「`spawn_agent` が提示するモデル」の節を参照してください。
+
### モードの変更は実行中のセッションに影響しますか?
いいえ。モードを変更した後、新しい Codex セッションを開始します。長時間実行されているアプリ ホストで依然として古いカタログ状態が表示される場合は、`ocx sync` を実行して、その Codex サーフェスを再起動します。
diff --git a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md
index 7cdae1cc726..ab3814d4e71 100644
--- a/docs-site/src/content/docs/ja/guides/subagent-v1-default.md
+++ b/docs-site/src/content/docs/ja/guides/subagent-v1-default.md
@@ -94,3 +94,9 @@ ocx v2 mode v1
```
CLI は確認を求めません。同じ設定を変更するため、このページの内容を理解したうえで選んでください。
+
+## 子エージェントの同時実行上限
+
+**Models → Model settings** のサブエージェント上限設定では、主エージェントを除く子エージェントの同時実行数の上限を指定します。v1、base、v2 のほか、グローバル v2 フラグが無効なハイブリッドモードでも利用できます。サーフェスのモードは、この上限とは別に選択します。
+
+上限を増やすとより多くの子を許可できますが、自動生成や高速化を保証するものではありません。変更後は新しい Codex セッションを開始してください。長時間動作している App ホストで以前の上限が使われ続ける場合は、そのホストを再起動してください。
diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md
index deead3c02df..c766a1cdfaf 100644
--- a/docs-site/src/content/docs/ja/reference/cli.md
+++ b/docs-site/src/content/docs/ja/reference/cli.md
@@ -20,7 +20,7 @@ opencodex CLI は `ocx` です。最初のコマンド名でディスパッチ
管理コマンドは、2 番目の構成パスを維持するのではなく、記録されたランタイム ポートと ID チェックを使用して、稼働中のプロキシの管理 API をラウンドトリップします。停止したプロキシまたは到達不能なプロキシは HTTP 503 として表され、ゼロ以外の CLI 終了が生成されます。オフライン構成操作として明示的に文書化されているコマンドは、代わりに、稼働中のプロキシを使用せずに設定ファイルを検証および編集できます。
-`ocx system codex-cli-update check` は稼働中のプロキシを必要とせず、パッケージレジストリにも問い合わせません。設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴メタデータを、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable`、Windows では `windows_inspection_deferred` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このコマンドはソフトウェアのインストールや修復、Codex または npm の実行、稼働中プロセスの制御、設定やキャッシュ状態への書き込みを行いません。
+`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。
Windows x64 インストールの観測は [`attest` コマンド](/ja/reference/cli/agents/)を参照してください。明示パスを省略すると、証明にバインドされたランチャースナップショットが特定した選択候補を観測します。更新権限やランタイム選択は証明しません。
diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md
index 9c891b8b913..ebe43edfffc 100644
--- a/docs-site/src/content/docs/ja/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ja/reference/cli/agents.md
@@ -204,7 +204,9 @@ opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex
ヘッドレス ランタイムの設定、起動、同期、診断、更新を管理します。
-`ocx system codex-restart --yes` は `ocx sync --restart-codex` と同じモジュールで Codex app-server を再起動し、デスクトップ アプリも完全に終了して再起動します。プロキシ自体が Codex アプリ内で動いている場合、完了できない引き渡しを約束せず、実行可能な案内とともに拒否します。
+`ocx system codex-restart --yes` は `ocx sync --restart-codex` と同じモジュールで Codex app-server を再起動します。モデル ピッカーは起動時に構築されるため、デスクトップ アプリを完全に終了して再起動するのは、現在のモデル カタログが書き込まれる前にアプリが起動していた場合、またはこのリクエストで `model_catalog_json` の参照先が変わった場合です。それ以外の場合、アプリは開いたまま自身の app-server を再起動し、未送信の下書きや保留中の承認を保持します。アプリまたはカタログの状態を読み取れない場合は完全な再起動を行います。プロキシ自体が Codex アプリ内で動いている場合、デスクトップの再起動は完了できない引き渡しを約束せず、実行可能な案内とともに拒否します。
+
+このリクエストで `model_catalog_json` の参照先が変わった場合も、シェルがカタログファイルの書き込み後に起動していてもデスクトップを再起動します。
```bash
ocx system settings --stream-mode eager-relay
@@ -216,7 +218,7 @@ ocx system settings --stream-mode eager-relay
ocx system codex-cli-update check --json
```
-`check` はパッケージレジストリに問い合わせず、設定済みのインストール候補について、秘匿化された実行ファイルの場所や所有権を示す根拠を含む来歴情報を、範囲を限定して検査します。公開ランチャー由来の信頼済みコンテキストが真正性を裏付けるのは候補のスナップショットだけであり、Codex が正常に実行されたことではありません。この単発コマンドは Codex を一切実行しないため、環境または永続化された状態から得た候補は報告対象にとどまります(`managed: false`、通常は `selection_unattested`)。`selectionAttested` は常に `false` です。JSON 出力には `candidateAvailable`、`candidateVersion`、`candidateSource`、`selectionAttested: false` が含まれます。Bun またはソースから直接起動するとランチャーの証明がないため、環境由来および永続化された候補を無視し、POSIX では `candidate_unavailable` を報告することがあります。Windows では、この最初のスライスは候補や構成のパスに対するファイルシステム I/O を一切行いません。信頼済みランチャーが取り込んだ絶対パスの環境候補だけを、アプリ同梱またはバージョンマネージャーとして字句的に報告でき、それ以外の Windows 候補はすべて失敗時閉鎖になります。このスライスは永続化された選択状態を一切読み取らないため、環境候補がキャプチャされていない Windows 実行では `candidate_unavailable` ではなく `windows_inspection_deferred` を報告します。コマンドは Codex CLI が導入されているかどうかを観測できないので、候補が存在しないと断定せず、検査が延期されたことを報告します。このコマンドは Codex やパッケージマネージャーの実行、shim の修復、設定やキャッシュ状態への書き込み、プロセスの停止、インストールを行いません。アプリ同梱、認識済みのバージョンマネージャー、未検証のスタンドアロン、曖昧な shim の各候補は管理対象外または不明として報告され、管理対象と判定されることはありません。
+`check` はレジストリへの問い合わせも Codex の実行も行いません。POSIX では、公開ランチャーが記録した環境候補とランタイムリゾルバーが選ぶ正規パスが一致すると `selectionAttested: true` になり、リンクされた npm-global パッケージのマニフェストで所有権を確認でき、候補バージョンが報告されている場合はそれがマニフェストのバージョンと一致するときに `managed: true` になります。永続化された候補は認証されません。Windows は候補や設定ファイルを読まずに検査を延期します。Bun やソースからの直接実行にはランチャーの証明がありません。`plan` は分離した一時 npm 設定・キャッシュ・ログで公式レジストリに問い合わせ、実行中のセッションや不完全なプロセス観測を拒否します。インストールやプロセスへのシグナル送信は行いません。[コマンドと拒否理由の詳細(英語)](/reference/cli/agents/#read-only-codex-cli-update-plan)を参照してください。
Windows で `CODEX_CLI_PATH=codex` のような単純なコマンド名、リモートパス、デバイスパスが候補としてキャプチャされた場合は、`candidate_path_unavailable` を報告します。候補は取得されていますが、そのパスはこの検査の対象になりません。
diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md
index 84acd32d7f1..17b27dc3f4f 100644
--- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md
@@ -28,7 +28,13 @@ ocx start --socks5-off
### `ocx stop`
-実行中のプロキシを (PID によって) 停止し、PID ファイルを削除して、ネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。
+実行中のプロキシを (PID によって) 停止し、PID ファイルを削除します。スタンドアロンの所有者ではネイティブ Codex を復元します。マネージド バックグラウンド サービスがインストールされている場合、`ocx stop` はそれを最初に停止するため、プロキシを再起動できません。Web ダッシュボードの **停止** ボタンは同じ処理 (`POST /api/stop`) を実行しますが、Windows タスク スケジューラだけは例外です。タスク終了後もラッパーがプロキシを再起動しうるため、ダッシュボードは `respawnable_service` で拒否し、何も変更せずに `ocx stop` の実行を促します。
+
+接続済みの hub クライアントまたは Remote Link Child では、`ocx stop`(成功した API 停止を含む)は接続が所有する Codex のルーティング、モデルカタログ、Grok 設定を保持します。`ocx start` はルーティングを再注入せず、その設定で再び処理します。Codex を自身のアカウントへ戻すには `ocx disconnect`、既にネイティブルーティングへ戻した後の再適用には `ocx sync --restart-codex` を使います。
+
+互換性を確認できない古い通常プロキシは、サービスやプロセスを停止する前に拒否されます。監視元からランタイムを更新・再起動して再試行してください。
+
+既に停止済みのクライアントでは、サービス管理側が非アクティブで、既知のローカル接続先にランタイムが存在しないことを確認してから、通常の未実行結果を返します。
### `ocx restart`
@@ -63,7 +69,7 @@ ocx eject back
### `ocx recover-history --ocx-compaction --yes`
-ルーティングされたプロバイダーで圧縮されたタスクをネイティブ Codex で再開する前に、その履歴を修復します。このコマンドは UUID で 1 つのタスクだけを選択し、非公開のバイト単位バックアップを保存してから、OpenCodeX 所有の `ocx1:` 圧縮状態だけをネイティブ Codex が再生できる通常の要約に変換します。ネイティブの暗号化コンテンツと他のタスクは変更しません。実行前に対象タスクを閉じてください。処理中に rollout が変更された場合、ファイルを置き換えずに修復を中止します。
+ルーティングされたプロバイダーで圧縮されたタスクをネイティブ Codex で再開する前に、その履歴を修復します。このコマンドは UUID で 1 つのタスクだけを選択し、非公開のバイト単位バックアップを保存してから、OpenCodeX 所有の `ocx1:`/`ocx2:` 圧縮状態だけをネイティブ Codex が再生できる通常の要約に変換します。ネイティブの暗号化コンテンツと他のタスクは変更しません。実行前に対象タスクを閉じてください。処理中に rollout が変更された場合、ファイルを置き換えずに修復を中止します。
### `ocx uninstall`・`ocx remove`
@@ -137,7 +143,7 @@ ocx status --json
認証不要の `GET /readyz` エンドポイントで同期後の準備状態を確認します。準備完了時は `200`、
`pending` または終端状態の `failed` では `Retry-After: 1` とともに `503` を返します。HTTP の
-サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない
+サニタイズ済み識別フィールドは `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}` です。`protocol` は hub の現在の remote protocol、`minimumClientProtocol` は互換性のある最小 client protocol、`managementUrl` は browser から見える canonical management origin です。`/readyz` がない
旧プロキシは `unreachable` として fail-closed し、`/healthz` は readiness ではなく別の liveness 確認です。
デフォルトでは 1 回だけ probe します。`--wait` は準備完了または timeout まで polling しますが、
終端 `failed` を確認すると即座に終了します。デフォルト timeout は 45 秒で、`--timeout ` には
@@ -156,6 +162,10 @@ ocx status --json
### `ocx sync [--restart-codex] [--restart-app-server-only]`
+> **Remote Link Child:** `ocx sync` は Home の Codex 設定も適用し、クライアントランタイムがその適用より前に起動していた場合は、再起動フラグなしでもランタイムを再起動します。再起動に失敗した場合は以前のランタイムが動作し続け、コマンドは 0 以外で終了します。`ocx status --json` と `ocx link status --json` の関連フィールドは [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(英語)を参照してください。
+
+再起動フラグを明示した場合、カタログとキャッシュのバイト列が変わらなくても、`config.toml` の変更が正常にコミットされれば再起動します。設定だけを扱う操作が変更なし、拒否、事前検証、ロールバックとなった場合は再起動しません。再起動フラグがなければ、sync が Codex を自動的に再起動することはありません。
+
構成されているすべてのプロバイダーからライブ モデル リストを取得し、マージされたカタログを Codex に再挿入します。プロバイダーを追加した後、または利用可能なモデルを更新するために実行します。
存続期間の長い Codex `app-server` プロセスがまだ実行されている場合、`ocx sync` は、`opencodex-catalog.json` / `models_cache.json` が更新されても、以前のメモリ内モデル リストを提供し続ける可能性があることを警告します。`--restart-codex` を渡すと、一致する `codex … app-server` および `codex-code-mode-host` プロセスを再起動し、さらに macOS、Linux、Windows で Codex デスクトップ アプリを完全に終了して再起動します。モデル ピッカーがカタログを読み直すためです。進行中の会話は終了します。広範な `pkill -f codex` 一致は意図的に回避されます。
@@ -166,6 +176,8 @@ ocx status --json
コマンドを Codex アプリ内から実行すると、再起動は切り離されたヘルパーに引き渡され、このセッションはアプリとともに終了します。
+**接続先を固定した同期。** `ocx sync --expect-link `(Remote Link Child)と `ocx sync --expect-hub-key `(hub クライアント)は、このマシンがまだそのリンク、またはそのキーで接続している場合にのみ同期します。そうでなければ何も変更せず、ローカルのプロバイダーも同期せず、終了コード 5 で終了し、stderr の最後の行は `{"phase":"child_link_mismatch"|"hub_client_mismatch","reason":...}` になります。不正な形式または重複したフラグは終了コード 2 で終了します。詳細は[英語版リファレンス](/reference/cli/lifecycle/#sync-only-from-the-expected-home---expect-link-and---expect-hub-key)を参照してください。
+
### `ocx sync-cache [--restart-codex] [--restart-app-server-only]`
Codex のローカル モデル ピッカー キャッシュを無効にし、アクティブな opencodex カタログから再構築されるようにします。 `ocx sync` と同じ、古い `app-server` 警告とオプションの再起動フラグが適用されます。
diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md
index f920a33c459..7e2a2c06150 100644
--- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md
@@ -131,6 +131,14 @@ Codex pool selection applies to the next request after clearing existing affinit
### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]`
+保存済みの ChatGPT プールアカウントは、ネイティブ Codex ホームがないホストでも、
+ダッシュボードやアカウント更新時を含めて一覧表示できます。一覧表示によって Codex ホームが
+作成されたり、ネイティブのメインアカウントからサインアウトしたりすることはありません。
+Codex クライアント連携を無効にしても、既存のネイティブログインのクォータ観測は無効になりません。
+明示的に指定した `CODEX_HOME` のパスが無効な場合は、引き続き設定エラーを返します。
+`OPENCODEX_HOME` のストレージが安全でない場合は、ネイティブ Codex ホームがなくてもエラーを返します。
+既定の Codex ホームのパス解決では、ファイル、リンク先が存在しないシンボリックリンク、辿れない親ディレクトリもエラーになります。
+
プロバイダーを使用しない場合、Codex プール、OAuth アカウント、および設定された API キー プールが一覧表示されます。 `--all` が存在しない限り、空のプロバイダーはスキップされます。プロバイダーを使用すると、その資格情報ファミリーのみがリストされます。人間の出力では `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` を使用します。手動で選択した Codex 行には `selected` というマークが付けられます。利用可能な Kiro アカウントが 2 つ以上保存されている場合、既定では 429 を受けると別のアカウントへ自動的に切り替え、既知の残り利用枠が最も多いアカウントを優先します。この切り替えはアカウントの存在によって有効になり、無効にはできません。`oauthAccountFailover.enabled: false` が断るのは 429 復旧ではなく、送信前にアカウントを選ぶ動作だけです。`ocx account login kiro` はアカウントを 1 件ずつプールへ追加します。結果が空であっても成功です。 `--json` は次を返します:
```text
diff --git a/docs-site/src/content/docs/ja/reference/configuration/advisor.md b/docs-site/src/content/docs/ja/reference/configuration/advisor.md
index c80ac5da93b..d78a179f231 100644
--- a/docs-site/src/content/docs/ja/reference/configuration/advisor.md
+++ b/docs-site/src/content/docs/ja/reference/configuration/advisor.md
@@ -1,6 +1,6 @@
---
title: アドバイザー
-description: OpenCodex 自身のエキスパート相談サイドカー — 設定されたエキスパートモデルがルーティングされたワーカーに助言を返します。manual と preflight の 2 つのポリシーを提供します。
+description: manual、preflight、証拠に基づく adaptive ポリシーによる独立した専門家への相談。
---
アドバイザーはワーカーのタスクをレビューし助言を返す独立したエキスパートモデルです。相談は OpenCodex がエンドツーエンドで所有します。プロキシはワーカーのターンに合成 `advisor` ツールを注入し、通常のルーティング権威を通じて相談を自ら実行し、助言を再注入して元のワーカーを継続させます。ワーカーは委譲も spawn もせず、プロバイダー資格情報も持ちません。
@@ -9,6 +9,8 @@ description: OpenCodex 自身のエキスパート相談サイドカー — 設
## 設定
+ダッシュボードの **Advisor** ページでカタログからモデルを選択します。ネイティブ、プロバイダー、アカウント指定、カスタム、コンボの識別子をそのまま保存します。無効なモデルと初期選択待ちのモデルは新規選択に表示されません。選択可能な一覧にない保存済み識別子は警告付きで保持され、別のモデルを選ぶか解除するまで変更されません。読み込みに失敗しても選択は保持され、再試行できます。テキストの助言を返せるモデルを選んでください。カタログは相談の互換性を保証しません。CLI と API は引き続きルーター対応のモデル文字列を受け付けます。
+
```json
{
"advisor": {
@@ -26,11 +28,12 @@ description: OpenCodex 自身のエキスパート相談サイドカー — 設
| `enabled?` | `boolean` | `false` | マスタースイッチ。無効ならリクエストパスにアドバイザーの動作は一切ありません。 |
| `model?` | `string` | — | エキスパートモデル。ルーターが受け付ける任意のモデル文字列:ネイティブモデル(`gpt-6-astra`)、明示的な `provider/model`(`anthropic/claude-sonnet-4-6`、`xai/grok-...`)、アカウント修飾ネイティブモデル。クロスプロバイダーを完全にサポートします。 |
| `effort?` | `string` | `"max"` | アドバイザー呼び出しの推論強度(`low`〜`ultra`)。 |
-| `policy?` | `"manual" \| "preflight"` | `"manual"` | 相談のタイミング。 |
+| `policy?` | `"manual" \| "preflight" \| "adaptive"` | `"manual"` | 相談のタイミング。 |
+| `maxConsultationsPerTask?` | `integer` | `3` | 適応型タスクの相談上限(1〜3回、手動呼び出しを含む)。 |
| `timeoutMs?` | `number` | `120000` | ループバック相談のタイムアウト。 |
| `contextSharingConsent?` | `"v1"` | なし | 設定されたアドバイザーのプロバイダーへタスク文脈を送ることへの操作者の同意。現在有効な値は `"v1"` だけです。欠落、古い値、その他の値ではタスク内容は送られません。`enabled: true` だけでは同意になりません。 |
-ダッシュボードの **Advisor** ページまたは `ocx advisor status|on|off|set --model --effort --policy ` で管理します。
+ダッシュボードの **Advisor** ページまたは `ocx advisor status|on|off|set --model --effort --policy ` で管理します。
現在の同意がないとき、`ocx advisor on` はプロバイダー間送信を有効にしません。開示を表示して停止します。`ocx advisor on --ack-context-sharing` と `ocx advisor consent` が `v1` を記録します。`ocx advisor consent --revoke` は同意を消し、送信を直ちに止めます。`ocx advisor set` は同意を与えません。ダッシュボードの同意チェックは初期状態でオフです。
@@ -78,5 +81,13 @@ OpenCodex はプロバイダー API キー、Authorization ヘッダー、OAuth
## PR1 の制限
- ネイティブ OpenAI パススルーのターン(ChatGPT プールのワーカー)には合成ツールが注入されません。アドバイザーはルーティング(翻訳)プロバイダーを対象とします。preflight 相談は run-turn アダプターに適用されますが、ツールは適用されません。
-- 適応トリガーはありません:詰み検出、繰り返し失敗の分析、エスカレーション階層、複数アドバイザー、投票はありません。`manual` と `preflight` のみです。
+- 意味的な停滞検出、完了前レビュー、段階的エスカレーション、複数専門家、投票は行いません。
- preflight の重複排除台帳はプロセス内です。プロキシ再起動後、進行中のタスクはもう一度 preflight 相談を受けることがあります。
+
+## 証拠に基づく適応型相談と活動記録
+
+`adaptive` は初回相談に加え、同じ作業ディレクトリの同じ検証が失敗し、その後のファイル編集が成功し、新たな検証実行も失敗した場合に相談します。成功した検証は失敗状態を解除します。編集前に開始した検証は修正後の再試行に数えません。初回と修正失敗は別々に重複を防ぎ、自動相談には安定した会話 ID が必要です。対応するツール呼び出しと結果、および明示的な終了コードが必要です。複雑なスクリプト、バッチ、shell による編集、曖昧な出力では発火しません。
+
+`ocx advisor set --policy adaptive --max-consultations 2` で設定します。手動と自動の試行はタスクごとに1〜3回の予算を共有し、送信前に `OPENCODEX_HOME/advisor-budget.sqlite` へ予約します。プロキシ再起動や Worker 変更でリセットされません。タスクのダイジェスト、回数、有効期限のみ保存し、最初の試行から24時間で期限切れになります(最大512タスク)。予算が使えない場合も Worker は継続します。送信済みの失敗・キャンセルには費用が発生する可能性があるため返却しません。会話 ID がなければリクエスト単位の手動相談のみです。
+
+Advisor ページと `ocx advisor activity --json`(`GET /api/advisor/activity`)は、このプロセスの24時間以内の最新100件のメタデータを表示します。画面が表示されている間は5秒ごとに更新します。理由、状態、モデル、所要時間、報告された入出力トークン、要求値とアダプターの推論変換値を表示します。本文、コマンド、助言、会話 ID は保存せず、再起動時に消えます。変換値は送信パラメーターの証拠であり、モデル内部の推論を保証しません。未報告は不明のままとし、採用状況や正確な費用は推測しません。
diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md
index 23e2efd7611..fc82f872333 100644
--- a/docs-site/src/content/docs/ja/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md
@@ -11,6 +11,16 @@ description: プロバイダー エントリ、認証、エンドポイント、
この推定は既定で無効です。プロバイダーごとに `inferContextWindow: true` で有効にします。未設定の場合、ウィンドウがないモデルは有効な Context cap、次に 128k を使います。
+## GPT-6.1 Terra
+
+[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) は GPT-5.6 Terra の後継として 2026年10月26日に公開されました。`gpt-6.1-terra` は `gpt-5.6-terra` と並行して利用でき、既定モデルやサブエージェントのモデル一覧は変更しません。GitHub Copilot では Responses を使用します。OpenRouter の ID は `openai/gpt-6.1-terra` で、コンテキストは 1,050,000、100万トークンあたり入力 $1.00、出力 $5.00 です。
+
+ネイティブ Codex:既定コンテキスト 500,000、長文コンテキスト上限 872,000、既定 effort は `low`。段階は `low`、`medium`、`high`、`xhigh`、`max`、`ultra` です。ネイティブの `ultra` は通信時に `max` として送信します。ネイティブ行の高速モードは Fast のみです。
+
+[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra):コンテキスト 1,050,000、最大出力 128,000。入力はテキストと画像、出力はテキスト。知識カットオフは 2026-06-01 です。effort は `none`、`low`、`medium`(既定)、`high`、`xhigh`、`max`。エンドポイントは `v1/responses` と `v1/chat/completions` ですが、ツール呼び出しには Responses が必要です。
+
+100万トークンあたり入力 $1.00、キャッシュ入力 $0.05、キャッシュ書き込み $1.25(30分)、出力 $5.00。入力が 272,000 トークンを超える場合、リクエスト全体の入力・キャッシュは 2×、出力は 1.5× となり、それぞれ $2.00 / $0.10 / $2.50 / $7.50 です。Fast モードは 2× です。
+
## 初回登録時のモデル選択
新しい非 OAuth 接続では、信頼できるモデル一覧の取得が完了するまでモデルの公開を保留します。Models タブの重複しないモデル行が20個以上なら、モデルのスイッチをすべて OFF にします。プロバイダー自体は ACTIVE のままです。実際の認証方式が OAuth または ChatGPT ログインなら既定値を維持します。
@@ -53,7 +63,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ
| `codexWsReuseAcrossTurns?` | `boolean` | `false` | 同一アカウントとスレッドで Codex WebSocket をターンをまたいで再利用します。既定はオフ。オンにすると忙しいソケットを最大750ms待ち、モデルやティアの変更では最大2本を残します。 |
| `upstreamHostCircuitThreshold?` | `number` | `0` | ネイティブOpenAI forwardのResponses送信とcompact送信で、実証済みの接続前DNS/TCP障害に適用するオプトインのサーキットしきい値です。`0`で無効、`1`〜`20`ではその回数の終端論理リクエストが失敗するとprovider-originを30秒間遮断します。遮断中はアカウント選択やupstream送信の前に`Retry-After`付き`503`を返し、時間経過後はhalf-openリクエストを1件だけ許可します。タイムアウトとHTTP応答は数えず、HTTP応答が1件でもあれば回路を閉じます。 Codex Pool ルーティングでアカウントが固定されていない場合にのみ適用され、`codexAccountMode: "direct"` とアカウント修飾セレクターでは動作しません。 |
| `modelCacheTtlMs?` | `number` | `300000` |プロバイダーごとの `/models` キャッシュの鮮度ウィンドウ。 |
-| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic プロンプト キャッシュ ポリシー: 無効、5 分間の一時的、または 1 時間の延長。 |
+| `cacheRetention?` | `"none" \| "short" \| "long"` | Anthropic ファーストパーティ: `"long"`、その他の Anthropic 互換エンドポイント: `"short"` | Anthropic プロンプト キャッシュ ポリシー: 無効、5 分間の一時的、または 1 時間の延長。未設定の場合、`api.anthropic.com`(API キーまたは OAuth アカウント プール)へのリクエストは 1 時間の TTL を使い、互換ゲートウェイやベンダーのエンドポイントは 5 分の TTL のままです。明示した値が常に優先されます。 |
| `tokenGuardian?` | `OcxTokenGuardianConfig` |オフ |オプションのプロアクティブな OAuth 更新および Codex アカウントのウォームアップ ポリシー。 |
各戦略で使う実効しきい値は、アカウントの `codexAccountAutoSwitchThresholds` があればその値、なければグローバルの `autoSwitchThreshold` です。0 は使用量による先行切り替えだけを無効にし、起動時のバインド、ハードロック、クールダウン、モデル利用資格の確認、障害復旧は引き続き適用されます。
@@ -89,7 +99,9 @@ account を削除しても mapping は保持され、同じ id を再追加す
| --- | --- | --- |
| `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`ollama-native`、`azure-openai` (または別名 `azure`) のいずれか。 |
| `baseUrl` | `string` |アップストリーム API のベース URL。ほとんどの組み込み固定エンドポイントは不一致を無視します。衝突安全キー プリセットは、古い同じ名前のカスタム宛先を保持します。 |
-| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, models? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。`maxConcurrentRequests` は実行中リクエスト数を制限する正の整数で、プロバイダー規則またはモデル規則だけに設定できます。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やすか同時実行数を減らします。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP と明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。同時実行数の上限がある場合、標準の Responses WebSocket ターンは応答本文の完了・エラー・キャンセル時に枠を解放できるよう HTTP/SSE を使います。 Cursor を含む `runTurn` アダプターでは、上限は物理送信数ではなく進行中のターン数を数えます。同じターンの RunSSE と BidiAppend は重なり得ますが、別のターンは待機します。後続送信にも開始間隔は適用されます。 |
+| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, models?, keySelection?, keys? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。`maxConcurrentRequests` は実行中リクエスト数を制限する正の整数で、プロバイダー規則またはモデル規則だけに設定できます。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やすか同時実行数を減らします。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP と明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。同時実行数の上限がある場合、標準の Responses WebSocket ターンは応答本文の完了・エラー・キャンセル時に枠を解放できるよう HTTP/SSE を使います。 Cursor を含む `runTurn` アダプターでは、上限は物理送信数ではなく進行中のターン数を数えます。同じターンの RunSSE と BidiAppend は重なり得ますが、別のターンは待機します。後続送信にも開始間隔は適用されます。 |
+| `requestPacing.keySelection?` | `"active"`, `"round-robin"` | 通常リクエストのキー選択です。既定の `active` は保存済みキーと既存の障害時切替を維持します。`round-robin` は保存済み選択を変えず、利用可能なプールのキーを順に割り当てます。トークンや料金ではなく割当回数を分散します。 |
+| `requestPacing.keys?` | `{ requestsPerMinute?, minIntervalMs?, maxConcurrentRequests? }` | キー参照ごとの共通 RPM・開始間隔・同時実行上限です。全モデルで共有し、プロバイダーとモデルの上限も同時に適用します。同一参照は枠を共有しますが、同じ上流プロジェクトの別参照は独立クォータを意味しません。TPM と日次上限は管理しません。 |
| `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 |
| `chatCompletionsPath?` | `string` | `openai-chat` リクエストの相対リソース パス。 `responsesPath` の対となる設定で、同じ形式ルールが適用されます。1つのアップストリームが Chat Completions と Responses を異なるプレフィックスで提供する場合に必要です。モデルごとの wire override はアダプターのみを変更し `baseUrl` は変更しないため、この設定がないと有効化された Chat リクエストが Responses ベースへ送信されます。同梱例は Z.AI です。 |
| `upstreamWebsocket?` | `boolean` | `openai-responses` リクエストで使用するアップストリーム Responses WebSocket トランスポート(既定値は無効)。ファーストパーティの `https://api.openai.com/v1` アップストリームでのみ有効です。カスタムプロバイダーのエンドポイントは常に制限付き HTTP/SSE を使用します。Bun はメッセージ全体を確保する前に受信 WebSocket メッセージのサイズ上限を適用できないためです。正規の ChatGPT `openai` プロバイダーでは、省略すると対象となるターンでアップストリーム WebSocket を使用し、`false` はストリーミングのターンを HTTP/SSE で送信し、`true` は拒否されます。`false` の間はネイティブのターン途中のステアリングとインジェクションを利用できません。このフィールドはクライアント側の `websockets` 設定とは独立しており、エンドポイントと認証情報のどちらも変更しません。HTTP のベース URL は SSE のままとなり、Responses 以外のパスと `openai-chat` リクエストは HTTP を使用します。 |
@@ -112,7 +124,7 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `modelAutoCompactTokenLimits?` | `Record` | モデルごとの正の安全な整数によるソフト自動圧縮予算。実効値であるコンテキストまたは最大入力の 90% の上限を下げることだけができ、有効なコンテキストウィンドウを解決できない場合は出力されません。canonical `openai` では、キーは provider や account-selector の接頭辞を含まない、サポート対象の正確なネイティブモデル ID でなければなりません。provider PATCH はエントリをマージし、キーを `null` にするとそのキーを削除し、フィールド全体を `null` にするとマップを消去します。これらの `null` tombstone は PATCH 専用です。 |
| `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 |
| `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 |
-| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。そのプロバイダーの正確なアップストリーム モデル ID をキーにします(プロバイダー識別子やルーティングされた `provider/model` ラベルではありません)。値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないモデル ID も、任意の OpenAI 互換エンドポイントを対象とするカスタムプロバイダーや、ローカル・内部プロバイダーで有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。ユーザーが明示的に全レートを 0 にした場合は、既知のゼロ料金として見積もります。自動料金に戻すにはそのモデルの設定を削除してください。カタログの全ゼロ料金は引き続きフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 |
+| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。そのプロバイダーの正確なアップストリーム モデル ID をキーにします(プロバイダー識別子やルーティングされた `provider/model` ラベルではありません)。値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないモデル ID も、任意の OpenAI 互換エンドポイントを対象とするカスタムプロバイダーや、ローカル・内部プロバイダーで有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。ユーザーが明示的に全レートを 0 にした場合は、既知のゼロ料金として見積もります。自動料金に戻すにはそのモデルの設定を削除してください。カタログの全ゼロ料金は引き続きフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 |
| `headers?` | `Record` |追加の上流ヘッダー。認証、Cookie、API キー ヘッダー、埋め込まれた改行、および無効な名前は拒否されます。 |
| `openRouterRouting?` | `OpenRouterProviderRouting` |デフォルトの OpenRouter `order`、`only`、および `allowFallbacks` 設定。 `openai-chat` を持つ正規 OpenRouter に対してのみ有効です。 |
| `modelOpenRouterRouting?` | `Record` |プロバイダー全体の OpenRouter 設定を置き換える正確なモデル ID のオーバーライド。 |
@@ -124,7 +136,7 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `modelReasoningEfforts?` | `Record` |モデルごとのラベル。空のリストは努力制御を非表示にします。 |
| `modelSupportsReasoningSummaries?` | `Record` |モデルを `false` に設定して、概要の広告を停止し、概要配信フィールドを削除します。 |
| `modelReasoningSummaryDelivery?` | `Record` |モデルごとの応答配信列挙型。既存の配信フィールドを書き換えます。 |
-| `modelAdapters?` | `Record` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は モデル (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 |
+| `modelAdapters?` | `Record` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は モデル (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 |
| xAI Responses オプトイン(ダッシュボード) | スイッチ | `xai` のみで、`grok-4.5` と `grok-4.6` の `modelAdapters` エントリを原子的に設定または削除します。片方だけの場合は、次のスイッチ操作で両方が正規化されるまで混合状態を表示します。他のオーバーライドと tier 動作は変わりません。 Grok 4.7 は OAuth でレジストリの既定値により Responses を使用し、明示的な `modelAdapters["grok-4.7"] = "openai-chat"` で Chat に切り替えられます。 |
| `xaiResponsesXSearch?` | `boolean` | デフォルトでは無効です。xAI Responses の宛先では、最終的なリクエスト正規化後もライブの `web_search` ツールが残っている場合にのみ、プロバイダーがホストする `x_search` 宣言を追加します。既存の宣言は重複させず、呼び出し元の `tool_choice` / `allowed_tools` セレクターの範囲を拡張することもありません。また、これは `search.xSearch` オプションを持つウェブ検索サイドカーとは別です。 |
| `modelPreferHostedTools?` | `Record` | hosted tool namespace を予約する非 forward Responses gateway 向けの完全一致モデル opt-in。現在は `["image_generation"]` のみを受け付けます。一致したモデルは `openai-responses` wire を使い、その hosted tool をサポートする必要があります。競合するクライアント `image_gen` 宣言を除去し、呼び出し元の tool choice を維持するため selector も書き換えます。OpenAI API の仮想 `-pro` モデルでは、まず選択した公開 ID に一致させ、解決後のベース wire-model ID をフォールバックとして使用します。`modelAdapters` は公開 ID、次にベース ID の順に解決し、後者の結果が最終 wire を決めます。未設定のモデルは通常の alias 動作を維持します。 |
@@ -138,12 +150,13 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `noStopModels?` | `string[]` |発信者指定の`stop`を拒否するモデル。`openai-chat`アダプター、Chatパススルー、Responsesパススルーはこれらのモデルに対してこのフィールドを送りません。組み込みの`xai`プリセットは、xAIがこれを拒否すると文書化している推論モデル(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)をここに登録します。`grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast`は呼び出し元の`stop`をそのまま受け取ります。 |
| `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 組み込みの`xai`プリセットは、xAIがこれらを拒否すると文書化している推論モデル(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)をここに登録します。非推論モデルは呼び出し元のペナルティをそのまま受け取ります。 |
| `noStructuredOutputModels?` | `string[]` | `openai-chat` エンドポイントが `response_format` を拒否する正確なモデル ID。要求モデルが項目と完全一致する場合だけフィールドを省略し、その他の `openai-chat` モデルでは structured-output 変換を維持します。 |
-| `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` / `opencode free` プリセットが DeepSeek 経路に既定で載せます。 |
+| `noJsonSchemaModels?` | `string[]` | `openai-chat` エンドポイントが `json_schema` 形式は拒否しつつ `json_object` は受け入れる正確なモデル ID。この要求はフィールドを削除せず `json_object` に降格して送るため、JSON を求めた呼び出し側は散文ではなく JSON を受け取れます。両方の一覧に載るモデルでは `noStructuredOutputModels` が優先します。`opencode go` / `opencode zen` プリセットが DeepSeek 経路に既定で載せます。 |
| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` の宛先が `developer` ロールを受け付けるかを記録します。`foldDeveloperRoleToSystem` が未設定なら `system`、`true` なら `system`、`false` なら `developer` として送ります。未設定はこの宛先について何も記録されていないことを意味し、`true` は上流がロールを拒否する記録、`false` は受け付ける記録です。いずれの場合もメッセージは会話内の位置を保ち、変わるのはロールだけです。ロールを拒否する宛先は `400 role 'developer' is not allowed` を返してターンが始まらないため、未記録の既定は畳む側にしてあります。 |
| `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` |正確なプレースホルダー ID、欠落している端末 ID、および(`repairInvalidIds` で)正規の `msg_`/`rs_` 接頭辞を欠く message/reasoning ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。組み込み DeepSeek は最後の 2 つをデフォルトで有効にします。 |
| `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 |
-| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key プロバイダーのみ(`authMode: "key"`)。オプトインの同一ターゲット 429 リトライ: `retryOn429` が無ければ無効で、オブジェクトがあれば `enabled: false` でない限り有効になります。429 時に待機(上流の `Retry-After` または固定間隔)してから、キー フェイルオーバーの前に同一キーで同一リクエストを再送します — メインのテキストターン回復ループ、Responses passthrough、画像/動画ブリッジ、web-search サイドカー、ターミナル継続要求をすべてカバーします。再送の対象はプリストリームの HTTP 429 応答のみで、カスタム `runTurn` トランスポートは HTTP リトライループの対象外です。`attempts` は最初の 429 以降の同一キー再送回数(合計送信数 = `attempts` + 1)で、メインの回復ループ・ターミナルガード継続・ブリッジ再試行で共有されるリクエスト単位の予算です。`attempts` を使い切っても同一キーでの再送が止まるだけで、通常のキー フェイルオーバーまたは最終エラー処理が利用可能なターゲットに応じて続きます — キー認証の passthrough ワイヤにはフェイルオーバーがないため、使い切った 429 はそのまま返ります。Codex 自体は 429 をリトライしないため、単一キーのプロバイダーでは唯一の防御です。デフォルト: `enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(1回の待機は `maxIntervalMs` で上限、その上限は 600000)、`respectRetryAfter: true`。 |
+| `promptCacheKey?` | `boolean` | `openai-chat` のプロバイダー全体で `prompt_cache_key` の転送を有効にします。アダプターは受け取ったキーを転送し生成しませんが、Claude Messages 変換では `metadata.user_id`、またはメタデータがなければモデル・システム・ツールの組み合わせから導出します。プリセットが指定しない限り既定は無効です。Mistral は[プロンプトキャッシュ契約](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)に従い有効で、明示的な `false` で無効になります。上流が対応を明記する場合のみ有効にしてください。厳格なゲートウェイは未知のフィールドを HTTP 400 で拒否する場合があります。 |
+| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API キーのプロバイダーのみ(`authMode: "key"`)。未設定なら無効ですが、正規の OpenCode Go と Command Code、および正規の Mistral の `openai-chat` は例外で、再送 6 回、間隔 10 秒、上限 60 秒のポリシーを使います。オブジェクトを指定すると有効になり、`enabled: false` はこの既定ポリシーも無効にします。ストリーム開始前の HTTP 429 で `Retry-After` または固定間隔を待ち、キーの切り替え前に同じキーで同じリクエストを再送します。テキストターンの回復、Responses passthrough、画像・動画ブリッジ、web-search サイドカー、ターミナル継続が対象で、独自の `runTurn` は対象外です。`attempts` は最初の 429 後の同一キー再送回数(総送信数 = `attempts` + 1)で、リクエスト全体で共有します。使い切ると同一キーの再送だけが止まり、通常のキー切り替えか最終エラー処理に進みます。キー認証の passthrough では切り替えがなく、そのまま 429 を返します。正規の Mistral Chat で明示的な上流の待機時間が許容上限を超えると、同一キー再送を止めて元の `Retry-After` を返しますが、別のプールキーが処理できる場合があります。他のプロバイダーでは待機時間を上限で切り詰めます。Mistral は認証情報とモデルごとに期限を共有します。`requestPacing.enabled: false` または `retryOn429.respectRetryAfter: false` で無効になります。再試行時刻のない 429 で分単位の予算不足が検証されると、控えめな 60 秒の回復待機後、プローブを 1 回実行し、成功すれば通常のトラフィックを再開します。正常なトラフィックから次のトークン消費は予測しません。公表された許容量を使い、既知の月間枠の枯渇は除外し、正確なリセット時刻を保証せず、履歴の圧縮も起動しません。Codex 自体は 429 を再試行しません。オブジェクトの既定値:`enabled: true`、`attempts: 3`、`intervalMs: 5000`、`maxIntervalMs: 60000`(待機の上限、設定の最大値は 600000)、`respectRetryAfter: true`。 |
| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | キー認証の `openai-chat` および `openai-responses` プロバイダーのみ。`authMode: "forward"` のプロバイダー(ChatGPT アカウントプール)はこのオプションを読まず、既定の再試行段数を維持します。ストリーム開始前に上流から返される一時的なステータス(500、502、503、504、520、521、522)に対するオプトインの再試行です。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。最初の Responses リクエスト、ターミナルガード継続、ネイティブの `/v1/chat/completions`、および 429/アカウント回復時の再取得が対象です。`attempts` は最初の送信を含め、1 回のリクエストで許可される上流への送信総数です(1~10、デフォルトは 3)。接続リセット回復と共有するリクエスト単位の単一予算であるため、`3` を指定した場合、プロバイダーに到達する実リクエストは最大 3 回です。待機には 400 ms を基準とする固定式の指数バックオフを使用し、上限は 5 秒で、`Retry-After` に従います。レート制限を扱う `retryOn429` とは別の機能であり、ストリーム開始後の失敗は再送されません。 各 Combo ターゲットでは、事前予約した初回送信もそのターゲットの設定総数に含まれます。`attempts: 1` は 1 回だけ送信し、予約を返却して余裕を作ったり、共有上限を広げたりしません。送信前のローカル拒否やキャンセルでは未使用の予約を解放します。 |
| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | ネイティブ `openai-responses` プロバイダー(`authMode: "forward"` を含む)と、上流ヘッダー受信前の汎用 Responses 変換経路に適用されます。呼び出し側が何も観測しないまま失敗した送信を、オプトインで置き換えます。設定がなければ無効で、オブジェクトを指定すると `enabled: false` でない限り有効になります。レスポンスヘッダーが届く前に接続が切れた場合と、ヘッダー後に SSE 本文が制御イベントだけを運んだまま切れた場合の両方が対象です。canonical ChatGPT upstream WebSocket で create フレームの送信後、Responses イベントが届く前にソケットが閉じたかエラーになった場合も同じく対象で、その置き換えは HTTP で送信します。置き換えるのは自己完結したリクエストだけで、`store: false`、完全な `input`、`previous_response_id` / `conversation` / `stream_id` がないこと、クライアントが実行するツールのみ、が条件です。`replacements` は、すべてのレッグとすべてのコンボ子リクエストを合わせて 1 つの論理リクエストが行える置き換え送信の回数です(1..2、デフォルトは 1)。レッグ単位の再試行回数でも送信予算でもないため、置き換え送信もそのレッグがすでに持つ送信許容量に収まる必要があります。すでに出力やツール呼び出しを送ったリクエストは、この値に関わらず置き換えません。元の送信がすでに開始されていた場合は置き換えた推論も課金される可能性があるため、既定では無効です。 汎用変換経路では、初回送信と再構築後の送信のどちらも、同じ許可と送信予算でヘッダー受信前の接続リセットを置き換えます。アダプター独自のトランスポートと、上流ヘッダー受信後の変換ストリーム障害は対象外です。 |
| `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 |
@@ -157,8 +170,8 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `anthropicEofTolerance?` | `boolean` | `message_stop` 前にストリームが終了しても、可視テキストまたは完全な JSON オブジェクトのツール入力が受信済みの場合に限り完了を許可します(Anthropic 互換ゲートウェイ向け)。デフォルトはオフ。 |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google トランスポート/認証モード。デフォルトは`ai-studio`です。 |
| `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google 専用です。省略時または `compatible` は互換スキーマと既存の非直接 400 修復を維持します。`reject-lossy` は初期損失または判定不能な上限付き比較を送信前に拒否し、制約を開く Vertex/Cloud Code Assist 修復を保留します。直接 AI Studio はこの修復を行いません。 |
-| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。 |
-| `location?` | `string` |頂点の位置。環境フォールバックは `GOOGLE_CLOUD_LOCATION` です。 |
+| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。Vertex の API キーは、これが設定されているとプロジェクトエンドポイントを使い、未設定なら `GOOGLE_CLOUD_PROJECT` があっても express モードを使います。ADC は `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` にフォールバックします。`"auto"` を指定すると、Vertex の各 API キーが自身のプロジェクトを解決します(解決に成功した結果はキーごとにキャッシュし、解決に失敗したキーは後続のリクエストで再度プローブします)。1 つのキープールで複数プロジェクトを扱えます。ADC は `"auto"` を拒否します。 |
+| `location?` | `string` | Vertex のロケーション。API キーのプロジェクトエンドポイントは既定で `global` を使い、`GOOGLE_CLOUD_LOCATION` を無視します。ADC にはこの値か `GOOGLE_CLOUD_LOCATION` フォールバックが必要です。 |
| `mcpServers?` | `Record` |カーソルのみ: 標準入出力またはストリーミング可能な HTTP MCP サーバー。 |
| `desktopExecutor?` | `DesktopExecutorConfig` |カーソルのみ: 外部コンピュータ使用および画面録画コマンド。 |
| `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 |
@@ -562,3 +575,8 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ
### `anthropicAccountPool.routes`
`anthropicAccountPool.routes` は Anthropic OAuth の保存済みアカウント ID をモデルに割り当てます。有効なプールでは、大文字小文字を区別する `match` グロブの最初の一致が初回選択と 429 再試行を制限します。`fallback: true` はルート内に適格なアカウントがない場合だけ通常のプールを使います。
+
+
+### `promptLengthPricing`
+
+省略可能な `promptLengthPricing` は4つの基本料金に追加します。`mode: "automatic"`(または省略)はカタログのコンテキスト料金規則を維持し、`mode: "flat"` はその規則を無効にします。`mode: "custom"` では、`thresholdTokens` に正の安全な整数、`comparison` に `"gt"`(>)または `"gte"`(≥)、`rates` に4つの絶対額の代替料金を指定します。判定にはキャッシュの読み書きを含む全プロンプトトークンを使い、出力は含めません。選択された料金は出力を含むリクエスト全体に適用され、しきい値の上下どちらでもカタログの規則を置き換えます。Fast/Priority 調整は料金の選択後に1回だけ適用され、そのモデルについてカタログが公開している Priority との関係に従います。推定値が下限として表示されるのは、カタログがその関係を `lower-bound` と宣言している場合(現在は xAI Grok)だけです。カタログに規則がないモデルでは、Priority 倍率は1回だけ適用されます。**Models → Price** で設定できます。**Reset to automatic** は基本料金と規則を含む上書き全体を削除します。既存設定との互換性は維持され、表示される推定料金だけに関する設定です。
diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md
index aa05232953d..50ddb2fe2ca 100644
--- a/docs-site/src/content/docs/ja/reference/proxy-formats.md
+++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md
@@ -35,6 +35,8 @@ Chat Completions と Messages の各エンドポイントは遅延ツールカ
これとは別に、すべての受信ワイヤで、プロバイダーの `default.` 名前空間の呼び出しはリクエストが宣言した素のツール名に正規化されます(`default.Read` → `Read`)。
+ネイティブ Messages では、`tools: []` と併用されたシステムメッセージ内のインラインツール定義も認識します。その名前は恒久的に削除されるまで呼び出せますが、無関係なクライアントツールは許可されません。ホスト型 MCP 呼び出しとサーバーツールの引数ストリームはネイティブ形式を維持します。
+
## エンドポイントの概要
|クライアントサーフェス |エンドポイント |非ストリームの結果が成功 |成功したストリームまたはソケットの結果 |
diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md
index 3508277d71b..cb8ba43379e 100644
--- a/docs-site/src/content/docs/ko/contributing.md
+++ b/docs-site/src/content/docs/ko/contributing.md
@@ -173,6 +173,10 @@ wire format을 쓴다는 이유만으로 다른 프로바이더 또는 목적지
`passthrough`가 아닌 주장에는 구체적인 제한을 적고, fixture 기반 주장에는 이를 입증하는 정확한 assertion id를
명시하세요. 비밀정보가 없는 request vector를 `tests/fixtures/compatibility/`에 추가하고 production adapter를
대상으로 실행하는 집중 테스트를 작성합니다.
+`passthrough` claim은 manifest test에서 그 feature에 묶인 selector 위의 `equals` assertion 가운데,
+기대값이 같은 위치의 호출자 값 그대로인 것만 인용할 수 있습니다. 그래서 제거되거나 바뀌었거나 관계없는
+필드를 무손실로 표시할 수 없습니다. custom tool과 그 grammar처럼 한쪽만 보존될 수 있는 측면은 별도
+claim으로 나누세요.
호환성 매니페스트는 수동적인 데이터입니다. 일반 router, Responses handler, server startup path가 manifest
catalog를 import하거나 Compatibility Lab을 활성화해서는 안 됩니다.
diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md
index df36ad351a9..4e2ec7dbdbe 100644
--- a/docs-site/src/content/docs/ko/getting-started/installation.md
+++ b/docs-site/src/content/docs/ko/getting-started/installation.md
@@ -26,8 +26,8 @@ npm install -g @bitkyc08/opencodex
install-scripts ... blocked because they are not covered by allowScripts`).
이 경우 번들 Bun 런타임이 준비되지 않을 수 있습니다. 런처는 허용된 번들 복구를
시도한 다음, 절대 경로 PATH 디렉터리의 실행 가능한 Bun을 검증합니다. 정식 버전의
-major가 패키지 pin과 같고 minor가 pin 이상이면 사용할 수 있습니다. 현재 의존성
-1.4.2에서는 1.4.0 이상, 2.0.0 미만입니다. 제한 시간 안에 버전 정책과 identity를 확인하며,
+major가 패키지 pin과 같고 버전이 pin 이상이면 사용할 수 있습니다. 현재 의존성
+1.4.4에서는 1.4.4 이상, 2.0.0 미만입니다. 제한 시간 안에 버전 정책과 identity를 확인하며,
`bun --version` 출력이 `bun -e`로 확인한 `Bun.version`과 같아야 합니다. POSIX에서는 런처가
상위 디렉터리와 심볼릭 링크를 따라 도달하는 구성 요소를 포함한 후보 경로 전체를 검사합니다.
검사하는 각 디렉터리와 심볼릭 링크, 최종 실행 파일은 root 또는 런처의 유효 사용자 소유여야 합니다.
diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md
index 2c06b538f7f..77cc5a35d1e 100644
--- a/docs-site/src/content/docs/ko/getting-started/quickstart.md
+++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md
@@ -18,7 +18,7 @@ ocx init
`ocx init`은 다음 과정을 안내합니다:
-1. **프로바이더 선택** — 내장 레지스트리 프리셋 104개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다.
+1. **프로바이더 선택** — 내장 레지스트리 프리셋 106개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다.
2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다.
3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다.
4. **프록시 포트** — 기본값은 `10100`입니다.
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 f4a6f3354ec..14ebff76cba 100644
--- a/docs-site/src/content/docs/ko/guides/claude-code.md
+++ b/docs-site/src/content/docs/ko/guides/claude-code.md
@@ -192,21 +192,21 @@ unknown은 설정이 아직 opencodex 프록시를 가리키는지 판단할 수
### Picker 모드: 1P Code 탭에 opencodex 모델 표시하기
-Picker 모드는 1P 모드의 일부예요. macOS에서 1P를 선택하면 기본으로 켜지지만,
+Picker 모드는 1P 모드의 일부예요. macOS와 Windows에서 1P를 선택하면 기본으로 켜지지만,
`claudeCode.intercept.picker: false`를 설정하면 꺼져요. 1P Desktop의 Code 탭 모델 선택기를 바꿔서
-사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인에서 로컬 인증 기관을
+사용 가능한 opencodex 모델을 이름으로 보여줘요. 처음 켤 때 macOS 로그인 키체인이나 Windows 현재 사용자 Root 저장소에서 로컬 인증 기관을
신뢰하라는 메시지가 표시될 수 있어요. 이 인증 기관은 `claude.ai`와 그 하위 도메인으로 제한돼요.
내보낼 수 있는 서명용 인증서와 키는 OS 자격 증명 저장소로 보호하며, 일반적인 재시작에서는 같은 것을 재사용해요.
OpenCodex 설정 디렉터리에 Picker 서명 키를 평문으로 저장하지 않아요. 제한된 CA의 전체 검증과 OS 신뢰 확인은
-계속 적용돼요. 승인한 인증서와 키가 같고 자격 증명 저장소를 사용할 수 있다면 재시작 시 인증서 신뢰 설정을
-추가하거나 삭제하지 않아요. 시작 시 복원은 신뢰를 설치하지 않아요. 신뢰가 없거나 철회됐거나 확인할 수 없다면
+계속 적용돼요(Windows에서는 정확한 지문과 확인된 리프로 검증해요). 승인한 인증서와 키가 같고 자격 증명 저장소를 사용할 수 있다면 재시작 시 macOS 인증서 신뢰 설정을
+추가하거나 삭제하지 않아요. 시작 시 복원이나 상태 확인은 신뢰를 설치하지 않아요. 신뢰가 없거나 철회됐거나 확인할 수 없다면
Picker는 대기 상태로 남아요. `ocx claude desktop picker on` 또는 `ocx claude desktop picker trust`를 직접 실행해
-신뢰를 부여하면 돼요.
+신뢰를 부여하면 돼요(Windows에서는 확인 창이 나올 수 있어요).
이전 인증서에서 한 번 마이그레이션할 때는 기존 신뢰를 제거하기 위한 동의가 필요할 수 있어요. 정리가 끝나지
않으면 Picker는 사용할 수 없고 적용된 프로필은 복호화하지 않는 중계로 동작해요. 키체인 잠금 해제나 앱의
-자격 증명 접근 허용은 별도의 macOS 동작이며, 재시작이나 업데이트 때도 메시지가 나올 수 있어요.
-Windows와 Linux에서는 Picker를 지원하지 않으며 Picker CA, 자격 증명 저장소, 프록시 작업을 시작하지 않아요.
+자격 증명 접근 허용은 별도의 macOS 동작이며, 재시작이나 업데이트 때도 메시지가 나올 수 있어요. Windows에서는 `on`이나 `trust`를 직접 실행할 때 루트 추가 확인이 나올 수 있어요.
+Linux에서는 Picker를 지원하지 않으며 Picker CA, 자격 증명 저장소, 프록시 작업을 시작하지 않아요.
기본 Claude 인터셉트는 계속 사용할 수 있고, 로컬 CA 파일에 소유자, 심볼릭 링크, 파일 권한, Windows ACL 검사를 적용해요.
Picker 모드가 켜져 있는 동안 Claude Desktop의 네트워크는 OpenCodex를 거쳐요. OpenCodex가 중단되면
@@ -575,7 +575,7 @@ Messages → Responses로 변환되는 요청에서는 Claude Code의 `/effort`
| 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) |
| 도구 결과의 `tool_reference` | 짝지어진 결과에 `Tool loaded: ` 텍스트로 보존합니다. 도구 선언을 추가하거나 번역 경로의 서버 측 지연 로딩을 활성화하지는 않습니다. |
| `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 |
-| Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) |
+| Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`). `api.anthropic.com`으로 번역하여 보내는 경로에서는 타입이 지정된 `web_search_*`와 `web_fetch_*` 선언도 Anthropic에 전달되어 서버에서 실행됩니다. 웹 검색 사이드카가 `web_search`를 제공하면 사이드카가 우선합니다. 검색과 가져오기 결과는 `server_tool_use` 및 대응하는 `web_search_tool_result` / `web_fetch_tool_result` 블록으로 클라이언트에 반환됩니다 |
| `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` |
| `max_tokens` | `max_output_tokens` |
| `stop_sequences` | `stop` |
diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md
index 22a2035c24f..40c716687da 100644
--- a/docs-site/src/content/docs/ko/guides/codex-app-models.md
+++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md
@@ -104,11 +104,12 @@ GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모
| 경로 | 선택기 id와 카탈로그 메타데이터 |
| --- | --- |
-| Codex 로그인(계정 한정 선택기 행 비활성) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 922,000토큰입니다. |
+| Codex 로그인(계정 한정 선택기 행 비활성) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 922,000토큰입니다. `gpt-6.1-terra`: 기본 500,000 / 장문 872,000. |
| Codex 로그인(계정 한정 선택기 행 활성, 유효한 selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. |
| Codex 로그인(명시적 Daybreak forward 행) | canonical `openai` provider에 정확한 `customModels` 항목이 있을 때만 `openai/gpt-daybreak-blue-latest`를 표시합니다. Daybreak wire id를 유지하고 고정된 Sol capability snapshot(컨텍스트 922,000; 자동 압축점 922,000)을 사용합니다. |
-| OpenAI(API key) | 정확히 열 개의 네임스페이스 행: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 세 개의 `*-pro` 가상 id, 두 Daybreak 별칭 (모두 컨텍스트 922,000; 최대 입력 922,000) |
+| OpenAI(API key) | 네임스페이스 모델에는 `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, `*-pro` 가상 id, Daybreak 별칭이 포함됩니다(컨텍스트 1,050,000; 최대 입력 922,000). `openai-apikey/gpt-6.1-terra`는 컨텍스트 1,050,000, 최대 출력 128,000입니다. |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` (1,050,000) |
| Cursor | 정적 폴백에는 `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` (1,000,000)와 Grok 4.5/4.6/4.7의 일반·Fast 항목(500,000)이 들어갑니다. 4.6과 4.7은 `xhigh`도 노출하며, 실시간 계정 탐색이 어떤 항목을 계속 보일지 정합니다. |
| xAI | 실시간 탐색이 기준입니다. 폴백 카탈로그에는 `xai/grok-4.6`과 `xai/grok-4.7`이 포함되며 기본값은 `xai/grok-4.5`입니다. 세 모델 모두 컨텍스트 창은 500,000입니다. Grok 4.6과 4.7은 `low` / `medium` / `high` / `xhigh`(업스트림 기본값: `high`)를 제공하고, Grok 4.5는 `high`까지만 제공합니다. |
@@ -172,6 +173,46 @@ fast_mode = true
`service_tier`가 제거됩니다(레지스트리가 정식 OpenAI를 `true`, DeepSeek과 Volcengine Ark를 `false`로 분류). 미분류 커스텀 게이트웨이는 호출자가 준 값을 그대로 보존하고 주입도 받지 않습니다. 따라서
처리 불가능한 곳에 fast 옵션이 노출되지 않으며, 커스텀 게이트웨이는 `true`로 명시적으로 옵트인할 수 있습니다.
+### Codex에서 Flex 선택
+
+`service_tier: "flex"` 지원이 확인된 라우팅 프로바이더와 모델에 한해 Flex를
+명시적으로 선언할 수 있습니다. 아래 필드는 **기존** `providers.nanogpt` 설정에
+병합해야 하며 URL과 API 키를 덮어쓰면 안 됩니다.
+
+```json
+{
+ "modelSupportsServiceTier": { "openai/gpt-6-luna": true },
+ "fastWire": {
+ "kind": "service-tier",
+ "canonicalToWire": { "priority": "priority", "flex": "flex" },
+ "foreignCallerTiers": "verbatim"
+ }
+}
+```
+
+적격한 `openai-responses` 라우트에서는 Luna의 Codex 속도 선택기에 Standard,
+Fast, Flex가 나타납니다. `openai-chat` 라우트는 Chat Completions API가
+호출자의 서비스 등급을 지원하는 것이 확인된 경우에만 `chatServiceTier: true`도
+설정해야 합니다. `fastWire` 매핑은 **프로바이더 전체에 적용**되므로 같은
+`providers.nanogpt` 항목에서 서비스 등급 사용이 이미 허용된 다른 모델에도
+Flex 선택지가 표시될 수 있습니다. `modelSupportsServiceTier`에 Luna만 적어도
+이미 적격인 다른 모델을 제외하는 허용 목록이 되는 것은 아닙니다. 다만 Flex 선택지는
+카탈로그에 게시되는 **일반 라우팅 모델 행**에만 표시되며, Combo 네임스페이스와
+Native Alias 행에는 표시되지 않습니다. 따라서 해당 항목에서 서비스 등급 사용이
+허용된 **모든 일반 라우팅 모델**의 Flex 지원 여부를 확인하세요.
+Luna만 Flex를 지원한다면 Fast 지원 모델이 다른 모델과 섞이지 않도록
+**별도 프로바이더 항목**으로 분리해야 합니다. Fast 지원만으로 Flex 지원을
+추정해서는 안 됩니다.
+
+OpenCodex의 전역 Fast 설정은 **Auto**(`fastMode` 생략)로 유지하세요.
+Fast 또는 Default 강제 모드는 Flex 선택을 덮어씁니다. `ocx sync` 후 Codex의
+모델 목록을 갱신하고 속도 선택기에서 **Flex**를 선택하거나, Codex CLI에서
+`/model --speed=flex`를 사용하면 됩니다. 이는 **선택 가능한 등급**이지
+모델별 강제 기본값은 아닙니다. 다른 모델로 바꾸더라도 Flex 사용이 자동 강제되지는
+않습니다. 상위 API로 `service_tier: "flex"`를 전달하지만, 실제 처리 가능 여부,
+요금과 지연 시간은 공급자에 따라 다릅니다. 메뉴나 요청 로그만으로 Flex 과금이
+확정되지는 않습니다.
+
### Codex 데스크톱에서 Ultra Fast 선택
`service_tier: "ultrafast"`를 지원하는 프로바이더에는 전송 매핑을 한 번 설정하면 됩니다.
@@ -198,11 +239,19 @@ opencodex의 Fast 모드를 **Auto**로 유지하세요(`fastMode` 생략). Fast
목록이 남아 있다면 Codex 데스크톱을 다시 시작한 뒤 라우팅 모델의 속도 메뉴에서 **Ultra Fast**를
선택하세요. 기본 속도는 Standard로 유지됩니다.
-사용 가능한 `service-tier` 전송 경로와 명시적인 `ultrafast → ultrafast` 매핑이 있어야 합니다.
-일반 Fast 지원, GPT와 비슷한 이름, 네이티브·계정별 항목, combo 별칭만으로는 추가되지 않습니다.
-매핑을 제거하거나 모델의 service-tier 지원을 끄면 다음 동기화 성공 시 카탈로그와 캐시에서
-자동 생성된 선택지도 제거됩니다. 별도의 `ultraFastTier` 설정은 기존처럼 사용자가 직접 넣은
-카탈로그 tier를 보존하는 용도입니다.
+이 선택지를 라우팅 항목에 자동 생성하려면 사용 가능한 `service-tier` 전송 경로와 명시적인
+`ultrafast → ultrafast` 매핑이 있어야 합니다. 일반 Fast 지원이나 GPT와 비슷한 이름만으로는
+추가되지 않습니다. 이 라우팅 매핑은 네이티브·계정별 네이티브 항목이나 combo 별칭에는
+적용되지 않습니다. 매핑을 제거하거나 모델의 service-tier 지원을 끄면 다음 동기화 성공 시
+카탈로그와 캐시에서 자동 생성된 라우팅 선택지도 제거됩니다.
+Codex가 HTTP로 모델 목록을 불러올 때도 현재 적용 중인 설정의 동일한 선언이 Ultra Fast 선택지를 결정합니다.
+
+라우팅 항목에서는 `ultraFastTier`가 `provider/model` 식별자가 정확히 일치하는 저장된 항목에 사용자가
+별도로 넣은 Ultra Fast tier를 보존합니다. 자동 생성된 라우팅 tier는 사용자 제공 tier로
+취급하지 않습니다. bare native `gpt-6-astra` / `gpt-6.1-sol` 항목과 신뢰된 계정별 복제
+항목에서는 `ultraFastTier`를 켜면 네이티브 기본 tier를 바꾸지 않고 누락된 Ultrafast 선택지도
+생성합니다. 끄면 자동 생성한 것으로 추적되는 네이티브 tier 메타데이터만 제거하며,
+사용자가 별도로 넣거나 수정한 네이티브 tier 항목은 보존합니다.
[OpenAI API 문서](https://developers.openai.com/api/docs/guides/ultrafast-mode)는 GPT-6 Astra와
GPT-6.1 Sol의 Ultrafast 지원을 안내합니다. API 사용 권한과
diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md
index 33ab38aac98..ad7c94bb7d5 100644
--- a/docs-site/src/content/docs/ko/guides/codex-integration.md
+++ b/docs-site/src/content/docs/ko/guides/codex-integration.md
@@ -13,6 +13,18 @@ Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가
권한과 main 계정 정책 검사는 유지됩니다. 이 fallback은 저장 계정의 쿨다운을 해제하거나 호출자
인증을 Pool 선택으로 저장하지 않습니다. 특정 계정에 정확히 고정된 요청은 그 계정에 계속 묶입니다.
+수동 Pool pin은 우선순위 선호이므로 위 fallback을 허용합니다. 특정 계정만 사용해야 하면
+계정이 지정된 모델을 선택하세요. 관측된 native main을 일시 정지하면 일치하는 호출자 자격 증명의
+Pool fallback도 거부하지만, 별도 호출자 로그인을 main으로 간주하지는 않습니다.
+
+계정 ID·개인/워크스페이스 구독 구분·이메일이 native main과 중복되는 Pool 등록 및 재인증은
+거부됩니다. 이메일이 없으면 다른 구성원이라는 근거로 취급하지 않습니다. 서로 다른 워크스페이스,
+이메일로 구분되는 구성원, 개인/워크스페이스 구독은 계속 사용할 수 있습니다. 사용량 조회 실패 시에도
+로그인 토큰의 구독 정보는 유지합니다. 기존 중복 계정은 삭제하지 않고 Pool 선택에서 제외하며 계정
+목록의 `selectionExcludedReason: "duplicate_main"`과 `selectionExcludedAction`으로 이유를 알립니다. native main을 사용하거나
+native Codex를 다른 계정으로 로그인하면 됩니다. 재로그인 감지는 권한을 확보한 다음 native 로그인
+관측에서 반영하며, 수동 목록 조회나 호출자 소유 요청은 이를 위해 native 자격 증명을 읽지 않습니다.
+
## 설정 주입
`ocx init`, `ocx start`, `ocx sync`는 모두 인젝터를 호출합니다. 기본 loopback 바인드에서는 Codex의 빌트인 `openai` 프로바이더 id를 그대로 유지한 채, 그 프로바이더가 opencodex를 바라보게 합니다.
@@ -207,6 +219,17 @@ Windows에서 Orca shell은 `CODEX_HOME`과 `ORCA_CODEX_HOME`을 Orca의 번들
전용 provider 모드의 `requires_openai_auth = true`는 Codex App/TUI의 계정 게이트 화면을 네이티브 Codex와 같은 조건으로 맞춥니다. opencodex는 `/v1/responses`도 WebSocket으로 제공합니다. 전용 provider는 `"websockets": true`일 때만 `supports_websockets = true`를 광고합니다. loopback에서는 Codex의 빌트인 provider가 먼저 WebSocket을 시도할 수 있으며, 비활성화된 proxy는 `426`을 반환해서 Codex가 HTTP/SSE로 fallback합니다.
+**추론 사용량 표시 보존(2.90.0 이상).** 정식 ChatGPT 경로를 HTTP/SSE로 받는
+클라이언트에는, Bun 런타임이 제공하는 상류 WebSocket 연결 응답의 실제
+`x-reasoning-included` 헤더를 전달합니다. 같은 연결을 재사용하는 요청에서도 유지합니다.
+이 표시는 서버가 보고한 사용량에 이전 추론량이 이미 포함됐음을 Codex에 알립니다.
+첫 Responses 이벤트 전에 받은 메타데이터가 우선하며, 이후 메타데이터는 본문에 남지만
+이미 확정한 응답 헤더를 바꾸지는 못합니다. 없는 표시를 만들어 붙이거나 사용량 숫자를
+수정하지 않습니다. 상류가 표시를 보내지 않는 경우와 Codex 자체의 사용량 상태 관리는
+별도 문제이므로, 이 변경만으로 조기 압축 해결을 보장하지 않습니다. 클라이언트도
+WebSocket으로 연결하면 상류 응답 전에 연결 승인이 끝나므로, 그 승인 헤더에 표시를
+나중에 추가할 수는 없습니다.
+
네이티브 ChatGPT forward 요청의 로컬 재생 상태가 만료되었거나 없으면 opencodex는
upstream 요청 전에 `previous_response_not_found`를 반환합니다. Codex WebSocket 클라이언트는
일반 스트림 재시도 한도 안에서 다시 연결하고, 완료된 도구 호출과 결과를 포함한 현재 보유
@@ -367,6 +390,8 @@ ChatGPT 계정을 추가하거나 재인증할 때 OpenCodex는 일반적으로
**Codex Set → Multi-auth**에서 **Codex Auth** 제목 줄의 **Codex 크레딧** 스위치를 켜면 메인 계정과 풀 계정의 최근 크레딧 잔액이 Week 바로 아래에 표시됩니다. 기본값은 꺼짐이며 `showCodexCredits`로 저장됩니다. 잔액은 로케일에 맞는 숫자로 표시하고, 응답에 따라 무제한 또는 초과 사용 한도 도달 안내를 표시합니다. 전체 크레딧 한도가 제공되지 않으므로 막대는 백분율이 아닌 사용 가능 상태를 나타냅니다. 스위치는 표시만 제어하며 새 로그인에는 해당 계정의 조회 결과가 필요합니다.
+**Use credits after limit**을 허용한 저장 계정은 포함 사용량 한도가 소진되고 크레딧 관측이 3분 이상 지나면 기존 백그라운드 주기에서 잔액을 다시 조회합니다. **Direct** 모드에서도 `codexAccountNamespaces`로 명시적으로 연결한 서브 계정 등이 대상입니다. Direct와 메인/서브 계정 분리를 유지하며, Pool 라우팅을 켜거나 연결되지 않은 계정을 조회하지 않습니다. 일시 정지·재인증 필요 계정은 제외합니다. 메타데이터 조회는 검증 대기 계정을 검증하거나 모델 요청을 보내지 않습니다. 조회 실패 시 재시도 대기를 유지하며 요청에는 여전히 최신의 사용 가능한 크레딧 증거가 필요합니다. 일반 응답의 사용량 헤더는 크레딧 잔액 관측 시각을 갱신하지 않습니다.
+
별도의 백그라운드 재검증은 기본적으로 꺼져 있습니다. Token Guardian, `openai`의 `proactive` 갱신 정책, `tokenGuardian.codexWarmupEnabled`가 필요하며 등록 검증 대기 계정은 제외합니다.
### 계정이 요청을 처리하지 못하게 된 이유
diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md
index c9ccf3757b3..e5d8c844516 100644
--- a/docs-site/src/content/docs/ko/guides/combos.md
+++ b/docs-site/src/content/docs/ko/guides/combos.md
@@ -132,7 +132,7 @@ ocx combo set balanced \
| 인증, 구독, 쿼터, 속도 제한, 과부하, 또는 상위 서버 오류로 분류됨 | 상태 코드만으로는 충분하지 않더라도 대상을 쿨다운으로 보내고 넘어갑니다. |
| 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, context overflow, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. |
| ChatGPT 계정으로 Codex를 사용할 때 해당 모델을 지원하지 않는다는 정확한 HTTP 400 거부 | 출력이 시작되기 전에 다음 설정 대상으로 넘어갑니다. 거부 문장 전체가 `detail`, `error.message` 또는 일반 텍스트에 있어야 하며, `detail`과 `error`가 충돌하면 중단합니다. 이 사유만으로 계정을 재인증 상태로 바꾸거나 공급자의 모든 모델에 쿨다운을 걸지는 않습니다. |
-| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값 또는 모델별 이미지 입력 거부(`param: input`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. |
+| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. |
| 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. |
| 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. |
@@ -194,7 +194,7 @@ v1/base/v2 모드와 암호화된 작업의 전체 흐름은 [Sub-agent Surface]
### 대시보드
-로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스는 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있으며, 대상 선택기에서는 비활성 모델과 중첩 콤보를 제외합니다.
+로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스에서 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있습니다. 모델 선택기에는 숨겨진 모델도 포함됩니다. 표시 설정은 외부 모델 목록에만 적용되며 콤보 내부 선택에는 영향을 주지 않습니다. 숨겨진 모델을 선택해도 외부에 표시되지 않습니다. 대상 선택기에서는 비활성 공급자, 중첩 콤보, 결정 서비스 전용 JEV 공급자를 계속 제외합니다.
각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 편집기는 사용 가능한 모든 대상에 대해 설정된 인증 정보의 추론 한도가 소진되었다는 서버 확인이 현재 유효할 때만 할당량을 이유로 저장과 만들기를 차단합니다. 표시 전용 계정·모델·검색·MCP 할당량이나 누락되거나 만료된 라우팅 근거 정보는 이 차단을 일으키지 않습니다. 차단은 해당 한도의 초기화 시점이나 데이터 유효기간이 끝나면 해제되며 페이지가 활성화되거나 표시될 때 다시 확인됩니다. 새로 고침은 콤보 데이터와 할당량을 모두 다시 불러옵니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요.
@@ -209,9 +209,9 @@ ocx combo set --targets provider/model[:weight],...
ocx combo remove --yes
```
-`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`,
+`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`, `--description`,
`--rename-from`도 받습니다. `--native-alias`에는 현재 지원되는 bare native alias와 비어 있지 않은 display name이 필요합니다. `--effort`, `--alias`,
-`--display-name`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의
+`--display-name`, `--description`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의
별칭이고 `delete`는 `remove`의 별칭입니다. 모든 combo 하위 명령은 `ocx route combo ...`에서도 사용할 수 있습니다.
### Management API
@@ -256,6 +256,7 @@ ocx combo remove --yes
| `alias` | 아니요 | 없음 | 선택적으로 앞뒤 공백을 제거한 공개 모델 ID입니다. 위의 alias 규칙을 따릅니다. 빈 값은 alias 없음으로 저장됩니다. |
| `nativeAlias` | 아니요 | `false` | 현재 지원되는 bare native alias가 routing/catalog 우선권을 갖도록 명시적으로 허용합니다. |
| `displayName` | 아니요 | 없음 | catalog 표시 전용 label입니다. `nativeAlias`가 true이면 필수입니다. |
+| `description` | 아니요 | 없음 | catalog 행에 표시되는 설명입니다. `displayName`과 같은 제한이 적용됩니다. 설정하지 않으면 생성된 `Routed via opencodex → combo (combo).` 문구가 유지됩니다. |
## 문제 해결
@@ -285,6 +286,6 @@ opencodex 인스턴스에 기록했는지 확인하세요.
## 선택적 매개변수 호환성
-일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값 또는 모델별 이미지 입력 거부(`param: input`)를 나타내는 구조화된 오류는 출력 시작 전에 다음 적격 대상으로 넘어갈 수 있습니다. 이 경우 쿨다운을 기록하지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다.
+일반적인 400 오류는 종료되지만, `user`를 명시적으로 거부하거나 `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값, 모델별 이미지 입력 거부(`param: input`) 또는 경로가 읽을 수 없는 입력 미디어 거부(`unsupported_input_modality`)를 나타내는 구조화된 오류는 출력 시작 전에 다음 적격 대상으로 넘어갈 수 있습니다. 이 경우 쿨다운을 기록하지 않습니다. 보안 정책 거부, 취소 및 이미 시작된 출력은 재실행하지 않습니다.
[Canonical compatibility details](/guides/combos/#request-local-target-compatibility).
diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md
index 446a6e20c64..37c38585ff1 100644
--- a/docs-site/src/content/docs/ko/guides/desktop-app.md
+++ b/docs-site/src/content/docs/ko/guides/desktop-app.md
@@ -91,8 +91,12 @@ macOS에서 메뉴의 **Start at Login** 항목은 시작 안전 페이지도
**status unknown**은 등록을 읽지 못했다는 뜻입니다. 앱이 프록시를 실행하는 동안 시작 안전 페이지는
백그라운드 서비스 설치를 막아 둡니다. 서비스를 대신 쓰려면 OpenCodex를 종료하고
터미널에서 `ocx service install`을 실행하세요. Codex 런처 shim은 이 페이지에서 설치할 수 있습니다.
-Codex를 실행할 때 프록시가 없으면 shim은 OpenCodex가 프록시를 다시 띄우기를 기다리고, macOS에서
-앱이 종료돼 있으면 앱을 열며, 그 밖의 경우에는 두 번째 프록시를 띄우지 않고 오류를 알립니다.
+Codex를 실행할 때 프록시가 없으면 shim은 OpenCodex가 프록시를 다시 띄우기를 기다립니다. macOS에서
+앱이 종료돼 있고 기록된 소유 정보가 유효한 **Start at Login** 등록과 일치하면 앱을 열고, 그 밖의
+경우에는 두 번째 프록시를 띄우지 않고 오류를 알립니다. 자동으로 다시 열 때는 그 등록에 기록된 앱
+번들 경로를 사용하고, 번들이 선언한 주 실행 파일이 등록된 Desktop 실행 파일과 같은지 확인합니다.
+등록이 없거나 오래됐거나 서로 맞지 않으면 OpenCodex를 직접 여세요. 번들 식별자가 같은 다른 사본을
+대신 고르지 않습니다.
shim이 "활성화 미확인"으로 보이면 셸 시작 파일의 다른 PATH 설정 뒤에
`. ~/.opencodex/codex-shell-env.sh`를 추가하세요.
diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md
index c5a058d89be..aa51c5a3bf6 100644
--- a/docs-site/src/content/docs/ko/guides/integrations.md
+++ b/docs-site/src/content/docs/ko/guides/integrations.md
@@ -3,7 +3,7 @@ title: 연동
description: 대시보드에서 OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline CLI, Kilo, Command Code와 Factory Droid를 opencodex에 연결합니다. 클라이언트마다 스위치가 하나씩 있으며 기록 전마다 백업합니다.
---
-**연결** 페이지는 클라이언트의 설정 파일에 opencodex 프로바이더 블록을 쓰고 다시 제거합니다. 다음 18개 클라이언트는 각각 스위치로 관리합니다.
+**연결** 페이지는 클라이언트의 설정 파일에 opencodex 프로바이더 블록을 쓰고 다시 제거합니다. 다음 클라이언트는 각각 스위치로 관리합니다.
| 클라이언트 | 설정 파일 | 형식 | 변경 적용 시점 | 자격 증명 |
|---|---|---|---|---|
diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md
index f9c973b95d4..e7fe1c3c387 100644
--- a/docs-site/src/content/docs/ko/guides/providers.md
+++ b/docs-site/src/content/docs/ko/guides/providers.md
@@ -55,8 +55,9 @@ shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다.
[`retryOn429`](/ko/reference/configuration/)(동일 키 429 재시도)는 API 키 프로바이더
(`authMode: "key"`)에만 적용됩니다. OAuth·forward·로컬 프리셋은 제외됩니다 — 같은 토큰을
-재전송해서는 안 되며, 로컬 런타임에는 보존할 원격 키가 없습니다. 옵트인입니다: 옵션이 없으면
-꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다.
+재전송해서는 안 되며, 로컬 런타임에는 보존할 원격 키가 없습니다. 문서화된 대상별 기본값을 제외하면 옵트인입니다. 정식 Mistral Chat은 기존 요청 단위 전송 한도 안에서 끈질긴 재시도 정책(재전송 6회, 대체 간격 10초)을 사용합니다. `enabled: false`를 포함한 명시적인 `retryOn429` 설정이 우선합니다. Mistral 프리셋은 전달받은 프롬프트 캐시 키도 기본으로 전달하며, `promptCacheKey: false`로 전달을 끌 수 있습니다. 캐시 친화성은 재사용 가능성을 높이지만 캐시 적중을 보장하거나 토큰 처리량 제한을 없애지는 않습니다.
+
+Mistral은 Studio 키와 Vibe 키 모두에 하나의 API 키 프로바이더를 사용합니다. 키 종류와 요금제 허용량은 [Mistral API 키 문서](https://docs.mistral.ai/admin/identity-access/api-keys)에 설명되어 있으며, 프록시는 키에서 구독 등급을 추론하지 않습니다. 모든 모델에서 업스트림이 명시한 `Retry-After` 기한은 같은 자격 증명과 모델의 호출 사이에서 공유됩니다. 재시도 시각이 없는 429는 유효한 분 단위 카운터가 용량 소진을 보여 주거나, 거절된 쿼리의 측정 비용이 남은 토큰을 넘지만 공시된 전체 허용량 안에 들어올 때 보수적인 60초 복구 대기를 시작할 수 있습니다. 이는 로컬 복구 정책이며 서버 재설정 시각을 정확히 예측하는 것이 아닙니다. 정상 트래픽에서는 다음 쿼리의 캐시나 비용을 예측하지 않습니다. 대기 후 복구 프로브를 한 번 보내고, 성공하면 일반 트래픽으로 돌아갑니다. 근거가 없거나, 형식이 잘못됐거나, 이용 권한이 0이거나, 월간 한도가 소진됐거나, 전체 허용량을 넘는 경우에는 재설정을 지어내거나 기록을 압축하지 않습니다. `requestPacing: { "enabled": false }` 또는 `retryOn429: { "respectRetryAfter": false }`로 공유 페이싱을 끕니다. 같은 키로 재전송할 때 Mistral의 재시도 시각이 `retryOn429.maxIntervalMs`를 넘으면 프록시는 일찍 재전송하지 않고 429와 재시도 힌트를 반환합니다. 라운드 로빈 키 선택이 켜져 있으면 대기 중인 요청은 키 쿨다운을 다시 확인하고 대체 키의 페이싱 용량을 확보하며, 출력 전의 키 거절은 요청의 기존 전송 한도 안에서 정상 키로 복구될 수 있습니다. 컨텍스트 창과 모델별 압축 설정은 서로 독립적으로 유지됩니다.
## 1. ChatGPT 로그인 (forward / 패스스루)
@@ -99,6 +100,7 @@ ocx login google-antigravity
ocx login cursor # Cursor 전용 PKCE 로그인
ocx login command-code # Command Code 브라우저 OAuth (또는 ~/.commandcode/auth.json 가져오기)
ocx login devin # Cognition/Devin: Devin CLI 자격 임포트 우선, 없으면 Auth0 브라우저 로그인
+ocx login dsh-account # DeepSeek Harness Desktop (~/.dsh/.credentials.yaml 권한 가져오기)
ocx login github-copilot # GitHub 디바이스 플로우 → Copilot 토큰 (Copilot Pro/Business)
ocx login codex # Codex 계정 풀 (별칭: chatgpt, openai / 프록시가 실행 중이어야 함)
ocx logout
@@ -115,10 +117,18 @@ ocx logout
| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. |
| `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. |
| `devin` | `devin` | `https://server.codeium.com` | 실험적인 비공식 Cognition/Devin 브리지. 로그인은 먼저 설치된 Devin CLI가 이미 보유한 자격을 가져옵니다(`devin auth login`이 `devin-session-token`을 자기 `credentials.toml`에 씁니다). 없으면 Auth0 브라우저 사인인을 열어 붙여넣은 토큰을 `RegisterUser`로 장기 API 키에 교환합니다. `ocx login devin-cli`는 deprecated alias로 계속 동작합니다. 모델 목록은 `GetCascadeModelConfigs`로 계정마다 조회하며, 스트리밍은 Connect-RPC 위에서 `runTurn` 경로만 씁니다. 대시보드 프리셋에는 기본으로 없으니 직접 추가하세요. |
+| `dsh-account` | `dsh-account` | `https://api.deepseek.com/anthropic` | 설치 및 로그인된 DeepSeek Harness (DSH) Desktop 계정을 재사용하는 실험적 자격 임포트 우선 계정 프로바이더입니다. 명시적 임포트 시 `~/.dsh/.credentials.yaml`에서 엄격히 읽기 전용으로 자격을 읽습니다. DeepSeek API 키 요금 청구와 완전히 독립적입니다. |
| `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. |
Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다.
+Vertex AI의 `gemini-4-argon`에는 스크린샷 같은 도구 결과 이미지를 해당 함수 응답 안에 담아
+전송합니다. 이미지가 있는 도구 호출 뒤에 발생하는 400 오류
+"Requests ending with a model turn are not supported"를 방지하며 이미지를 보존합니다.
+중복된 결과나 일치하는 호출이 없는 결과의 이미지는 도구 응답 뒤의 별도 메시지로 보존하고,
+호출과 연결할 수 없었다는 표시를 남깁니다.
+별도의 설정 변경은 필요하지 않으며, 이 처리는 Vertex Argon에만 적용됩니다.
+
### Google 도구 스키마 손실 진단
Google 도구 선언은 선택된 엔드포인트 클래스에 맞춰 컴파일됩니다. `ocx debug provider on`,
@@ -188,9 +198,20 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http
롤백은 스냅샷이 있을 때만 가능하므로, 세션 저장소가 존재하지만 캡처할 수 없는 경우(파일을 읽을 수 없음, 스키마 불일치, 토큰 선택 모호), `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE`이 실제 CLI 저장소와 다른 가져오기 경로를 가리키는 경우, 또는 기본 CLI 데이터베이스에 인식 가능한 토큰 행이 없는 경우 **계정 추가**는 `kiro-cli` 로그아웃을 거부합니다. 일반 `kiro-cli` 데이터 경로의 손상된 데이터베이스를 수리하거나 제거하고, 가져오기 전용 선택자가 설정돼 있으면 해제한 뒤 다시 시도하세요. 기존 `kiro-cli` 세션이 아예 없는 환경에서는 영향이 없습니다.
+### DeepSeek Account (DSH)
+
+`dsh-account`는 설치 및 로그인된 DeepSeek Harness (DSH) Desktop 애플리케이션의 인증된 계정 자격 증명을 재사용하는 실험적 자격 임포트 우선 계정 프로바이더입니다.
+
+- **DeepSeek API 키와 독립:** `dsh-account`는 사용자의 인증된 DeepSeek 세션 할당량(일반/보너스 지갑 잔액 및 총 지출)을 사용하며, `deepseek` 프로바이더는 플랫폼 API 키 잔액에서 청구됩니다. 두 자격 증명 도메인 및 라우팅은 엄격히 분리되어 상호 폴백되지 않습니다.
+- **명시적 가져오기 전용:** OpenCodeX는 DeepSeek Harness의 설치 및 로그인 여부(`~/.dsh/.credentials.yaml`)를 감지하지만, 사용자의 명시적 명령(`ocx login dsh-account` 또는 웹 대시보드에서 가져오기 클릭) 없이 토큰을 자동 복사, 가져오기 또는 사용하지 않습니다.
+- **DSH 자격 증명 저장소 읽기 전용:** OpenCodeX는 `~/.dsh/.credentials.yaml`을 엄격히 읽기 전용으로 취급합니다. OpenCodeX는 DeepSeek Harness에 속한 어떠한 파일도 쓰기, 수정, 삭제하거나 권한을 변경하지 않습니다.
+- **격리된 자격 증명 복사본 및 로그아웃:** 가져오기 시 OpenCodeX는 DeepSeek의 신뢰할 수 있는 ID 엔드포인트(`/auth-api/v0/users/current`)에서 토큰을 검증하고 자체 복사본을 OpenCodeX 인증 저장소에 저장합니다. OpenCodeX에서 로그아웃하거나 계정을 삭제해도 OpenCodeX 로컬 복사본만 삭제되며, 업스트림 원격 세션 로그아웃을 트리거하거나 DeepSeek Harness Desktop에서 로그아웃되지 않습니다.
+- **백그라운드 토큰 자동 갱신 없음 및 401 재가져오기:** DSH 세션 부여에는 자동 백그라운드 갱신 엔드포인트가 없습니다. 업스트림에서 HTTP 401(`ACCOUNT_TOKEN_INVALID`)을 반환하면 OpenCodeX는 계정을 `needsReauth`로 표시하고 라우팅을 중단합니다. 액세스를 복원하려면 사용자가 `ocx login dsh-account`로 명시적 재가져오기를 수행해야 합니다.
+- **서드파티 면책 조항:** OpenCodeX는 서드파티 클라이언트의 세션 부여 재사용에 대한 DeepSeek의 공식 보증이나 승인을 주장하지 않습니다.
+
## 3. API 키 카탈로그
-opencodex에는 빌트인 프리셋이 104개 들어 있습니다. 키 방식 85개, OAuth 15개, 로컬 3개,
+opencodex에는 빌트인 프리셋이 106개 들어 있습니다. 키 방식 85개, OAuth 17개, 로컬 3개,
기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고,
입력한 키를 검증한 뒤 저장합니다(검증은 프로바이더별로 다릅니다). 주요 항목은 다음과 같습니다:
@@ -245,7 +266,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim
| Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` |
| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` |
| [BigModel Coding Plan — Responses (정적 모델 목록)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` |
-| Qwen Cloud | Token plan(기본): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 종량제: `https://dashscope.aliyuncs.com/compatible-mode/v1` · 또는 사용자 지정 |
+| Qwen Cloud | Token plan(기본): `https://token-plan.maas.qwencloudapi.com/compatible-mode/v1` · 종량제: `https://dashscope.aliyuncs.com/compatible-mode/v1` · 또는 사용자 지정 |
| Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` |
| SiliconFlow | `https://api.siliconflow.cn/v1` |
| Volcengine Ark · Coding Plan · Agent Plan | `https://ark.cn-beijing.volces.com/api/v3` · `https://ark.cn-beijing.volces.com/api/coding/v3` · `https://ark.cn-beijing.volces.com/api/plan/v3` |
@@ -265,13 +286,25 @@ Chat Completions 모델(그리고 Responses 전용이라 Responses로 보내는
[OpenGateway 대시보드](https://opengateway.ai/api-keys)에서 키를 발급한 뒤
`ocx provider add opengateway`를 실행하거나 대시보드에서 **OpenGateway**를 선택하세요.
Chat 요청은 설정한 Bearer 키를 사용하며 공개 모델 목록은 키의 유효성을 검증하지 않습니다.
+OpenGateway는 Responses에서 네이티브 커스텀 도구 호출의 재전송을 거절하므로 프리셋은 코드 모드의 `exec` 등 Codex 커스텀 도구를 전송 전에 함수 도구로 변환합니다.
+Responses 도구 결과 배열은 텍스트로 변환하고 이미지는 짝이 맞는 도구 결과 묶음 뒤의 메시지에 보존합니다. refusal이나 불투명한 내용 등 지원하지 않는 결과 part는 전송 전에 명시적인 클라이언트 입력 오류로 거절합니다.
+이미지의 `original` 상세도는 게이트웨이가 지원하는 `high`로 바꾸며 이미지 바이트는 그대로 유지합니다.
+네이티브 Responses 경로는 텍스트가 없는 완료 응답도 검사합니다. 출력 예산이 소진됐으면
+`incomplete / max_output_tokens`로 알리며 요청한 예산을 임의로 늘리지 않습니다.
+예산이 남고 안전하게 재전송할 수 있는 빈 완료는 기존 호출 한도 안에서 한 번만 재시도합니다.
+`emptyCompletionRetry: false` 또는 `OCX_EMPTY_COMPLETION_RETRY=0`이면 이 재시도를 끕니다.
+텍스트·거절·도구 동작·알 수 없는 스트림 이벤트가 관측되면 재전송하지 않고 원본을 유지합니다.
+다른 제공자의 동작과 재시도 기본값에는 적용하지 않습니다.
+재전송은 명시적인 `store: false`, 완전한 입력, 클라이언트 실행 도구만 있는 요청에 한정합니다.
+`web_search` 같은 서버 실행 도구가 선언돼 있으면 도구 이벤트가 없어도 자동 재전송하지 않습니다.
+
+**OpenCode Zen**(`opencode-zen`)은 [opencode.ai/auth](https://opencode.ai/auth)에서 발급한 API 키로 `https://opencode.ai/zen/v1`을 사용합니다. 무료 모델은 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸릴 수 있습니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않음). 속도 제한 429에 `Retry-After`가 없으면 opencodex가 안내와 합성 `Retry-After`를 추가하며, 유효한 업스트림 헤더가 우선합니다. 동일 키 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다.
-**OpenCode Zen**(`opencode-zen`)과 키 없는 **OpenCode Free** 프리셋은
-`https://opencode.ai/zen/v1`을 공유합니다. 그 게이트웨이의 무료 모델은 종종 분당 약 15–20회 요청의 짧은 창 속도 제한에 걸립니다(커뮤니티 측정; OpenCode는 RPM을 공개하지 않음). Zen은 `Retry-After` / `X-RateLimit-*` 헤더 없는 일반 429를 반환할 수 있습니다. 이는 키 없는 데스크톱 할당량(`opencode-free`에서 약 5시간당 Big Pickle/무료 모델 200회)과 별개입니다. Zen이 그런 429에서 `Retry-After`를 생략하면 opencodex는 클라이언트 오류에 안내를 더하고 합성 `Retry-After`를 붙입니다(업스트림 `Retry-After`가 있으면 그것이 우선). 동일 키 대기 재시도는 [`retryOn429`](/ko/reference/configuration/)로 선택합니다.
+정식 `opencode-zen` 제공자에서 API 키 인증과 공식 경로 `https://opencode.ai/zen/v1` 또는 `https://opencode.ai/inference/v1`을 사용하면 Gemini는 기본 `googleMode: ai-studio`에서 Google 네이티브 API를 자동으로 사용합니다. Zen 모델 ID는 `-tiered` 접미사가 없는 Flash ID도 그대로 유지합니다. Claude는 `googleMode`와 관계없이 Anthropic Messages를 사용합니다. 저장된 설정을 읽을 때 이 네이티브 모델의 조건에 맞는 기존 Chat/Responses 재정의와 조건에 맞는 Responses 전용 `image_generation` 호스팅 도구 선호 설정만 무시합니다. 사용자 지정 제공자, 키 인증이 아닌 경로, 그 밖의 선호 설정은 그대로 유지합니다.
-**키 없는 `opencode-free` 등급은 현재 서드파티 클라이언트에 닫혀 있습니다.** Zen은 `x-opencode-session` 헤더가 없는 요청을 모두 거부하고, 오류 타입 `MissingSessionID`와 "OpenCode's free tier can only be used in OpenCode" 메시지를 돌려줍니다. 관문은 헤더가 있는지만 보기 때문에 프록시가 값을 지어내 통과할 수도 있지만, opencodex는 그렇게 하지 않습니다. 세션 식별자와 버전이 붙은 `opencode/` User-Agent를 만들어 보내는 것은 자신이 OpenCode 클라이언트라고 주장하는 일이고, OpenCode는 이 키 없는 등급에 서드파티 연동 계약을 공개한 적이 없습니다. 그렇게 받아낸 HTTP 200은 허가가 아니라 뚫린 관문일 뿐입니다. 그래서 opencodex는 우회하지 않고 제한을 그대로 알립니다. `opencode-free`로 보낸 요청은 업스트림 관문을 설명하는 오류를 돌려받습니다.
+**`opencode-free` 지원이 제거되었습니다.** OpenCode의 업스트림 무료 등급 제한으로 모든 모델이 `403 permission_denied`를 반환하기 때문입니다([#7255](https://github.com/lidge-jun/opencodex/issues/7255)). 기존 설정을 불러오면 해당 항목과 종속 모델 참조를 메모리에서 비활성화하고 프로세스당 한 번 경고하며 파일은 변경하지 않습니다. 기본 공급자였다면 표준 OpenAI OAuth 경로가 기본값이 됩니다. `opencode-go`, API 키를 사용하는 `opencode-zen` 또는 다른 공급자를 사용하세요. [OpenCode Zen 약관](https://opencode.ai/docs/zen/).
-같은 모델로 가는 지원 경로는 [opencode.ai/auth](https://opencode.ai/auth)에서 발급받은 OpenCode Zen API 키를 쓰는 **`opencode-zen`** 프리셋입니다. 나중에 OpenCode가 키 없는 등급의 서드파티 경로를 공개하면 opencodex도 따라갈 수 있고, 그때까지 이 프리셋은 제한을 기록해 두는 역할을 합니다. 업스트림 약관: [opencode.ai/docs/zen](https://opencode.ai/docs/zen/).
+정상 Claude Desktop 할당과 기본값은 유지됩니다. 소스 모델 허용 목록이 비게 되는 규칙은 비활성화되며, 주입 모델이 제거되면 네이티브 하위 에이전트 동기화도 비활성화됩니다. 이후 설정을 저장하면 제거된 참조는 다시 기록되지 않고 정상적인 동시 편집은 유지됩니다.
대부분은 bearer 키와 함께 `openai-chat` 어댑터를 사용하며, **Xiaomi MiMo**(`xiaomi`) 같은
Anthropic 호환 프리셋은 `anthropic` 어댑터(`x-api-key`)를 사용합니다. Xiaomi에는 OpenAI Chat
@@ -438,6 +471,9 @@ Sol/Terra/Luna를 폴백 목록에 넣어 둡니다.
| Codex 로그인(Pool 또는 Direct) | `gpt-5.6-*` | 922,000 |
| OpenAI (API key) | `openai-apikey/gpt-5.6-*`와 `*-pro` | 922,000 (max input 922,000) |
| OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` | 922,000 |
+| Codex | `gpt-6.1-terra` | 500,000 / 872,000 |
+| OpenAI API | `openai-apikey/gpt-6.1-terra` | 1,050,000 (128,000 max output) |
+| OpenRouter | `openrouter/openai/gpt-6.1-terra` | 1,050,000 |
| Cursor | `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` | 1,000,000 |
네이티브 GPT-5.6 항목은 고정된 업스트림 reasoning 단계를 그대로 따릅니다. 예를 들어 Luna에는
@@ -458,7 +494,7 @@ Amazon Bedrock 네이티브 API처럼 이 구현 중 어느 것과도 맞지 않
Gateway**는 URL에 계정 + 게이트웨이 id를 채워야 합니다.
Copilot은 혼합 wire 카탈로그를 제공합니다. 모델(`gpt-5.3-codex`, `gpt-5.4`,
-`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)은 에이전트
+`gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)은 에이전트
트래픽에 대해 `/chat/completions`를 거부하므로 opencodex는 이 모델들을 내장 기본값으로
Responses API를 통해 라우팅하고, 다른 Copilot 모델은 모두 chat completions를 유지합니다.
우선순위는 하드 wire 핀 → 명시적 [`modelAdapters`](/ko/reference/configuration/providers/)
diff --git a/docs-site/src/content/docs/ko/guides/remote-link.md b/docs-site/src/content/docs/ko/guides/remote-link.md
index ea3733f92e8..5ad0f0ea302 100644
--- a/docs-site/src/content/docs/ko/guides/remote-link.md
+++ b/docs-site/src/content/docs/ko/guides/remote-link.md
@@ -55,7 +55,7 @@ Home의 프로바이더를 사용할 컴퓨터에서 다음을 진행합니다.
- **Connected**는 SSH 터널이 준비되어 Child가 Home 링크를 사용할 수 있다는 뜻입니다.
- **Reconnecting**은 터널을 다시 연결하는 중이라는 뜻입니다. 재시도 중에는 요청이 일시적으로 `Retry-After`와 함께 `503`을 반환할 수 있습니다. 자기 대시보드에서 연결한 Child에서는 요청이 먼저 터널이 돌아오기를 최대 15초 기다립니다.
-- **Failed**는 조치가 필요하다는 뜻입니다. SSH 인증, 확인한 호스트 키, 포워딩 또는 타임아웃 사유를 확인하세요. SSH 터널을 띄운 쪽이 절전, 장애, 재시작 뒤에도 스스로 다시 시도합니다. Home이 추가한 Child라면 Home이, 자기 대시보드에서 연결한 Child라면 Child가 시도합니다. 타임아웃이나 포워딩 오류 뒤에는 약 1분마다, 인증 오류 뒤에는 5분마다 시도합니다. 호스트 키가 바뀐 경우에는 다시 시도하지 않습니다. 바로 다시 연결하려면 Home 대시보드에서 **Retry**를 누르거나 Home에서 `ocx link reconnect --link-id `를 실행하세요. OpenCodex를 재시작하지 않고 그 링크의 터널만 다시 띄웁니다.
+- **Failed**는 조치가 필요하다는 뜻입니다. SSH 인증, 확인한 호스트 키, 포워딩 또는 타임아웃 사유를 확인하세요. SSH 터널을 띄운 쪽이 절전, 장애, 재시작 뒤에도 스스로 다시 시도합니다. Home이 추가한 Child라면 Home이, 자기 대시보드에서 연결한 Child라면 Child가 시도합니다. 타임아웃이나 포워딩 오류 뒤에는 1분 뒤에 다시 시도하고, 다시 실패할 때마다 간격을 두 배로 늘려 최대 5분마다 시도합니다. 인증 오류 뒤에는 5분마다 시도합니다. 호스트 키가 바뀐 경우에는 다시 시도하지 않습니다. 바로 다시 연결하려면 Home 대시보드에서 **Retry**를 누르거나 Home에서 `ocx link reconnect --link-id `를 실행하세요. OpenCodex를 재시작하지 않고 그 링크의 터널만 다시 띄웁니다.
링크가 실패해도 로컬 프로바이더로 조용히 전환하지 않습니다.
diff --git a/docs-site/src/content/docs/ko/guides/remote-workspace.md b/docs-site/src/content/docs/ko/guides/remote-workspace.md
index ef5b6490aec..1dcdf4564ad 100644
--- a/docs-site/src/content/docs/ko/guides/remote-workspace.md
+++ b/docs-site/src/content/docs/ko/guides/remote-workspace.md
@@ -93,8 +93,32 @@ ocx remote-workspace status
프롬프트를 보내면 수락 여부를 즉시 응답하고, 대시보드는 진행과 완료 상태를 폴링합니다. 수락 응답을 잃으면 초안은 제출 여부를 알 수 없다는 알림과 함께 남습니다. 다시 보내기 전에 세션 진행 상태를 확인하세요. 대시보드는 프롬프트를 자동 재시도하지 않습니다.
+턴을 시작하는 동안 Pi 프로세스가 종료되거나 확장 기능에서 오류가 발생하면, Pi가 아직 해당 턴의 수락을 응답하지 않았더라도 세션에 실패가 표시됩니다.
+
프롬프트 실행 중에도 **Stop**을 사용할 수 있습니다. Hub 코딩 에이전트의 현재 턴을 중단하고 활성 Executor 명령을 취소하며, 늦게 온 응답이 중지된 세션을 다시 열지 못하게 합니다.
+## 여러 세션을 분할해서 보기
+
+왼쪽 세션 목록에서 세션을 클릭하면 현재 선택한 창에서 열립니다. 세션을 다른 창의
+왼쪽·오른쪽·위·아래 가장자리로 끌어다 놓으면 화면이 분할됩니다. 최대 네 개의 서로 다른
+세션을 동시에 표시할 수 있고, 이미 표시 중인 세션은 중복 창을 만드는 대신 이동합니다.
+
+세션 옆 **오른쪽으로 분할** 또는 **아래로 분할** 버튼도 사용할 수 있습니다. 경계선을
+드래그하거나 경계선에 키보드 초점을 놓고 방향키를 눌러 크기를 조절하세요. **Home**과
+**End** 키는 최소·최대 크기로 조절합니다. 각 세션의
+입력 중인 글, 전송 상태, 중지 동작은 독립적입니다. **창 닫기**는 배치만 변경하며 세션을
+중지하지 않습니다.
+
+같은 브라우저에서 이 Hub를 다시 열면 창 배치가 복원됩니다. 브라우저 저장소에는 세션
+식별자, 분할 배치와 경계선 크기만 저장하며, 작성 중인 글이나 모델 응답은 저장하지 않습니다.
+새로고침하면 아직 보내지 않은 글은 지워집니다. 화면 너비가 760px 이하이면 경계선 없이
+창을 세로로 쌓아 표시하고, 화면이 넓어지면 저장된 분할 배치와 크기로 돌아갑니다.
+
+이 화면은 OpenCodex 브라우저 대시보드와 데스크톱 앱에서 공통으로 사용합니다. CLI에서는
+`ocx gui`로 대시보드를 열고 정상적인 Hub 설정 후 **원격 워크스페이스**를 선택하세요.
+이 기능은 `codex` 터미널 화면이나 별도 공식 ChatGPT/Codex 앱 내부의 대화 배치를 변경하지
+않습니다.
+
## 재시작과 재연결 동작
Hub는 제한된 세션 메타데이터와 최근 이벤트의 작은 스냅샷을 저장합니다. Hub 재시작 뒤 완료되지 않은 세션은 원래 Executor를 기다립니다. 해당 기기가 다시 연결되면 다음 프롬프트가 원래 Codex 작업, Claude Code 세션 또는 Pi 세션 ID를 이어갑니다.
diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md
index b1aca7ee1b1..9716fc27937 100644
--- a/docs-site/src/content/docs/ko/guides/sidecars.md
+++ b/docs-site/src/content/docs/ko/guides/sidecars.md
@@ -74,8 +74,11 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패
라우팅 모델이 해당 프로바이더의 `noVisionModels`에 있거나 해당 모델이 `modelInputModalities`에서
텍스트 전용으로 선언되어 있고 요청에 이미지가 들어오면, opencodex는 사용 가능한 비전 사이드카 계획이 있을 때에만
메인 호출 **전에** 각 이미지를 설명한 텍스트로 바꿉니다. 사용 가능한 계획이 없으면 원본 이미지는 텍스트 전용
-백엔드로 전달되지 않고 제거됩니다. 모델 카탈로그는 사이드카로 처리되는 모든 모델에 image input을 알립니다.
-콤보는 모든 멤버가 네이티브로 또는 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은
+백엔드로 전달되지 않고 제거됩니다. `visionSidecar.enabled: false`이면 모델 카탈로그는 사용자 정의 모델과
+콤보 멤버를 포함한 텍스트 전용 모델에 사이드카 기반 이미지 입력을 추가하지 않습니다. 네이티브 이미지 지원과
+명시적으로 설정한 이미지 입력은 유지됩니다. `enabled`를 생략하면 사이드카 기반 표시는 켜진 상태이며,
+카탈로그 표시는 사이드카의 인증 정보나 사용 가능 용량을 보장하지 않습니다.
+콤보는 모든 멤버가 네이티브로 또는 활성화된 사이드카를 통해 이미지를 수용하고 콤보의 `imageInput` 설정이 비활성화되지 않은
경우에만 image input을 알립니다. 따라서 Codex 앱 같은 클라이언트는 사이드카가 실행되기 전에 첨부를 차단하지 않고 허용합니다.
`visionSidecar.model`이 없거나 빈 값이면
OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로 사용합니다. 시작 시 명시적으로
@@ -139,6 +142,10 @@ OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.6-luna`를 폴백으로
`PUT /api/sidecar-settings`는 같은 필드를 받습니다. 부분 업데이트는 보내지 않은 키를 유지합니다. `timeoutMs`는 런타임 정수 범위(1–2147483647 ms)를 사용합니다.
+비전의 실제 활성화 상태가 바뀌면 저장 후 관리되는 Codex 카탈로그와 모델 캐시도 갱신합니다.
+갱신을 완료하지 못하면 응답의 `catalogRefreshPending`이 `true`가 되며 저장된 설정은 유지됩니다.
+카탈로그 갱신 완료가 실행 중인 클라이언트의 다시 읽기를 확인하는 것은 아닙니다.
+
웹 검색 사이드카 카드도 같은 구성입니다. 모델 선택기의 첫 행은 **끔 (Off)** 행입니다. 끄면
OpenCodex가 `web_search` 가로채기를 멈추고 Codex 통합이 `~/.codex/config.toml`에
`web_search = "disabled"`를 씁니다. Codex는 자체 모드가 그렇게 될 때까지 네이티브 호스팅
diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md
index c0273412ece..3b1fb5ab0c5 100644
--- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md
+++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md
@@ -33,6 +33,42 @@ opencodex는 이 값을 Codex가 읽는 실시간 `/v1/models` 카탈로그와
v2 로스터의 경우 적합성은 세 가지 상태로 나뉩니다. `"v2"`로 표시된 항목, 명시적으로 `null`로 설정된 항목, 또는 `multi_agent_version` 필드가 없는 항목은 사용 가능합니다. 진짜 `"v1"` 핀은 해당 모델이 다른 협업 서피스에 속한다고 명시하므로 제외됩니다.
+## `spawn_agent`가 광고하는 모델
+
+Codex는 `spawn_agent` 도구 설명에 모델을 최대 5개까지 나열하며, 그 목록은 Codex가 직접 고릅니다. 선택기에 보이는 카탈로그 행을 `priority` 오름차순으로 정렬한 뒤 앞의 5개를 가져옵니다. **Subagents** 페이지의 로스터(`subagentModels`)는 로스터 순서대로 소속 모델에 가장 낮은 priority(계정 셀렉터가 없으면 `0`~`4`)를 주는 방식으로만 이 목록에 영향을 줍니다. 목록을 로스터로 제한하지는 않습니다.
+
+모델을 1~4개만 지정하면 Codex는 남은 칸을 priority 순으로 다음 행들로 채웁니다. 이 행들은 사용자가 고른 것이 아니며, 대시보드도 어떤 모델이 들어갔는지 보여 주지 않습니다. 어떤 행이 들어가는지는 카탈로그에 따라 다릅니다.
+
+| 카탈로그 | 남은 칸을 채우는 행 |
+| --- | --- |
+| Codex 계정 셀렉터 없음 | 나머지 라우팅 `provider/model` 행. 모두 priority `5`를 공유하므로 provider 이름, 그다음 모델 id의 알파벳 순서로 들어갑니다. 지정하지 않은 네이티브 GPT 행은 priority가 `100`에 지정한 id 수를 더한 값 이상이라 그 뒤에 옵니다. |
+| Codex 계정 셀렉터 있음 | 계정 지정 네이티브 행(`/`). 지정한 행 바로 뒤에 옵니다. 지정하지 않은 라우팅 행은 priority `1005`로 옮겨져 그 뒤에 옵니다. |
+
+예를 들어 계정 셀렉터가 없고 로스터가 다음과 같으면:
+
+```json
+{ "subagentModels": ["xai/grok-4.5", "anthropic/claude-sonnet-5"] }
+```
+
+| 칸 | 모델 | Priority | 이유 |
+| ---: | --- | ---: | --- |
+| 1 | `xai/grok-4.5` | `0` | 로스터 첫 번째 항목 |
+| 2 | `anthropic/claude-sonnet-5` | `1` | 로스터 두 번째 항목 |
+| 3 | `deepseek/deepseek-v4-flash` | `5` | 알파벳 순으로 첫 번째인 남은 라우팅 행 |
+| 4 | `google/gemini-3-pro` | `5` | 다음 라우팅 행 |
+| 5 | `moonshot/kimi-k3` | `5` | 다음 라우팅 행 |
+| 6+ | `zai/glm-5.2`, 그다음 네이티브 GPT 행 | `5`, 그다음 `102` | 5개 밖 |
+
+로스터가 비어 있으면 opencodex는 아무것도 고정하지 않고, 5개는 카탈로그의 원래 priority를 따릅니다.
+
+채워진 칸도 일반 광고 항목이므로 메인 에이전트가 그 모델을 고를 수 있습니다. 이 목록은 허용 목록도 아닙니다. 메인 에이전트가 정확한 이름을 대면 Codex는 5개 밖의 카탈로그 모델도 받아들입니다. Codex가 광고하는 목록을 제어하려면:
+
+- 모델 5개를 지정하세요. 로스터가 가득 차면 채울 칸이 남지 않습니다.
+- 절대 제안되지 않았으면 하는 모델은 비활성화하거나 해당 provider의 `selectedModels`에서 빼세요. 비활성화되었거나 선택되지 않은 모델은 선택기에서 빠지므로 칸을 채울 수 없습니다.
+- `modelPickerOrder`는 Codex가 정렬에 쓰는 priority를 바꿉니다. 라우팅 행만 담은 목록은 나열된 미지정 행을 `1000` 이상으로 옮기므로, 나열되지 않은 라우팅 행이 먼저 칸을 채웁니다. bare 네이티브 id가 들어 있는 목록은 지정한 행까지 포함해 선택기 전체를 정렬합니다. 나열된 행이 앞에 오므로 나열되지 않은 어떤 행보다 먼저 광고 칸을 차지합니다. [Model ordering](/guides/model-ordering/)을 참고하세요.
+
+v2에서는 Codex가 5개를 고르기 전에 `"disabled"`로 고정된 행을 건너뜁니다. 그 밖에는 똑같이 동작합니다. opencodex v2 가이던스 로스터(`{{roster}}`)는 5개 안에 든, 설정된 로스터 모델만 나열합니다. 그래서 채워진 모델은 Codex 도구 설명에만 나타나고 opencodex 가이던스에는 나타나지 않습니다. Codex는 세션을 시작할 때 카탈로그를 읽으므로 로스터 변경은 새 세션부터 적용됩니다.
+
## 위임 모델과 추론 강도
대시보드의 **서브에이전트 위임** 설정은 다음 세 가지 값을 제어합니다.
@@ -161,6 +197,10 @@ curl -X PUT http://localhost:10100/api/injection-model \
선택기에서 숨겨져 있거나, 다섯 모델 표시 한도를 넘었거나, 카탈로그에 없거나, v1에 고정되어 있을 수 있습니다. `v2`, `null`, 또는 값이 없는 서피스 값은 사용할 수 있지만, 실제 `"v1"` 핀은 사용할 수 없습니다.
+### 지정하지 않은 모델이 왜 `spawn_agent` 목록에 보이나요?
+
+Codex는 광고하는 칸을 최대 5개까지 카탈로그에서 priority 순으로 채웁니다. 지정한 모델이 5개보다 적으면 다음 행들이 남은 칸을 차지합니다. 위의 "`spawn_agent`가 광고하는 모델" 절을 참고하세요.
+
### 모드를 바꾸면 실행 중인 세션에도 반영되나요?
아닙니다. 모드를 바꾼 뒤에는 새 Codex 세션을 시작하세요. 오래 실행 중인 App 호스트에 오래된 카탈로그 상태가 남아 있으면 `ocx sync`를 실행한 뒤 해당 Codex 서피스를 다시 시작하세요.
diff --git a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md
index 9bb8994418d..dbf8fee9103 100644
--- a/docs-site/src/content/docs/ko/guides/subagent-v1-default.md
+++ b/docs-site/src/content/docs/ko/guides/subagent-v1-default.md
@@ -94,3 +94,9 @@ ocx v2 mode v1
```
CLI는 확인 메시지를 띄우지 않습니다. 같은 설정이므로 이 문서의 동작을 이해한 뒤 선택하세요.
+
+## 자식 에이전트 동시 실행 상한
+
+**Models → Model settings**의 서브에이전트 상한 설정은 주 에이전트를 제외한 자식 에이전트의 최대 동시 실행 수를 정합니다. v1, base, v2와 전역 v2 플래그가 꺼진 하이브리드 모드에서도 사용할 수 있습니다. 서피스 모드는 이 상한과 별도로 선택합니다.
+
+상한을 높이면 더 많은 자식을 허용하지만, 자식을 자동으로 만들거나 실행 속도를 보장하지는 않습니다. 변경 후 새 Codex 세션을 시작하세요. 오래 실행된 App 호스트가 이전 상한을 계속 사용한다면 해당 호스트를 다시 시작하세요.
diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md
index 68c30f9871e..59dc83a038c 100644
--- a/docs-site/src/content/docs/ko/reference/architecture.md
+++ b/docs-site/src/content/docs/ko/reference/architecture.md
@@ -69,7 +69,8 @@ HTTP 경계는 `server/index/serve-options.ts`가 맡고, Responses 데이터
건드리지 않은 채 종료 상태, 지연 시간, 프로바이더/모델, 최선 추정 토큰 사용량을 기록합니다.
요청 전 입력량 추정은 라우팅된 어댑터가 실제로 보내는 내용을 따릅니다. `openai-chat` 모델이
-`preserveReasoningContentModels`에 없으면 어댑터가 이전 assistant thinking을 보내지 않으므로
+명시적 `preserveReasoningContentModels` 목록 또는 미설정 시 DeepSeek Flash/Pro 감지에 따른
+유효 thinking 보존 정책에서 제외되면 어댑터가 이전 assistant thinking을 보내지 않으므로
추정에서도 뺍니다. 그래서 보내지도 않는 기록 때문에 로컬 컨텍스트 한도에서 잘못 거부되는 일이
없습니다. reasoning을 보존하는 모델과 다른 어댑터는 이전 thinking을
실제로 보내므로 계속 계산에 넣습니다.
diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md
index 21a88093a16..3abe8fbd115 100644
--- a/docs-site/src/content/docs/ko/reference/cli.md
+++ b/docs-site/src/content/docs/ko/reference/cli.md
@@ -17,7 +17,7 @@ opencodex CLI는 `ocx`입니다. 첫 번째 명령 이름으로 분기하며, `s
관리 명령은 기록된 런타임 포트와 신원 검사를 사용해 살아 있는 프록시의 management API와 왕복 통신하며, 두 번째 설정 경로를 따로 두지 않습니다. 멈췄거나 닿을 수 없는 프록시는 HTTP 503으로 표시되며 CLI는 0이 아닌 종료 코드를 반환합니다. 명시적으로 오프라인 설정 작업으로 문서화된 명령은 라이브 프록시 없이 설정 파일을 검증하고 수정할 수 있습니다.
-`ocx system codex-cli-update check`는 실행 중인 프록시가 없어도 되며 패키지 레지스트리를 조회하지 않습니다. 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 메타데이터를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`, Windows에서는 `windows_inspection_deferred`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 명령은 소프트웨어를 설치하거나 복구하지 않고, Codex나 npm을 실행하지 않으며, 실행 중인 프로세스를 제어하거나 설정 또는 캐시 상태를 쓰지 않습니다.
+`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요.
Windows x64 설치 관측은 [`attest` 명령](/ko/reference/cli/agents/)을 참조하세요. 명시적 경로 없이 증명에 바인딩된 런처 스냅샷이 식별한 선택 후보를 관측하며, 업데이트 권한이나 런타임 선택을 증명하지 않습니다.
diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md
index 545e1e4a790..c8bb7e53731 100644
--- a/docs-site/src/content/docs/ko/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ko/reference/cli/agents.md
@@ -88,6 +88,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5
## 관측성과 디버그
+`ocx usage --top 25` (`ocx observe usage --top 25`도 동일)는 사람이 읽는 모델 표의 행 수를 1~1000 정수로 지정합니다. 기본값은 10이며 `--search` 사용 시 100입니다. 검색 필터링 후 로컬 표시에만 적용되며 합계, 제공자/계정 행, API 매개변수는 바뀌지 않습니다. `--json`은 유효한 `--top`을 무시하고 기존 전체 보고서 또는 100행 검색 뷰를 유지합니다. 잘못된 값은 요청 전에 종료 코드 2를 반환합니다.
+
`ocx logs filter --json`은 최근 로그 한 구간을 읽고 조건에 맞는 행을 고릅니다. `--scan-limit`은 읽을 행 수, `--limit`은 출력할 행 수입니다. 결과가 비어 있어도 전체 기록에 일치 항목이 없다는 뜻은 아닙니다. `ocx usage --search `는 모델 행만 검색하고 보고서의 총계와 조회 권한 범위는 유지합니다.
`ocx companion usage --json`은 저장된 모델·공급자 표시 설정으로 오늘과 30일 사용량을 읽습니다. 한 구간이 실패하면 다른 구간은 남기고 `partial: true`와 종료 코드 1을 반환합니다. 설정 기본값을 사용했다면 `settingsFallback`, 손상된 설정의 기본값이라면 `settingsCorrupt`도 확인하세요. 자세한 옵션과 응답은 [영문 CLI 기준 문서](/reference/cli/agents/#filter-a-bounded-log-snapshot)를 참고하세요.
@@ -99,7 +101,7 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5
| 별칭 | 대응 리소스 |
| --- | --- |
| `ocx logs [filters] [--follow] [--json|--jsonl]` | `ocx observe logs` |
-| `ocx usage [--range ] [--surface ] [--provider ] [--model ] [--json]` | `ocx observe usage` |
+| `ocx usage [--range ] [--surface ] [--provider ] [--model ] [--search ] [--top <1-1000>] [--json]` | `ocx observe usage` |
| `ocx storage [--json]` | `ocx observe storage` |
| `ocx memory [--json]` | `ocx observe memory` |
@@ -235,9 +237,14 @@ opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가
헤드리스 런타임 설정, 시작, 동기화, 진단, 업데이트를 관리합니다.
`ocx system codex-restart --yes`는 `ocx sync --restart-codex`와 같은 모듈로 Codex
-app-server를 재시작하고 데스크톱 앱도 완전히 종료한 뒤 다시 띄웁니다. 프록시 자체가
-Codex 앱 안에서 실행 중이면 넘길 수 없는 handoff를 약속하지 않고, 대신 실행 가능한
-안내와 함께 거절합니다.
+app-server를 재시작합니다. 모델 선택기는 앱 실행 시점에 만들어지므로, 데스크톱 앱은 현재
+모델 카탈로그가 기록되기 전에 실행된 경우나 이 요청으로 `model_catalog_json` 경로가 바뀐 경우 완전히 종료한 뒤 다시 띄웁니다. 그렇지 않으면
+앱은 열린 채로 자체 app-server를 다시 띄우고, 보내지 않은 초안과 대기 중인 승인을 유지합니다.
+앱이나 카탈로그 상태를 읽을 수 없으면 전체 재시작을 수행합니다. 프록시 자체가 Codex 앱 안에서
+실행 중이면 데스크톱 재시작은 넘길 수 없는 handoff를 약속하지 않고, 대신 실행 가능한 안내와
+함께 거절합니다.
+
+이 요청으로 `model_catalog_json` 경로가 바뀌면 셸이 카탈로그 파일 작성 후 시작되었어도 데스크톱을 재시작합니다.
```bash
ocx system settings --stream-mode eager-relay
@@ -249,7 +256,7 @@ ocx system settings --stream-mode eager-relay
ocx system codex-cli-update check --json
```
-`check`는 패키지 레지스트리를 조회하지 않고, 설정된 설치 후보에 대해 전체 경로를 숨긴 실행 파일 위치와 소유권 근거를 포함한 provenance 정보를 제한된 범위에서 검사합니다. 신뢰할 수 있는 배포 런처 컨텍스트가 인증하는 것은 후보 스냅샷뿐이며, Codex가 성공적으로 실행되었다는 사실은 인증하지 않습니다. 이 단발성 명령은 Codex를 전혀 실행하지 않으므로 환경 또는 저장된 상태에서 얻은 후보는 보고 전용입니다(`managed: false`, 일반적으로 `selection_unattested`). `selectionAttested`는 항상 `false`입니다. JSON 출력에는 `candidateAvailable`, `candidateVersion`, `candidateSource`, `selectionAttested: false`가 포함됩니다. Bun이나 소스에서 직접 실행하면 런처 증거가 없으므로 환경 및 저장된 후보를 무시하고 POSIX에서는 `candidate_unavailable`을 보고할 수 있습니다. Windows에서는 이 첫 조각이 후보 또는 설정 경로의 파일시스템을 전혀 읽지 않습니다. 배포 런처가 증명한 절대 환경 후보에 한해서 앱 번들 또는 버전 관리자라는 어휘적 표지만 보고하며, 그 밖의 Windows 후보는 모두 실패 닫힘 처리합니다. 이 조각은 저장된 선택 상태를 전혀 읽지 않으므로, 환경 후보가 캡처되지 않은 Windows 실행은 `candidate_unavailable`이 아니라 `windows_inspection_deferred`를 보고합니다. 명령이 Codex CLI 설치 여부를 관측할 수 없으므로, 후보가 없다고 단정하는 대신 검사가 연기되었음을 보고합니다. 이 명령은 Codex나 패키지 관리자를 실행하거나 shim을 복구하지 않고, 설정 또는 캐시 상태를 쓰거나 프로세스를 중지하거나 어떤 것도 설치하지 않습니다. 앱에 포함된 후보, 인식된 버전 관리자의 후보, 검증되지 않은 독립 실행형 후보, shim 상태가 모호한 후보는 관리 대상이 아니거나 알 수 없는 것으로 보고되며, 관리 대상으로 분류되지 않습니다.
+`check`는 레지스트리를 조회하거나 Codex를 실행하지 않습니다. POSIX에서는 배포 런처가 캡처한 환경 후보와 런타임 리졸버가 선택한 정규 경로가 같으면 `selectionAttested: true`가 될 수 있습니다. 연결된 npm-global 패키지 매니페스트가 소유권을 증명하고, 후보 버전이 보고된 경우 그 버전이 매니페스트 버전과 일치하면 `managed: true`가 될 수 있으며 저장된 후보는 인증되지 않습니다. Windows는 후보나 설정 파일을 읽지 않고 검사를 연기합니다. 직접 Bun/소스 실행은 런처 증거가 없어 후보를 신뢰하지 않습니다. `plan`은 격리된 임시 npm 설정·캐시·로그로 공식 레지스트리를 조회하고 활성 세션이나 불완전한 프로세스 관측을 거절합니다. 설치하거나 프로세스에 신호를 보내지 않습니다. [명령과 거절 사유의 전체 설명(영문)](/reference/cli/agents/#read-only-codex-cli-update-plan)을 참고하세요.
Windows에서 `CODEX_CLI_PATH=codex` 같은 단순 명령 이름이나 원격 경로·장치 경로가 후보로 캡처되면 `candidate_path_unavailable`을 보고합니다. 후보는 캡처됐지만 해당 경로가 이 검사 대상에 적합하지 않은 경우입니다.
diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
index aa2e4a8db43..23105712070 100644
--- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
@@ -44,7 +44,7 @@ ocx start --socks5-off
### `ocx stop`
-실행 중인 프록시를 PID 기준으로 중지하고, PID 파일을 삭제한 뒤 기본 Codex를 복원합니다. 관리형
+실행 중인 프록시를 PID 기준으로 중지하고 PID 파일을 삭제합니다. 독립 실행 소유자라면 기본 Codex를 복원합니다. 관리형
백그라운드 서비스가 설치되어 있으면 `ocx stop`이 먼저 그 서비스를 중지하므로 프록시가 다시
올라올 수 없습니다. 웹 대시보드의 **Stop** 버튼도 같은 동작(`POST /api/stop`)을 하지만, Windows 작업 스케줄러는 예외입니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 대시보드는 `respawnable_service`로 거절하고 아무것도 바꾸지 않은 채 `ocx stop` 실행을 안내합니다.
@@ -54,6 +54,12 @@ ocx start --socks5-off
넘어가지 않습니다. 영수증에 근거한 지연 복원도 최종 복원과 영수증 정리를 부모가 담당하며,
부모의 공유 설정 복원이 실패하면 종료 실패로 남기고 미완료 영수증을 보존합니다.
+연결된 hub 클라이언트나 Remote Link Child에서는 성공한 API 종료를 포함해 `ocx stop`이 연결 소유의 Codex 라우팅, 모델 카탈로그와 Grok 설정을 보존합니다. `ocx start`는 라우팅을 다시 주입하지 않고 보존한 설정으로 서비스를 재개합니다. Codex를 자체 계정으로 돌리려면 `ocx disconnect`를, 이미 기본 라우팅으로 복원했다면 다시 연결하려고 `ocx sync --restart-codex`를 실행하세요.
+
+보존 기능을 확인할 수 없는 오래된 일반 프록시는 서비스나 프로세스를 중지하기 전에 거절합니다. 런타임을 관리하는 앱이나 서비스에서 업데이트·재시작한 뒤 다시 시도하세요.
+
+이미 중지된 클라이언트는 서비스 관리자가 비활성이고 알려진 로컬 주소에 런타임이 없음을 확인한 뒤 정상적인 미실행 결과를 반환합니다.
+
### `ocx restart`
프록시가 실행 중이면 확인된 정확한 PID와 포트에 in-place 재시작을 요청하고, 정상 드레인을
@@ -106,7 +112,7 @@ dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체
### `ocx recover-history --ocx-compaction --yes`
-라우팅된 provider를 통해 압축된 작업을 native Codex에서 다시 열기 전에 해당 기록을 복구합니다. 이 명령은 UUID로 정확히 하나의 작업을 선택하고 비공개 바이트 단위 백업을 저장한 뒤, OpenCodeX가 소유한 `ocx1:` 압축 상태만 native Codex가 재생할 수 있는 일반 요약으로 변환합니다. native 암호화 콘텐츠와 다른 작업은 변경하지 않습니다. 실행 전에 선택한 작업을 닫으십시오. 처리 중 rollout이 변경되면 파일을 교체하지 않고 복구를 중단합니다.
+라우팅된 provider를 통해 압축된 작업을 native Codex에서 다시 열기 전에 해당 기록을 복구합니다. 이 명령은 UUID로 정확히 하나의 작업을 선택하고 비공개 바이트 단위 백업을 저장한 뒤, OpenCodeX가 소유한 `ocx1:`/`ocx2:` 압축 상태만 native Codex가 재생할 수 있는 일반 요약으로 변환합니다. native 암호화 콘텐츠와 다른 작업은 변경하지 않습니다. 실행 전에 선택한 작업을 닫으십시오. 처리 중 rollout이 변경되면 파일을 교체하지 않고 복구를 중단합니다.
### `ocx uninstall` · `ocx remove`
@@ -209,7 +215,7 @@ ocx status --json
인증이 필요 없는 `GET /readyz` 엔드포인트로 동기화 후 준비 상태를 확인합니다. 준비되면 `200`,
`pending` 또는 종단 상태인 `failed`이면 `Retry-After: 1`과 함께 `503`을 반환합니다. HTTP의 정제된
-식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는
+식별 필드는 `{service, version, uptime, pid, port, status, protocol, minimumClientProtocol, managementUrl, features}`입니다. `protocol`은 허브의 현재 원격 프로토콜, `minimumClientProtocol`은 호환되는 최소 클라이언트 프로토콜, `managementUrl`은 브라우저에서 보이는 표준 관리 origin입니다. `/readyz`가 없는 이전 프록시는
`unreachable`로 fail-closed하며, `/healthz`는 준비 상태가 아닌 별도의 liveness 확인입니다. 기본값은 한 번의
probe이며, `--wait`는 준비 또는 timeout까지 polling하지만 종단 `failed`를 확인하면 즉시 종료합니다.
기본 timeout은 45초이며, `--timeout `는 `--wait`와 함께 써야 하고 양의 정수인 1~300초 범위를 받습니다. CLI JSON은
@@ -238,6 +244,10 @@ single-flight/lock 파일을 만들 수 있는지, 건강하지 않은 OAuth 또
### `ocx sync [--restart-codex] [--restart-app-server-only]`
+> **Remote Link Child:** `ocx sync`는 Home의 Codex 설정도 적용하며, 클라이언트 런타임이 그 적용보다 먼저 시작되었으면 재시작 플래그 없이도 런타임을 재시작합니다. 재시작이 실패하면 이전 런타임이 계속 서비스하고 명령은 0이 아닌 코드로 끝납니다. `ocx status --json`과 `ocx link status --json`의 관련 필드는 [Remote Link apply parity](/reference/cli/lifecycle/#remote-link-apply-parity)(영문)를 참고하세요.
+
+재시작 플래그를 명시하면 카탈로그와 캐시 바이트가 같아도 `config.toml` 변경이 성공적으로 커밋된 뒤 재시작합니다. 설정만 다루는 작업이 변경 없음, 거부, 사전 검증 또는 롤백으로 끝나면 재시작하지 않습니다. 재시작 플래그가 없으면 sync는 Codex를 자동으로 재시작하지 않습니다.
+
설정된 모든 공급자에서 라이브 모델 목록을 가져와 병합된 카탈로그를 Codex에 다시 주입합니다.
공급자를 추가한 뒤나 사용 가능한 모델을 새로 고칠 때 실행합니다.
@@ -260,6 +270,8 @@ app-server / code-mode-host 프로세스에만 `SIGTERM`을 보내고 데스크
명령을 Codex 앱 안에서 실행하면 재시작은 분리된 helper에 넘기고, 이 세션은 앱과 함께
종료됩니다.
+**대상을 고정한 동기화.** `ocx sync --expect-link `(Remote Link Child)와 `ocx sync --expect-hub-key `(hub 클라이언트)는 이 머신이 여전히 그 링크나 그 키로 연결되어 있을 때만 동기화합니다. 그렇지 않으면 아무것도 바꾸지 않고 로컬 공급자도 동기화하지 않으며, 종료 코드 5로 끝나고 stderr 마지막 줄에 `{"phase":"child_link_mismatch"|"hub_client_mismatch","reason":...}`를 출력합니다. 형식이 잘못되었거나 반복된 플래그는 종료 코드 2로 끝납니다. 자세한 내용은 [영어 레퍼런스](/reference/cli/lifecycle/#sync-only-from-the-expected-home---expect-link-and---expect-hub-key)를 참고하세요.
+
### `ocx sync-cache [--restart-codex] [--restart-app-server-only]`
Codex의 로컬 모델 선택기 캐시를 무효화하여, 활성 opencodex 카탈로그에서 다시 빌드되게 합니다.
diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md
index a60a7f21685..a0dae108083 100644
--- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md
@@ -241,12 +241,28 @@ Codex pool selection applies to the next request after clearing existing affinit
### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]`
+저장된 ChatGPT 풀 계정은 네이티브 Codex 홈이 없는 호스트에서도 대시보드와 계정 새로 고침을
+포함해 계속 나열할 수 있습니다. 목록 조회는 Codex 홈을 만들거나 네이티브 메인 계정에서
+로그아웃하지 않습니다. Codex 클라이언트 통합을 비활성화해도 기존 네이티브 로그인의 할당량
+관측은 비활성화되지 않습니다. 명시적으로 지정한 `CODEX_HOME` 경로가 유효하지 않으면 계속
+설정 오류를 반환합니다. `OPENCODEX_HOME` 저장소가 안전하지 않으면 네이티브 Codex 홈이
+없어도 오류를 반환합니다.
+기본 Codex 홈의 경로를 확인할 때도 파일, 대상이 없는 심볼릭 링크, 탐색할 수 없는 상위 디렉터리는 오류를 발생시킵니다.
+
제공자를 지정하지 않으면 Codex 풀, OAuth 계정, 설정된 API 키 풀을 나열합니다. `--all`이 없으면 비어 있는 제공자는 건너뜁니다. 제공자를 지정하면 해당 자격 증명 계열만 나열합니다. 사람이 보는 출력은 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` 형식을 사용하며, 수동으로 선택한 Codex 행에는 `selected`가 표시됩니다. 사용 가능한 Kiro 계정이 두 개 이상 저장되어 있으면 기본적으로 429 응답 시 다른 계정으로 자동 전환하며, 알려진 잔여 할당량이 가장 많은 계정을 우선합니다. 이 전환은 계정 존재만으로 활성화되며 끌 수 없습니다. `oauthAccountFailover.enabled: false`는 429 복구가 아니라 요청을 보내기 전 계정을 고르는 동작만 거부합니다. `ocx account login kiro`는 계정을 한 번에 하나씩 풀에 추가합니다. 빈 결과도 성공입니다. `--json`은 다음을 반환합니다:
```text
{ accounts: AccountRow[], notes: string[] }
```
+Codex JSON 행에는 등록된 `id`와 같은 `slotId`, 그리고 `native-main` 또는 `pool`인
+`slotKind`가 포함됩니다. 프록시가 상위 계정과 사용자 식별값을 모두 확인하면
+`uai1_` 뒤에 소문자 16진수 64자리가 붙는 `upstreamAccountKey`도 제공합니다.
+같은 `openai` 제공자에서 키가 같으면 한 상위 사용자의 슬롯이며, 각 슬롯과 한도 상태는
+그대로 유지해야 합니다. 키는 이 설치 환경에 한정되고 관리자 토큰이 바뀌면 달라질 수
+있습니다. 키가 없을 때는 가려진 이메일이나 한도가 비슷하다는 이유로 묶지 않습니다.
+상위 계정 ID·사용자 식별값·인증 정보 원문은 목록에 포함되지 않습니다.
+
### `ocx account current [--json]`
활성 계정이나 키를 보여줍니다. 수동 고정이 없는 Codex 풀은 우선순위를 고려한 자동 선택을 보고합니다. 가장 우선순위가 높은 적격 tier를 고르고, 그 tier 안에서 할당량 라우팅 기준으로 가장 적게 사용한 항목을 선택합니다. 활성 자격 증명이 없는 다른 계열은 그 상태를 보고하고도 종료 코드 0으로 끝납니다. `--json`은 다음을 반환합니다:
diff --git a/docs-site/src/content/docs/ko/reference/configuration/advisor.md b/docs-site/src/content/docs/ko/reference/configuration/advisor.md
index d0539ae96e1..0e1b4d940fe 100644
--- a/docs-site/src/content/docs/ko/reference/configuration/advisor.md
+++ b/docs-site/src/content/docs/ko/reference/configuration/advisor.md
@@ -1,6 +1,6 @@
---
title: 어드바이저
-description: OpenCodex가 소유한 전문가 상담 사이드카 — 구성된 전문가 모델이 라우팅된 워커에게 조언을 반환하며, manual과 preflight 정책을 제공합니다.
+description: manual, preflight 및 증거 기반 adaptive 정책을 제공하는 독립 전문가 상담.
---
어드바이저는 워커의 작업을 검토하고 조언을 반환하는 독립 전문가 모델입니다. 상담은 OpenCodex가 엔드투엔드로 소유합니다. 프록시가 워커 턴에 합성 `advisor` 도구를 주입하고, 정상적인 라우팅 권위를 통해 상담을 직접 실행하며, 조언을 재주입해 원래 워커가 계속 진행하게 합니다. 워커는 위임하지 않고, 아무것도 spawn하지 않으며, 제공자 자격 증명도 가지지 않습니다.
@@ -9,6 +9,8 @@ description: OpenCodex가 소유한 전문가 상담 사이드카 — 구성된
## 구성
+대시보드 **어드바이저** 페이지에서 카탈로그 선택기로 전문가 모델을 선택하세요. 네이티브, 공급자, 계정 지정, 사용자 정의 및 콤보 선택자의 정확한 카탈로그 식별자를 저장합니다. 비활성 모델과 초기 선택 대기 모델은 새 선택에 표시하지 않습니다. 선택 가능한 목록에 없는 저장된 식별자는 경고와 함께 유지되며, 다른 모델을 선택하거나 지우기 전까지 변경되지 않습니다. 카탈로그 로드 실패 시 현재 선택을 보존하고 재시도를 제공합니다. 텍스트 조언을 반환할 수 있는 모델을 선택하세요. 카탈로그가 상담 호환성을 보장하지는 않습니다. CLI와 API는 라우터가 지원하는 모델 문자열을 계속 허용합니다.
+
```json
{
"advisor": {
@@ -26,11 +28,12 @@ description: OpenCodex가 소유한 전문가 상담 사이드카 — 구성된
| `enabled?` | `boolean` | `false` | 마스터 스위치. 비활성화 시 요청 경로에 어드바이저 동작이 전혀 없습니다. |
| `model?` | `string` | — | 전문가 모델. 라우터가 허용하는 모든 모델 문자열: 네이티브 모델(`gpt-6-astra`), 명시적 `provider/model`(`anthropic/claude-sonnet-4-6`, `xai/grok-...`), 계정 한정 네이티브 모델. 크로스 프로바이더를 완전히 지원합니다. |
| `effort?` | `string` | `"max"` | 어드바이저 호출의 추론 강도(`low`~`ultra`). |
-| `policy?` | `"manual" \| "preflight"` | `"manual"` | 상담 시점. |
+| `policy?` | `"manual" \| "preflight" \| "adaptive"` | `"manual"` | 상담 시점. |
+| `maxConsultationsPerTask?` | `integer` | `3` | 적응형 작업 상담 한도(1–3회 시도, 수동 호출 포함). |
| `timeoutMs?` | `number` | `120000` | 루프백 상담 타임아웃. |
| `contextSharingConsent?` | `"v1"` | 없음 | 설정된 어드바이저 프로바이더로 작업 맥락을 보내는 운영자 동의. 현재 값은 `"v1"`뿐입니다. 없거나, 오래되었거나, 다른 값이면 작업 내용을 보내지 않습니다. `enabled: true`만으로는 동의가 아닙니다. |
-대시보드 **Advisor** 페이지 또는 `ocx advisor status|on|off|set --model --effort --policy `로 관리합니다.
+대시보드 **Advisor** 페이지 또는 `ocx advisor status|on|off|set --model --effort --policy `로 관리합니다.
현재 동의가 없으면 `ocx advisor on`은 프로바이더 간 전송을 켜지 않습니다. 고지를 출력하고 멈춥니다. `ocx advisor on --ack-context-sharing`와 `ocx advisor consent`가 `v1`을 기록합니다. `ocx advisor consent --revoke`는 동의를 지우고 전송을 즉시 멈춥니다. `ocx advisor set`은 동의를 부여하지 않습니다. 대시보드의 동의 확인란은 미리 선택되지 않습니다.
@@ -78,5 +81,13 @@ OpenCodex는 프로바이더 API 키, Authorization 헤더, OAuth 토큰, 백엔
## PR1 제한
- 네이티브 OpenAI 패스스루 턴(ChatGPT 풀 워커)에는 합성 도구가 주입되지 않습니다. 어드바이저는 라우팅(번역) 제공자를 대상으로 합니다. preflight 상담은 run-turn 어댑터에 적용되지만 도구는 적용되지 않습니다.
-- 적응형 트리거가 없습니다: 막힘 감지, 반복 실패 분석, 에스컬레이션 계층, 다중 어드바이저, 투표가 없습니다. `manual`과 `preflight`만 있습니다.
+- 의미 기반 막힘 감지, 완료 검토, 단계별 에스컬레이션, 다중 전문가 또는 투표는 제공하지 않습니다.
- preflight 중복 제거 원장은 프로세스 내에 있습니다. 프록시 재시작 후 진행 중인 작업은 preflight 상담을 한 번 더 받을 수 있습니다.
+
+## 증거 기반 적응형 상담과 활동 기록
+
+`adaptive`는 초기 상담 외에 같은 작업 디렉터리의 같은 검증이 실패하고, 이후 파일 편집이 성공한 뒤 새 검증 실행도 실패했을 때 상담합니다. 검증 성공은 실패 상태를 지웁니다. 편집 전에 시작한 검증은 수정 후 재시도가 아닙니다. 초기 상담과 수정 실패 상담은 별도로 중복을 방지하며 자동 상담에는 안정적인 대화 식별자가 필요합니다. 짝지어진 도구 호출/결과와 명시적 종료 코드가 필요합니다. 복잡한 스크립트, 일괄 호출, shell 편집 및 모호한 출력은 트리거하지 않습니다.
+
+`ocx advisor set --policy adaptive --max-consultations 2`로 설정합니다. 수동 및 자동 시도는 작업별 1–3회 예산을 공유하며 전송 전에 `OPENCODEX_HOME/advisor-budget.sqlite`에 예약합니다. 프록시 재시작이나 Worker 변경으로 초기화되지 않습니다. 작업 다이제스트, 횟수, 만료 시간만 보관하며 첫 시도 후 24시간에 만료됩니다(최대 512개 작업). 예산을 사용할 수 없어도 Worker는 계속합니다. 전송된 실패·취소 호출도 비용이 발생할 수 있으므로 예약을 돌려주지 않습니다. 안정적인 식별자가 없으면 요청별 수동 상담만 가능합니다.
+
+Advisor 페이지와 `ocx advisor activity --json`(`GET /api/advisor/activity`)은 이 프로세스의 최근 24시간 상담 메타데이터를 최대 100개 표시합니다. 화면이 보일 때 5초마다 갱신합니다. 이유, 상태, 모델, 시간, 보고된 입력/출력 토큰, 요청 및 어댑터 매핑 추론값을 포함합니다. 작업 본문, 명령, 조언, 대화 식별자는 저장하지 않으며 재시작 시 사라집니다. 매핑은 전송 파라미터의 증거일 뿐 내부 추론을 보장하지 않습니다. 미보고 값은 알 수 없음으로 유지하며 정확한 비용이나 조언 채택을 추정하지 않습니다.
diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md
index 6ece10e3718..6a0a43ef73b 100644
--- a/docs-site/src/content/docs/ko/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md
@@ -11,6 +11,16 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할
이 추정은 기본적으로 꺼져 있습니다. 공급자별로 `inferContextWindow: true`를 설정해야 켜집니다. 설정하지 않으면 창이 없는 모델은 활성 Context cap, 그다음 128k를 사용합니다.
+## GPT-6.1 Terra
+
+[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/)는 GPT-5.6 Terra의 후속 모델로 2026년 10월 26일 출시되었습니다. `gpt-6.1-terra`는 `gpt-5.6-terra`와 함께 제공하며 기본 모델이나 하위 에이전트 모델 목록은 바꾸지 않습니다. GitHub Copilot에서는 Responses를 사용합니다. OpenRouter ID는 `openai/gpt-6.1-terra`이며 컨텍스트는 1,050,000, 100만 토큰당 입력 $1.00 / 출력 $5.00입니다.
+
+네이티브 Codex: 기본 컨텍스트 500,000, 장문 상한 872,000, 기본 effort `low`, 단계는 `low`, `medium`, `high`, `xhigh`, `max`, `ultra`입니다. 네이티브 `ultra`는 전송 시 `max`로 변환합니다. 네이티브 행의 고속 모드는 Fast만 제공합니다.
+
+[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra): 컨텍스트 1,050,000, 최대 출력 128,000, 텍스트·이미지 입력과 텍스트 출력, 지식 기준일 2026-06-01입니다. effort는 `none`, `low`, `medium`(기본), `high`, `xhigh`, `max`입니다. 엔드포인트는 `v1/responses`와 `v1/chat/completions`이며 도구 호출에는 Responses가 필요합니다.
+
+100만 토큰당 입력 $1.00, 캐시 입력 $0.05, 캐시 쓰기 $1.25(30분), 출력 $5.00입니다. 입력이 272,000 토큰을 초과하면 요청 전체에 입력·캐시 2×, 출력 1.5×를 적용하여 각각 $2.00 / $0.10 / $2.50 / $7.50입니다. Fast 모드는 2×입니다.
+
## 처음 등록할 때의 모델 선택
신규 비-OAuth 연결은 신뢰할 수 있는 모델 목록을 확보할 때까지 모델 노출을 보류합니다. Models 탭의 중복 없는 모델 행이 20개 이상이면 모델 스위치를 모두 OFF로 설정합니다. 프로바이더는 활성 상태를 유지합니다. 실제 인증 방식이 OAuth나 ChatGPT 로그인인 연결은 기존 기본값을 유지합니다.
@@ -53,7 +63,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동
| `codexWsReuseAcrossTurns?` | `boolean` | `false` | 같은 계정과 스레드의 Codex WebSocket을 턴 사이에 재사용합니다. 기본값은 꺼짐입니다. 켜면 바쁜 소켓을 최대 750ms 기다리고, 모델이나 티어가 바뀌어도 소켓을 최대 2개 유지합니다. |
| `upstreamHostCircuitThreshold?` | `number` | `0` | 네이티브 OpenAI forward Responses와 compact 전송에서 입증된 연결 전 DNS/TCP 실패에 적용하는 선택적 회로 차단 임계값입니다. `0`은 비활성화하며, `1`~`20`은 이 횟수만큼 최종 논리 요청이 실패하면 provider-origin을 30초 동안 차단합니다. 차단 중에는 계정 선택이나 업스트림 전송 전에 `Retry-After`가 포함된 `503`을 반환하고, 시간이 지나면 반개방 요청 하나만 허용합니다. 타임아웃과 HTTP 응답은 집계하지 않으며, HTTP 응답이 하나라도 오면 회로를 닫습니다. Codex Pool 라우팅에서 계정이 고정되지 않은 경우에만 적용되며, `codexAccountMode: "direct"` 및 계정 한정 선택자에서는 동작하지 않습니다. |
| `modelCacheTtlMs?` | `number` | `300000` | 공급자별 `/models` 캐시의 최신성 창입니다. |
-| `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic 프롬프트 캐시 정책입니다. 비활성, 5분짜리 임시, 1시간짜리 확장 중 하나입니다. |
+| `cacheRetention?` | `"none" \| "short" \| "long"` | Anthropic 퍼스트파티: `"long"`, 그 외 Anthropic 호환 엔드포인트: `"short"` | Anthropic 프롬프트 캐시 정책입니다. 비활성, 5분짜리 임시, 1시간짜리 확장 중 하나입니다. 설정하지 않으면 `api.anthropic.com`(API 키 또는 OAuth 계정 풀) 요청은 1시간 TTL을 쓰고, 호환 게이트웨이와 벤더 엔드포인트는 5분 TTL을 유지합니다. 명시한 값이 항상 우선합니다. |
| `tokenGuardian?` | `OcxTokenGuardianConfig` | 꺼짐 | 선택적 선제 OAuth 갱신과 Codex 계정 워밍업 정책입니다. |
각 전략의 계정별 유효 임계값은 `codexAccountAutoSwitchThresholds` 항목이 있으면 그 값이며, 없으면 전역 `autoSwitchThreshold`입니다. 0은 사용량 기반 사전 전환만 비활성화합니다. 시작 바인딩, 하드 잠금, 쿨다운, 모델 사용 권한 검사와 장애 복구는 계속 적용됩니다.
@@ -89,7 +99,9 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| --- | --- | --- |
| `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `ollama-native`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. |
| `baseUrl` | `string` | 상위 API 기본 URL입니다. 대부분의 내장 고정 엔드포인트는 불일치를 무시합니다. 충돌 안전 키 프리셋은 같은 이름의 이전 사용자 지정 목적지를 보존합니다. |
-| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, models? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. `maxConcurrentRequests`는 진행 중 요청 수를 제한하는 양의 정수이며 provider 또는 model 규칙에서 단독으로 사용할 수 있습니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 늘리거나 동시성을 줄일 수 있습니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP 및 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. 동시성 제한이 있으면 표준 Responses WebSocket 턴은 응답 본문의 완료·오류·취소 때 점유 슬롯을 해제할 수 있도록 HTTP/SSE를 사용합니다. Cursor를 포함한 `runTurn` 어댑터에서는 물리적 전송 수가 아니라 진행 중인 턴 수를 제한합니다. 같은 턴의 RunSSE와 BidiAppend는 겹칠 수 있지만 다른 턴은 대기합니다. 후속 전송에도 시작 간격은 적용됩니다. |
+| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, maxConcurrentRequests?, models?, keySelection?, keys? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. `maxConcurrentRequests`는 진행 중 요청 수를 제한하는 양의 정수이며 provider 또는 model 규칙에서 단독으로 사용할 수 있습니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 늘리거나 동시성을 줄일 수 있습니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP 및 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. 동시성 제한이 있으면 표준 Responses WebSocket 턴은 응답 본문의 완료·오류·취소 때 점유 슬롯을 해제할 수 있도록 HTTP/SSE를 사용합니다. Cursor를 포함한 `runTurn` 어댑터에서는 물리적 전송 수가 아니라 진행 중인 턴 수를 제한합니다. 같은 턴의 RunSSE와 BidiAppend는 겹칠 수 있지만 다른 턴은 대기합니다. 후속 전송에도 시작 간격은 적용됩니다. |
+| `requestPacing.keySelection?` | `"active"`, `"round-robin"` | 정상 요청의 키 선택 방식입니다. 기본 `active`는 저장된 키와 기존 오류 전환을 유지하며, `round-robin`은 저장된 활성 키를 변경하지 않고 사용 가능한 풀 키를 요청별 순서대로 배정합니다. 토큰이나 비용이 아니라 배정 횟수를 분산합니다. |
+| `requestPacing.keys?` | `{ requestsPerMinute?, minIntervalMs?, maxConcurrentRequests? }` | 키 참조별 동일한 RPM·최소 시작 간격·동시 요청 제한입니다. 모든 모델에서 공유하며 프로바이더·정확한 모델 상한과 함께 적용합니다. 중복 참조는 한 슬롯을 공유하지만 같은 업스트림 프로젝트의 다른 참조가 독립 할당량을 뜻하지는 않습니다. TPM·일일 한도는 처리하지 않습니다. |
| `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. |
| `chatCompletionsPath?` | `string` | `openai-chat` 요청의 상대 리소스 경로로, `responsesPath`와 동일한 형식 규칙이 적용되는 대응 항목입니다. 하나의 업스트림이 Chat Completions와 Responses를 서로 다른 접두사로 제공할 때 필요합니다. 모델별 wire override는 어댑터만 바꾸고 `baseUrl`은 그대로 두므로, 이 설정이 없으면 옵트인된 Chat 요청이 Responses base로 전송됩니다. Z.AI가 제공되는 예시입니다. |
| `upstreamWebsocket?` | `boolean` | `openai-responses` 요청에 대한 업스트림 Responses WebSocket 전송을 선택적으로 활성화합니다(기본값 `false`). 퍼스트파티 `https://api.openai.com/v1` 업스트림에서만 적용되며, 사용자 지정 공급자 엔드포인트는 항상 제한된 HTTP/SSE를 사용합니다. Bun은 전체 메시지를 할당하기 전에는 수신 WebSocket 메시지 크기 제한을 적용할 수 없기 때문입니다. 정식 ChatGPT `openai` 공급자에서는 생략하면 대상 턴에서 업스트림 WebSocket을 사용하고, `false`는 스트리밍 턴을 HTTP/SSE로 전송하며, `true`는 거부됩니다. `false`이면 네이티브 턴 중 스티어링과 주입을 사용할 수 없습니다. 이 필드는 클라이언트 측 `websockets` 설정과 독립적이며 엔드포인트와 자격 증명을 변경하지 않습니다. HTTP 기본 URL은 SSE를 유지하고, Responses가 아닌 경로와 `openai-chat` 요청은 HTTP를 사용합니다. |
@@ -112,7 +124,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| `modelAutoCompactTokenLimits?` | `Record` | 모델별 양의 안전 정수형 소프트 자동 압축 예산입니다. 유효한 컨텍스트 또는 최대 입력의 90% 한도를 낮출 수만 있으며, 유효 컨텍스트 창을 결정할 수 없으면 내보내지 않습니다. canonical `openai`에서는 키가 공급자나 계정 선택자 접두사가 없는 정확한 지원 네이티브 모델 ID여야 합니다. 공급자 PATCH는 항목을 병합하며, 키를 `null`로 지정하면 해당 키를 삭제하고 필드 전체를 `null`로 지정하면 맵을 지웁니다. 이 `null` tombstone은 PATCH에서만 사용할 수 있습니다. |
| `defaultMaxOutputTokens?` | `number` | 클라이언트가 `max_output_tokens`를 생략했을 때 쓰는 공급자 전반의 `openai-chat` 폴백입니다. |
| `modelMaxOutputTokens?` | `Record` | 양수 모델별 `openai-chat` 폴백 예산입니다. 정확한 일치와 패턴 일치가 공급자 기본값보다 우선합니다. |
-| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 해당 공급자의 정확한 업스트림 모델 ID를 키로 사용하며(공급자 식별자나 라우팅된 `provider/model` 레이블이 아님) 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 커스텀 공급자는 `openai-chat` 어댑터로 임의의 OpenAI 호환 엔드포인트를 대상으로 할 수 있으며, 내장 카탈로그에 없는 로컬·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 사용자가 모든 요율을 명시적으로 0으로 설정하면 비용을 0으로 추정합니다. 자동 가격으로 되돌리려면 해당 모델 항목을 삭제하세요. 카탈로그의 전부 0인 요율은 계속 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. |
+| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 해당 공급자의 정확한 업스트림 모델 ID를 키로 사용하며(공급자 식별자나 라우팅된 `provider/model` 레이블이 아님) 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 커스텀 공급자는 `openai-chat` 어댑터로 임의의 OpenAI 호환 엔드포인트를 대상으로 할 수 있으며, 내장 카탈로그에 없는 로컬·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 사용자가 모든 요율을 명시적으로 0으로 설정하면 비용을 0으로 추정합니다. 자동 가격으로 되돌리려면 해당 모델 항목을 삭제하세요. 카탈로그의 전부 0인 요율은 계속 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. |
| `headers?` | `Record` | 추가 상위 헤더입니다. Authorization, cookies, API-key 헤더, 내장 개행, 잘못된 이름은 허용하지 않습니다. |
| `openRouterRouting?` | `OpenRouterProviderRouting` | 기본 OpenRouter `order`, `only`, `allowFallbacks` 선호도입니다. 정식 OpenRouter와 `openai-chat`에서만 유효합니다. |
| `modelOpenRouterRouting?` | `Record` | 공급자 전반의 OpenRouter 선호도를 덮어쓰는 정확한 모델 id별 재정의입니다. |
@@ -124,7 +136,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| `modelReasoningEfforts?` | `Record` | 모델별 레이블입니다. 빈 목록이면 effort 제어를 숨깁니다. |
| `modelSupportsReasoningSummaries?` | `Record` | 모델을 `false`로 두면 summary 광고를 멈추고 summary 전달 필드를 제거합니다. |
| `modelReasoningSummaryDelivery?` | `Record` | 모델별 Responses 전달 enum입니다. 기존 delivery 필드를 다시 씁니다. |
-| `modelAdapters?` | `Record` | 혼합 와이어 게이트웨이를 위한 모델별 `openai-chat` 또는 `openai-responses` 와이어 재정의입니다. 명시적 항목이 레지스트리 기본값보다 우선합니다. DeepSeek 프리셋은 `deepseek-v4-flash`에 네이티브 Responses를 선택할 수 있고, GitHub Copilot은 모델(`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)을 Responses 전용 기본값으로 선언합니다. 이 모델들은 에이전트 트래픽에서 `/chat/completions`를 거부하기 때문입니다. `gpt-5.4-nano`처럼 기본값이 없는 모델은 여기서 직접 옵트인할 수 있습니다. 단일 와이어 상위 항목과 정식 ChatGPT forward는 재정의를 거부합니다. |
+| `modelAdapters?` | `Record` | 혼합 와이어 게이트웨이를 위한 모델별 `openai-chat` 또는 `openai-responses` 와이어 재정의입니다. 명시적 항목이 레지스트리 기본값보다 우선합니다. DeepSeek 프리셋은 `deepseek-v4-flash`에 네이티브 Responses를 선택할 수 있고, GitHub Copilot은 모델(`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-6.1-sol`, `gpt-6.1-terra`, `gpt-6-astra`, `grok-4.5`, `grok-4.6`, `mai-code-1.1-flash`, `mai-code-1-flash-picker`)을 Responses 전용 기본값으로 선언합니다. 이 모델들은 에이전트 트래픽에서 `/chat/completions`를 거부하기 때문입니다. `gpt-5.4-nano`처럼 기본값이 없는 모델은 여기서 직접 옵트인할 수 있습니다. 단일 와이어 상위 항목과 정식 ChatGPT forward는 재정의를 거부합니다. |
| xAI Responses 옵트인(대시보드) | 스위치 | `xai`에서만 `grok-4.5`와 `grok-4.6`의 `modelAdapters` 항목을 원자적으로 설정하거나 지웁니다. 한 항목만 있으면 다음 스위치 쓰기가 둘을 정규화할 때까지 혼합 상태로 표시됩니다. 다른 재정의와 티어 동작은 바뀌지 않습니다. Grok 4.7은 OAuth에서 레지스트리 와이어 기본값으로 Responses를 사용하며, 명시적 `modelAdapters["grok-4.7"] = "openai-chat"` 항목으로 Chat으로 전환할 수 있습니다. |
| `xaiResponsesXSearch?` | `boolean` | 기본적으로 비활성화됩니다. xAI Responses 대상에서는 최종 요청 정규화 후에도 실제 `web_search` 도구가 남아 있을 때만 공급자가 호스팅하는 `x_search` 선언을 추가합니다. 기존 선언은 중복하지 않고, 호출자의 `tool_choice`/`allowed_tools` 선택기 범위를 확장하지 않으며, 웹 검색 사이드카의 `search.xSearch` 옵션과는 별개입니다. |
| `modelPreferHostedTools?` | `Record` | hosted tool namespace를 예약하는 non-forward Responses gateway용 정확한 모델 ID opt-in입니다. 현재 `["image_generation"]`만 허용하며, 일치하는 모델은 `openai-responses` wire를 사용하고 해당 hosted tool을 지원해야 합니다. 충돌하는 클라이언트 `image_gen` 선언을 제거하고 호출자의 tool choice를 유지하도록 selector도 다시 씁니다. OpenAI API 가상 `-pro` 모델은 선택한 공개 ID를 먼저 일치시키고, 해석된 기본 wire-model ID를 대체값으로 사용합니다. `modelAdapters`는 공개 ID를 먼저, 그 다음 기본 ID를 해석하며, 두 번째 결과가 최종 wire를 결정합니다. 설정하지 않은 모델은 일반 alias 동작을 유지합니다. |
@@ -138,12 +150,13 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| `noStopModels?` | `string[]` | 호출자가 지정한 `stop`을 거부하는 모델입니다. `openai-chat` 어댑터, Chat 패스스루, Responses 패스스루가 이 모델에는 해당 필드를 보내지 않습니다. 기본 `xai` 프리셋은 xAI 문서가 이 값을 거부한다고 밝힌 추론 모델(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)을 여기에 넣습니다. `grok-4.20-0309-non-reasoning`, `grok-composer-2.5-fast`은 호출자가 보낸 `stop`을 그대로 받습니다. |
| `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. 기본 `xai` 프리셋은 xAI 문서가 이 값을 거부한다고 밝힌 추론 모델(`grok-4.7`, `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-4.20-multi-agent-0309`, `grok-4.20-0309-reasoning`, `grok-build-0.1`)을 여기에 넣습니다. 추론이 없는 모델은 호출자가 보낸 penalty를 그대로 받습니다. |
| `noStructuredOutputModels?` | `string[]` | `openai-chat` 엔드포인트가 `response_format`을 거부하는 정확한 모델 ID입니다. 요청 모델이 항목과 정확히 일치할 때만 필드를 생략하며, 그 외 `openai-chat` 모델에서는 structured-output 변환을 유지합니다. |
-| `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen`, `opencode free` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. |
+| `noJsonSchemaModels?` | `string[]` | `openai-chat` 엔드포인트가 `json_schema` 형식은 거부하지만 `json_object`는 받는 정확한 모델 ID입니다. 이런 요청은 필드를 지우는 대신 `json_object`로 낮춰 보내므로, JSON을 요청한 클라이언트가 산문 대신 JSON을 받습니다. 한 모델이 두 목록에 모두 있으면 `noStructuredOutputModels`가 우선합니다. `opencode go`, `opencode zen` 프리셋이 DeepSeek 경로에 기본으로 싣습니다. |
| `foldDeveloperRoleToSystem?` | `boolean` | `openai-chat` 목적지가 `developer` 역할을 받는지 기록합니다. `foldDeveloperRoleToSystem`이 없으면 `system`, `true`이면 `system`, `false`이면 `developer`로 보냅니다. 값이 없다는 것은 이 목적지에 대해 기록된 것이 없다는 뜻이고, `true`는 상위 서비스가 역할을 거부한다는 기록, `false`는 받아들인다는 기록입니다. 어느 경우에도 메시지는 대화 안의 원래 위치를 유지하며 역할만 바뀝니다. 역할을 거부하는 목적지는 `400 role 'developer' is not allowed`로 응답해 턴이 시작조차 못 하므로, 기록이 없는 상태의 기본값을 접는 쪽으로 둡니다. |
| `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean; repairInvalidIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id, 누락된 종료 id, 그리고(`repairInvalidIds`) 정규 `msg_`/`rs_` 접두사가 없는 message/reasoning id를 복구합니다. function-call id는 다시 쓰지 않습니다. 내장 DeepSeek은 마지막 두 가지를 기본으로 켭니다. |
| `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. |
-| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API-key 프로바이더 전용(`authMode: "key"`). 동일 대상 429 재시도: `retryOn429`가 없으면 기능이 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 429 시 대기(업스트림 `Retry-After` 또는 고정 간격) 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다 — 일반 텍스트 턴 복구 루프, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 모두 포함합니다. 재전송 대상은 프리스트림 HTTP 429 응답뿐이며, 커스텀 `runTurn` 전송은 HTTP 재시도 루프에서 제외됩니다. `attempts`는 첫 429 이후의 동일 키 재전송 횟수(총 전송 = `attempts` + 1)이며, 메인 복구 루프·터미널 가드 연속 요청·브리지 재시도가 공유하는 요청 단위 예산입니다. `attempts`를 모두 소진해도 동일 키 재전송만 중단되며, 이후에는 일반 키 장애 조치 또는 최종 오류 처리가 사용 가능한 대상에 따라 진행됩니다 — 키 인증 passthrough 와이어에는 장애 조치가 없으므로 소진된 429가 그대로 반환됩니다. Codex 자체는 429를 재시도하지 않으므로 단일 키 프로바이더의 유일한 방어선입니다. 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(단일 대기는 `maxIntervalMs`로 상한, 그 자체는 600000으로 상한), `respectRetryAfter: true`. |
+| `promptCacheKey?` | `boolean` | `openai-chat` 프로바이더 전체에서 `prompt_cache_key` 전달을 켭니다. 어댑터는 받은 키만 전달하지만 Claude Messages 변환은 `metadata.user_id` 또는 메타데이터가 없을 때 모델/시스템/도구 조합에서 키를 유도할 수 있습니다. 프리셋이 선언하지 않으면 기본적으로 꺼집니다. Mistral은 [프롬프트 캐시 계약](https://docs.mistral.ai/studio/conversations/advanced/prompt-caching)에 따라 켜며 명시적 `false`로 끕니다. 엄격한 게이트웨이는 알 수 없는 필드를 HTTP 400으로 거절할 수 있으므로 업스트림이 지원을 명시할 때만 켜세요. |
+| `retryOn429?` | `{ enabled?: boolean; attempts?: number; intervalMs?: number; maxIntervalMs?: number; respectRetryAfter?: boolean }` | API 키 프로바이더 전용(`authMode: "key"`). 미설정 시 꺼지지만 정식 엔드포인트의 OpenCode Go와 Command Code, 정식 Mistral `openai-chat`은 예외로 재전송 6회, 간격 10초, 상한 60초 정책을 사용합니다. 객체를 지정하면 켜지며 `enabled: false`는 이 기본 정책도 끕니다. 스트림 시작 전 HTTP 429에서 `Retry-After` 또는 고정 간격을 기다린 후 키 장애 조치 전에 동일 키로 동일 요청을 재전송합니다. 텍스트 턴 복구, Responses passthrough, 이미지/비디오 브리지, web-search 사이드카, 터미널 연속 요청을 포함하며 커스텀 `runTurn`은 제외됩니다. `attempts`는 첫 429 이후 동일 키 재전송 횟수(총 전송 = `attempts` + 1)로 요청 전체에서 공유됩니다. 소진 시 동일 키 재전송만 중단되고 일반 키 장애 조치나 최종 오류 처리가 이어집니다. 키 인증 passthrough에는 장애 조치가 없어 원래 429를 반환합니다. 정식 Mistral Chat의 명시적 대기 시간이 허용 상한을 넘으면 동일 키 재전송을 중단하고 원래 `Retry-After`를 반환하지만 풀의 다른 키가 처리할 수 있습니다. 다른 프로바이더는 대기 시간을 상한으로 제한합니다. Mistral은 자격 증명/모델별 재시도 기한을 공유하며 `requestPacing.enabled: false` 또는 `retryOn429.respectRetryAfter: false`로 끕니다. 재시도 시각이 없는 429에서 분당 예산 부족이 검증되면 보수적인 60초 복구 대기 후 한 번 탐색하고, 성공하면 일반 트래픽으로 돌아갑니다. 정상 트래픽에서는 다음 토큰 비용을 예측하지 않습니다. 공시된 허용량을 사용하고 소진된 월간 허용량은 제외하며 정확한 초기화 시각을 보장하거나 기록 압축을 유발하지 않습니다. Codex 자체는 429를 재시도하지 않습니다. 객체 기본값: `enabled: true`, `attempts: 3`, `intervalMs: 5000`, `maxIntervalMs: 60000`(대기 상한, 설정 최대 600000), `respectRetryAfter: true`. |
| `transientRetryOn5xx?` | `{ enabled?: boolean; attempts?: number }` | 키 인증 `openai-chat` 및 `openai-responses` 프로바이더 전용입니다. `authMode: "forward"` 프로바이더(ChatGPT 계정 풀)는 이 옵션을 읽지 않고 기본 재시도 단계를 유지합니다. 스트림 시작 전의 일시적인 업스트림 상태(500, 502, 503, 504, 520, 521, 522)를 선택적으로 재시도합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 최초 Responses 요청, 터미널 가드 연속 요청, 네이티브 `/v1/chat/completions`, 429/계정 복구 재조회를 포함합니다. `attempts`는 최초 전송을 포함하여 요청 하나에 허용되는 업스트림 전송의 총횟수(1..10, 기본값 3)입니다. 연결 재설정 복구와 요청 단위 예산 하나를 공유하므로 `3`이면 실제로 프로바이더에 도달하는 요청은 최대 세 번입니다. 대기에는 400ms로 고정된 지수 백오프를 사용하고 상한은 5초이며 `Retry-After`를 따릅니다. 속도 제한을 처리하는 `retryOn429`와는 별개이며, 스트림 도중의 실패는 절대 재전송하지 않습니다. 각 Combo 대상은 선예약한 첫 전송을 해당 대상의 설정 총횟수에 포함합니다. `attempts: 1`은 한 번만 전송하며, 예약을 환불해 여유를 만들거나 공유 상한을 늘리지 않습니다. 전송 전 로컬 거부나 취소는 미사용 예약을 해제합니다. |
| `retryOnReset?` | `{ enabled?: boolean; replacements?: number }` | 네이티브 `openai-responses` 프로바이더(`authMode: "forward"` 포함)와 업스트림 헤더 수신 전의 일반 Responses 변환 전송 경로에 적용됩니다. 호출자가 아무것도 관측하지 못한 채 실패한 전송을 선택적으로 대체합니다. 이 옵션이 없으면 꺼져 있고, 객체가 있으면 `enabled: false`가 아닌 한 활성화됩니다. 응답 헤더가 오기 전에 연결이 끊어진 경우와, 헤더 이후 SSE 본문이 제어 이벤트만 실은 채 끊어진 경우를 모두 다룹니다. canonical ChatGPT 업스트림 WebSocket에서 create 프레임을 보낸 뒤 Responses 이벤트가 오기 전에 소켓이 닫히거나 오류가 난 경우도 같은 방식으로 다루며, 이때 대체 전송은 HTTP로 보냅니다. 자체 완결된 요청만 대체합니다. `store: false`, 완전한 `input`, `previous_response_id`·`conversation`·`stream_id` 없음, 클라이언트가 실행하는 도구만 해당합니다. `replacements`는 모든 구간과 모든 콤보 자식을 합쳐 논리 요청 하나가 만들 수 있는 대체 전송 횟수입니다(1..2, 기본값 1). 구간별 재시도 횟수도 전송 예산도 아니므로, 대체 전송도 해당 구간이 이미 가진 전송 허용량 안에 들어가야 합니다. 이미 출력이나 도구 호출을 내보낸 요청은 이 값과 무관하게 대체하지 않습니다. 원본 전송이 이미 시작됐다면 대체한 추론도 과금될 수 있어서 기본값은 꺼짐입니다. 일반 변환 전송 경로에서는 최초 전송과 재구축한 전송 모두 같은 대체 권한과 전송 예산으로 헤더 수신 전 연결 리셋을 대체합니다. 어댑터가 자체 관리하는 전송 경로와 업스트림 헤더 수신 후 변환 스트림 장애는 제외됩니다. |
| `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. |
@@ -157,8 +170,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| `anthropicEofTolerance?` | `boolean` | `message_stop` 전에 스트림이 끝나도 표시 텍스트 또는 완전한 JSON 객체 툴 입력을 받은 경우에만 완료를 허용합니다(Anthropic 호환 게이트웨이용). 기본값은 꺼짐. |
| `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 전송/인증 모드입니다. 기본값은 `ai-studio`입니다. |
| `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google 전용입니다. 생략하거나 `compatible`이면 호환 스키마와 기존 비직접 400 복구를 유지합니다. `reject-lossy`는 초기 손실 또는 판정 불가능한 상한 비교를 전송 전에 거부하고 제약을 개방하는 Vertex 또는 Cloud Code Assist 복구를 보류합니다. 직접 AI Studio는 이 복구를 수행하지 않습니다. |
-| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. |
-| `location?` | `string` | Vertex 위치입니다. 환경 변수 폴백은 `GOOGLE_CLOUD_LOCATION`입니다. |
+| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. Vertex API 키는 이 값이 있으면 프로젝트 엔드포인트를 쓰고, 없으면 `GOOGLE_CLOUD_PROJECT`가 설정돼 있어도 express 모드를 씁니다. ADC는 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`로 폴백합니다.`"auto"`로 설정하면 각 Vertex API 키가 자신의 프로젝트를 확인합니다(확인에 성공한 결과는 키마다 캐시하고, 확인에 실패한 키는 이후 요청에서 다시 탐색합니다). 하나의 키 풀이 여러 프로젝트에 걸칠 수 있으며, ADC는 `"auto"`를 거부합니다. |
+| `location?` | `string` | Vertex 위치입니다. API 키 프로젝트 엔드포인트는 기본값이 `global`이고 `GOOGLE_CLOUD_LOCATION`을 무시합니다. ADC는 이 값이나 `GOOGLE_CLOUD_LOCATION` 폴백이 필요합니다. |
| `mcpServers?` | `Record` | Cursor 전용입니다. stdio 또는 Streamable HTTP MCP 서버입니다. |
| `desktopExecutor?` | `DesktopExecutorConfig` | Cursor 전용입니다. 외부 computer-use 및 record-screen 명령입니다. |
| `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. |
@@ -186,6 +199,8 @@ API 키 공급자는 리터럴 키나 환경 참조를 둘 수 있습니다. OAu
대시보드 연결 테스트와 라이브 모델 발견은 범위가 제한된 GET 전용 전송을 사용합니다. 아웃바운드 프록시가 없으면 opencodex는 호스트 이름을 한 번만 확인하고, 검증된 주소로만 연결합니다. HTTPS는 원래 Host, SNI, 인증서 검증을 유지하며, 공급자 설정으로 인증서 검사를 끌 수는 없습니다.
+직접 응답 다운로드는 로컬 처리가 다음 데이터를 받을 준비가 될 때까지 잠시 멈춥니다. gzip과 deflate 응답에도 적용됩니다. 이 대기 시간은 공급자의 무응답 시간으로 계산하지 않으며, 요청 전체의 제한 시간과 취소는 계속 적용됩니다.
+
이 작업들은 [서버에 설정된 아웃바운드 fetch](/ko/reference/configuration/server/)를 사용합니다. `config.proxy`로 설정했거나 SOCKS5 `ALL_PROXY`에서 상속된 서버 SOCKS5 프록시는 대상이 `NO_PROXY`에 걸리지 않을 때 OpenCodex의 내장 터널을 사용합니다. `HTTP_PROXY`와 `HTTPS_PROXY`는 Bun의 네이티브 HTTP(S) 처리를 그대로 사용하지만, SOCKS가 아닌 `ALL_PROXY`는 네이티브 HTTP fetch 경로가 아닙니다. URL과 리터럴 주소 검사는 계속 실행되지만, 선택된 프록시가 최종 경로, DNS 응답, 피어를 고르므로 opencodex는 그 피어를 고정하거나 검증할 수 없습니다. 이는 명시적인 보안 한계입니다.
사설/로컬 목적지는 `allowPrivateNetwork: true`가 필요하며, 아웃바운드 프록시가 활성화된 경우에는 일치하는 `NO_PROXY` 항목도 필요합니다. loopback은 자동으로 추가됩니다. CIDR 항목은 해석하지 않으므로 각 LAN 호스트는 따로 적어야 합니다. matcher는 정확한 호스트, 도메인 접미사, 선택적 포트, 괄호로 감싼 IPv6, `*`를 지원합니다. 예를 들면 `192.168.1.50`은 따로 적어야 합니다. 메타데이터와 link-local 목적지는 계속 차단됩니다. 진단 요청은 리디렉션을 거부하고, 자격 증명이 제거된 대상만 보고합니다. 일반적인 공급자 요청의 리디렉션 검토는 이 진단 가드와 별도로 유지됩니다.
@@ -391,6 +406,12 @@ Cursor 서버 주도 로컬 도구는 기본값으로 비활성화됩니다. Cod
필드는 최상위가 아니라 `providers.cursor`에 설정하십시오. 대시보드에서는 **Providers → Cursor → Edit JSON**을 사용해 저장한 뒤 다시 시작합니다. 레거시 `unsafeAllowNativeLocalExec: true`는 `nativeLocalExec`이 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. MCP, 화면 녹화, computer use는 `mcpServers`와 `desktopExecutor`가 따로 제어합니다.
+네이티브 `grep`이 허용된 디렉터리 검색에서는 `glob`으로 파일을 고릅니다. `*.ts`는 모든 하위 폴더의
+파일명에 적용되고, `src/**/*.ts`는 선택한 검색 폴더를 기준으로 한 상대 경로에 적용됩니다(`./`로 시작해도 됩니다). 모든 운영체제에서
+패턴의 경로 구분자는 `/`를 사용합니다. 문자 범위와 중괄호 선택 패턴을 지원하며, Bun 1.4.4에서는
+`**/!(*.test).ts` 같은 제외 패턴과 `part-{1..3}.ts` 같은 숫자 범위도 지원합니다.
+필터는 검색 폴더를 바꾸거나 네이티브 실행을 활성화하지 않습니다.
+
각 `mcpServers.`는 `command`(stdio) 또는 `url`(Streamable HTTP) 중 하나를 받습니다. stdio는 `args`, `env`, `cwd`도 받습니다. HTTP는 `headers`를 받습니다. 둘 다 `enabled`(기본값 true)와 `toolPrefix`를 지원합니다. `desktopExecutor`는 `computerUseCommand`, `recordScreenCommand`, `cwd`, `env`, `timeoutMs`(기본값 `30000`)를 받습니다. 명령은 `sh -c`를 거치며 stdin에서 JSON 요청 하나를 읽고, stdout에 JSON 결과 하나를 써야 합니다.
:::caution[보안]
@@ -578,3 +599,8 @@ source 재정의가 0이면 꺼지고, 전역 0이어도 source에 양수 재정
### `anthropicAccountPool.routes`
`anthropicAccountPool.routes`는 모델을 저장된 Anthropic OAuth 계정 ID에 연결합니다. 풀이 활성화되면 대소문자를 구분하는 `match` 글롭의 첫 일치가 최초 선택과 429 재시도를 제한합니다. `fallback: true`는 해당 경로에 적격 계정이 없을 때만 일반 풀로 확장합니다.
+
+
+### `promptLengthPricing`
+
+선택 항목 `promptLengthPricing`은 네 기본 요율을 확장합니다. `mode: "automatic"` 또는 생략은 카탈로그의 컨텍스트 규칙을 유지하고, `mode: "flat"`은 이를 비활성화합니다. `mode: "custom"`은 양의 안전한 정수 `thresholdTokens`, `"gt"` (>) 또는 `"gte"` (≥)인 `comparison`, 네 절대 대체 요율을 담은 `rates`를 사용합니다. 임계값에는 캐시 읽기와 쓰기를 포함한 모든 프롬프트 토큰이 포함되며 출력은 제외됩니다. 선택한 요율은 출력을 포함한 전체 요청에 적용되고 임계값 양쪽에서 카탈로그 규칙을 대체합니다. Fast/Priority 조정은 요율을 선택한 뒤 한 번만 적용되며, 해당 모델에 대해 카탈로그가 공개한 Priority 관계를 따릅니다. 추정치는 카탈로그가 그 관계를 `lower-bound`로 선언한 경우(현재 xAI Grok)에만 하한으로 표시됩니다. 카탈로그 규칙이 없는 모델은 Priority 배수를 한 번만 적용합니다. **Models → Price**에서 설정합니다. **Reset to automatic**은 기본 요율과 규칙을 포함한 전체 재정의를 삭제합니다. 기존 설정은 호환되며 이 설정은 표시되는 추정 비용에만 해당합니다.
diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md
index 9cad2c81f9a..bf854d93543 100644
--- a/docs-site/src/content/docs/ko/reference/proxy-formats.md
+++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md
@@ -40,6 +40,8 @@ Chat Completions와 Messages 엔드포인트는 지연된 도구 카탈로그를
별도로, 모든 인바운드 와이어에서 공급자의 `default.` 네임스페이스 호출은 요청이 선언한 원래 도구 이름으로 정규화됩니다(`default.Read` → `Read`).
+네이티브 Messages는 `tools: []`와 함께 제공된 시스템 메시지의 인라인 도구 정의도 인식합니다. 해당 이름은 영구적으로 제거될 때까지 호출할 수 있으며, 다른 클라이언트 도구를 허용하지는 않습니다. 호스팅된 MCP 호출과 서버 도구의 인수 스트림은 네이티브 형식을 유지합니다.
+
## 엔드포인트 개요
| 클라이언트 표면 | 엔드포인트 | 성공한 비스트리밍 결과 | 성공한 스트리밍 또는 소켓 결과 |
@@ -292,12 +294,17 @@ call creation 이후 클라이언트는 다음의 지원되는 모든 inbound
프록시는 업스트림 join URL을 정규화한 뒤, 양방향 텍스트 및 바이너리 프레임을 투명하게 릴레이합니다. 업스트림
인증은 프록시가 소유한 상태로 유지되며, 클라이언트 프로토콜 헤더는 보존됩니다.
+sideband 연결을 종료할 때 프록시는 업스트림의 정상 종료를 1초 동안 기다린 뒤, 응답하지 않는
+전송 연결을 강제로 닫습니다. 클라이언트 업그레이드가 실패한 경우에도 실제 업스트림 연결이
+닫힌 것을 확인한 후에만 해당 계정의 활성 통화 용량을 반환합니다.
+
call creation과 sideband join은 같은 OpenAI 계정으로 이루어져야 하며, 그렇지 않으면 업스트림이 join을
거부합니다(`404`). 두 요청 모두 Codex의 `session-id`와 `thread-id` 헤더를 실어 보냅니다. Pool 모드는
계정 선택을 그 쌍에 묶어 두므로(프로세스 로컬) 프록시에 도착한 join은 통화를 만든 계정을 그대로 쓰고,
Direct 모드는 두 요청 모두 호출자의 현재 bearer를 전달합니다. 릴레이되는 클라이언트 헤더는 정확히
`openai-alpha`, `x-session-id`, `session-id`, `thread-id`, `originator`, `x-oai-attestation`,
-`x-codex-turn-metadata`(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는
+`x-codex-turn-metadata`, `voice-session-id`(실시간 `x-session-id`와 별개인 클라이언트 음성 세션 id)
+(`src/server/live.ts`의 `LIVE_CLIENT_PROTOCOL_HEADERS`)이며, 각 헤더는
호출자가 보낸 경우에만 전달되고 프록시가 만들어 내지 않습니다. `Authorization`과 ChatGPT 계정 id는
ChatGPT 경로에서 프록시가 소유합니다(Pool은 저장된 계정으로 교체, Direct는 검증된 호출자 bearer를 전달).
API 키 프로바이더는 자체 bearer를 씁니다. Codex가 join을 프록시로 보내는 것은
diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md
index 70cf9514f98..ef88ecb76b5 100644
--- a/docs-site/src/content/docs/reference/adapters.md
+++ b/docs-site/src/content/docs/reference/adapters.md
@@ -164,6 +164,17 @@ body and response, with narrow compatibility rewrites for routed gateways.
`forward` uses configured static headers without relaying caller authorization; `key` uses the
configured provider key.
+When history from Google is replayed to canonical ChatGPT Codex or the official OpenAI Responses
+API, the adapter removes item-level `extra_content` from the outgoing HTTP request or WebSocket
+create. Native `/responses/compact` applies the same projection. The original history keeps the Google
+thought signature for later Google turns. Other Responses gateways retain their extensions.
+
+OpenAI-operated destinations receive replayed `url_citation` annotations with missing or invalid
+titles and indices repaired. This applies to ordinary HTTP/WebSocket requests and native
+`/responses/compact`. Custom gateways keep the original annotations even when
+`supportsNativeCompactEndpoint: true` enables native compaction. Citation repair leaves the
+client's stored history unchanged.
+
The adapter preserves the incoming client's `User-Agent` as a fallback in both auth modes because
some Responses-compatible providers use the Codex client fingerprint for compatibility behavior.
An explicitly configured provider `User-Agent` remains authoritative regardless of header casing;
@@ -172,6 +183,46 @@ with `forwardClientHeaders`; provider `headers` win for this option, and credent
names are refused. Canonical ChatGPT forward auth retains its separate fixed header allowlist.
Only `originator`, `x-client-request-id`, `x-codex-app-version`, and `user-agent` are supported by `forwardClientHeaders`; arbitrary names are rejected on load/write and ignored at runtime.
+### Native compaction on custom gateways
+
+Responses compatibility alone is not evidence of native compaction support. Custom gateways
+continue using portable text summaries unless the operator declares the relevant capability:
+
+- `supportsNativeCompactionTrigger: true` preserves v2 `compaction_trigger` requests and native output.
+- `supportsNativeCompactEndpoint: true` opts key/local/forward routes into the separate v1 endpoint.
+ OAuth retains routed compaction and its existing credential lifecycle. V2 support does not imply v1 support.
+- `decodesNativeCompactionBlobs: true` preserves native blobs on subsequent turns when there is
+ no known serving-provider mismatch. Neither generation flag implies this replay capability.
+
+Custom native v1 also honors `preserveResponsesReasoningContent: true` for plaintext reasoning
+input, just like ordinary custom Responses requests. Absent or `false` keeps content blank;
+OpenAI-operated compact destinations keep blanking even if the flag is set. Internal `ocxr1:`
+envelopes are always removed. Plaintext preservation is independent of the generation flags,
+native blob replay capability, and serving-identity checks.
+
+If the custom provider also requires adjacent tool results (`requiresAdjacentResponsesToolResults: true`),
+both ordinary Responses and native v1 remove opaque reasoning ciphertext and require plaintext replay.
+Missing `reasoning_text` is filled from the item's `summary_text`, or a minimal placeholder if no
+usable summary exists. Existing plaintext is preserved. OpenAI-operated compact keeps its own policy.
+
+For a gateway whose **v2 generation and replay** have both been verified, merge these fields into
+its existing provider configuration, retaining its real URL and configured authentication:
+
+```json
+{
+ "adapter": "openai-responses",
+ "supportsNativeCompactionTrigger": true,
+ "decodesNativeCompactionBlobs": true
+}
+```
+
+These flags do not make the gateway an official OpenAI destination, forward caller credentials,
+change account selection, or bypass normal compatibility sanitization. Validate an actual compact
+response and continued conversation, not just HTTP 200. Keep blobs on the backend that minted them;
+cross-provider compaction routing overrides still use portable summaries. V1 fallback summaries
+remain portable even when the destination separately accepts v2 triggers. Native-capable routes
+are excluded from portable emergency compaction recovery for their respective protocol.
+
Adapter selection does not select the upstream transport. Eligible requests can use the
[upstream WebSocket proxy route](/reference/proxy-formats/#json-and-sse-output); invalid or unsupported
WebSocket proxy settings fall back to HTTP/SSE. HTTP fetch-based Responses handling uses the
@@ -278,6 +329,14 @@ MiMo model Command Code serves.
**Auth:** `key` (`x-api-key` by default, or `Authorization: Bearer` with `apiKeyTransport: "bearer"`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max).
- Converts messages to Anthropic content blocks (text, base64 image, `tool_use`, `thinking`).
+- Routed Codex compaction keeps the client tool catalog, system prompt and tool-selection
+ options to preserve their prompt-cache prefix. Summary requests disable search/media
+ sidecars; upstream-hosted tools retain their declarations with `tool_choice: none`, as does a
+ mandatory tool choice, while the advertised catalog stays the same. A request that advertises a
+ sidecar web-search tool (for example Exa) still changes its tool prefix on compaction.
+ A summary that attempts a tool call fails without publishing a function call or replacing
+ history. A successful summary returns exactly one compaction item. Image normalization
+ and the existing one-shot tighter-image retry after HTTP 413 remain unchanged.
- Translated Anthropic Messages reasoning replay shares the request translation budget, including
encoding/decoding copy overhead. Requests exceeding it return HTTP 413 with
`translation_buffer_limit`; signatures and opaque reasoning data are never truncated to fit.
@@ -446,7 +505,9 @@ MiMo model Command Code serves.
The plan's precise credit balance is retained with that identity-fenced quota reading.
Separately, Kiro `meteringEvent` credit values are measured request spend: the last value
within a physical response is used, and credits from separately billed sends are added.
- Token usage remains estimated; credits are never inferred from tokens.
+ Token usage remains estimated; credits are never inferred from tokens. Requests with hosted
+ search or image tools retain the adapter's full prompt estimate in Usage and daily activity,
+ including replayed history, even when the model does not invoke those tools.
- After an admitted request, reads that account's available models from the regional management
service without delaying the request. Cached results add model IDs to the shipped catalog.
Empty or unrecognised replies retain the shipped list and any last good account list. Model
@@ -454,7 +515,8 @@ MiMo model Command Code serves.
limits inform context windows, conservatively combined with shipped limits when evidence is partial.
Accounts that have not served have no list evidence yet.
- Participates in multi-account rotation. With two stored accounts, a request-rate refusal
- cools the refused account briefly; an exact monthly-quota refusal on HTTP 400 or 429
+ cools the refused account briefly; an exact `MONTHLY_REQUEST_COUNT` or
+ `OVERAGE_REQUEST_LIMIT_EXCEEDED` refusal on HTTP 400 or 429
excludes it until the observed reset or evidence expiry. A confirmed HTTP 403 suspension
quarantines that account in process, while an ordinary 403 does not rotate. Reactive
rotation remains available when `oauthAccountFailover.enabled` is false. Refusal-aware
diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md
index d9a323ae3c5..02885f50ef5 100644
--- a/docs-site/src/content/docs/reference/architecture.md
+++ b/docs-site/src/content/docs/reference/architecture.md
@@ -73,7 +73,8 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules:
status, latency, provider/model labels, and best-effort token usage without changing the response.
The pre-dispatch input estimate follows what the routed adapter actually sends. For `openai-chat`
-models outside `preserveReasoningContentModels`, the adapter drops replayed assistant thinking, so
+models excluded by the effective Chat thinking replay policy (an explicit
+`preserveReasoningContentModels` list, or DeepSeek Flash/Pro detection when unset), the adapter drops replayed assistant thinking, so
the estimate excludes it too; history that never reaches the provider therefore cannot cause a local
context-limit refusal. Models that preserve reasoning, and other adapters,
send that thinking and still count it.
diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md
index 32368c3265f..d6ba1d76c35 100644
--- a/docs-site/src/content/docs/reference/cli.md
+++ b/docs-site/src/content/docs/reference/cli.md
@@ -146,8 +146,9 @@ proxy.
`ocx system codex-cli-update check` needs no live proxy and makes no package-registry request. It
inspects bounded provenance metadata for the configured install candidate, including its redacted
executable location and ownership evidence. Trusted published-launcher context authenticates that candidate snapshot,
-not a successful Codex execution. Because this one-shot command never executes Codex, environment and persisted candidates
-remain report-only (`managed: false`, normally `selection_unattested`) and `selectionAttested` remains `false`.
+not a successful Codex execution. On POSIX, `selectionAttested` can be `true` when the runtime resolver
+selects the same canonical environment candidate without executing it. A linked npm-global package
+manifest can then establish `managed: true` when it proves ownership and, if a candidate version is reported, that version matches the manifest; persisted candidates remain unattested.
The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`, and `selectionAttested`.
Inspecting the configured candidate requires a trusted published-launcher context;
a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report
@@ -156,6 +157,13 @@ only a proof-captured absolute environment candidate can receive lexical app-bun
every other Windows candidate fails closed. The command does not install or repair software, execute
Codex or npm, control a running process, or write configuration/cache state.
+`ocx system codex-cli-update plan --json` adds a read-only update decision for an attested POSIX
+npm-global installation. It resolves `latest` through the official npm registry, validates the
+exact version and SHA-512 integrity, and refuses active sessions or incomplete process observation.
+Registry queries use isolated temporary npm configuration, cache and logs with best-effort cleanup.
+No software is installed and no process is signalled. Windows returns `windows_inspection_deferred`.
+See [planning details](/reference/cli/agents/#read-only-codex-cli-update-plan).
+
For Windows x64 installation observation, see [the `attest` command](/reference/cli/agents/#explicit-installation-observation-on-windows-x64). Without explicit paths it observes the selected candidate identified from the proof-bound launcher snapshot; it does not grant update authority or attest runtime selection.
List or status is the default where unambiguous. Use `--json` only on leaves that support it, and
@@ -256,6 +264,8 @@ local configuration and local Lab inspection do not require startup.
`ocx status --json` includes `cliCommand`: configured Desktop intent, the expected executable, observed PATH candidates, `pathFirst`, `desktopFirstOnPath`, issue codes, and `shellResolution: "unobserved"`. The human status report prints one command-selection line. `ocx doctor` adds an “ocx command selection” section: invalid, unsafe (`record-unsafe`) or enabled-pending records and missing/unusable Desktop targets fail the check, while disabled cleanup-pending records, PATH ordering conflicts and incomplete scans warn. `OCX_NO_DESKTOP_HANDOFF=1` suppresses package-launcher handoff for one invocation; it leaves the Desktop shim and PATH configuration in place. These read-only observations do not execute candidates, resolve parent-shell aliases/functions, or identify the proxy’s runtime owner. On Windows, package-launcher handoff is disabled; user `Path` order selects the Desktop `ocx.exe`. Status and doctor still read the record and report the first PATH candidate. A possible cmd current-directory candidate is reported separately from PATH order.
+`ocx status --json` also includes `install`: how this `ocx` was installed, using the same detector `ocx update` acts on. `install.method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` (the compiled desktop binary) or `unknown` when detection failed. `install.evidence.basis` names what decided it (`package-layout`, `mise-backend-record`, `standalone-binary` or `unavailable`); a mise install adds the mise tool alias and backend, and a failure adds an `error` code. No path is added.
+
Output flags are per command. `doctor` rejects `--json` with exit 2;
[`v2` (family reference)](/reference/cli/agents/)
supports `--json` for local and `--live` targets. Even for JSON-capable management commands, API failures
diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md
index 882c7ef59db..501f13e7bfb 100644
--- a/docs-site/src/content/docs/reference/cli/agents.md
+++ b/docs-site/src/content/docs/reference/cli/agents.md
@@ -257,13 +257,29 @@ unqualified bare OpenAI model id. Bare `gpt-5.6-*` native aliases use Codex Pool
Account-qualified OpenAI routes remain distinct, while provider-qualified routes such as
`openai-apikey/gpt-5.6-*` use their configured API key and never fall through to the native alias.
Read the safety and visibility contract in the guide before enabling the compatibility pair.
-With `--strategy jev` only, the decision method is chosen by one of two mutually exclusive flags.
+Only when the effective strategy is JEV (`--strategy jev`, or a stored JEV Combo when `--strategy` is omitted), the decision method is chosen by one of two mutually exclusive flags.
`--decision-provider ` names a configured `jev-decision` row (for example a self-hosted
Ollama `tev1`) as a System One-compatible server. `--decision-model ` names an ordinary
opencodex route (for example `ollama/qwen3:4b`) that answers the same choice as JSON; it cannot be
-this combo or any JEV combo. Omitting both uses TypeSafe. `--decision-timeout ` sets the
+this combo or any JEV combo. A new JEV Combo that omits both uses TypeSafe; an existing JEV Combo keeps
+its stored decision method until one of the flags changes it. `--decision-timeout ` sets the
decision deadline (1000–120000, default 4000); `-` clears any of the three.
+`ocx combo set --decision-quota-signals on|off|-` controls per-Combo advisory evidence.
+`--decision-quota-tiers '{"limited":80}'` sets partial threshold overrides; `-` clears them.
+Both are JEV-only. Omitted policy survives `--targets-file` or `--targets` replacement and rename. Without
+`--strategy`, replacing the targets of an existing Combo keeps its stored strategy, so a JEV Combo stays JEV with
+its decision method, quota settings and session pin; a new Combo defaults to failover. If the kept JEV
+policy rejects the new targets, the command fails, names that policy, and saves nothing. Off retains thresholds;
+switching away from JEV removes both settings. This adds quota data to the selected decision
+backend, not probes, new account selection or hard exclusions. See
+[quota defaults, freshness and privacy](/guides/combos/#advisory-quota-signals-for-jev).
+
+`ocx combo set --session-pin ` (JEV only) keeps each client session on the target
+JEV chose for it while the session stays active, so the provider prompt cache stays warm. The
+duration is the idle window: `90s`, `20m`, `1h` or milliseconds, from 1 minute to 24 hours; `-`
+turns it off. See [Session pin for JEV](/guides/combos/#session-pin-for-jev).
+
`ocx combo test [--combo ] [--decision-provider | --decision-model ]
[--decision-timeout ]` sends one synthetic decision probe through a saved combo's method or an
unsaved selection and reports the gate, backend, and latency; it may spend one decision call.
@@ -302,6 +318,7 @@ explicit `lastResort: false`. `--image-input auto|disabled` and
`--reasoning-effort-mode strict|adaptive` preserve omission, while explicit `auto`
and `strict` override saved disabled/adaptive values. Reasoning-effort mode is
separate from `--effort-mode fallback|force`, which governs the default effort.
+`--allow-metered-targets on|off` grants or clears operator consent for marked combo targets on a loopback-only proxy; public and linked callers still need configured-key consent.
`--native-alias on|off` preserves explicit false; the legacy bare flag means true.
Turning it off may require the intended alias clear, `--native-alias off --alias -`,
to avoid retaining an incompatible native alias. Set/rename is an upsert without
@@ -326,6 +343,25 @@ There is no monetary cost or comparative savings field in this report.
See [Combos](/guides/combos/) for routing behavior and configuration guidance.
+## Fallback off
+
+### `ocx fallback ...`
+
+```bash
+ocx fallback status --json
+ocx fallback off --kind provider
+ocx fallback on --route combo/coding
+ocx fallback clear --provider anthropic --kind model
+ocx fallback local on --device mac-studio
+```
+
+`off`, `on` and `clear` take `--kind model|provider|all` (default `all`) and at most one scope:
+`--provider `, `--route ` or `--device `; with none they set the global switch.
+A device set to off always refuses; otherwise route beats provider beats global. `local` records whether a
+device may use its own providers while the Home is unreachable, a permission GOAT reads; OpenCodex itself
+still answers `home_unreachable` there. The commands talk to the live proxy; on a Remote Link Child,
+`status` reads the Home and writes are refused. See [Fallback off](/guides/fallback-off/).
+
## Routing profiles
Existing profiles support inspection and evaluation through the live API:
@@ -415,7 +451,8 @@ Inspect proxy requests, usage, storage, memory, and debug data. The direct alias
| Alias | Equivalent resource |
| --- | --- |
| `ocx logs [filters] [--follow] [--json|--jsonl|--events]` | `ocx observe logs` |
-| `ocx usage [--range ] [--since --until ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--json]` | `ocx observe usage` |
+| `ocx usage [--range ] [--since --until ] [--surface ] [--provider ] [--model ] [--api-key-id ] [--search ] [--top <1-1000>] [--json]` | `ocx observe usage` |
+| `ocx usage compare [--range ] [--json]` | `ocx observe usage compare` |
| `ocx storage [--json]` | `ocx observe storage` |
| `ocx memory [--json]` | `ocx observe memory` |
@@ -450,6 +487,43 @@ Any displayed totals reflect readable records only. If a filter has no readable
the warning and guidance instead of total lines; skipped records may contain matches.
`--json` preserves the response-level `usageIncomplete` diagnostic and reason.
+### Compare a range with the previous equal window
+
+```bash
+ocx usage compare --range today --json
+ocx usage compare --range 7d --provider xai
+```
+
+`ocx observe usage compare` is the equivalent family form. The command reads `GET /api/usage`
+twice: the named range ending now, then the same range evaluated one equal window earlier
+(one local day for `today`/`1d`, seven for `7d`, thirty for `30d`). Default range is `30d`.
+`--range all` is refused because open history has no previous equal window. `--since` and
+`--until` are not accepted; the windows are derived. Surface, provider, model, and API-key
+filters apply to both reads. Human output prints current, previous, signed delta, and percent
+change. `--json` is `{schemaVersion,range,current,previous,delta,change,incomplete}`. The two
+reads are sequential, not an atomic snapshot. Custom `since`/`until` on each response must
+match the requested window. Both reads use the same connection and credential. If the
+connection, account, or credential changes during the command, it reports an owner
+mismatch and asks you to retry instead of printing a comparison.
+
+### Page indexed request history
+
+```bash
+ocx logs history --provider xai --status 200 --limit 50 --json
+ocx logs history --cursor '' --json
+ocx logs get --json
+```
+
+`ocx observe logs history` and `ocx observe logs get` are the equivalent family
+forms. These read the derived request-history index, not the recent `ocx logs`
+ring. `--limit` is 1–100 (default 50). `--from` and `--to` accept epoch
+milliseconds or full ISO datetimes with a timezone and are inclusive. `--json`
+returns `{entries,hasMore,nextCursor,index}`. `--jsonl` emits rows only; when
+more pages exist, `nextCursor=…` is printed on stderr so a caller can resume
+without mixing the token into the row stream. Empty matches succeed. `get`
+returns one canonical row by request ID; `ocx logs explain` remains the
+route-decision view for that same ID.
+
### Filter a bounded log snapshot
```bash
@@ -489,7 +563,7 @@ ocx usage --range 7d --search 'model-a' --json
`--search` matches a trimmed case-insensitive substring in model, provider or
resolved model after reading the report. It sorts model rows by descending total
-tokens with stable ties and keeps up to 100. JSON adds
+tokens with stable ties and keeps up to 100 by default. JSON adds
`modelView:{query,matchedModelCount,returnedModelCount,limit:100,truncated}`;
human output labels the view and shows its selected model rows. Report totals,
provider/day/account rows, exact filters and incomplete/window metadata remain
@@ -500,6 +574,8 @@ preserves the existing output. Exact `--provider`/`--model` still scope the
underlying report. Search does not broaden selected-key or connected-client
self scope and is never sent as a new Hub API query parameter.
+`ocx usage --top 25` (also `ocx observe usage --top 25`) sets the human model table limit to an integer from 1 to 1000. The default is 10, or 100 with `--search`; the limit applies after search filtering. It changes local display only: report totals, provider/account rows and API parameters stay unchanged. With `--json`, a valid `--top` is ignored; JSON retains its existing full report or 100-row search view. Invalid limits return exit 2 before fetching.
+
### Saved companion usage totals
```bash
@@ -1020,10 +1096,15 @@ the CLI, the API, and the GUI use the same bytes.
Manage headless runtime settings, startup, sync, diagnostics, and updates.
-`ocx system codex-restart --yes` restarts Codex app-servers and fully quits and relaunches the
-Codex desktop app, through the same module as `ocx sync --restart-codex`. When the proxy itself
-is running inside the Codex app, the command refuses with an actionable message instead of
-promising a handoff it cannot complete.
+`ocx system codex-restart --yes` restarts Codex app-servers through the same module as
+`ocx sync --restart-codex`. It quits and relaunches the Codex desktop app when the app launched before the current model catalog was written or this request changed
+its `model_catalog_json` binding, because the model picker is built at
+launch. Otherwise the app stays open, respawns its own app-server, and keeps unsaved drafts and
+pending approvals. If the app or catalog state cannot be read, it takes the full restart. When
+the proxy itself is running inside the Codex app, the desktop restart refuses with an actionable
+message instead of promising a handoff it cannot complete.
+
+A `model_catalog_json` binding changed by this request also forces the desktop restart, even when the shell started after the catalog file was written.
```bash
ocx system settings --stream-mode eager-relay
@@ -1077,9 +1158,9 @@ ocx system codex-cli-update check --json
`check` makes no package-registry request and inspects bounded configured-candidate provenance evidence,
including a redacted executable location and ownership evidence. Trusted published-launcher context authenticates
-the candidate snapshot, not successful Codex execution. Because this one-shot command never executes Codex,
-environment and persisted candidates remain report-only (`managed: false`, normally `selection_unattested`);
-`selectionAttested` remains `false`. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`,
+the candidate snapshot, not successful Codex execution. On POSIX, selection is attested only when the runtime
+resolver selects the same canonical environment candidate without executing it. A linked npm-global manifest
+can then establish `managed: true` when it proves ownership and, if a candidate version is reported, that version matches the manifest; persisted candidates remain unattested. The JSON report exposes `candidateAvailable`, `candidateVersion`, `candidateSource`,
and `selectionAttested`. Inspecting the configured candidate requires a trusted published-launcher context;
a direct Bun/source launch has no such proof, ignores ambient and persisted candidate state, and may report
`candidate_unavailable` on POSIX. On Windows this first slice performs no candidate or configuration filesystem I/O:
@@ -1094,6 +1175,26 @@ and are never classified as managed.
On Windows, a captured bare command such as `CODEX_CLI_PATH=codex`, a remote path, or a device path reports `candidate_path_unavailable` instead. Those cases have a captured candidate; its path is not eligible for this inspection.
+#### Read-only Codex CLI update plan
+
+```bash
+ocx system codex-cli-update plan --channel latest --json
+```
+
+Run through the published Node launcher with the intended `CODEX_CLI_PATH` in the shell environment.
+The plan requires an attested POSIX npm-global installation and a linked package manifest version.
+It queries the official npm registry for an exact version, SHA-512 integrity and same-origin tarball,
+using the launcher's captured PATH and isolated temporary npm configuration, cache and logs. Proxy
+settings and `NODE_EXTRA_CA_CERTS` remain supported; cleanup is best-effort. No Codex command runs.
+
+An applicable plan reports `applicable: true` and a deterministic `planId` binding platform,
+installation-root digest, installed version, channel and target version/integrity. The plan is never
+persisted. It refuses downgrades, already-current versions, active Codex sessions and incomplete
+process observation. Windows returns `windows_inspection_deferred` before registry/process queries.
+A refusal exits 0: inspect `applicable` and `refusal`. The printed install argv is indicative; this
+command installs nothing, changes no application state and never signals a session. No apply command
+is exposed, and a plan ID is not installation authorization.
+
#### Explicit installation observation on Windows x64
```text
diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md
index 1a8f7a509d1..cabb3012544 100644
--- a/docs-site/src/content/docs/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/reference/cli/lifecycle.md
@@ -82,8 +82,19 @@ ocx start --socks5-off
### `ocx stop [--json]`
-Stop the running proxy (by PID), remove the PID file, and restore native Codex. If a managed
+Stop the running proxy (by PID), remove the PID file, and restore native Codex for standalone owners. If a managed
background service is installed, `ocx stop` also stops it first so it cannot respawn the proxy.
+On a connected client (a Remote Link Child or a hub client), `ocx stop` leaves Codex routing, its
+model catalog and the Grok config as they are, including after a successful API stop: the connection
+owns them, and `ocx start` serves them again without re-injecting routing. Use `ocx disconnect` to hand Codex back to its own account. If Codex on a connected
+client already reads native routing, `ocx status` names `ocx sync --restart-codex` to point it back.
+A connected stop verifies the local runtime before changing its service: a client-role listener
+with the matching PID is supported, while an ordinary proxy must explicitly advertise preservation
+across API, signal and service shutdown. Older or unverified ordinary runtimes are refused before
+stop actions. Update/restart that runtime through its supervisor and retry; use `ocx disconnect`
+only when you intend to release the connection-owned integrations.
+Repeated stops of an already-stopped client confirm service-manager inactivity and absence at the
+known local endpoints before returning the normal not-running result.
While the OpenCodex desktop app is running, `ocx stop` does not keep the proxy down: the app treats
it as an unexpected exit and starts a proxy again, within seconds for one it started and after about
a minute for one it had only attached to. Use the app's tray **Stop proxy** (for a proxy the app
@@ -113,8 +124,11 @@ failure to restore shared client configuration keeps the stop failed and its rec
carries the outcome class (`stopped`, `not-running`, `history-incomplete`, `history-deferred`,
or `failed`), the service and proxy classifications, whether the runtime is down, and a stable
one-line message. Exit codes are identical with and without `--json`: 0 on success, 1 on failure,
-79 when only Codex history cleanup did not complete, and 80 when the shared teardown was deferred
-and is still owed.
+79 when only Codex history cleanup did not complete, 80 when the shared teardown was deferred
+and is still owed, and 81 when a dashboard update that will restart the proxy handed Codex routing
+and its catalog to the restarted proxy instead of restoring native Codex. With 81 the proxy is
+down, and the restarted proxy releases the kept teardown after its first sync; if it never starts,
+the next `ocx stop` restores native Codex.
For a desktop-approved takeover, `ocx stop --json` also accepts the complete set of
`--expect-*` values supplied by `ocx resolve --json`. On Windows, this guarded stop can
@@ -153,6 +167,14 @@ Eligibility refusals name the reason and next action: use the owning installatio
owning service/desktop app on Windows. Unknown ancestry or a changed target calls for
`ocx status` before any restart.
+On a connected client runtime (a Remote Link Child or a hub client), `ocx restart` asks the runtime
+to start its own replacement on the same port. If the replacement does not answer within the
+restart budget it is stopped, and the old runtime binds the port again and keeps serving; the
+command then reports the restart as not completed. A client runtime run by a background service or
+the desktop app refuses the request and keeps serving ("Restart not performed; the running client
+runtime was kept"); restart it with `ocx service restart` or from the app. See
+[Remote Link apply parity](#remote-link-apply-parity).
+
If package files or the Bun runtime remain incomplete after the installer exited or failed,
run `ocx status`, stop any running proxy through its owner (`ocx stop`, or its service or
desktop app), then reinstall with the same package manager and start it again. See
@@ -245,7 +267,7 @@ intended.
Repair one thread that was compacted through a routed provider before resuming it through native
Codex. The command reads the exact thread selected by UUID, saves a private byte-for-byte backup,
-then converts only OpenCodeX-owned `ocx1:` compaction state into a plain summary that native Codex
+then converts only OpenCodeX-owned `ocx1:`/`ocx2:` compaction state into a plain summary that native Codex
can replay. Native encrypted content and other threads are left unchanged. Close the selected
thread before running the command; a concurrent rollout change makes recovery stop without
replacing the file.
@@ -279,6 +301,9 @@ only in build metadata; neither side is called older. Versions are not trimmed a
is not normalized. JSON exposes the same advice in `versionSkew`, whose fields remain
`cliVersion`, `proxyVersion`, `skewed`, and `warning`.
+On a Remote Link Child and its Home, `--json` also reports `appliedCodexSettingsDigest`,
+`lastApplyAt` and `runtimeStartedAt`; see [Remote Link apply parity](#remote-link-apply-parity).
+
Print a read-only diagnostic summary: proxy PID, `/healthz` reachability, dashboard URL, config path,
default provider, Codex autostart setting, service state, shim state, and the redacted effective Codex
home. Only the explicit, high-confidence Windows Orca runtime-home signature adds an actionable App-home
@@ -394,12 +419,19 @@ not traffic missed between polls; injection seq cannot exclude restart/eviction
Check post-sync readiness through the unauthenticated `GET /readyz` endpoint. It returns `200` when
ready, or `503` with `Retry-After: 1` for `pending` and terminal `failed`. Its sanitized HTTP identity
is `{service, version, uptime, pid, port, status}` plus the remote-hub protocol fields
-`{protocol, minimumClientProtocol, managementUrl}`. `protocol` is the hub protocol this proxy
+`{protocol, minimumClientProtocol, managementUrl, features}`. `protocol` is the hub protocol this proxy
speaks and `minimumClientProtocol` the oldest client it still accepts, so a client can refuse an
incompatible pairing before sending anything else. `managementUrl` is the origin a client should
use for the management plane: the configured `hub.managementPublicOrigin` when `runtimeRole` is
`hub`, and otherwise the origin the request itself arrived on. A readiness request with no
-HTTP(S) origin is rejected rather than answered with a guess. Old proxies without `/readyz` fail
+HTTP(S) origin is rejected rather than answered with a guess.
+`features` lists the peer features this build serves, so a peer can check a capability without
+comparing versions: `relay-auth/` names the Remote Link relay authentication version,
+`link-management-relay` the read-only Remote Link management relay, `link-peer-report/` that
+a Remote Link Home reports each Child's version and compatibility in `ocx link status`, `quota-pool/1` the
+`GET /api/quota-pool` management read, and `served-by-header` the `x-opencodex-served-by` response
+header. A feature this build does not serve is absent. The list holds fixed names only, never
+account or configuration data. Old proxies without `/readyz` fail
closed as `unreachable`; `/healthz` is separate liveness, not readiness. The command performs one probe by
default; `--wait` polls until ready or timeout, but exits immediately when it observes the terminal `failed` state. The
default timeout is 45 seconds; `--timeout ` requires `--wait` and accepts positive integer seconds from 1–300.
@@ -408,6 +440,12 @@ The CLI's own `--json` output is deliberately narrower than the HTTP body: it em
`unreachable`. Exit codes are 0 for ready; 1 for not-ready, pending, failed, timeout, or
unreachable; and 64 for invalid arguments.
+On a Remote Link Child, readiness also covers the link. While the link cannot carry a request,
+`/readyz` answers `503` with `status: "pending"` and `link: {state: "unavailable", reason}`, so
+`ocx ready --wait` keeps waiting until the link is back; a working link adds
+`link: {state: "available"}` to the `200` answer. `/healthz` on a Child keeps meaning only that the
+process runs. The reasons are listed in [Remote Link](/guides/remote-link/#link-status).
+
### `ocx resolve [--json]`
Resolve the runtime facts a shell needs without re-implementing them: the config home, the
@@ -493,6 +531,11 @@ upstream status but never consumes or resumes a grant.
### `ocx sync [--restart-codex] [--restart-app-server-only]`
+An explicitly requested restart also runs after a successful committed `config.toml` change, even if the catalog and cache bytes are unchanged. A configuration-only no-op, refusal, preflight, or rolled-back write does not trigger it. Without a restart flag, sync never automatically restarts Codex.
+
+On a Remote Link Child, `ocx sync` also applies the Home's Codex settings and, when the client runtime
+started before that apply, restarts it; see [Remote Link apply parity](#remote-link-apply-parity).
+
Fetch the live model list from every configured provider and re-inject the merged catalog into Codex.
Run it after adding a provider or to refresh available models.
@@ -520,6 +563,46 @@ and a stale picker is not.
When the command runs from inside the Codex app, the restart is handed off to a detached helper
and this session ends with the app.
+#### Sync only from the expected Home: `--expect-link` and `--expect-hub-key`
+
+```bash
+ocx sync --expect-link [--restart-codex]
+ocx sync --expect-hub-key [--restart-codex]
+```
+
+A script that reads `ocx connect status --json` and then runs `ocx sync` cannot tell whether the
+machine was disconnected or relinked to another Home in between; a plain `ocx sync` would then
+sync the local providers or the other Home's catalog. Passing the identity you read closes that
+gap. `--expect-link` takes the `link.linkId` of a Remote Link Child and `--expect-hub-key` the
+`apiKeyId` of a hub client. The check runs under the same lock that chooses the sync path, and
+every later write is bound to the connection that passed it.
+
+When the identity holds, the command is exactly the connected sync above. Otherwise it changes
+nothing (not even the automatic Codex shim repair other commands run first), never syncs the local
+providers, exits **5**, and prints a human line followed by one JSON line as the last line on stderr:
+
+```json
+{"phase":"child_link_mismatch","reason":"other_link"}
+```
+
+`phase` is `child_link_mismatch` for `--expect-link` and `hub_client_mismatch` for
+`--expect-hub-key`.
+
+| `reason` | Machine state |
+| --- | --- |
+| `disconnected` | Not connected to any Home or hub |
+| `invalid_state` | The client configuration or a connect marker cannot be read |
+| `disconnect_pending` | A disconnect has started and not finished |
+| `pending_operation` | A key rotation, a connect, or another sync is in progress (including one that holds the locks this check needs) |
+| `hub_client` | `--expect-link` on a hub client |
+| `link_client` | `--expect-hub-key` on a Remote Link Child |
+| `other_link` / `other_key` | Connected, but to a different link or with a different key |
+
+A missing or malformed value, a repeated flag, or both flags together exit 2 before any state is
+read. A connection that changes after the check, while the catalog is being fetched, still fails
+as a plain connected sync does (exit 1, nothing written). Versions without these flags ignore
+them, so check `ocx --version` first.
+
### `ocx sync-cache [--restart-codex] [--restart-app-server-only]`
Invalidate Codex's local model picker cache so it is rebuilt from the active opencodex catalog. The
@@ -679,7 +762,7 @@ If the proxy is newer instead, check the CLI installation and `PATH` as describe
| `restart` | The same refresh and a guaranteed restart on every platform. On macOS an unchanged, already-loaded job is kickstarted in place. Not an alias of `repair`. |
| `start` | Start an installed service. |
| `stop` | Stop the service and restore native Codex. |
-| `status` | Report service and proxy diagnostics plus log paths. |
+| `status` | Report service and proxy diagnostics plus log paths. `--json` prints the passive document described below. |
| `uninstall` | Remove the service and restore native Codex. |
| `remove` | Alias of `uninstall`. |
@@ -687,6 +770,8 @@ On Windows, a bare `ocx service` runs the install path only after both Task Sche
proven absent. If either status query is inconclusive, it refuses to register anything and asks you
to run `ocx service status`; use explicit `ocx service install` only after confirming absence.
+`ocx service status --json` prints one passive document for scripts and peers such as GOAT's device inventory. It runs the same reads as the text report and never installs, starts, stops or repairs anything; `--json` on any other `service` verb is rejected. Fields: `schemaVersion` (1), `platform`, `supported`, `backend` (`launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered), `label` (the job, unit, task or WinSW service name), `installed`, `enabled`, `running` (the service manager's verdict), `viable`, `stale`, `conflict`, `serving` (`probed`, `ok`, `port`; probed only when a registration exists), `summary`, `logPath`, `ownershipLease` and `desktopLogin`. An unsupported platform or a Docker container still gets a document, with `supported: false`.
+
### Runtime ownership
The OpenCodex desktop app can take the background proxy over from a CLI installation. When it does,
@@ -1095,6 +1180,54 @@ target, mode, schedule and optional last/next-run facts. `job.status` describes
existing idle/running state, not a cleanup started by this command. Read back on
an unknown outcome rather than replaying the write.
+## Remote Link apply parity
+
+A Remote Link Child applies its Home's OpenCodex-owned Codex settings when it is linked
+(`ocx link add` on the Home) and on every `ocx sync`. Three facts let a peer check that the
+Child really runs under the settings it applied, without reading either machine's configuration.
+
+`ocx status --json`, on any device, carries:
+
+| Field | Meaning |
+| --- | --- |
+| `appliedCodexSettingsDigest` | Lowercase hex SHA-256 of the applied settings (below). On a Child, the values its last recorded apply wrote. On a Home or standalone install, the settings document it serves its Children right now, computed from its current configuration. `null` on a hub client, on a Child with no recorded apply, and on a Child whose apply of changed settings is in progress or failed (the previous digest is withdrawn first, so it is never reported for values Codex no longer has). |
+| `lastApplyAt` | Child only: epoch milliseconds of the last successful apply that changed the applied settings. Re-applying the same values is not a new apply and keeps it, so an unchanged `ocx sync` never forces a restart. `null` elsewhere. |
+| `runtimeStartedAt` | Child only: epoch milliseconds the running client runtime started. `null` when no client runtime answers. |
+
+The digest is computed over this compact UTF-8 JSON, with the keys in exactly this order and no
+whitespace, from the values actually applied:
+
+```json
+{"version":1,"settings":{"fastMode":true,"multiAgentMode":"v1","webSearchSidecarEnabled":false,"subagentDefaults":null}}
+```
+
+`subagentDefaults` is `null` when unset, otherwise `{"model":…,"reasoningEffort":…}` in that order.
+The example above hashes to `57c751eff89844f93db71a6ee4acdac03afbc29fec52734ade80a40193e826cd`. The digest
+never contains a setting value, and the Home's name and version are not part of it, so a Home and
+a Child that applied the same values report the same digest.
+
+On the Home, every row of `ocx link status --json` carries what that Child last reported:
+`lastApplyAt`, `runtimeStartedAt`, `appliedCodexSettingsDigest` (same meanings as above) and
+`reportedAt` (epoch milliseconds, the Home's clock). A linked Child reports shortly after its runtime
+starts and then once a minute; the Home keeps reports in memory only, so after a Home restart the
+fields read `null` until the next report. A Child whose runtime started before `lastApplyAt` is
+running an older runtime than its last apply.
+
+For a link the Home added, each row also carries `tunnel`: `attempt` (ssh attempts since the link
+was last connected), `nextRetryAt` (ISO time of the next scheduled retry, `null` while one runs or
+none is due), `lastTransition` (`{from, to, at, cause}`) and `lastFailure` (`{cause, at}`). A cause
+is one of `auth`, `hostkey`, `forward`, `remote_forward_conflict`, `timeout`, `network`,
+`unknown`, `spawn_failed` and `listener_unavailable`. The Home keeps it in memory, so it starts
+empty after a Home restart. `GET /api/link/status` includes the field only when asked with
+`?diagnostics=1`, because an older `ocx link status` rejects fields it does not know.
+
+After `ocx sync` applies the Home's settings on a Child, it restarts the Child's client runtime when
+that runtime started before `lastApplyAt`, using the same restart as `ocx restart`. A runtime that
+already started after the apply is left alone, nothing is started when no client runtime is
+running, and a failed restart exits nonzero while the previous runtime keeps serving; run
+`ocx sync` or `ocx restart` again to retry. Two applies on one Child never interleave: a second
+`ocx sync` while one is applying fails fast and changes nothing.
+
## Forced link revoke and remote recovery
Only when skipping remote cleanup is explicitly intended, use the actual selected
@@ -1139,3 +1272,13 @@ Use `ocx connect --pairing-code-stdin`, `ocx connect status`, `ocx sync`,
On Linux, `ocx service install` checks `loginctl show-user -p Linger` before printing boot advice. Enabled linger is reported as already enabled. Disabled linger gets the `loginctl enable-linger $USER` hint; missing or unreadable diagnostics state the uncertainty and do not fail installation.
On macOS, `ocx service status` distinguishes a loaded Desktop login item from a valid enabled registration awaiting the next login. `ocx status` credits the latter in restart safety when the same Desktop app currently supervises the proxy. An incorrect plist label or command, a disabled entry, an unverified load probe, or changed process identity still cannot earn protection.
+
+## Custom Codex provider consent
+
+`ocx codex-provider status` lists the provider IDs approved for the selected Codex home.
+`ocx codex-provider allow ` asks before allowing URL-only management on start/sync;
+`--yes` is explicit noninteractive consent for that named provider.
+`ocx codex-provider revoke ` restores an unchanged managed URL and revokes permission.
+These commands are local and do not require a running proxy. See
+[custom provider routing](/guides/codex-integration/#opt-in-a-custom-provider-to-managed-local-routing)
+for eligibility, restore behavior and transport preservation.
diff --git a/docs-site/src/content/docs/reference/cli/messaging.md b/docs-site/src/content/docs/reference/cli/messaging.md
index 65df17bd87f..b64b25e7255 100644
--- a/docs-site/src/content/docs/reference/cli/messaging.md
+++ b/docs-site/src/content/docs/reference/cli/messaging.md
@@ -1,9 +1,9 @@
---
-title: Local Codex Messaging
-description: Discover loaded local Codex sessions and submit one correlated queued message.
+title: Codex Messaging
+description: Queue correlated messages to loaded Codex sessions locally or through explicit SSH peers.
---
-`ocx message` is an opt-in, command-local surface for existing Codex sessions.
+`ocx message` is an opt-in surface for existing Codex sessions.
It starts no proxy or app-server, resumes no thread and installs no skill.
Linux/macOS Unix sockets are supported; Windows fails explicitly. The existing
Codex daemon must support experimental `thread/queue/add`. No CLI version pin or
@@ -85,5 +85,182 @@ The final loaded-target check and queue submission are not atomic: if the target
unloads afterwards, an explicit rejection is `not_sent` with `queue_rejected`;
a lost or invalid reply is `unknown`. OpenCodex does not resume it or retry.
-No remote hosts, bearer management, SSH, Claude messaging,
-dashboard, isolation or idle-notification controls are part of this surface.
+## Explicit remote peers
+
+Remote messaging is independent of Remote Link and normal proxy startup. It does
+not redirect providers, share provider credentials or start a native daemon.
+Both nodes need a compatible OpenCodex CLI and an existing queue-capable daemon.
+Linux has isolated interoperability evidence; macOS requires its own qualification.
+Windows remote transport is unsupported.
+
+On each node, explicitly enable messaging. On the receiving node, keep the
+foreground owner running:
+
+```bash
+ocx message enable --port 39176 --json
+ocx message serve --json
+```
+
+On the initiating node, use your existing OpenSSH destination/alias. Check the
+offered fingerprint against a trusted source before confirming it:
+
+```bash
+ocx message hosts probe --ssh worker --json
+ocx message hosts add worker --ssh worker --fingerprint 'SHA256:' --json
+ocx message hosts list --json
+ocx message serve --host worker --json
+```
+
+Private probe records keep their alias-prefixed format even when your normal SSH
+configuration enables `HashKnownHosts`; your normal known_hosts file is not changed.
+Each enrollment attempt uses its own transaction-qualified pinned-key scratch file,
+removed after its SSH helper settles, including failures, without depending on another
+mutation owner's lock. If cleanup cannot be confirmed, the command reports an unknown
+storage outcome, not success. The recovery journal is separate.
+
+`hosts add worker` authorizes outbound messages to `worker`, **not arbitrary messages
+back into this initiating node**. The SSH control exchange authorizes that outbound
+direction at the receiving node using your existing SSH login. The enrollment receipt
+and `hosts list` show each node's local inbound policy. On the initiating node the
+default is `responses-only`: the peer can see and reply only to original local sender
+sessions awaiting a correlated response to a request this node sent. A response must
+match the request UUID, enrollment and original local destination; it cannot target
+another session or become an unsolicited request/notification.
+
+To grant broader access on either node, explicitly run on that node:
+
+```bash
+ocx message hosts allow-inbound --json
+# Withdraw the broad grant without removing enrollment:
+ocx message hosts deny-inbound --json
+```
+
+`allow-inbound` permits all three message kinds into **any loaded local Codex session**
+and exposes their directory metadata to that peer. It is not a responses-only grant.
+Consent changes apply to existing connections before subsequent reads/submissions.
+Older records without a local inbound policy default to `responses-only`, not broad access.
+Reply routes are bounded to 256 outstanding requests per foreground owner and expire
+after 24 hours or owner retirement/restart. No sender context means no reply route.
+A queued or uncertain response consumes its route; a definite native rejection does not.
+An expired/lost route requires explicit operator/sender reconciliation, never automatic replay.
+
+Each `serve` is foreground-owned: keep it running and use a separate terminal
+for messaging. At most four distinct `--host` selections may be supplied. Nothing
+installs a service or starts an owner automatically. SSH uses confirmed pinned
+host keys and your configured keys/agent; password prompts are disabled. The
+wrapper owns loopback-only forward and return routes, so the receiving node can send
+correlated replies without an SSH login back to the initiator, subject to the policy above.
+
+```bash
+ocx message status --json
+ocx message sessions --host worker --json
+printf '%s\n' 'Please review and reply using the supplied route.' |
+ ocx message send --host worker --name reviewer --stdin --json
+```
+
+`--host` selects an exact enrolled alias or machine UUID. Reply commands include
+the validated source machine UUID and thread UUID; source identity is resolved on
+the source daemon, not guessed from a remote directory. Without sender context,
+there is no invented reply command. Local commands without `--host` retain their
+no-state/no-SSH/no-listener path.
+The receiver validates the complete canonical envelope against the authenticated
+machine and native message UUID, including reply host/command and standard peer guidance.
+A peer cannot label the wrapper as another machine or omit its non-user-message warning.
+Session names and thread IDs remain descriptive claims by that machine, not per-session authentication.
+
+Receiver-issued directional capabilities live in private messaging state under
+`OPENCODEX_HOME` (or the default OpenCodex configuration home). They never appear in
+normal command output, helper arguments, URLs or message envelopes. Mutual proofs
+authenticate the actual WebSocket connection before native/body frames. A separate
+health probe is not sufficient identity evidence. This is an OpenCodex gateway
+protocol: raw `codex queue --remote` does not implement its authentication handshake.
+The gateway uses the same native experimental `thread/queue/add` contract; it does
+not alter Codex's own `--ws-auth`, configuration or native bearer-token listeners.
+It exposes only loaded metadata and text queueing, not general app-server control.
+
+Status distinguishes enabled configuration, a running owner and initiated/leased
+routes. Return leases expire after 25 seconds without refresh; they are not delivery
+or processing receipts. Losing a route or changing owner configuration requires
+explicit owner restart. Transport recovery never resends an uncertain message.
+
+```bash
+ocx message hosts remove worker --json
+ocx message disable --json
+```
+
+Removal revokes local admission first; an unconfirmed remote cleanup exits 3 and
+requires removal on that peer too. Each outgoing removal uses an independent pinned-key
+scratch file; a failure preparing that file still reports local removal and unconfirmed
+remote cleanup. Scratch cleanup is attempted on both success and failure. Generated cleanup guidance uses
+`hosts remove --transaction --json`; the transaction
+guard refuses an old receipt rather than removing a newer enrollment. Ordinary removal
+without this optional guard still removes the currently selected peer.
+Disable retires the current owner generation;
+dispatch refuses new work immediately, and the foreground owner notices changed
+configuration within its ten-second maintenance interval. The sender checks local
+authority again immediately before dispatch; revocation after dispatch cannot recall
+an in-flight message or already accepted native work. A lost enrollment response retains one private
+transaction: repeat the exact same add command to reconcile, rather than enrolling
+under another name within the same enabled generation. Disable/re-enable or port
+changes invalidate that recovery generation. Completed or revoked transactions cannot
+restore a removed peer or erase another enrollment's recovery record.
+If the saved local journal has malformed JSON, record shapes or field values,
+`hosts add`, `hosts list`, `status` and `hosts abandon` refuse with
+`invalid_enrollment_journal`. Its bytes are preserved for explicit operator recovery;
+OpenCodex does not repair, replace or delete it automatically. `hosts add` sends no
+enrollment control request in this case, although its preliminary host-key probe may run.
+Unsafe storage or a busy mutation lock retains its separate error classification.
+If a valid journal can no longer be reconciled, inspect `ocx message hosts list --json` or
+`ocx message status --json` for `pendingEnrollment` (alias, transaction UUID and whether
+its generation is stale), then explicitly abandon that exact local journal:
+
+```bash
+ocx message hosts abandon --transaction --json
+```
+
+Abandonment works while disabled too. It leaves existing peers unchanged, does not retry
+enrollment and exits 3 because remote cleanup is unconfirmed. Follow its cleanup guidance
+on the remote node if an orphan enrollment remains. Stale journals are not silently deleted.
+A full receiver returns `peer_capacity` without enrolling; free a slot and repeat the same
+transaction or explicitly abandon it. Malformed or lost control replies remain uncertain.
+Existing trusted 0755 configuration homes are accepted without chmod; messaging's own
+subdirectory remains private. An immediate owner restart can replace a stale return lease
+after the new endpoint authenticates and the old endpoint no longer authenticates within a
+one-second check. A still-authenticating old endpoint retains its lease until expiry;
+temporary unreachability can permit takeover, not replay. Unexpected tunnel loss visibly
+retires the foreground owner; deliberate peer removal leaves unrelated routes running.
+Crash mutation locks fail closed and need operator recovery;
+the wrapper never guesses that an old lock is abandoned.
+State and recovery-journal publication sync the file and containing directory before dependent
+remote work; journal removal also syncs its directory. `remote_storage_unknown` (exit 3) means
+the storage change's crash durability could not be confirmed. On macOS, an ACL refusal or probe
+failure after file replacement also reports this code: the new state is already visible, but its
+ACL privacy and crash durability are unconfirmed. Inspect state and permissions before explicit
+recovery rather than automatically retrying or rolling back. A safely readable journal with malformed
+JSON or schema does not block peer removal, but is retained for operator recovery; unsafe storage and
+failure to invalidate a matching journal still refuse removal.
+
+The foreground owner shares finite connection/request/helper/buffer limits across
+all peers and rejects overload without an unbounded queue: enrolled peers together may
+hold twelve authenticated connections, four more stay reserved for the owner's own
+control, outgoing probes and renewal, and at most eight tunnel
+children and two transient control/inspection helpers, ten total. Setup is bounded by
+30 seconds across the whole selected peer batch. A final lease renewal precedes startup
+success; final and periodic renewal batches have five-second deadlines, within the setup
+deadline where applicable, and periodic maintenance runs every ten seconds.
+Cancellation stops only owned
+SSH helpers/listeners; it leaves Codex daemons, threads and unrelated proxies alone.
+Cleanup joins withdrawn tunnel routes too. Direct-child exit and natural pipe EOF must
+both settle within the three-second cleanup ceiling; otherwise cleanup is incomplete and
+the helper reservation remains charged. Cancelling retained pipes does not prove descendants
+exited. Detached descendants or descendants that close inherited pipes are not tracked.
+Helper stdout capture is limited to 64 KiB; stderr is discarded without being printed or stored.
+After capture overflow, the helper is stopped and discarded output is drained to natural EOF.
+Successful cleanup reports the original output error; genuinely incomplete cleanup remains
+`cleanup_incomplete` and keeps its helper reservation charged.
+Same-uid processes can read private enrollment state; machine authentication does
+not establish non-forgeable per-agent identity. Native traffic bypassing this gateway
+is not controlled by it.
+
+Mesh, dashboard/cache controls, isolation, Claude messaging, managed skills and
+idle notifications remain outside this contribution.
diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md
index 27387b52951..8c0c237f941 100644
--- a/docs-site/src/content/docs/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md
@@ -24,7 +24,7 @@ both `--adapter` and `--base-url`.
| `list` | `--json`, `--jsonl` | List configured providers and the remaining registry entries; `--jsonl` emits one configured provider object per line. |
| `add ` | `--adapter `, `--base-url `, `--api-key `, `--responses-path `, `--auth-mode `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync`, `--live` | Save locally by default; `--sync` attempts client sync in either output mode. `--live` writes through the running proxy and cannot combine with `--sync`. `--force` permits overwrite. |
| `edit ` | provider field flags, `--headers `, `--model-context-tier `, `--upstream-http-version `, `--fast `, `--context-window `, `--json` | Edit validated live provider fields without replacing key pools. Repeat `--model-context-tier` for multiple Copilot models. `--headers` merges custom request headers; pass `{}` or `-` to clear them. |
-| `test ` | `--json` | Probe the real upstream model endpoint. |
+| `test ` | `--model `, `--yes`, `--json` | Check discovery; with `--model`, request one confirmed completion test. |
| `show ` | `--json` | Show config with API keys masked. |
| `remove ` | `--json`, `--live`, `--yes` | Local removal refuses the default and last provider. Live removal requires `--yes` and uses server dependency checks, default reassignment and account/custom-model cleanup. |
| `set-default ` | `--json`, `--live` | Select a local default, or update the running proxy with `--live`. |
@@ -54,6 +54,13 @@ ocx models live --provider ark --json
Use it for scripts that process one configured provider object per line.
`--json` and `--jsonl` cannot be combined.
+Each configured row also has `active`. It is `false` when this device is linked to a Home
+(a Remote Link Child or a Hub-mode client): the row is local configuration that the Home's
+providers shadow, so it does not serve requests while the link is connected. It is `true` when the
+row actually serves, including when the link state cannot be read. Rows never carry credential
+values: `baseUrl` drops embedded userinfo and masks credential-shaped query values such as `key`
+or `token`, while keeping ordinary parameters such as `api-version`.
+
:::caution[Custom headers are not a credential channel]
`--headers` is for non-secret request metadata — routing hints, tenant or
project selectors, tracing ids. It is **not** a place to put authentication
@@ -120,10 +127,37 @@ notices. Null, absent or `not-requested` refresh can exit 0 without a client syn
Read back before recovery; do not repeat a persisted write to repair convergence.
Errors use safe prose on stderr, including in JSON mode, rather than a JSON error envelope.
-`provider test` is separate: it checks upstream model discovery connectivity. A failed connection exits 1; success and a static catalog with no applicable discovery endpoint exit 0.
+`provider test ` without `--model` checks upstream model discovery connectivity. A failed connection exits 1; success and a static catalog with no applicable discovery endpoint exit 0.
`applicable: false` is an expected static-catalog result, not successful inference
or a failed connection.
+To send one completion request for a chosen model, use:
+
+```bash
+ocx provider test my-relay --model my-model
+ocx provider test my-relay --model my-model --yes --json
+```
+
+This test may incur provider charges. An interactive terminal asks for confirmation;
+`--yes` accepts the charge warning. Non-interactive use and `--json` require
+`--yes`. Using `--yes` without `--model` is a usage error. Declining confirmation
+sends no request and exits 1. The command uses the running proxy's management API.
+
+The completion test sends the fixed prompt `Reply exactly OK.`, requests a 32-token output cap, stops after 20 seconds or 64 KiB, and never retries or fails over.
+It supports key-auth or local OpenAI Chat and Responses providers, including
+per-model wire overrides. Disabled, OAuth, forwarded-auth and unsupported adapter
+or transport configurations return an applicability reason without sending a completion.
+TLS profiles, HTTP version pins, enabled request pacing and custom executors with
+a provider proxy override are currently unsupported.
+
+Results include time to first text delta (TTFT), total latency, the requested and
+wire model ids, sanitized upstream model metadata and a distinct match, mismatch
+or unknown comparison. Missing or conflicting metadata is unknown. The comparison
+uses the upstream id before masking, against the id actually sent after alias and
+suffix handling. Provider-reported metadata cannot verify model identity:
+`identityVerified` is always `false`. Generated text is not returned. A failed
+completion exits 1; success and a not-applicable result exit 0.
+
### Transport settings and request pacing
`provider edit` is already live. Use `--upstream-http-version http1.1` to pin that
@@ -218,6 +252,13 @@ as OrcaRouter are reused until the provider revokes them); API-key login provide
key, validate it when possible, and save the resulting provider config. The command prints the
currently accepted OAuth and API-key provider ids when the name is missing or unknown.
+Failed OAuth logins exit with status 1 and print a fixed diagnostic: the HTTP status and standard
+OAuth error code (such as `invalid_grant`) when the provider returned one, `network_error` or
+`timeout` when the provider could not be reached, `configuration_error` with the reason when a
+local setting such as a Codex account namespace blocks the login, and `oauth_authentication_failed`
+otherwise. Pool 2 (`anthropic2`) also reports `configuration_error` before prompting when a custom
+provider owns that name or its config file cannot be read or parsed.
+
Use the same command to **reauthenticate** after `ocx status` / `ocx doctor` reports
reauthentication required or a terminal refresh failure (or use Reauthenticate in the dashboard).
Codex pool accounts are not one of those OAuth or API-key providers, but `ocx login codex` reaches
@@ -238,8 +279,18 @@ ocx login xai
ocx login anthropic
ocx login orcarouter-oauth # browser consent + S256 PKCE
ocx login orcarouter # paste an existing API key
+ocx login anthropic --headless # no browser, no local port
```
+`--headless` runs the **code-display** login for hosts whose browser cannot reach the proxy's loopback
+callback (containers, remote servers). The command prints an authorization URL; approve it in any
+browser, and the provider shows a `code#state` string to paste back at the prompt. No callback port
+is bound and no browser is launched. Only `anthropic` and `anthropic2` support it today; any other provider exits
+with the list of providers that do. For Codex accounts use the device-code login instead,
+`ocx account login codex --device` — `ocx login codex --headless` is refused rather than passed on.
+The paste window is 10 minutes, and each login accepts one code: if the exchange fails, start a new login. A successful login becomes the provider's active account, exactly
+like a browser login.
+
OAuth reauthentication preserves operator settings such as model selections, pricing overrides,
and account failover preferences. Login-owned transport/authentication fields and registry-owned
catalog metadata are refreshed. A live-discovery provider keeps its selected default model; a
@@ -302,7 +353,7 @@ Only fields named by `supported` may be written; null is not false, and an inert
policy is not active pooling. Threshold zero and `enabled:false` are independent.
Use `--enabled on|off`, `--threshold 0..100`, `--strategy`, `--sticky 1..100`, and
`--quota-window` only where supported. OpenAI does not support pool enabled/window
-writes; `reset-first` is OpenAI-only, `least-loaded` Kiro-only, and Anthropic windows
+writes; `reset-first` is for OpenAI and generic OAuth pools (never Anthropic), `least-loaded` Kiro-only, and Anthropic windows
are `five-hour`, `weekly`, `max-utilization`. Omitted fields stay unchanged.
Read DTO fields such as routes, per-account concurrency or Anthropic `nativeMessages`
do not imply a setter in this command; use only the listed CLI options. A save that
@@ -351,6 +402,32 @@ grant counts, nullable dates, cooldown, pending operation and journal availabili
Empty grants are not the same as unavailable status. It never consumes a grant
or resumes a pending spend. Consumption remains a human GUI operation.
+### Use remaining included quota
+
+To keep using a selected account past a proactive switching threshold or the main-account
+protection threshold, explicitly enable its remaining-quota preference:
+
+```bash
+ocx account use-remaining openai main on
+ocx account use openai main
+ocx account use-remaining openai main status --json
+ocx account use-remaining openai main off
+```
+
+Replace `main` with a unique pool-account ID or alias to target an added account.
+Enabling this disables proactive usage switching for that account. For the main login, it also
+moves both effective hard-lock thresholds to 100%, so a 95% reading can still admit a request.
+The preference persists across restarts until turned off; original threshold settings remain
+stored and resume applying when it is disabled. It does not select an account itself or override
+round-robin scheduling, manual/automatic pause, entitlement checks, reauthentication, or upstream
+cooldowns. Paid-credit permission remains a separate setting; enabling this never opts an account
+into credits or redeems a reset grant. The default-on main hard lock still refuses at 100%.
+
+`GET /api/codex-auth/accounts/use-remaining?id=` reads the live policy.
+`PUT /api/codex-auth/accounts/use-remaining` accepts `{ id, useRemainingQuota: boolean }`.
+Both require the existing management authentication. This control is currently exposed through
+the CLI and API.
+
### Main-account quota protection
In **Codex settings → Multi-auth → Advanced settings**, **Block main account**
@@ -374,6 +451,22 @@ percentages, for example `{ "short": 90, "long": 98 }`. Both must be integers fr
and `short` must not exceed `long`; omitted fields use the defaults. The settings API accepts the
same object and returns the effective values in `mainAccountHardLock.thresholds`.
+**While the lock blocks, requests can fall back to another model instead of failing.** The
+**Fallback while blocked** row sits beside the protection switch and works like the shadow-call
+row: picking a model — for example `deepseek/deepseek-chat` — turns the reroute on immediately,
+the optional thinking effort applies to it, and the switch pauses the reroute without forgetting
+the model. While the block is in force and no Codex account can serve a request for a native Codex
+model, that request is routed to the fallback model (with the configured effort) instead of being
+refused. A usable Pool account still serves the request first; provider-qualified selectors,
+combos, account-qualified rows and Luna Reserve keep their own destinations; and a fallback that
+itself names a native Codex model is ignored, because rerouting gpt-* to gpt-* would only re-enter
+the same refusal. The lock itself is unchanged: it releases on its own as before, and the reroute
+ends with it. Configure `codexMainAccountHardLockFallback` in `config.json` as
+`{ "model": "deepseek/deepseek-chat", "reasoningEffort": "low" }`; `enabled: false` pauses the
+reroute while keeping the model. An absent section, an omitted model, or a malformed section means
+requests are refused as usual; `reasoningEffort` must be a declared effort. The settings API
+accepts the same object, rejects invalid writes before mutation, and `null` clears the section.
+
The dashboard and `ocx status` show **possible usage outside opencodex** when fresh readings rise
by at least one point in the same reset episode without recent main-account activity through this
proxy. This is an advisory observation, not proof: a long-running turn can also cause it. The lock
@@ -384,9 +477,10 @@ reset changes and expires after six hours or at the observed reset, whichever co
An unreadable 5h reading cannot hide a weekly block. Unknown usage does not fabricate a zero, and a missing reading does
not erase an already measured blocking tuple. A predicted reset time alone does not unlock it.
-While blocked, the minute sweep waits for the latest known blocking reset, then checks owned usage.
-If no future reset is known or a check remains blocked, recovery uses a capped 5/10/20/40/60-minute
-schedule; a longer `Retry-After` also delays profile and token preparation. Only fresh valid usage or authoritative window-absence evidence
+While blocked, recovery checks owned usage metadata on a capped 5/10/20/40/60-minute schedule, so a reset
+redeemed outside opencodex is noticed before the predicted reset; the first check waits five minutes, checks
+stay at least five minutes apart, and the next check is pulled in to the latest known blocking reset. A paused
+or reauthentication-required main receives no checks before its predicted reset. A longer `Retry-After` also delays profile and token preparation. Only fresh valid usage or authoritative window-absence evidence
can lift the block. In Pool mode, a quota `--refresh` bypasses cache freshness but still honors failed-read pacing;
a deferred read makes no new diagnostic attempt. Other pause, reauthentication, and upstream limits remain independent.
@@ -580,6 +674,13 @@ changes Orca files, or changes the active pool account.
### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]`
+Stored ChatGPT pool accounts remain listable on hosts without a native Codex home, including in the
+dashboard and on account refresh. Listing does not create a Codex home or sign out the native main
+account. Disabling the Codex client integration does not disable an existing native login's quota
+observation; invalid explicit `CODEX_HOME` paths still report a configuration error. Unsafe
+`OPENCODEX_HOME` storage reports an error even when the native Codex home is missing.
+Default-home resolution also reports errors for files, dangling links, and parent directories that cannot be traversed.
+
Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty
providers are skipped unless `--all` is present. With a provider, lists only that credential family.
Human output uses `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`; a manually chosen Codex row is marked
@@ -597,6 +698,14 @@ returns:
{ accounts: AccountRow[], notes: string[] }
```
+Codex JSON rows include `slotId` (the registered `id`) and `slotKind` (`native-main` or
+`pool`). When the proxy can verify both the upstream account and user identity, it also
+includes `upstreamAccountKey` (`uai1_` followed by 64 lowercase hexadecimal characters).
+Equal keys within the `openai` provider identify slots for the same upstream user; keep
+each slot and its quota state separately. The key is local to this installation and can
+change when its administrator token rotates. If it is absent, do not infer a match from
+masked email or quota values. Raw upstream IDs, user claims and credentials are not listed.
+
An empty provider listing names the next command in human output and JSON `notes`:
`ocx account login ` for OAuth/Codex, or `ocx account add-key `
with a human-controlled piped stdin source for API keys. An empty global listing
@@ -638,7 +747,9 @@ from that account's own credential and Cloud Code Assist project id. The per-acc
talks to Google's Cloud Code Assist host through the pinned outbound transport, regardless of a
configured `baseUrl`: a custom base URL is a routing choice for requests, not a second source of
Google's accounting for a stored credential. An account without a project id, or one whose probe
-is redirected or fails, shows `unavailable`.
+is redirected or fails, shows `unavailable`. When fallback model discovery supplies quota readings,
+detailed displays retain up to 256 model diagnostics with distinct bounded labels. These rows do not
+affect provider summary headlines, severity, account ranking, routing, or reset notifications.
Devin rows come from Cognition's `GetUserStatus` for that account's own key, sent to its
allowlisted api-server host. If an older credential has no host, the probe uses the configured
@@ -676,6 +787,8 @@ separately billed sends add to the request spend. Credits are never estimated fr
With two or more Kiro accounts logged in, request-rate, confirmed monthly-quota, and
confirmed suspension refusals can rotate before output. Monthly exhaustion excludes only
that login until reset or evidence expiry; completed service clears an older verdict.
+Quota refusals include the exact `MONTHLY_REQUEST_COUNT` and `OVERAGE_REQUEST_LIMIT_EXCEEDED`
+reasons on HTTP 400 or 429. Plans allowing overage can keep serving after the monthly gauge reaches 100%.
Reactive rotation remains available when proactive account preference is off. Accounts are added one at a time —
`ocx account login kiro` hands off to the Kiro CLI and appends the new account to the pool. In the dashboard, Login and Add account also offer Builder ID, Google, and GitHub device login alongside the Kiro CLI choice; native device login adds an account without signing the CLI out.
Users of one organization (IAM Identity Center) profile share its profile ARN, so a Kiro CLI account is identified by that profile together with the email `kiro-cli whoami` reports for the session. Two users of the same organization therefore stay separate accounts, and signing the same user in again updates that user's account. When `kiro-cli whoami` reports no user, the profile alone identifies the account as before. If that profile already has an account with a known user, such a login only refreshes the account whose stored Kiro CLI session it continues; any other login is refused instead of replacing an account, and an Add account login switches the Kiro CLI back to its previous session. Run `kiro-cli whoami` to confirm the signed-in user, then retry. Reauthenticating an account saved before its user was known records the user that signs in, unless that user already has another account of the same profile.
@@ -734,7 +847,9 @@ Clear the manual Codex account selection without resolving an account id, so it
### `ocx account pause|resume [--json]`
-Pause or resume one account in the Codex, Anthropic, or generic OAuth provider pool, including
+Accounts paused by the opt-in [per-provider auto-pause policy](/reference/configuration/providers/#auto-pause-permanently-refused-accounts) use the same commands. Listing shows the fixed pause reason; resume clears it.
+
+Pause or resume one account in the Codex, Anthropic, generic OAuth, or API-key provider pool, including
`google-antigravity`. For the Codex pool, `main` identifies only the built-in Codex account;
OAuth accounts must be identified by id or a unique alias. A paused OAuth account
is excluded from request selection, 429 failover, and proactive token refresh, and cannot be
@@ -815,12 +930,50 @@ openai: { provider, autoSwitchThreshold: number, enabled: boolean }
generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null }
```
+### `ocx account preserve-signed-in openai [on|off|status] [--apply-to-bound] [--json]`
+
+Opt in to **use other eligible accounts first and keep the signed-in ChatGPT/Codex Desktop
+identity as fallback**. A bare invocation or `status` reads the running proxy's policy, affected
+entry IDs, manual pin and next unbound choice. `on` persists `codexPreserveSignedInAccount: true`;
+`off` restores your existing custom account order without rewriting any priorities.
+
+The reserved group includes native `__main__` and ordinary pool entries with the same selected
+ChatGPT account/workspace **and** normalized member email. Sharing a workspace alone is insufficient.
+The group follows the latest physical login observed by the proxy's native-login lifecycle;
+no incoming request credential can define it. If member/workspace evidence is unavailable,
+`identityKnown: false` reports that only main can be identified. Status obtains a fresh owned
+observation; a busy or unreadable native profile returns 503 without applying the change.
+
+All four Codex strategies prefer other identities with quota headroom, preserving custom order
+within each group. Reserved entries remain fallback when preferred tiers drain. **Fallback can
+consume the signed-in quota**, subject to existing model access, pause, cooldown, reauthentication,
+hard-lock and paid-credit consent checks. This policy does not grant paid-credit consent.
+
+Healthy existing bindings stay in place, and manual pins and exact account-qualified model routes
+override the preference. To apply it to ongoing work, run:
+
+```bash
+ocx account preserve-signed-in openai on --apply-to-bound
+```
+
+This explicitly releases only affected bindings and any affected manual pin for the **next request**;
+in-flight requests keep their captured account. Unrelated bindings and pins remain. Moving work may
+lose its warm prompt cache, and account-scoped uploads or continuation state can prevent a move.
+`codexAccountPriorityFailback` remains a separate opt-in, with its existing quota-strategy,
+positive-threshold and fresh-observation conditions; it can also move work toward a preferred group.
+
+For **never use this identity through the pool**, use `ocx account pause openai main` instead:
+linked pause excludes main and matching pool entries without changing the desktop login file.
+Only requests that reach OpenCodex are controlled. This does not unlock an already-disabled
+desktop composer or promise to fix client-side quota gating.
+
### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]`
Reads or sets one Codex pool account's selection order: **higher is used earlier**, the default is
`0`, and the range is `-100` through `100`. Only the `openai` Codex pool is ordered, so other
providers exit 1. `main` targets the Codex Desktop login, which is ordered like any other pool
-account — `ocx account priority openai main last` is how you keep it as the reserve.
+account. `ocx account priority openai main last` lowers only that entry; use
+`preserve-signed-in` above to reserve its matching ordinary pool entries too.
Preset words stand in for small integers: `first` is `+2`, `earlier` is `+1`, `normal` is `0`,
`later` is `-1`, and `last` is `-2`. `reset` returns the account to the default and drops its stored
@@ -1041,6 +1194,12 @@ verified official-price fallbacks. Unknown models return `null`; no price is inv
Automatic defaults are derived on read and do not populate `modelCosts` in your config,
so catalog updates remain effective. Use `set-price` to save provider-specific rates.
+`price --json` also preserves a saved `promptLengthPricing` policy in `cost`; the four
+`effectiveCost` rates are the base rates, before evaluating a request's prompt length or tier.
+Use **Models → Price** or the management API to edit a prompt-length policy. Existing `set-price`
+commands replace the four base rates and keep the model's saved Flat or Custom threshold policy;
+`--auto` removes both the rates and any policy.
+
Discover command syntax with `ocx models --help` before live work. `list-custom`
reads local configuration; `add` and `remove` save locally unless given `--live`.
Local custom writes opportunistically synchronize when a proxy exists. The remaining
diff --git a/docs-site/src/content/docs/reference/code-signing.md b/docs-site/src/content/docs/reference/code-signing.md
new file mode 100644
index 00000000000..a85a1b381cf
--- /dev/null
+++ b/docs-site/src/content/docs/reference/code-signing.md
@@ -0,0 +1,101 @@
+---
+title: Windows Code Signing
+description: How OpenCodex signs its Windows binaries through SignPath Foundation, how to verify a signature, and the project's code signing policy.
+---
+
+OpenCodex is moving its Windows downloads, the standalone `ocx.exe` and the desktop installer
+`OpenCodex--windows-x64.msi`, to Authenticode code signing through
+[SignPath Foundation](https://signpath.org). A signed file tells Windows, SmartScreen and antivirus
+products who published it and that nobody changed it after the build, which is what stops
+unsigned-binary false positives like the one reported in
+[#7207](https://github.com/lidge-jun/opencodex/issues/7207).
+
+Signing is rolling out. The release workflow is wired, and SignPath is validating it with a test
+certificate. Until the production certificate is issued, Windows release files stay unsigned, and
+SmartScreen may ask once (**More info → Run anyway**). Releases published after that switch carry
+a signature from **SignPath Foundation**.
+
+## Code signing policy
+
+Free code signing provided by [SignPath.io](https://about.signpath.io/), certificate by
+[SignPath Foundation](https://signpath.org/).
+
+- Committers and reviewers: [lidge-jun](https://github.com/lidge-jun)
+- Approvers: [lidge-jun](https://github.com/lidge-jun)
+
+Every signing request for a release is approved by hand in SignPath before the certificate is
+used. Only binaries built from this repository's source by GitHub Actions are signed. Third-party
+files bundled with a release, such as the prebuilt keyring addons, keep their upstream form; the
+SignPath Foundation policy forbids signing them with the project certificate.
+
+### Privacy
+
+This program will not transfer any information to other networked systems unless specifically
+requested by the user or the person installing or operating it. In practice, OpenCodex forwards
+requests to the providers you configure. To look for new versions it also reads release
+metadata from GitHub and the npm registry, and those checks carry none of your prompts,
+credentials or usage data.
+
+## Verifying a download
+
+In PowerShell, check the file you downloaded:
+
+```powershell
+Get-AuthenticodeSignature .\OpenCodex--windows-x64.msi | Format-List Status, SignerCertificate
+Get-AuthenticodeSignature .\ocx.exe | Format-List Status, SignerCertificate
+```
+
+A signed release reports `Status : Valid` and a signer certificate whose subject names
+`SignPath Foundation`. In Explorer, the same information is under **Properties → Digital
+Signatures**. Every release file also has a `.sha256` next to it on the release page, which you can
+compare with `Get-FileHash -Algorithm SHA256`.
+
+## What gets signed
+
+| File | Signed contents |
+|---|---|
+| `ocx--bun-windows-x64.zip` | `ocx.exe` inside the archive. The zip and its `.sha256` are built from the signed binary. |
+| `OpenCodex--windows-x64.msi` | The installer, plus every `.exe` it installs (the desktop app and its bundled `ocx.exe`). |
+
+The desktop updater signature (`.msi.sig`) and `latest.json` are produced from the signed
+installer, so in-app updates install the same bytes the release page offers.
+
+## For maintainers
+
+The release workflow (`.github/workflows/release.yml`) signs through the official
+`signpath/github-action-submit-signing-request` action. It reads one secret and three repository
+variables:
+
+| Name | Kind | Value |
+|---|---|---|
+| `SIGNPATH_API_TOKEN` | secret | API token of the SignPath CI user |
+| `SIGNPATH_ORGANIZATION_ID` | variable | SignPath organization ID |
+| `SIGNPATH_PROJECT_SLUG` | variable | `opencodex` |
+| `SIGNPATH_SIGNING_POLICY_SLUG` | variable | `test-signing` today; `release-signing` once the certificate is issued |
+
+SignPath artifact configurations: `windows-standalone-zip` (one `ocx.exe` at the zip root) and
+`windows-msi-zip` (one `.msi` at the zip root, deep-signed).
+
+Signing never blocks a release. What a run does depends on the policy:
+
+| Policy | Dry run | Real release |
+|---|---|---|
+| No token, or an incomplete configuration | Unsigned | Unsigned, with a notice or warning |
+| `test-signing` | Signed with the test certificate, kept only as a workflow artifact | Unsigned, with a notice; a test certificate never ships |
+| `release-signing` | Signed and verified as trusted | Signed and verified as trusted |
+
+If a signing request fails, times out or is rejected, or if verification fails, the job logs a
+warning and ships the unsigned file together with its original updater signature.
+
+**Switching to the production certificate.** Once SignPath issues it, set the repository variable
+`SIGNPATH_SIGNING_POLICY_SLUG` to `release-signing` (Settings → Secrets and variables → Actions →
+Variables). No workflow change is needed. From then on, every release, including a dry run, opens
+two signing requests (the standalone binary and the installer). An approver must approve both in
+[app.signpath.io](https://app.signpath.io) while the run waits. The workflow waits up to an hour
+per request before it gives up and ships unsigned.
+
+**Testing without publishing.** The **Windows signing test** workflow
+(`.github/workflows/windows-signing-test.yml`, manual dispatch only, no inputs) builds `ocx.exe`
+and the MSI on GitHub-hosted Windows runners, signs both with `test-signing`, verifies the
+signatures, and keeps the test-signed files as short-lived workflow artifacts. It publishes
+nothing and never uses the release policy.
diff --git a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md
index d2e2ee8c2e3..0a7112cab84 100644
--- a/docs-site/src/content/docs/reference/codex-retry-diagnostics.md
+++ b/docs-site/src/content/docs/reference/codex-retry-diagnostics.md
@@ -48,6 +48,60 @@ that the account pool routed to the main account, the earliest known time anothe
pool account recovers, whichever comes first (never sooner than one minute).
Resume the Goal after that time; routing then picks an account from fresh state.
+## Stored-account credit metadata retries
+
+An explicitly selected stored account can reach its included usage limit while its last
+credit balance observation is older than five minutes. If credit spending is enabled for
+that account and the evidence is stale, missing or incomplete, its local 429 advises retrying
+after the 300-second base metadata cadence (or at the included reset if sooner). A later known upstream
+cooldown for the selected quota scope, including account-wide holds, still takes precedence; when
+both apply, the later deadline wins. Unrelated
+model-group holds are not imported. The original included-window reset remains attached to the
+internal refusal as `resetAt`; the shorter hint is a metadata recheck opportunity, not a new quota reset.
+
+The request remains refused until fresh spendable credit evidence is available. This hint
+does not perform a refresh, bypass metadata-query pacing, guarantee recovery, change account
+selection, or enable credit spending. Direct account bindings retain their identity. Explicit
+credit opt-out, an older cached null without provenance, a known spending veto or a finite empty
+balance without unlimited entitlement retain the included-reset advice. A newly observed missing
+or malformed credit summary retracts spending authority but permits the short metadata retry;
+an explicit spending veto still wins. Unlimited credits with a zero balance require fresh evidence
+just like other credits. Ordinary upstream 429 delays keep the existing parser ceiling of 24 hours
+for numeric and HTTP-date Retry-After values.
+
+## Triage: `idle timeout waiting for SSE`
+
+Codex ends a stream that has sent no event for its `stream_idle_timeout_ms`
+(300 seconds by default) and retries the turn. The report alone does not say
+why the stream went quiet. On the native ChatGPT upstream WebSocket, one
+possible cause is a path that failed silently after the response started: a VPN
+or tunnel restart, a Wi-Fi rejoin or an expired NAT mapping can drop it without
+a FIN or RST reaching the proxy.
+
+On the canonical ChatGPT Codex endpoint
+(`https://chatgpt.com/backend-api/codex/responses`, the exchange with the
+metadata channel), the proxy pings the upstream for the whole exchange. Once the
+response has started, a peer that has answered at least one ping on that
+exchange and then delivers no frame and no pong for two ping intervals ends the
+stream with a retryable failure. Codex then reconnects 30 to 45 seconds after
+the last frame or pong instead of after 300 seconds, and the reconnect row names
+the cause:
+
+```text
+Reconnecting... 1/5 (stream disconnected before completion: Upstream stream terminated unexpectedly: codex websocket stopped answering after the response started [cause=after-response-started ...])
+```
+
+When a ping reaches a path that a restarted tunnel resets, the same row reports
+`codex websocket closed before a Responses terminal event (close 1006)`
+instead, within about one ping interval. An `idle timeout waiting for SSE` that
+still arrives after the full timeout comes from a peer that kept answering pings
+while sending no events, a peer that never answered a ping, or an exchange
+without the metadata channel, such as the OpenAI API Responses WebSocket, which
+sends no pings. All three keep the client's idle deadline on purpose.
+`ocx logs get --json` shows the attempt's `codexWsStage` with its
+`pings` and `pongs` counts, which tell the three apart. Raising
+`stream_idle_timeout_ms` only makes them wait longer.
+
## Why the first reconnect row can be absent
The public Codex `rust-v0.158.0-alpha.2.1` source gates the ordinary stream-retry
diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md
index 06cf68b87c6..baba267dadf 100644
--- a/docs-site/src/content/docs/reference/configuration.md
+++ b/docs-site/src/content/docs/reference/configuration.md
@@ -48,6 +48,10 @@ Aliases are optional short request names. They never change the native model id
Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. Codex model pickers show the qualified alias while preserving the canonical `provider/model` routing id. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern.
+### Native reasoning retention
+
+Native reasoning ciphertext retention on model and account changes has two opt-in settings, both off by default. Configure them on the dashboard overview or see [Native reasoning retention](/reference/configuration/server/#native-reasoning-retention) for the exact scope and experimental account behavior.
+
### Cursor effort rows
`cursorEffortRows` is an optional boolean and defaults to `false`. When enabled, the raw OpenAI-style
diff --git a/docs-site/src/content/docs/reference/configuration/advisor.md b/docs-site/src/content/docs/reference/configuration/advisor.md
index 139256cb206..036b322d589 100644
--- a/docs-site/src/content/docs/reference/configuration/advisor.md
+++ b/docs-site/src/content/docs/reference/configuration/advisor.md
@@ -1,6 +1,6 @@
---
title: Advisor
-description: The OpenCodex-owned expert consultation sidecar — a configured expert model advises routed workers, with manual and preflight policies.
+description: Independent expert consultation with manual, preflight and evidence-based adaptive policies.
---
The advisor is an independent expert model that reviews the worker's task and returns advice.
@@ -16,6 +16,8 @@ never sees — even a worker that never spawns anything can be advised.
## Configuration
+On the dashboard **Advisor** page (under the **Subagents** sidebar row), choose the expert model from the catalog selector. It saves the exact catalog identifier, including native, provider, account-qualified, custom and combo selectors. Disabled models and models awaiting initial selection are not offered as new choices. A saved identifier missing from the selectable catalog remains visible with a warning and is preserved until you choose another model or clear it. Catalog loading failures preserve the selection and provide Retry. Choose a model that can return text advice: the catalog does not guarantee consultation compatibility. The CLI and API continue to accept router-supported model strings.
+
```json
{
"advisor": {
@@ -33,14 +35,15 @@ never sees — even a worker that never spawns anything can be advised.
| `enabled?` | `boolean` | `false` | Master switch. Disabled means zero advisor behavior on the request path. Enabling does not record consent. |
| `model?` | `string` | — | The expert model. Any model string the router accepts: a bare native model (`gpt-6-astra`), an explicit `provider/model` (`anthropic/claude-sonnet-4-6`, `xai/grok-...`), or an account-qualified native model. Cross-provider is fully supported: the worker and the advisor do not need to share a provider. |
| `effort?` | `string` | `"max"` | Reasoning effort for the advisor call (`low` through `ultra`). |
-| `policy?` | `"manual" \| "preflight"` | `"manual"` | When the advisor is consulted. |
+| `policy?` | `"manual" \| "preflight" \| "adaptive"` | `"manual"` | When the advisor is consulted. |
+| `maxConsultationsPerTask?` | `integer` | `3` | Adaptive task allowance (1–3 attempts), including manual calls. |
| `timeoutMs?` | `number` | `120000` | Loopback consultation timeout. |
| `contextSharingConsent?` | `"v1"` | absent | Operator consent to send task context to the configured Advisor provider. Only `"v1"` is current. Absent, stale, or any other value means no task context is sent. |
Manage it from the dashboard **Advisor** page (the context-sharing checkbox starts unchecked) or
with `ocx advisor status`, `ocx advisor on --ack-context-sharing`, `ocx advisor consent`,
`ocx advisor consent --revoke`, `ocx advisor off`, and
-`ocx advisor set --model --effort --policy `.
+`ocx advisor set --model --effort --policy `.
`ocx advisor on` without `--ack-context-sharing` does not enable cross-provider sharing when
consent is missing: it prints this disclosure and stops. `ocx advisor set` does not grant consent.
@@ -56,6 +59,42 @@ consent is missing: it prints this disclosure and stops. `ocx advisor set` does
attempted consultation that FAILS is not silently treated as advice: the task retries after
the failure's ledger entry expires, so a temporary advisor outage does not permanently
silence the policy.
+- **`adaptive`** — adds failed-repair consultations to the initial preflight attempt. The proxy
+ must observe a completed failing validation, a subsequent successful file-edit tool result,
+ and a new execution of the **same command in the same working directory** failing again.
+ A passing validation clears its failure state. A check started before the edit cannot count
+ as a retry after the edit. The initial and failed-repair stages have separate dedup claims.
+ Only clients with a stable conversation identity receive automatic adaptive consultations.
+
+### Adaptive budget and evidence
+
+```sh
+ocx advisor set --policy adaptive --max-consultations 2
+ocx advisor activity --json
+```
+
+The adaptive task budget counts manual and automatic **attempts**, across requests and worker
+model changes. The server reserves an attempt before dispatch in a private
+`advisor-budget.sqlite` file in `OPENCODEX_HOME`; proxy restarts do not reset it. A task is
+identified by a digest of conversation identity and the latest user-task boundary. Reservations
+expire 24 hours after the first attempt, so this is a bounded task-lifetime approximation,
+not a permanent session or daily monetary budget. Only digests, counts and expiry times are
+stored. There is room for 512 live tasks; a full, unavailable or locked budget skips the expert
+call while the worker continues. Failed or disconnected dispatched calls retain their reservation
+because they may have incurred usage. Revoked consent before dispatch spends no allowance.
+Clients without a stable identity keep manual, request-bounded access but receive no automatic
+adaptive consultation. Legacy `manual` and `preflight` retain their previous task-dedup behavior.
+
+The collector recognizes paired shell calls with explicit numeric exit status, including
+`exec_command`, `shell_command`, `shell`, `Bash`, and terminal `write_stdin` polls. Supported
+commands include common Bun/npm/pnpm/yarn test, typecheck, lint, check and build commands,
+`pytest`, `python -m pytest`, `tsc`, `eslint`, `cargo test/check/clippy/build` and `go test/vet`.
+File edits use successful `apply_patch`, `Write`, `Edit`, `write_file` or `edit_file` results.
+Single Code Mode helpers with literal JSON arguments (bare keys accepted) are recognized;
+arbitrary JavaScript, batches, shell pipelines/substitutions, shell-based edits, ambiguous
+output and error prose alone provide no trigger. At most the latest 512 messages since the
+latest user task are examined. Failed-repair context includes the validation, bounded first
+and repeated failure excerpts, and source message references before the general transcript.
## Consent
@@ -98,7 +137,7 @@ prevents structural breakout and forged fields; it cannot guarantee prompt-injec
A dedicated consultation-result protocol could distinguish advice from ordinary user input more
strongly.
-Each request allows at most three consultations and four Advisor-owned worker continuations.
+Each request allows at most three consultations, including automatic attempts, and four Advisor-owned worker continuations.
After consultation exhaustion the `advisor` tool is removed. A repeated call receives one final
paired limit result; if the worker calls it again, typed 502 `advisor_continuation_limit` ends the
request without another hidden worker call. The bound is shared with empty-completion retries.
@@ -113,6 +152,17 @@ model** — never merged into the worker's token counts — and each consultatio
`[advisor]` log line with trigger, duration, status, and usage, so an advisor call is always
provable from the logs.
+The **Recent advisor activity** section and `GET /api/advisor/activity` show the latest 100
+consultation metadata records from this process, retained for up to 24 hours. The dashboard
+refreshes them every five seconds while visible. Records include trigger, status, worker and
+expert models, duration, reported input/output tokens, requested effort and adapter-mapped
+effort. A budget refusal is shown as blocked. Records contain no task text, commands, advice,
+thread identifiers or provider error bodies and are cleared on proxy restart.
+
+Mapped effort describes what the adapter serialized, not a guarantee about the provider's
+hidden reasoning. Unreported effort or usage stays unknown; it is never presented as zero
+usage or confirmed reasoning support. No advice-adoption score or exact monetary cost is inferred.
+
## Failure behavior
The advisor fails open. A DISPATCHED consultation that fails (unavailable model, misconfigured
@@ -125,12 +175,12 @@ A manual `advisor()` call without current consent returns a consent-required too
sends nothing. An advisor failure never fails the coding request, and a
consultation never switches the session's main model.
-## PR1 limitations
+## Limitations
- Native OpenAI passthrough turns (ChatGPT-pool workers) do not get the synthetic tool; advisor
support covers routed (translated) providers. Preflight consultation applies to run-turn
adapters; the tool does not.
-- No adaptive trigger: no stuck detection, repeated-failure analysis, escalation tiers, multiple
- advisors, or advisor voting. `manual` and `preflight` are the only policies.
+- No semantic stuck detection, completion review, escalation tiers, multiple advisors or voting.
+ Adaptive consultation requires the structured validation/edit/retry evidence described above.
- The preflight dedup ledger is process-local; after a proxy restart, a task in progress may
receive one more preflight attempt.
diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md
index ed106c598e0..1b6791280cd 100644
--- a/docs-site/src/content/docs/reference/configuration/agents.md
+++ b/docs-site/src/content/docs/reference/configuration/agents.md
@@ -68,6 +68,12 @@ Mode changes apply to new sessions. `maxConcurrentThreadsPerSession` is a `PUT /
`config.json` key; `ocx v2 threads ` writes `max_concurrent_threads_per_session` under
`[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled.
+The Models page's sub-agent limit counts children. A stored V2 total of 1 is root-only
+and displays 0; setting one child saves a V2 total of 2. New child limits must be at least
+1, and both the child count and converted API value must be safe integers (at most
+9,007,199,254,740,991). With global V2 disabled, including hybrid mode, no root slot is added.
+The API rejects unsafe limits with 400 before changing the feature flag or configuration.
+
**Always proactive delegation** in Subagents → Advanced (formerly **Ultra mode**) changes the
delegation trigger without changing reasoning effort. Its preset preserves user instructions,
authority boundaries, task scope, and tool rules. The dashboard toggle, `PUT /api/v2` field
@@ -378,6 +384,9 @@ Synthetic-row effort and combo defaults are preserved as the effective input bef
Each selected destination resolves its own pin, then applicable caps and wire normalization.
Compaction requests are exempt. `none` means effort omission and provider-default behavior,
not guaranteed reasoning disablement.
+For canonical OpenAI forwarding, the final model's supported effort list takes precedence:
+`gpt-6.1-sol` and `gpt-6.1-terra` start at `low`, so an incoming `none` or `minimal` is sent as `low`, including
+after a compaction model switch or combo failover.
`GET /api/effort-caps` includes the map. `PUT /api/effort-caps` accepts `modelPinnedEfforts`
alongside the existing caps: omitted fields stay unchanged, `null` clears the map, and a map
diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md
index 50c3520b0df..808d56d2c23 100644
--- a/docs-site/src/content/docs/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/reference/configuration/providers.md
@@ -3,9 +3,46 @@ title: Provider Configuration
description: Provider entries, authentication, endpoints, model catalogs, quotas, context caps, and provider-specific options.
---
+
+
A provider tells opencodex where a model lives, which wire adapter it speaks, and how requests are
authenticated.
+## DeepSeek: tokens versus image bytes
+
+DeepSeek documents a separate [48 MiB request-body limit](https://api-docs.deepseek.com/guides/files_api/).
+A screenshot-heavy session can reach that limit before its token context fills; increasing
+`model_auto_compact_token_limit` cannot prevent it.
+
+Uploads are **off by default**. Set `modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } }`
+on your provider if you accept separately persisted images on DeepSeek's servers for one hour.
+PATCH `null` on the axis or model entry clears the opt-in.
+
+For opted-in models on first-party Responses URLs, bodies above 40 MiB replace older inline images
+with [Files API references](https://api-docs.deepseek.com/guides/files_api/), aiming for 32 MiB.
+Image bytes, history and token usage stay unchanged. Files request one-hour expiry; successful
+partial uploads remain until expiry even if generation fails. The bounded reference cache is
+credential-isolated and lasts at most 50 minutes (less if the provider reports earlier expiry).
+A retry can reuse successful uploads; another credential uploads its own copy.
+
+Only `user` and `developer` message images and tool-output images are uploaded; DeepSeek rejects
+images in other roles, so those are never persisted.
+Foreign gateways and Chat Completions are unchanged. Explicit `detail: low` stays inline because
+file references ignore it. Failures stop without discarding images or retrying generation. Repairs
+have a 60-second upload deadline, a 128 MiB input cap and at most two simultaneous preparations;
+provider pacing/concurrency still applies. Oversized text or unconvertible images still need
+compaction; this transport repair does not extend the model's context window.
+
+## GPT-6.1 Terra
+
+[GPT-6.1 Terra](https://openai.com/index/introducing-gpt-6-1-terra/) was released on October 26, 2026 as the successor to GPT-5.6 Terra. `gpt-6.1-terra` is available alongside `gpt-5.6-terra`; no default model or sub-agent roster changes. GitHub Copilot uses Responses; OpenRouter lists `openai/gpt-6.1-terra` with 1,050,000 context and $1.00 input / $5.00 output per 1M tokens.
+
+Native Codex: default context 500,000, long-context ceiling 872,000, default effort `low`, ladder `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Native `ultra` is sent as `max` on the wire. The native row offers Fast only.
+
+[OpenAI API](https://developers.openai.com/api/docs/models/gpt-6.1-terra): context 1,050,000, maximum output 128,000; text/image input and text output; knowledge cutoff 2026-06-01. Efforts: `none`, `low`, `medium` (default), `high`, `xhigh`, `max`. Endpoints: `v1/responses` and `v1/chat/completions`; tool calling requires Responses.
+
+Per 1M tokens: $1.00 input, $0.05 cached input, $1.25 cache write (30 minutes), $5.00 output. Above 272,000 input tokens, the whole request uses 2× input/cache and 1.5× output: $2.00 / $0.10 / $2.50 / $7.50 respectively. Fast mode costs 2×.
+
## Context windows on custom providers
When a custom provider omits a model's context window, OpenCodex can infer it from the bundled
@@ -15,6 +52,13 @@ compatibility fallback. With it, the order is: discovered/configured limits (the
Azure metadata, model-ID metadata, enabled Context cap as the window, then 128k. An enabled
Context cap still limits the result. Built-in providers keep their existing behavior and ignore the flag.
+`GET /api/providers` includes `inferContextWindow` only when configured, preserving both `true` and
+`false`. To change it, `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to
+clear the setting and restore the default-off behavior; other types return `400`. Omitting it keeps
+the current setting. A same-name `POST /api/providers` also keeps an omitted setting, including
+`false` and any change committed while the save was validating. A submitted boolean wins, including
+`false`, even if the stored value is `true`.
+
```json
{ "providers": { "my-relay": { "adapter": "anthropic", "baseUrl": "https://relay.example/v1", "inferContextWindow": true } } }
```
@@ -69,6 +113,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR
| `contextCapValue?` | `number` | `350000` | Default used on first enable. A later enable restores the selected provider value. Updating the global value with `setAll: true` changes enabled caps only; `setAll: true` without a value enables all configured providers at the current global value. |
| `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. |
| `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. Manual pause and resume also update existing main and pool entries for the same account and workspace. |
+| `codexUseRemainingQuotaAccountIds?` | `string[]` | `[]` | Accounts opted into using their remaining included quota. Disables proactive usage switching for listed accounts; for `__main__`, effective short/long hard-lock thresholds become 100%. Original thresholds remain stored and apply again after disabling. Pause, authentication, eligibility, cooldown and paid-credit permission still apply. Managed by `ocx account use-remaining openai on`; this persistent preference does not select the account. |
| `creditCodexAccountIds?` | `string[]` | `[]` | Accounts allowed to keep serving from ChatGPT credits after a usage limit, including the main `__main__` account. Upstream does not refuse an account that holds credits at 100%; it serves the request and draws the balance. Spending is opt-in: an account not listed here is skipped by selection while one of its usage windows (weekly or monthly, only monthly on 30-day plans, or the 5-hour window) reads 100% with its reset still ahead, and returns once that reset passes. A weekly or monthly reading without a reset time does not hold the account; a 5-hour reading at 100% without a reset holds it only while that reading is still fresh. When no other account is available, automatic selection finds none rather than spending credits. An unlisted `__main__` is also refused, like a hard-lock refusal, when a request names it or carries its credential. Listing `__main__` does not lift the main-account hard lock (on by default at 98%), which still stops the main login first; turn the lock off to let the main account spend credits (a lock at 100% still stops it at 100%). New accounts start unlisted. Managed by the **Use credits** switch in the Codex Auth header and the **Use credits after limit** switch in each account card's **⋯** menu. |
| `codexQuotaAutoRefresh?` | `Record` | `{}` | Per-Codex-login-account opt-in for automatic `fiveHour` and `weekly` window activation in Pool mode; Direct mode does not run this worker. In Providers/Codex Auth **Advanced settings**, one control enables or disables both supported windows across all current main and added accounts. New accounts are not opted in automatically. Enable skips windows absent from live WHAM data; disable also clears stale enabled windows. The UI reuses granular `/api/settings` writes, reconciles partial failures, and retries the original ON/OFF intent without replacing unrelated settings or completed reset markers. The API still rejects enabling an unavailable window with HTTP 409. At a reported reset time, opencodex sends one minimal non-stored Codex message using that account's quota and persists the activated timestamp. This does not apply to API-key providers. |
| `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. When account-qualified picker rows are enabled, each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. |
@@ -76,6 +121,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR
| `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. |
| `codexAccountPriorities?` | `Record