Skip to content

[WRONG BRANCH] feat(kimi): register k3-256k in the picker and the price catalog - #5448

Closed
yuanyuanlove wants to merge 87 commits into
lidge-jun:mainfrom
yuanyuanlove:feature-20260921-kimi-k3-256k
Closed

yuanyuanlove wants to merge 87 commits into
lidge-jun:mainfrom
yuanyuanlove:feature-20260921-kimi-k3-256k

Conversation

@yuanyuanlove

@yuanyuanlove yuanyuanlove commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Problem

The Kimi subscription endpoint (api.kimi.com/coding) lists k3-256k alongside kimi-for-coding[-highspeed] and k3 (live GET /coding/v1/models, verified 260921). It is the same K3 served under the explicit 256K-ceiling id — the same 988-token scaffold and identity answer as bare k3 on the same input.

OpenCodex only knew k3 and k3[1m], so:

  1. k3-256k was missing from the picker.
  2. Usage logged under k3-256k had no price-catalog entry, and the logs UI rendered the cost cell as 无法估算 / unestimable.

Change

  • KIMI_CODING_K3_MODELS gains k3-256k — the picker, context window (262_144, the advertised ceiling), reasoning ladder and locked-parameter lists all derive it automatically from the preset record.
  • expected-prices gains kimi/k3-256k and kimi-code/k3-256k entries at the same KIMI_K3 rate (input 3 / output 15 / cacheRead 0.3), sourced as verified-derived with the live probe note.
  • Parity and overlay-membership tests updated for the new id.

Verification

  • bun run test tests/providers/provider-registry-parity.test.ts tests/usage/usage-cost.test.ts — 157 pass, 0 fail
  • bun run typecheck — clean
  • Branch merged with origin/main (086a0f5) before delivery.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

github-actions Bot and others added 30 commits September 20, 2026 12:33
…#5247)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…link refusal (lidge-jun#5264)

* fix(transport): decide DNS pinning by whether the proxy applies to the request

Carries the proxy-applies decision onto current dev and tightens the
cases exact-head review found uncovered.

The DNS-pinned provider transport used to leave pinning whenever ANY
proxy variable was present (outboundProxyConfigured). Presence is not
application: with only a scheme-mismatched variable set, an https:
request still downgraded to the unpinned fetch even though Bun fetch
would never use that proxy for it, and a local DNS failure silently
degraded to the same unpinned fetch. The benchmark/fake-IP admission,
the transport downgrade, the DNS-failure degradation and the
private-network NO_PROXY demand now key on one snapshot: whether a
proxy actually applies to this request (a usable, scheme-matched
proxy variable that NO_PROXY does not exempt).

effectiveProxyFor models that decision. Two corrections to the
carried model: a non-SOCKS ALL_PROXY counts for plain http: targets
on every CI platform, not just POSIX — the provider-outbound e2e
drives that exact request through the proxy on Linux, macOS and
Windows — and a present-but-unusable scheme-matched variable fails
closed instead of falling through to ALL_PROXY, because no usable
proxy is guaranteed either way and keeping the pinned transport is
the safe direction.

The Mihomo IPv6 fake-IP gate deliberately does not move to the new
snapshot. Its documented condition is stricter — a scheme-matched
variable or a SOCKS5 ALL_PROXY, with a non-SOCKS ALL_PROXY never
counting — and admission pins the fetch to that value explicitly, so
it now reads schemeMatchedProxyFor. That keeps every documented and
tested lidge-jun#3462 behaviour byte-identical, including a SOCKS URL written
into a scheme variable remaining a valid explicit binding.

Regressions pin a scheme-mismatched variable keeping the pinned
transport with benchmark answers rejected, a NO_PROXY match keeping
it, a mismatched variable not demanding NO_PROXY for private
providers, a DNS failure with only a mismatched variable surfacing
instead of degrading, and the degradation surviving for the proxy
that genuinely applies.

Every caller of providerOutboundGet/Post — provider discovery, the
model-catalog gather, quota probes, ollama show and the management
model-refresh routes — shares this single decision function. The
main inference dispatch and OAuth token exchange do not use the
DNS-pinned transport today and are unchanged.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(integrations): reject symlinked managed write targets

Carries the managed-target symlink refusal onto current dev, without
the src/config.ts re-export — that file sits exactly at its file-size
ratchet cap, and the only consumer imports the leaf directly.

A managed client configuration lives in a directory another process
can write, and the writer used to resolve a symlink at the final path
component both when inspecting the target and when committing the
atomic replacement. A symlink swapped in between read and write could
redirect the write — and the ownership journal's confidence — onto a
file the integration does not manage.

Three layers now refuse that. loadTarget probes the named directory
entry without following it whenever the IO exposes a no-follow probe,
so apply, refresh, disable and restore all classify a symlinked
target as unsafe before any write is planned; the Aside profile guard
forwards that probe so the per-profile path keeps the same boundary.
fileIO.writeText rejects a non-regular entry up front. And the atomic
commit replaces the named directory entry itself: a new
atomicWriteFileNoFollow resolves only the parent (an OS alias above
the configured root stays legitimate) and re-validates the target
inside the write immediately before the rename, so a link exchanged
after validation is refused rather than followed. A swap past the
last check can only replace the named entry, never redirect through
it.

Regressions pin an omo catalog symlink refused at rest with its
target byte-identical, a symlink swapped in during apply's snapshot
window refused with no ownership recorded, a Cline pair member
exchanged for a symlink at the write boundary unable to redirect the
replacement, and disable and restore each refusing a symlinked
target while leaving the linked file alone. Refresh shares the apply
observation and write path, so it inherits the same refusals.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* docs(devlog): record lane B transport and write-safety progress

* docs(devlog): link lane B progress to its pull request

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
…ey model policy (lidge-jun#5265)

* fix(devin): send the configured output budget instead of the encoder default

Codex never sends max_output_tokens, and the devin adapter only forwarded a
caller-supplied value, so every devin turn was capped at the cloud-direct
encoder's 8192 fallback however the provider was configured. A turn that
legitimately needed more ended as an upstream incomplete/max_output_tokens the
client then retried into the same deterministic wall.

Resolve the cap the way the other adapters do, highest authority first: an
explicit caller value forwarded unchanged, then the configured per-model cap,
then the provider default, then the encoder fallback. The lookup is the same
UID-aware hint resolution the input ceiling already uses, extracted so both
sides read a per-model number identically.

The output cap and the history ceiling stay separate. CompletionConfiguration
lidge-jun#2 is the output cap and lidge-jun#3 is the context window, so the resolver reads
neither contextWindow nor modelContextWindows -- collapsing them would ask
Cognition to generate a whole context window of output.

Also stop OAuth startup reconciliation from deleting an operator's output
budget. No OAuth preset declares defaultMaxOutputTokens or
modelMaxOutputTokens, so the delete-when-preset-undefined branch was the only
branch either field ever took and a hand-edited value was wiped before the
next startup finished. A preset that does declare one still refreshes the row.

Closes lidge-jun#5190

* feat(coding-agent): derive the projected-history ceiling from the model context window

The coding-agent CLIs replay the whole conversation each turn as one projected
user message over stream-json stdin, and the projection had a flat
200k-character history ceiling: roughly 50k tokens of English or code, a
fraction of what even a 128k-token model holds and far below the 1M-token
families. Long sessions lost their earliest context at a bound unrelated to
the model.

Derive the ceiling from the declared model context window on the routed
provider row -- modelContextWindows by model, then the provider-wide
contextWindow -- at three characters per token, floored at the legacy 200k cap
so small windows and missing metadata behave exactly as before, and capped by
a 4M hard ceiling so runaway metadata cannot unbound stdin. The resolution
lives once in the shared runCodingAgentTurn driver, so both CodeBuddy and
Qoder turns get it and the family adapters are unchanged.

This ceiling is a runaway-memory bound on replayed history, measured in
characters. It is deliberately not the output budget: caller-side compaction
remains the token authority, and nothing here decides how long a reply may
run.

Co-authored-by: mdwsk88 <924038395@qq.com>

* fix(adapters): bound the event queue by retained payload, not by event count alone

The adapter event queue capped how many events it buffered but never how much
those events held. Coalescing merges adjacent deltas up to 64 KiB an item, so
the old 1024-event cap admitted about 64 MiB of retained text before it said
anything, and a single oversized event was unbounded on its own.

Charge what the queue actually retains, against two separate budgets. The
aggregate budget bounds everything held at one moment; the per-event budget
bounds one event and applies however empty the queue is. They describe
different failures -- a consumer that is not keeping up versus an event that
is malformed -- so they report different terminal messages and an operator can
tell which happened.

The accounting is exact on every path. Each queued item records what it was
charged, so a merge pays only for the text it appends, a dequeue gives back
precisely what it took, a refused event is priced before anything is retained
and never charged, and the terminal record that explains a refusal is admitted
past the budget it reports but still charged and released. Draining therefore
returns the counter to zero after a normal turn, after an overflow abort and
after a consumer walks away mid-stream; retainedCodeUnits() exposes that so a
regression can assert it rather than infer it from an abort that happened to
fire.

A long healthy stream is still not capped by its total length: every dequeue
releases its charge, so only an undrained backlog accumulates. The aggregate
default is sized for the other legitimate case -- a synchronous producer that
fills the queue before its consumer is scheduled, as the image loop does with
over a million one-character deltas -- which is roughly 1.2 MB of retained
text and must not abort.

Retention is measured by walking own enumerable properties rather than by
naming each variant's string fields, because a hand-written per-variant table
is exhaustive over the AdapterEvent union and would silently stop counting a
member added on another branch. The walk carries depth and node ceilings so
one push stays cheap against the open provider-shaped payloads two members
carry.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* feat(server): scope an admission key to the models and providers it may reach

A hub serving several clients with their own data-plane keys had no way to stop
a mail or cron key spending a coding key's Grok or Claude quota. Hub-level
model selection cannot express it: it hides a model from everyone or from
no one.

An admission key may now declare allowedProviders and allowedModels. Absent or
empty means unrestricted, which is every existing key, so nothing changes until
an operator sets one.

Two rules decide what this is. A scope names destinations, not selectors: it is
evaluated against the resolved provider and model a turn will actually bill,
never against the string the client sent. Alias resolution, a policy or combo
selection, a subagent fallback and a compaction override all rewrite that
string, so a scope checked at the front door would authorize one destination
and reach another. And a scope is never management authority -- it narrows
which models an inference key may call and grants nothing else.

Enforcement sits where each route becomes concrete. On the Responses path that
is the single point every produced route passes through, which covers the
direct name, an alias, a policy or combo child, a shadow-intercept target,
both subagent-fallback re-routes and, through translation, the Chat and
Messages surfaces. The native Chat lane and the compaction route send without
re-entering that path, so each applies the same predicate itself. A refusal is
403 with a stable model_not_allowed_for_key type naming the caller's own
selector; the resolved destination stays in the server log, because a key that
may not reach a provider has no business learning that its alias points there.

/v1/models filters by the same predicate, so what a key can see and what it
can call cannot diverge. That filter is a convenience and not the boundary:
hiding a row only stops a client that reads the catalog first.

A malformed scope drops the key rather than degrading to undefined, unlike
every other field on the record. Degrading a damaged permission field reads as
"allowed everything", which is the one direction it must never fail.

Management exposes the lists on GET /api/keys and accepts them on PATCH, where
rename and scope are independent edits, and ocx access key get/set reads and
writes them without printing or rotating the secret.

Closes lidge-jun#5049

* docs(devlog): record lane E of the phase 2 consolidation batch

* fix(server,adapters): close the virtual-model scope gap and harden queue retention

Three findings from an adversarial review of this branch.

The scope check ran before applyOpenAiVirtualModel, which rewrites route.modelId
to the wire id that is actually billed. A key allowing only the public selector
was therefore authorized on one model and sent on another. The settled route is
now re-checked after normalization, so the id that is billed is the id that was
authorized.

The account-qualified branch of /v1/alpha/search resolves a model through the
router and bills the account it names, so it applies the same rule. The
endpoints that spend quota without routing a model -- images, audio, realtime,
and the non-account-qualified search branch -- are recorded in the lane document
as uncovered rather than left to read as covered.

The queue's per-event budget comment claimed it bound any single event. It bounds
a RETAINED one: an event handed straight to a waiting consumer is never held, so
refusing it would abort a turn over memory this queue does not own. The comment
now says what the code does. retainedEventCodeUnits also guards a non-object,
so a malformed adapter emission becomes a terminal event rather than a TypeError
thrown out of push with the queue half-updated.

The scope regression reached the config schema through config/schema/leaf-validators
directly, which enters that module cycle from the wrong end and threw a TDZ
ReferenceError on CI. It now loads a hand-written config.json through src/config
the way a startup does, which also proves the stronger property: a damaged
permission field drops that key alone and its valid neighbour survives.

---------

Co-authored-by: mdwsk88 <924038395@qq.com>
Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
…-in (lidge-jun#5267)

* fix(codex): name the undo command in injected Codex routing

A Windows user whose proxy had stopped was locked out of Codex sign-in
(lidge-jun#5261). The root openai_base_url opencodex writes keeps pointing Codex's
built-in openai provider at 127.0.0.1:10100 after the proxy is gone, and
the injection survives reboot, so the lockout persists.

The only surface such a user can still read is config.toml, and it named
no way out: the marker said "Auto-injected by opencodex" and nothing
else. The recovery they found was to hand-delete the routing lines and
the catalog file, which is worse than "ocx restore" -- a model_catalog_json
target that no longer exists makes Codex fail on a missing file.

Routing markers now read "# Auto-injected by opencodex (undo: ocx
restore)". Every ownership predicate matches OCX_SECTION_MARKER as a
substring rather than by equality, so both the new line and markers
written by earlier builds are still recognized, stripped and restored.
An in-place rewrite refreshes the marker, so an existing install gains
the hint on its next start instead of keeping a bare marker.

Scope is routing only. Prompt layers keep the bare marker, because
"ocx restore" is not what undoes them.

* fix(cli): point a dead-proxy status report at the offline Codex restore

When the proxy is down, "ocx status" says Codex requests will fail and
then offers only ways to bring the proxy back: restart it, install the
service, repair the service. For the user in lidge-jun#5261 that was the wrong
half of the choice. Their injected routing points Codex's own built-in
openai provider at a dead loopback port, so they were stopped at Codex
sign-in, and every suggestion on screen asked them to fix opencodex
first.

Add the other half. When the proxy is down and the routing is one
opencodex owns, the report now says that sign-in fails too, and names
"ocx restore", which needs no proxy, no management API and no network.

The sentences live in a pure function beside unusedProxyWarningLines so
they are testable without spawning the CLI. Routing opencodex does not
own is excluded: "ocx restore" would not remove somebody else's local
gateway, so advertising it there would be a false promise.

* test(codex): pin the Codex sign-in lockout behind a stopped proxy

Covers the state lidge-jun#5261 was actually reported in: routing on disk, proxy
gone, nothing listening on the loopback port Codex is pointed at. The
tests never start a proxy or bind a port, because recovery has to work
without one, and a test that needed a live proxy would be exercising the
wrong state.

What it holds:

- Routing written before the recovery hint existed is still recognized,
  and recovery from the old and new marker forms is byte-identical, so
  an install that upgrades mid-incident restores the same way.
- Removal clears the dead base URL, the realtime sideband override and
  the catalog pointer together, while leaving the user's own keys. The
  catalog pointer matters as much as the routing: left behind, it names
  a file only opencodex maintains and Codex fails on a missing target.
- An install that predates the hint gains it in place on the next
  injection, without adding an ownership line or breaking idempotency.
- A user-owned root override is still untouched and gains no hint.
- The dead-proxy advice appears only for routing opencodex owns.
- Both recovery surfaces name a command the CLI registry actually has.
  That one is derived from the marker rather than restated, so renaming
  the command in one place fails here instead of shipping a config file
  that points at nothing.

* docs: add a troubleshooting page for a Codex lockout behind a stopped proxy

There was no page for the state in lidge-jun#5261, and it is the one a user in it
can actually reach: Codex is unusable, so the docs site and the config
file are what is left.

The page names the mechanism, both ways out, and the manual edit for
someone without the CLI. It warns specifically against deleting the
catalog pointer on its own, which is the repair people reach for and
which produces the same symptom from a second cause.

Account-pool failures are covered separately on the same page rather
than folded into the lockout. They happened in the same session in the
report, but the pool needs a live management API and a fixed loopback
callback port, so they are a different problem with a different fix.

* docs(devlog): record lane H, the Codex sign-in lockout response

Names the mechanism, the four independent source reads that agreed on
it, and the three gaps this lane deliberately leaves open. Also records
that the account-pool failures which opened the report are a second
cause with a different fix, so a later reader does not merge them.

* fix(codex): correct the marker refresh fallout and the manual removal steps

Three existing cases asserted that injecting over routing we already own
returns the file byte for byte apart from the URL. Refreshing the
ownership marker breaks that literal expectation, and hosted CI failed on
exactly those three. The contract they protect still holds -- injection
is idempotent and no unrelated value moves -- so they now expect our own
marker to refresh and assert everything else unchanged, including the
malformed tail that must be returned verbatim.

The troubleshooting page told a stuck user to delete every
"# Auto-injected by opencodex" comment and the line below it. That same
comment sits above other managed keys, such as an injected
developer_instructions, so following it would have cost configuration
that has nothing to do with sign-in. It now names the three keys to
remove and says to go by the key rather than the comment.

The lockout test claimed more migration than it exercised: the fixture
carries two markers and only the routing writer had run. It now asserts
the exact marker list at each step, which pins the real behaviour --
each writer refreshes only the marker it owns -- and covers the realtime
override as well. Dropped one assertion that restated how the constant
is defined rather than testing behaviour.

* test(codex): derive marker assertions from the constants they describe

Swept every marker occurrence under tests/ and classified each one as
routing output, prompt-layer output, or an input fixture. The three
cases hosted CI failed on are already fixed; this closes the class that
produced them rather than the three instances.

Assertions on what the injector WRITES above a routing key now come from
OCX_ROUTING_MARKER_LINE, and the two that checked a substring now assert
the whole line. A substring check passes even when the wrong ownership
line is written above a routing key, which is exactly the defect that
would have to be caught here.

The four prompt-layer files kept a private literal copy of the bare
marker. They now derive it from OCX_SECTION_MARKER and say why: prompt
layers keep the short marker because "ocx restore" is not their undo, so
the two scopes cannot drift apart silently.

Input fixtures are deliberately left as literals. A hand-written config
or one from an older build is what those tests exist to exercise, and
rewriting them to the current constant would delete the backward
compatibility coverage instead of strengthening it.
…idge-jun#5210, lidge-jun#5212, lidge-jun#5213) (lidge-jun#5271)

* fix(chat): carry allowed_tools and a caller's parallel_tool_calls to the wire

Two ways a Chat Completions caller restricts tool use reached the parser and
were then dropped on the way out, both under a normal HTTP 200.

A tool_choice of type allowed_tools is a record, is not type "function", and
carries no "function" member, so it fell past every branch of the Chat inbound
translator and body.tool_choice was never assigned. The upstream received the
full catalogue and no choice at all. Chat nests the subset under allowed_tools
and names each entry under a member keyed by its own type, while the Responses
shape mapToolChoice reads carries mode and tools on the choice itself with a
flat name, so neither level lined up. Flatten both. An entry nobody can name is
refused rather than skipped, because dropping one widens the very subset the
field was sent to narrow.

parallel_tool_calls had three provider states and two branches, in two places.
When a provider expresses no preference, which is the default for every provider
that never configured the knob, neither branch ran and an explicit request-level
false was lost — on the translated path and, from its own copy of the same
branch, on the native Chat passthrough. That state now forwards the caller's
false. An explicit true still omits the key, matching the configured opt-out, so
strict OpenAI-compatible hosts never see a knob they did not have to accept
before. The NVIDIA and pinParallelToolCallsFalse pins are unchanged.

The decision moved into openai-chat/parallel-tool-calls.ts, which both builders
now read, so the three states cannot drift between them again. openai-chat.ts is
811 lines against its 822-line cap.

Regressions assert on the serialized outbound request body for both builders,
since a successful tool call and a 200 response look identical with or without
either constraint.

Closes lidge-jun#5211

* fix(adapters): preserve tool declaration strict and allowed_callers

Three fields a caller sets on a tool declaration were parsed, carried
internally, and then dropped by the outbound adapter, so the request was
dispatched as though the constraint were in force and answered normally.

Messages to Messages rebuilt every tool from name, description and input_schema
alone. Anthropic is the target that defines strict, the Messages inbound already
kept the source intent deliberately, and the OpenAI Chat adapter already
forwarded it, so Anthropic was the one destination losing it. It now emits an
explicit strict: true. An unstated strict stays absent: the inbound records it
as false, so a false on the wire cannot be told apart from silence and must not
become an opt-out nobody asked for.

allowed_callers had no carrier at all. The identifier existed once in the tree,
raising a caller_mode diagnostic that only becomes a refusal when the operator
has set claudeCode.compatibility. The field now rides OcxTool.allowedCallers
from the Messages inbound through the Responses schema — where an undeclared key
is stripped, which is why it never reached buildTools — to the Anthropic wire.
The OpenAI Chat and Gemini builders have no counterpart for it, so they refuse
with a 400 rather than rebuild the declaration without the fence, in the shape
ollama-native and kiro already use for a tool_choice they cannot enforce. The
unrestricted ["direct"] default is not treated as a restriction.

Gemini expresses schema-enforced calling as functionCallingConfig.mode
VALIDATED. The mode was plumbed to the wire compiler but only reachable by
matching a model name, so a strict declaration arrived as an ordinary AUTO turn.
It now replaces the absent-choice default. NONE, ANY and a forced-name choice
are stronger constraints the caller asked for and are never overwritten.

Native passthrough is unaffected on every route.

Closes lidge-jun#5210

* fix(openai-chat): keep developer messages in their conversation position

A developer message kept its slot only when the provider base URL host was
exactly api.openai.com. On every other OpenAI-compatible Chat endpoint its text
was appended to the system prompt and the message itself was skipped, so an
instruction written to apply from the second turn onward arrived ahead of the
first one and the caller got an ordinary completion either way.

The two halves of a Claude Code route were working against each other because
of it: lidge-jun#4161 established that folding in-conversation instructions into the
prompt preamble is harmful and made the Claude inbound mint chronological
developer items specifically to preserve timeline order, and this adapter then
folded them again on every host but one.

One destination already had the chronological behaviour, keyed to a model id
and a registry entry, because hoisting a newly appended reminder rewrites the
reusable prompt prefix. That is a property of prompt-prefix caching rather than
of that destination, so it is now what every destination gets, and the
model/registry test is gone. A reminder that arrives while a tool call is open
is still deferred past the result, which is what keeps tool-call adjacency
intact; it lands in its own slot immediately after, never at the front.

This commit changes placement only. The wire role is still developer on
api.openai.com and system elsewhere, and is addressed separately.

Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com>

* fix(openai-chat): forward the developer role instead of inferring it from the host

A developer message reached the upstream as developer only when the provider
base URL host was exactly api.openai.com. Everywhere else it was rewritten to
system, so every OpenAI-compatible gateway was assumed not to support a standard
Chat Completions role until proven otherwise — including gateways that proxy
OpenAI itself — and the instruction silently lost the precedence the caller
chose.

The role is now forwarded as sent. A destination that genuinely rejects it sets
foldDeveloperRoleToSystem, which converts the role where the message already is
and never moves it, so the placement contract from the previous commit holds on
both paths. That makes the conversion a recorded decision about one destination
rather than an inference from its hostname, which is what the hostname test
could never express.

The flag is registered in the provider config schema and in the exhaustive
provider field policy, which is keyed on keyof OcxProviderConfig and fails
typecheck until a new key is classified.

Closes lidge-jun#5213

* fix(inbound): carry inline document bytes through to the wires that hold them

Both inbound parsers reduced an attached document to its name before any
adapter ran, so no adapter could forward one even to a target that has a
representation for it. The Messages inbound replaced a base64 document block
with a "[document: title]" marker, and the Chat file part matched no branch of
the content loop at all. The request succeeded either way, so the caller could
not tell "the model read the document" from "the model was told a document
existed".

OcxContentPart gains a document member carrying the media type and the base64
payload. The Anthropic wire emits it as the document block the caller sent, the
OpenAI Chat wire as the file part that is its direct counterpart, and Gemini as
the inline_data part it already uses for images and video.

Widening the union is the hazard here, so the part also carries the marker every
text-only consumer already falls back to. That keeps a wire with no document
representation emitting exactly what it emitted before instead of undefined or a
mislabelled [video]. Six consumers needed more than the fallback and are fixed
explicitly: ollama-native and the Cursor tool-result decoder would have read a
nonexistent imageUrl, and Kiro, Devin, Cursor and coding-agent text serializers
would have produced an empty turn. Token admission counts the encoded payload
rather than the marker.

The untranslated-media refusal is narrowed to match, and only where a converter
actually builds the part: user content on the Chat projection, user and
developer messages on the Responses one. A file in a tool output, a system
message or an assistant message is still refused, because those converters
flatten their content to a string and exempting them would restore the silent
drop the scanner exists to prevent. The scanner and the decoder share one
predicate, so a request cannot be exempted in one and reduced to a marker in the
other; a ";notbase64," parameter is not a payload. A reference with no bytes —
a file_id, a remote source — is unchanged in every position.

Tool-result documents keep the lidge-jun#939 marker: the Responses tool-output
vocabulary has no file block and every adapter's tool-result path flattens to
text, so carrying bytes there needs a separate change.

Closes lidge-jun#5212

* fix(adapters): refuse unrepresentable declarations by default, not per adapter

Adversarial review of the whole branch found the same shape of hole in two of
its fixes: a constraint the normalized request now carries still reached wires
that rebuild the declaration or the message without it, and answered normally.

tools[].allowed_callers was refused by the OpenAI Chat and Gemini builders
because those are the two the report named. Cursor, Devin, Kiro, Command Code,
Ollama and the coding-agent wires rebuild tools from name, description and
schema, so a caller-restricted tool reached those upstreams unrestricted. Inline
document bytes had the same problem from the other direction: admission exempted
every user-content document without knowing the destination, and a wire with no
carrier replaced the bytes with the marker and continued.

Both are now default-deny allowlists in adapters/declaration-carrier.ts,
enforced at the single guard in adapters/input-media-guard.ts that every
registered adapter passes through. allowed_callers reaches the anthropic wire;
document bytes reach anthropic, openai-chat and google. Adding an AdapterWire
member makes the omission visible in those lists rather than at a customer's
upstream, which a per-adapter opt-in could never do. The Responses passthrough
stays exempt from the whole guard because it forwards the original body.

The refusal no longer names the tool, which is caller-controlled and put client
metadata into an error body. An allowed_tools entry whose selector kind is
neither function, custom, nor a hosted type is refused rather than flattened to
a bare name. Token admission counts a document's payload arithmetically instead
of rebuilding a request-sized data URL to measure it.

* test(document): derive the attachment marker from its source constant

A restated literal is the union-defect class AGENTS.md records: the next change to the marker breaks a test for the wording rather than for the contract. Every assertion about it now reads inlineDocumentMarker, and the data URL spelling comes from inlineDocumentDataUrl.

* fix(adapters): type the document scan against OcxMessage and keep a developer document's role

Two defects from a final adversarial pass over the branch.

The document scan took content shaped as OcxContentPart[], but context.messages is
OcxMessage[] and an assistant turn carries OcxAssistantContentPart[], which is not
assignable to the user-content union. It now takes OcxMessage and reads the
discriminant structurally, which is all it ever needed.

A developer message carrying a document reached the structured-content branch and
was emitted as role user, undoing the role preservation the same adapter had just
established. A developer message with images keeps the user-compatible shape it
has always had on this wire; a document has no such precedent and keeps its role.

* docs(devlog): record lane A meaning preservation

What each of the four contracts restores, the review findings that changed the shape of the fix, the union-defect check run before push, and the one gap left open.

* docs(structure): repoint the instruction-ordering links at the renamed heading

Renaming the section from the OpenCode Go exception to the universal contract left five documents linking a heading anchor that no longer exists, which is what the SSOT gate is for. The link text now describes the contract rather than the destination it used to be scoped to.

* test(anthropic): await the registered adapter build

createRegisteredAdapter wraps openai-chat in withClinePassDeepSeekV4ToolReplayCompatibility, whose buildRequest is async, so reading .body off the returned promise parsed undefined. The refusal cases in the same file already tolerated both shapes.

---------

Co-authored-by: Yum-wu <118118663+Yum-wu@users.noreply.github.com>
…n#5266)

* refactor(usage): state the attempt recovery vocabulary once

The recovery kinds were written twice: as a union and as the read-back whitelist
that normalizedAttempt filters against. The two are not interchangeable. A member
added only to the union compiles, is written to disk, and is then dropped on the
next read, so the row loses the one field that says why the attempt recovered.

Declare each vocabulary as a frozen roster and derive both the type and the Set
from it, so the declaration cannot drift from itself.

* feat(lib): one stage, cause and resend vocabulary for a failed request

Roadmap items 7 and 14 want the same substrate: item 7 divides a failure into
pre-header, headers-only, protocol prelude, semantic output, side effect and
terminal and decides resend permission per stage; item 14 wants one cause
dictionary spanning logical request, attempt, physical send and terminal.
Defined separately they typecheck on each branch and contradict each other in
the merge, which is the class that blocked 2.60.0, so they are one module.

The resend decision is derived from three small per-member facts -- what the
caller observed at a stage, what a cause proves about whether the origin ran the
turn, and what a resend would have to change -- rather than written out as a
stage-by-cause matrix. A matrix of that size is a restatement: it has to be
re-derived by hand whenever a member is added, and the cell nobody revisited is
how two correct branches merge into a wrong table.

The module adds no record store. Durable shapes stay in src/usage/log.ts and
projections read them structurally. It stays a leaf: both imports are types,
erased at runtime, so nothing here reaches a request path that lacked it.

The tests run over the full stage-by-cause cross product, so none of them can be
satisfied by a request that returned 200 and none can go stale when a member is
added.

* feat(metrics): project recovery counters through the shared cause dictionary

recoveryClass() ended in `default: return "other"`, so a recovery kind added
later compiled cleanly and then disappeared into a bucket an operator cannot act
on. Key the projection on the shared cause instead and make it total, so a
missing member is a typecheck failure.

This also separates four refusals that used to be indistinguishable in the
counter. Waiting out a rate limit, changing account on quota exhaustion,
changing the prompt on a policy refusal and dropping stale ciphertext are four
different operator responses; `quota`, `policy` and `ciphertext` are new label
values so the metric can tell them apart. An opaque blob rejection moves from
`payload` to `ciphertext`, which is the one existing series whose meaning
changes: the payload was never the problem, the stale encrypted state was.

Label cardinality is unchanged in kind. Every value still comes from a frozen
roster, so no user, model, account or request identifier can reach a series.

* feat(responses): say the Codex WebSocket failure in the shared vocabulary

The WebSocket transport was the one surface whose failures could not be compared
with anything else, which is the reported symptom in lidge-jun#4191: an unanswered
socket, a socket carrying only control frames and a socket that died mid-reply
all reached the user as the same sentence.

This is a projection, not a second classifier. classifyCodexWsFailure stays the
only place that reads the counters; this restates its answer as the stage and
cause the durable log, the metrics projection and the HTTP path already use.

It does not relax the transport's own rule. The no-replay-after-send contract in
codex-ws-exchange.ts holds regardless of what the projection returns; the shared
table independently agrees that everything past before-send is refused.

* fix(responses): recover from a relayed ciphertext rejection

An OpenAI-compatible gateway does not forward the upstream error envelope; it
puts the real payload inside its own message string. The single-shot sanitized
rebuild keys on that envelope, so behind such a gateway it never matched and a
turn carrying a stale reasoning blob failed outright instead of being resent
without it.

Recognise exactly one identity through the wrapper: an embedded
invalid_request_error carrying invalid_encrypted_content. The generic classifier
is deliberately NOT re-run against the embedded payload. Doing so would also
admit the code-less unverifiable-ciphertext wording, the lidge-jun#4469 caller mismatch
and the two xAI decoder strings, each of which was accepted on evidence about
how one specific upstream words its own rejection -- and a gateway in between is
not that evidence.

The embedded object is found by counting braces outside string literals, because
the payload legitimately contains braces and escaped quotes and the gateway
appends prose after the closing brace. The scan is bounded so an
upstream-controlled string cannot decide how much work the classifier does.

Nothing else moves: the rebuild stays single-shot, still requires the send to
have carried a blob, still requires a 4xx on the Responses adapter, and is still
recorded as opaque-blob-rejection, which the shared table classifies as a
ciphertext refusal repaired rather than repeated.

The regression cases are mostly negative, because recognising the wrapper is the
easy half and admitting only the coded identity through it is the half a broad
implementation gets wrong.

Co-authored-by: cmdy <zhang_lin66@foxmail.com>

* docs: bind the resend rule and record the lane C dispositions

INV-RESEND-01 states the rule the substrate exists to hold: once the caller has
observed output or an externally visible effect no cause automatically permits a
resend, and an unknown upstream execution state is not made replayable by having
budget left. It is bound to the cross-product test, so deleting that file fails
structure:check rather than quietly unbinding the rule.

The management-api reference now lists the closed recovery label set, including
that a rejected opaque reasoning blob counts as ciphertext rather than payload.

The lane document records what was carried, what was deferred and why, including
one defect found while mapping the substrate and deliberately not half-landed:
the GUI declares its own recovery-kind roster with nine of the durable thirteen
members, so four kinds render without a label. Fixing it needs strings across ten
locale catalogs and a screenshot this branch cannot produce.

---------

Co-authored-by: cmdy <zhang_lin66@foxmail.com>
…on and add privacy-bounded cache diagnostics (lidge-jun#5268)

* fix(codex): preserve cache affinity across model detours

Carries lidge-jun#5209.

A gated-model detour under pool.cacheAffinity + the quota strategy evicted a
cache-warm shared binding on a threshold crossing (a hint), before the account
was actually exhausted. The three shared-state/affinity preservation
predicates now use the 100%-exhaustion boundary via
hasCodexSharedStateQuotaHeadroom, matching live-binding quota re-evaluation.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(reasoning): scope learned reasoning-effort refusals to credential identity

Carries lidge-jun#5145.

A learned upstream refusal was persisted under a destination-wide key
(provider, model, effort), so every credential reaching the same destination
inherited it. Each learned fact is now bound to a one-way SHA-256 digest of
the active credential; the support row key becomes a JSON array; the snapshot
advances to version 2 and legacy destination-wide rows are ignored on load.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(cursor): isolate live roster and Max Mode evidence by account

Carries lidge-jun#5229.

Cursor pooled accounts shared module-level singletons for the Claude
wire-spelling map and the Max-Mode evidence set, so a discovery recorded
under one credential could rewrite the wire id or arm ultra for a request
resolved under a different account. Both maps are now keyed by a non-secret
sha256 scope over the upstream destination and credential, and a
provider-scoped evidence entry is dropped when its model cache clears.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(codex): fence entitlement credential refreshes behind admission

Carries lidge-jun#5214.

Background and data-plane entitlement resolves (catalog sync, convergence,
serve-options /models, CLI startup discovery, ensureCodexEntitlementFreshness)
could refresh or rewrite the native auth.json while native-main lifecycle,
recovery, or profile-switch drains intend the physical native identity to
stay untouched, and a refused claim also took down Pool discovery. Adds
model-entitlement-admission.ts plus withNativeMainCredentialAdmission in
native-main-admission.ts, applied at the five sites; the test file lands in
the codex-integration domain registered in the layout map.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* feat(usage): show cache metrics by model

Carries lidge-jun#4793.

The Usage page's Models table now shows input tokens, output tokens, cache
hits, cache writes, and cache hit rate for each model; providers without
cache telemetry render an em dash. Includes translations for all supported
GUI locales, dashboard documentation, and a rendered GUI regression test.

Co-authored-by: xdober <10195626+xdober@users.noreply.github.com>

* test(codex): move cache-affinity detour cases to a sibling under the file-size cap

codex-routing.test.ts sits exactly at its file-size cap; the carried lidge-jun#5209
cases would have grown it 83 lines over. The three detour cases move to
codex-routing-cache-affinity-detour.test.ts byte for byte with their own
minimal harness, registered in both layout.json and the expected fixture.

* fix(codex): bind Cursor and Devin live rosters to the observing credential

The live Cursor and Devin model rosters are entitlement-specific, but their
provider roster cache was scoped by provider name alone: a credential switch
could read the previous account's fresh or stale plan roster, and a failed
discovery's cooldown suppressed the next credential's first fetch while
offering it the previous account's stale list. Bind the cache entry to an
irreversible credential fingerprint (the Qoder precedent), make the stale
fallback credential-scoped, and let a credential with no roster of its own
fetch through another credential's cooldown. Quota and rate-limit health stay
account-scoped by design: they describe the subscription, not the token
generation, and the 401/403 quarantine is already generation-fenced.

* fix(codex): fence cancelled entitlement refreshes behind caller cancellation

A data-plane /v1/models request now passes its own signal into admitted
entitlement resolution, and the native-main token refresh re-checks that
signal after the upstream grant resolves and before the auth.json commit: a
refresh that resolves after its caller went away no longer rewrites the
physical credential on behalf of a request that no longer exists. The reauth
twin already fenced its commit the same way; the roster-cache publication
stays fenced by credential identity and mutation epoch, which is the
correct boundary for a shared flight.

* feat(usage): opt-in privacy-bounded cache diagnostic (lidge-jun#5178)

Under OPENCODEX_CACHE_DEBUG=1 the proxy writes one record per finalized
request to <config-dir>/cache-debug.jsonl (0600, 200-to-100 rolling), letting
an operator compare two requests and tell a client prefix change, an account
change, and a proxy transformation change apart as the cause of a cache-read
drop. Records hold only presence booleans, counts, closed enums, the raw
upstream cache counter before defaulting, and process-local HMAC equality
tags (independent process-random key, never persisted) for the prompt-cache
key, allowlisted session headers, the account log label, and ordered
instruction/tool/message blocks capped at 128 per section with only the
first divergent section/index. No prompt text, tool names, raw identifiers,
or header values are recorded, and no tag survives a process restart, so a
fingerprint can never become a public or durable correlation key. The
request path reaches the module through a process-local registration hook so
responses/core.ts gains no runtime import, and an all-zero usage frame with
a measured cache counter now survives extraction instead of collapsing to
"unreported", which is what keeps a measured zero distinct from an absent
counter downstream. Off by default.

* docs(devlog): record lane D account/cache-generation progress

* fix(usage,tests): close review findings on the diagnostic and the moved admission test

Pre-CI adversarial review found two blocking defects: the carried
entitlement-admission test kept its tests-root import paths after the domain
move (every case failed at load), and the diagnostic's block splitter
aliased an array-valued instructions field, so observation would have
mutated the live request body the adapter was about to serialize. Both are
fixed, the second with a mutation regression test. The all-zero usage
extraction change is reverted: it reclassified spend settlement for
placeholder frames, and the measured-zero versus absent distinction already
rides the provenance enum for every frame that reports tokens.

* fix(catalog,codex): derive the reasoning-rung type and scope discovery cooldown to its credential

Exact-head CI on this branch failed gates, both typecheck-dependent shards and
one Cursor case. Three causes, fixed here.

catalog/effort.ts and catalog/build-entries.ts cast a partially populated
ladder to Array<{ effort?: string }> and push a canonical CODEX_REASONING_LEVELS
rung into it, which also carries description. That was always a type error, but
reasoning-effort.ts -> providers/reasoning-metadata.ts -> providers/key-store.ts
-> the ../config barrel formed an import cycle in which the rung type degraded
and the excess-property check never ran. Carried lidge-jun#5145 breaks that cycle by
design, so the latent error surfaced here first. reasoning-effort.ts now exports
CodexReasoningLevel and the three casts derive Array<Partial<CodexReasoningLevel>>
from it rather than restating a narrower shape. The translator-budget contract
test, which spawns tsc over the project, was downstream of these errors.

The Cursor cooldown case was a real regression from this lane. Scoping only the
roster reads to the credential left the failure cooldown provider-wide, so the
branch had to require a credential-scoped stale entry before honouring it, and a
discovery that fails before caching anything has no stale entry -- reopening the
timeout storm lidge-jun#54 closed. The scope now sits where the observation belongs: a
discovery failure records the credential that observed it and suppresses only
that credential. A failure recorded without an identity stays
credential-agnostic and suppresses everyone, so plain-endpoint providers and the
existing Qoder branch are unchanged.

cache-diagnostic.ts narrowed draft.promptCacheKey through optional chaining and
then read it again unguarded; the inbound key is bound once.

* fix(gui-tests): derive the usage header and locale symbol checks from their sources

The carried lidge-jun#4793 columns broke three GUI assertions that restate what the
page and the catalogs already own.

usage-custom-range listed the models-table headers as English literals and
omitted the API list-price column that ships today, so the case failed on any
tree where both exist. The expectation now maps the ordered column keys the
page renders through the en catalog, which is where that copy lives.

The French accidental-English guard and the zh-TW stale-placeholder guard both
flagged usage.unavailable, whose value is an em dash. A value with no letters
once its placeholders are removed has nothing to translate and is identical in
every locale by construction, so both checks now derive that from the value
instead of taking one more allowlist entry. Real words still fail: the existing
entries that carry letters, such as uptime.hour, remain allowlisted and
required.

* docs(devlog): record the lane D CI dispositions

* test(ci): quarantine the 50 MiB sideband relay case into its own lane

sideband GET /v1/live/{callId} relays a 50 MiB WebSocket frame end to end
against a hard 15s deadline while sharing a process with the rest of its
--shard=N/2 half, so its result measures the whole process rather than the
relay. On dev it lands in shard 1 and its echo leg alone spends 7.4s of that
budget. Three test files added elsewhere in this branch made Bun repartition
the halves, the case moved to shard 2, and the echo leg went past 15s twice
with the peer never receiving the frame -- with nothing on the sideband path
changed.

SERIAL_FULL_SUITE_FILES is the mechanism this repository already has for that
category; its own guard describes it as quarantining load-sensitive files into
one-worker lanes. The deadline, the assertion and the macOS leg are unchanged;
the case simply stops sharing a process, which also keeps it from breaking the
next branch that adds a test file anywhere in the tree.

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
Co-authored-by: xdober <10195626+xdober@users.noreply.github.com>
* fix(codex): stop the Codex shim hiding a failed autostart, and never block Codex

The shim ran "ocx ensure" with both streams discarded and its exit status
ignored, then launched Codex regardless. A proxy that failed to come up
was therefore completely silent, and Codex started against injected
routing pointing at a port nothing was listening on -- the lidge-jun#5261 shape,
with nothing on screen naming opencodex.

It now checks the exit status and prints one line on stderr when the
start failed, naming "ocx doctor" and "ocx restore". Ensure's own streams
stay discarded: it prints progress and warnings on exit-zero runs too,
and a wrapper that leaked those would put noise in front of every
ordinary launch, which is how a diagnostic gets ignored. The exit status
is the signal, and the one line is the whole message.

PowerShell had the opposite defect in the same place. An "ensure" that
threw escaped the try/finally, which has no catch, so Codex never
launched at all -- the autostart helper creating the exact lockout it
exists to prevent. That path now catches, reports, and hands over to the
real launcher, with Codex's exit status still authoritative.

The Unix revision marker moves to 3 so installed Unix shims are detected
as obsolete and regenerated. Windows shims have no revision marker and
are excluded from obsolete-shim refresh, so existing Windows wrappers
keep the old text until reinstalled; that gap is recorded in the lane
document rather than papered over here.

The new behaviour is covered by running the generated script against a
stand-in ensure, in a sibling file because codex-shim.test.ts is close
to its line cap and the cap only moves down. The test constants it kept
as private literal copies now come from the source module.

* fix(cli): report a Codex catalog pointer whose file is gone

A model_catalog_json naming a file that no longer exists does not degrade
Codex, it stops Codex loading its configuration at all. That presents as
the same blank wall as the dead routing in lidge-jun#5261 while having a different
cause and a different fix, and it is the state the reporter's machine was
left in after the catalog file was deleted by hand.

Injection already repairs this: the chooser refuses a missing owned path
and the caller strips the stale line. My earlier note that the pointer
survived injection was wrong, and the end-to-end coverage for it already
exists. What was missing is that the repair only reaches someone who runs
opencodex again, and the whole difficulty of this state is that Codex is
the thing that stopped working, so nothing prompts them to.

"ocx status" now says it out loud, names the file, and offers both
outcomes: regenerate the catalog with "ocx start", or take opencodex out
of Codex with "ocx restore". Which one they want is their choice.

A catalog the user named is left alone whether or not it exists, exactly
as during injection. Claiming it would put opencodex's recovery advice in
front of a problem that is not opencodex's to explain. An unreadable or
absent config reports nothing rather than inventing a finding.

* fix(cli): say at setup time that Codex routing outlives the proxy

Applying the Codex integration writes routing that survives a restart,
and then setup ends on "Setup complete". It does not install a background
service -- that is a separate command -- so "routing written, nothing
listening" is an ordinary state after the next reboot rather than a
corruption. Nobody says so, which is half of why lidge-jun#5261 read as a Codex
fault rather than an opencodex one.

Setup now ends by reusing the existing restart-health model: when the
install is restart-unsafe it prints what status and doctor already say
about it, and names "ocx restore" as the way out that does not need the
proxy back. It runs after the autostart choice, because that choice is
what decides whether the warning applies, and a diagnostic that cannot
be computed does not fail a completed setup.

Deliberately not a Windows boot trigger. The scheduled task runs as the
interactive user, so before logon there is no session for it to run in;
adding a BootTrigger would read like a fix and change nothing. Making it
genuinely pre-logon means a different principal and a different service
backend, which is a larger change than this lane, and is recorded as
such rather than half-done here.

* fix(oauth): report a browser launch that never happened

The URL launcher swallowed its own failure and returned nothing, so the
Codex login route answered identically whether a browser opened, failed
to open, or was deliberately skipped. The CLI then printed the URL and
started polling, and a user whose machine could not launch a browser sat
watching something that looked like it was working. That is the
account-pool half of lidge-jun#5261: not an error, a silence.

openUrl now resolves a result instead of returning void. It still never
rejects and never throws, because a browser that will not open is an
inconvenience rather than a login failure -- the URL remains a valid
thing to open by hand and the flow stays live. Callers that genuinely do
not care say so with void.

The Codex login response carries browserLaunch, and the CLI prints a
recovery line only when the launch failed. It names the fixed callback
port, because that is the part a user cannot work out alone: ChatGPT
supplies the redirect URI, so the flow cannot move to a free port, and
--device is the way around it.

Existing tests mocked openUrl as returning void, which the awaiting
caller would have read as a failed launch; all eight mock sites now
resolve a result. The new test does not exercise the started case: the
launcher command is fixed per platform, so proving it would mean opening
a real browser on whatever machine runs the suite.

* docs(devlog): record lane H2, the remaining lockout dead ends

Explains why the four paths belong in one lane: none locks anyone out
alone, and the incident is what happens when every signal is missing at
once. Records two corrections rather than burying them -- lane H's claim
that the catalog pointer survives injection was wrong, and a Windows
BootTrigger is a fake fix given the task's interactive principal -- plus
the three residuals this lane deliberately leaves open.

* test(ci): record the new shim test's cold-spawn disposition

Hosted CI failed on the guard that every test file bounding a spawned
child with the internal deadline must declare whether it warms that
child's module graph. The new shim test spawns children and did not.

Recorded as unwarmed, for the same reason as the file it sits beside:
its children are throwaway shell scripts standing in for ensure and for
the real Codex launcher, so the cold cost is shell and process startup
rather than a repository module graph, and an import scan has nothing to
warm. The generated shim never loads a repository module in the child --
the point of the file is what the shell does with an exit status.

* fix: close the gaps an adversarial review found in this lane

Blocker: the PowerShell shim test's Codex-failure fixture also exited 19
from ensure, which the wrapper now correctly reports as a failed
autostart, so that phase no longer isolated a Codex failure and both
PowerShell variants would have failed on Windows. Its ensure now
succeeds, which is what the phase always meant.

The setup warning said "nothing here will restart the proxy" for every
restart-unsafe install. A healthy launcher shim is also restart-unsafe,
because it covers CLI launches only, but it does restart the proxy for
those -- and the summary line printed directly beneath said so. The
warning now states the dependency without the false absolute, and a test
pins that the shim case does not claim otherwise.

The URL launcher resolved "started" on the spawn event, which only
proves a process began. A launcher with no handler spawns happily and
exits nonzero a moment later without opening anything, so a login could
still report a launch that never happened. It now watches briefly for an
immediate nonzero exit, which is how those failures arrive.

"ocx gui" awaits the launch and says so when it did not happen. It still
exits 0: the proxy is serving and the URL it printed is reachable, only
the launch failed.

The catalog finding is tagged (local) on its header like every other
local-state line, so a connected client cannot read a finding about its
own Codex home as something the hub reported.

Catalog ownership stays decided by basename, now stated as a choice
rather than left to look like an oversight: it is the same test injection
applies, and a detector drawing the line elsewhere would report a state
injection would then treat differently.

Also removes the temporary directory the launcher test created, and
corrects that file's header to describe what it actually covers.
…ery limits (lidge-jun#5216, lidge-jun#5215) (lidge-jun#5294)

* fix(gui): resolve combos by what they are, and describe failover as it runs

Two user-visible strings on the compaction-routing surface described behaviour
the code does not have.

The panel decided whether a selection was a combo by testing for a "combo/"
prefix. A combo reached through an alias carries no prefix, so it was described
as an ordinary provider and none of its targets were named -- the answer existed
and the operator could not see it. The panel now asks what the selection
resolves to: the combo list is keyed by the public model id the server already
computes, which is the alias when one is set and "combo/<id>" otherwise, so both
spellings answer the same way. It reads that list through parseComboList, the
same reader the combo workspace uses, so the selector rule is not spelled out a
second time here.

The combo warning told the operator that a covered compaction goes to every
target, including failover targets. It does not. core-combo.ts dispatches one
target per loop iteration, returns as soon as one responds, and advances only
after a retryable failure. An operator reading the old text would budget fan-out
cost and fan-out latency for something that never happens. The warning now says
the targets are attempted in order and the first that answers is used, which is
both what happens and what someone debugging a slow compaction needs.

Wording changed in all ten locales. The regression covers the aliased combo the
prefix test could not see, and asserts the ordering sentence rather than the
fan-out claim.

Closes lidge-jun#5216

* docs: check the provider discovery limits against the registry

The provider guides restate a byte ceiling and a row ceiling for thirteen
fixed-host presets, in eight pages, and nothing compared any copy to the
registry. lidge-jun#5198 fixed a preset count that had drifted across sixteen files for
months for exactly that reason; these limits are the same shape one layer down.

Every number is now read from that preset's modelDiscovery and asserted against
every shipped guide, so lowering a ceiling fails in all eight locales at once
instead of leaving seven translations describing the old one. A grouped section
must first agree in the registry before one sentence may speak for two presets,
which is what makes the Nscale/Vultr and Command Code sentences legitimate
rather than convenient.

Sections are located by brand name and the presence of a KiB or MiB token, not
by a translated sentence. A restated anchor phrase is the same hand-copied value
the guard exists to remove, and the brand names are Latin in all eight published
locales. The byte ceiling is compared as an exact token set rather than a
substring, so a stale number left beside the current one fails.

The structure record claimed the guides carried identical limits. That claim was
false when it was written: the Korean guide had no Featherless section, so it
documented twelve of the thirteen limited presets. The section is added and the
prose is replaced by a description of what is actually asserted.

Closes lidge-jun#5215

* fix(gui,test): close three defects an adversarial pass found in this lane

Combo target lookup read a plain object by the selected model id. A combo id is
free-form, so an alias of "constructor" or "toString" resolved to an inherited
Object member and the renderer tried to join a function. Read it with
Object.hasOwn.

Recognizing a combo only through the fetched list lost the canonical prefix as
a signal of its own. When /api/combos has not answered yet or failed, a
"combo/x" selection was described as an ordinary provider named "combo" -- worse
than the alias gap this lane set out to fix, because that path is reachable
whenever the management API is briefly unavailable. The prefix is kept as an
independent signal and the target names fall back to the existing
"its configured target providers" wording.

The documentation guard compared the row ceiling as a substring of the whole
paragraph, so the byte ceiling's own digits could satisfy it: a Hyperbolic
paragraph saying "256 KiB and 128 raw rows" would have passed an expected 256
rows. Row numbers are now read from the prose with the unit tokens removed. All
104 locale/section combinations still pass, verified by transcribing the test's
own logic over the eight guides.

* docs(devlog): record lane G onboarding, update and screen improvements

Why the recovery path cannot live in the dashboard, what each of the six targets needed, the differential between the two workspace pull requests with the three findings that decide their sequencing, and the one src/ defect this lane identified and left stated rather than half-fixed.

* fix(gui): key the combo lookup by Map, not by a caller-configured object key

A combo's public model id is free-form and operator-configured, and
readComboProviders wrote it straight into an object literal. That is a
prototype-pollution sink on the write side, and the read side returned an
inherited member for an alias of "constructor" or "toString" -- the previous
commit guarded the read with Object.hasOwn and left the write as it was.

A Map removes both. There is no prototype to shadow, the guard disappears, and
the failed-fetch fallback returns an empty Map rather than an empty object, so
the two branches keep the same type.

* fix(gui): build the combo target list in one pass

React Doctor's js-flatmap-filter fired on the map().filter(Boolean) this lane
introduced at CompactionRoutingPanel.tsx:51 -- one new warning in one file, and
the job's blocking threshold is warning. flatMap does the same work in a single
pass. The related js-combine-iterations rule is switched off in
gui/doctor.config.json, but this is a different rule and is enabled, so this is
a real new finding rather than an accepted one.
* refactor(usage): one terminal classification for a finished request

Three surfaces answered "how did this request end" three different ways. The
durable row carries terminalStatus and closeReason, the Prometheus exporter had
its own private classifyResult, and the dashboard read the numeric HTTP status
and nothing else.

That is not cosmetic. A turn cut short by max_output_tokens is durably
status 200 with terminalStatus "incomplete", which the exporter reports as
incomplete and the dashboard rendered as a green 200: the metric and the
operator disagreed about whether the user got an answer.

Move the classifier into src/usage/request-outcome.ts and have the exporter
import it, including its result label set, so the four strings are stated once.
Semantic terminal facts are read before the numeric status, which is the whole
point; the status is consulted only when no terminal event was recorded.

The module also names the send totals a surface should show, because reporting
sends without the unresolved remainder is how a duplicate-send incident stays
invisible. It is a leaf: its only import is a type.

* fix(gui): make the logs page agree with the ledger and the exporter

Carries the rehydration half of lidge-jun#2366 — the half that brings the durable
terminal facts out to where an operator reads them. Its separate attribution
vocabulary is deliberately left behind, because the landed stage and cause model
already owns that question and two vocabularies for one thing is the class of
defect this batch exists to remove.

The page classified every request by its numeric HTTP status alone and showed no
send count at all, so it disagreed with both other surfaces about the same
request. A turn cut short by max_output_tokens is durably incomplete and is
reported incomplete by the exporter; the page rendered a green 200. The data was
never missing — /api/logs spreads the whole durable entry — the page simply did
not declare terminalStatus, closeReason or spend.

It now declares them and calls the shared classifier rather than reimplementing
the precedence, so agreement is structural instead of a rule someone maintains.
It also shows the upstream send count, and names the unresolved remainder when
there is one, because a send total without it is how a duplicate-send incident
stays invisible.

The recovery-kind union is now the durable roster instead of a copy. The copy had
drifted to nine of thirteen members, so key-401, oauth-account-429,
opaque-blob-rejection and reasoning-effort-downgrade each reached the operator as
"Unknown recovery reason" — four real causes rendered as an absence of one. The
satisfies clause makes the next added kind a typecheck failure here rather than a
silent fallback, and the four missing labels are added across all ten catalogs.

Co-authored-by: chilung <b0423031@gmail.com>

* test(usage): hold the three surfaces to one answer

The exporter is driven over the full cross product of status, terminal status
and close reason and its emitted result label is compared against the shared
classifier, so the two cannot drift apart without a case objecting. The cases
that actually broke are asserted by name as well: an incomplete 200 is not a
success, and a cancelled 200 is aborted.

A source oracle holds the dashboard to the same contract. It has to call the
shared classifier rather than read the status, it has to show the send total and
the unresolved remainder, and its recovery-label map has to cover every member of
the durable roster. That last one is a source oracle rather than a type check
because the page is compiled by a separate project, which is how the copy drifted
to nine of thirteen members unnoticed in the first place.

Every label key the page names is required to exist in all ten catalogs, so a new
recovery kind cannot ship with an English label and nine blanks.

One case asserts the exporter's whole label set is still protocol, result,
recovery and le after thirty-two requests carrying recoveries, which is the
bounded-cardinality promise stated as an assertion rather than a convention.

* docs(devlog): record lane C2 and refresh the deferred dispositions

Each item that did not land carries the reason that is true against current dev,
not the one written a day ago. lidge-jun#3748's blocker is now narrower and more useful
than "parallel store": the recorder does not yet record why a request finally
failed, so there is nothing closed to group by. lidge-jun#3983's emission path turns out
not to be ephemeral, because stderr is redirected to the service log under both
launchd and systemd. lidge-jun#5063 has a concurrent-append data-loss window that the
rename cannot see.

Retention and masking are stated in one table rather than reimplemented, with the
policy that projections inherit both instead of getting their own.

---------

Co-authored-by: chilung <b0423031@gmail.com>
…ata planes (lidge-jun#5290)

Per-key model and provider scope (lidge-jun#5265) is evaluated on the resolved route, at the capture point
the Responses path funnels every destination through. Four authenticated endpoints spend provider
quota without resolving a model through the router, so the predicate never reached them: the
standalone Images relay, file transcription and the dictation socket, voice call-create and the
realtime sockets, and both unrouted branches of /v1/alpha/search.

Each one now applies the landed predicate where its destination becomes concrete, never to the
string the client sent. Images checks the provider it settles on and the model the caller named,
and checks the xAI bridge and the Antigravity fallback against the model each of those picks for
itself. Audio and voice resolve through one upstream decision, so one check on each of its return
paths covers transcription, dictation, external call-create and the sideband join; a refused
forward request releases its probe lease. The native voice relay reads the model from the
call-create session or the socket query. The search relay checks the account an unqualified model
resolved to, and checks the sidecar fallback against the backend and model the operator configured.

A destination nobody named -- a body with no model, or a join onto a call this process never
recorded -- refuses a key that carries a model list, since no entry in that list can describe it.
The external voice path records the model a call settled on in its binding so a rejoin is judged
against it. A provider-only scope is judged on the provider alone, and a key with no scope behaves
exactly as before on every surface.

No new policy system: the denial helper composes the existing scope resolution, predicate and 403
response for handlers that return a Response instead of throwing into a route resolver. Nothing
here reads or moves a credential, and no logging was added; a refusal carries only the selector the
caller already sent.

Four endpoint-level regression suites cover the refusals, that no upstream call is attempted, that
an allowed scope still reaches its destination, and that an unscoped key stays unrestricted.

Refs lidge-jun#5049. The issue stays open: this covers the four endpoints named in the lidge-jun#5265 review and
nothing beyond them.
… provider (lidge-jun#5289)

* feat(proxy): decide a provider's outbound egress per request

The global `proxy` is one value for every upstream, so it cannot express
the split lidge-jun#2894 describes: one gateway must exit through a regional proxy
while another stays direct on the local network. `src/lib/provider-egress.ts`
is the single authority that answers that question for one request, in the
same shape lidge-jun#5087 established for the global decision -- the question is never
"is a proxy configured" but "does a proxy apply to THIS request".

Four states, resolved against the destination:

  absent            inherit the global decision, byte-identical to today
  "direct" / null   never use the global proxy for this provider
  http(s) URL       this provider's own HTTP(S) proxy
  socks5(h) URL     this provider's own SOCKS5 proxy

`providers.<name>.noProxy` is applied to whichever route resolved, so it
carves an exemption out of the provider's own proxy AND out of an inherited
global one. That second case is how a provider exempts a single host without
owning a proxy of its own.

Two deliberate divergences from the issue's sketch. An empty string is
rejected rather than read as a third spelling of DIRECT: a dashboard field
the operator merely cleared must not silently switch a provider from
inheriting the global proxy to refusing it. And a malformed value throws
instead of degrading, because falling back to the global proxy would send a
credential out a route nobody chose while falling back to direct would leave
a restricted network with no exit -- both read as success at the call site.

Direct egress is expressed to the runtime as `proxy: false`, which overrides
HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY alike. `undefined`, `null`
and `""` all mean "no option given" and fall back to the environment, so none
of them can express it. `configuredOutboundFetch` had to learn the same
distinction: reading `false` as "no string supplied" fell through to
ALL_PROXY and sent a request pinned to direct egress through the global
SOCKS proxy instead, which would have succeeded by the wrong exit.

Configuration and request time share one definition through
`providerEgressConfigError`, so a value the loader or the dashboard accepts
is one the transport can carry. `proxy` is classified credential-bearing
alongside `apiKey`: a proxy URL routinely embeds `user:password@`, so it
never reaches the dashboard DTO, and nothing derived from it is logged --
not a hash, not a prefix, because a short digest over a known host is a
guessable stand-in for the secret and a durable correlation key.

Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com>

* feat(proxy): apply the provider route to inference, discovery and quota

Three transport owners now consume the decision instead of re-deriving it.

Inference (`providerFetch`). The route is resolved per request rather than
once per wrapper, because `noProxy` is evaluated against the destination and
two sends through the same executor can legitimately take different exits.
The resolved value reaches the dispatch init, so it survives `dispatchOverride`
and the fresh-connection policy.

Discovery and quota (`providerOutboundRequest`). This is the chokepoint every
`providerOutboundGet`/`Post` caller shares -- provider discovery, the
model-catalog gather, the management provider test and the Ollama show probe.
A provider route replaces the global decision outright rather than combining
with it: an explicit proxy applies even where global NO_PROXY exempts the
host, because the operator named that proxy for that provider and
`providers.<name>.noProxy` is the exemption belonging to that choice. A
provider pinned to `direct` keeps the DNS-pinned transport, which reaches the
peer through node:http and therefore needs nothing from the runtime's proxy
handling -- the one path where direct egress is available by construction.

An explicit proxy is pinned onto the request unconditionally, including
through the DNS-failure degradation. Letting fetch re-infer the route there
would move the request to a different exit at the exact moment local DNS
stopped working, which is when the proxy matters most.

Quota (`vendor-probes-key.ts`). Seventeen probes were bare global fetches,
so a provider pinned to its own proxy still sent its quota probe by the
process-wide route -- reporting a healthy account while inference failed, or
sending the key out an exit the operator did not choose. Each probe already
receives its provider config, so the route was available; only the transport
was wrong.

Where the route cannot be carried it is refused rather than dropped. A
caller-supplied `provider.fetch` executor owns its own routing, so an
explicit route throws instead of running the executor by a contradicting
route. The WebSocket upstream selects its proxy from the process environment
when it dials, so an explicit route serves those turns over HTTP/SSE and says
so once per provider; a transport change nobody asked for is the same class of
silent substitution this batch exists to remove.

The regressions assert which transport carried each request and which proxy
value it was pinned to. Asserting a 200 would pass with the route dropped
entirely, which is the defect, not the fix.

Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com>

* docs(proxy): document per-provider egress and its uncovered surface

The provider guide gains the two fields and a worked example matching the
issue's real case. The transport inventory records, per request path, whether
a provider route is honoured -- and where it is not, which is the part that
matters: OAuth token exchange and refresh, the OAuth-backed quota probes and
the API-key validation probes all reach fixed vendor endpoints from modules
that hold no provider config, so a provider pinned to its own proxy still
refreshes credentials by the process-wide route. Cursor's HTTP/2 transport,
the coding-agent subprocess providers and the Lab pinned sender are recorded
for the same reason.

The lane document records the egress work, the disposition for the CodeBuddy
and native-wire bundle, and the union-defect sweep.

* fix(proxy): resolve the provider route at the physical send

Adversarial review of the branch found three defects in the first pass.

The route was resolved when the fetch wrapper was built, but a
`dispatchOverride` can rebuild a queued request against a different upstream
host before it leaves -- account reselection moves the regional host for
Copilot, and Anthropic pool rotation rebuilds the request entirely. The
original `dispatchInit` was reused with its now-stale proxy value, so a
host-scoped `noProxy` decision could be inverted and a bearer could leave by
a route the operator excluded. The decision now sits in
`sendWithConnectionPolicy`, against the destination actually being sent to
and around whichever executor was just selected. That is the same boundary
and the same reason as lidge-jun#4992, which that function's own comment already
records for the connection policy.

Refusing every `provider.fetch` as transport-owning was too broad. The xAI
route installs a wrapper on every request that only adds a generated request
id and forwards the init, so an explicit route would have thrown for xAI --
one of the two providers lidge-jun#2894 names. Executors that forward their init are
now marked transparent and carry the route; the mark is opt-in, so an executor
arriving from configuration stays opaque and is still refused. The executor
`providerFetch` returns is marked too, because Cursor hands it back as
`provider.fetch`.

xAI's default executor also fell back to the bare global fetch, which ignores
a socks5 value. A per-provider SOCKS5 route would have sent the request
unproxied while the configuration named a proxy. It now routes through
`configuredOutboundFetch` like every other default.

Two smaller ones: the WebSocket downgrade notice logged a configuration-controlled
provider name unredacted, which this repository treats as potentially
token-shaped everywhere else, and its notice set had no bound.

* fix(proxy): bind the route on native Chat sends and refuse before dispatch

Two more defects from review of the previous commit.

Native Chat builds its own physical send and calls the connection policy with
`activeProvider.fetch ?? execute`. A provider transport wins over the executor
that carries the egress binding, so that send omitted the route entirely --
and since the xAI route now always installs a transport, xAI native Chat would
have followed global routing while its configuration named a proxy, and an
opaque executor would have been invoked instead of refused. The binding now
travels with that send, resolved against the provider the send actually uses,
which matters because reselection can replace it mid-dispatch.

Moving the refusal to the physical send also moved it after
`options.beforeDispatch`, which commits attempt accounting and consumes
admission state. A refusal firing after it would charge an attempt for a send
that never happens, and a throwing hook would mask the egress error with an
unrelated one. The wrapper now fails fast before the hook; the authoritative
decision still happens at the send, against the destination that send uses.

* fix(proxy): decide the route once at the outermost physical boundary

A third review round found that the executor `providerFetch` hands to a
`dispatchOverride` was not marked transparent. Every override selects
`provider.fetch ?? execute`, so for an ordinary provider with no custom
transport that executor IS the selected one -- and an explicit route would
have been refused on every overridden path, after the attempt had already
been recorded by `commitKeyAttemptSend` or `noteProviderAttemptSend`. Only
xAI escaped it, because its own wrapper carries the mark. None of the
existing regressions covered the production-shaped nested send, so two now do.

Marking it alone would have been wrong in the other direction: these calls
nest, and the inner pass would have recomputed the route from the closure's
provider after the override had already decided with the reselected one. The
outermost boundary now decides and marks the init; the inner pass honours the
mark. An override that simply calls the executor still gets a decision rather
than losing the route.

The pre-dispatch fast fail is narrowed to match. With no override, the input
and executor at that point are the final ones, so the full decision is made
before `beforeDispatch`. With an override, only the configured value is
validated, because refusing against a destination the override is about to
replace would reject a request whose real route is fine.

A refusal caused by `noProxy` now names `noProxy` rather than telling the
operator to remove a `proxy` override they never wrote. The provider guide
gained the coverage limits it was missing -- it described the three states
without saying which transports cannot carry them.

* fix(proxy): give the provider egress config fields a declared output type

The two zod field schemas used `z.unknown().superRefine(...)` so the shared
resolver could produce the message, but never narrowed the result. That makes
the parsed provider record carry `proxy: unknown` and `noProxy: unknown`,
which is not assignable to `OcxProviderConfig` -- four errors in
`config-schema.ts`, and a typecheck-based adapter contract test that asserts
zero errors reported one. Both CI failures had this single cause.

They now transform to their declared types, matching the superRefine-plus-transform
idiom the neighbouring field schemas already use. Validation is unchanged and
still delegates to `providerEgressConfigError`, so configuration and request
time keep one definition of a usable value.

The fetch-helpers import boundary test pins the exact runtime-import list for
that file; it gains the two modules this lane added.

* test(proxy): keep credentialed proxy fixtures off the email pattern

The privacy scan reads a URL userinfo pair as an address: `user:pw@host.tld`
looks exactly like `pw@host.tld`. Three fixtures that deliberately carry a
credential to prove it never reaches a log or an error tripped it.

They move to a `.test` host, which the scanner already allows for fixtures and
which the repository uses elsewhere for the same reason. The assertions are
unchanged: the credential must still not appear in the sanitized label, the
described route, or the validation error.

* docs(devlog): record the lane F outcome and the defects each gate caught

Names the exact-head CI evidence, the three route-seam defects adversarial
review caught before CI ran, and the two CI caught after review had cleared
them.

* docs(devlog): describe the credential-fixture defect without reproducing it

The lane document explained why the privacy scan rejected the credentialed
proxy fixtures by quoting the shape that triggered it, which tripped the same
scan on the document. It now describes the shape instead of writing one.

---------

Co-authored-by: jingzxy <113401179+jingzxy@users.noreply.github.com>
…onitor integrated into Usage (lidge-jun#5196)

* docs(devlog): plan macOS menu bar companion (Phase 0 roadmap)

Nine numbered docs covering the roadmap for a maintainer-owned macOS menu
bar app in app/, consolidating the two competing community PRs (#387
Swift/SwiftUI, #421 Tauri/React).

- 000_plan: constraints, dependency-ordered phase map, accept criteria
- 001_pr_survey: head-to-head of both PRs; stack decision is Swift/AppKit
  runtime with HTTP management-API transport, plus the salvage list
- 002_api_surface: live payload inventory, the seconds-vs-milliseconds
  quota timestamp trap, and the default-provider 400 trap
- 003_design_read: Design Read and dial lock (V2/M1/D7), inheriting the
  existing gui/src/styles.css tokens
- 010-050: diff-level decade docs, one per implementation phase

* docs(devlog): fold 13 audit blockers into the macOS app roadmap

Adversarial Phase-0 review returned FAIL. Corrections, all verified against
live source and the running proxy:

- /api/stop calls stopServiceIfInstalled() before responding, so nothing
  restarts the proxy. The app now ships Stop proxy, never Restart, and
  never spawns a process.
- /api/usage supports only 7d/30d/all; 24h silently degrades to 30d. The
  range is now a closed enum and the UI labels the range the response
  returned, not the one it requested.
- defaultProvider lives on /api/config, not /api/settings. Added the model,
  the client method, and the test.
- The bundle script now defines every path before use and copies Info.plist
  before plutil; it could not have run as previously written.
- release.yml grants contents:write and id-token:write at workflow level, so
  the new jobs declare explicit least-privilege permissions. Added a separate
  attach-macos job so packaging can never block the npm publish, and pinned
  both new actions to full SHAs.
- Re-surveyed PR #421 at head 049ef2ac: the committed src-tauri/target tree
  was already removed by the contributor. The closing comment must credit
  that fix rather than repeat a stale defect.
- /api/logs exists for per-request activity; documented as a deliberate v1
  exclusion instead of an implicit gap.
- Phase 1 no longer claims verification via a Phase 4 script.
- Added StartupHealth service fields, security-review acceptance evidence to
  Phase 4, and removed developer-absolute paths from tracked docs.

* docs(devlog): fold round-2 audit blockers (design lock, bundle ownership, plist)

Round-2 adversarial review returned FAIL on 9 findings, most of them caused
by round-1 edits that corrected prose without correcting the specs those
documents actually lock.

- 003 was never touched in round 1, so the design lock still mandated a 24h
  sparkline and a Restart button that 002/030 prove impossible. Wireframe now
  shows LAST 7 DAYS and Stop proxy.
- release.yml's input is named dry-run, not dry_run. inputs.dry_run would
  resolve to null and the attach-macos guard would silently pass during a
  dry run — the exact failure that guard exists to prevent.
- Bundle ownership was relocated, not resolved: Phase 2 claimed a launchable
  .app while the builder stayed in Phase 4. Phases 1-3 now verify through
  swift test/build/run; Phase 4 owns the bundle end to end.
- app/Info.plist was missing CFBundleExecutable, CFBundlePackageType, and
  CFBundleIconFile, so the specified bundle would not have launched.
- Re-read #421 at head 049ef2ac: menubar/src/api.ts:12-13 returns the token
  into renderer memory, so the PR's isolation claim does not hold. Removed
  that credit from both the survey and the planned closing comment, and
  removed the remaining stale rejection sentences.
- Scoped the absolute-path criterion to files this unit touches; unrelated
  historical devlogs already contain such paths.
- loading is now explicitly exempt from the next-action rule, and per-section
  empty states are defined with their own copy.
- The range-fallback test injects a stubbed response, since the closed
  UsageRange enum makes the curl path unreachable from production code.

* docs(devlog): clear round-3 residual nits

Round-3 audit returned GO-WITH-FIXES (blockers=0). Cleared all three:

- 010 still called the first bundle a Phase-2 deliverable, contradicting
  000/020/040. Phase 4 owns the bundle end to end.
- 001's salvage list still credited #421 with renderer-side token isolation,
  contradicting its own verified analysis, and claimed all four surfaces were
  adopted when per-request activity was deliberately excluded.
- 040 and 050 still scoped the absolute-path rule to every tracked file, which
  pre-existing historical devlogs already violate. Both now match 000's
  unit-scoped wording.

* feat(app): add macOS menu bar core — discovery, client, formatting

Phase 1 of the macOS companion (010_phase1_core.md). Zero third-party
dependencies; AppKit and Foundation only.

- Discovery resolves the proxy from OPENCODEX_HOME/runtime-port.json with a
  10100 fallback. The host is pinned to loopback and never read from the
  record, so a file write cannot redirect the app at another host.
- ProxyModels mirror the live payloads. QuotaReport.normalized() absorbs two
  real traps: the window key differs per provider (weekly/monthly/custom),
  and weeklyResetAt arrives in seconds from openai but milliseconds from
  anthropic within the same array, so timestamps are disambiguated by
  magnitude.
- UsageRange is a closed enum because the server silently degrades an
  unrecognized range to 30d; UsageReport.rangeLabel is derived from the
  response so the UI can never label 30 days of data as something else.
- ProxyClient is an actor. ProxyError carries human sentences only, never a
  response body, since bodies can echo configuration.
- Format renders an em dash for unknown and a real zero for zero; the live
  proxy reports 3.6e10 tokens, so everything is abbreviated.

Testing is an executable target rather than a .testTarget: Command Line
Tools resolves neither XCTest (module not found) nor the swift-testing
runtime (Testing.framework fails to dlopen). Requiring full Xcode to run
these tests would exclude most contributors. 31 cases pass via
`swift run --package-path app MenuBarCoreTests`.

Verified live against the running proxy: endpoint discovery, health
(at-risk, service-managed), defaultProvider=openai from /api/config, 7-day
usage (44.5K requests, 7.34B tokens, $6.15K), and four provider quotas with
correctly resolved reset windows.

* fix(app): fold code-review blockers into the menu bar core

Adversarial review of c7fbf57c returned FAIL on 10 findings. All verified
against the live proxy or Apple docs before folding.

- Info.plist: add NSAllowsLocalNetworking. macOS 14 stopped allowing IP
  loads under ATS, so the packaged bundle could not reach 127.0.0.1 at all
  while swift run stayed green — the app's primary function, broken only in
  the artifact users would actually download.
- ProxyClient: wire the lazy Keychain retry that the plan required and the
  code never implemented. A CredentialStore protocol is injected, the key
  loads once, and exactly one retry follows a 401 so a stale key cannot spin.
- Keychain: set kSecUseDataProtectionKeychain on every query, without which
  kSecAttrAccessible is ignored on macOS; tighten to ThisDeviceOnly; update
  before add so a failed add cannot destroy a working key.
- ProxyModels: live kimi reports fiveHourPercent alongside weeklyPercent, and
  cursor and google-antigravity each carry two customWindows. Added the
  five-hour fields and normalizedWindows() returning every window;
  normalized() keeps an explicit longest-horizon precedence.
- isEmptyOrUnknown preserves three states so an omitted request count cannot
  render as "No requests".
- Cancellation now propagates instead of reading as a stopped proxy, and
  unrelated transport failures get their own .transport case.
- ProxyEndpoint is failable; baseURL is built once instead of force-unwrapped.
- Format promotes at rollover: 999_999 renders 1.00M, not 1000K.
- TransportSuite adds 14 cases over status mapping, the 401 retry path,
  cancellation, request shape, and body redaction. 51 pass, 0 fail.
- Harness no longer counts a case as passed when it recorded a failure.

Live re-verification across all six providers: Kimi 5h+week, Cursor's three
windows, and correct primary-window selection for each.

* fix(app): per-request 401 retry and pressure-based quota selection

Round-2 code review found two real defects, both semantic rather than
syntactic, and both proven with gated probes.

- Concurrent initial 401s produced a false authorization failure. The actor
  suspends across each request, so two calls can both receive 401; the first
  loaded the key and retried while the second saw the global
  didAttemptCredentialLoad flag and threw .unauthorized even though a usable
  key now existed. Retry eligibility is now decided per request against the
  key that request actually sent, so a caller that started before the load
  still retries with it, and a caller that already used the current key does
  not loop. Actor isolation prevented data races here but not reentrancy.

- The compact quota row preferred the longest horizon, which could hide the
  window actually blocking the user: 99% of a five-hour limit alongside 10%
  monthly rendered as a green 10%. Selection is now highest reported usage,
  with ties breaking toward the longer horizon since that one does not
  recover on its own. Live proof: Cursor's row moved from month=10% to
  API usage=42%, which the previous logic concealed.

Regressions: a gated concurrent-401 case asserting two successes, exactly one
credential load, and four total requests; plus pressure-selection cases for
higher-short-window, tie-break, and unmeasured-window inputs. 51 -> 55 cases,
all passing. Live re-verified across all six providers.

* feat(app): add menu bar status item and popover UI

Phase 2 (020_phase2_ui.md). AppKit rather than SwiftUI: this is a fixed-width
column of rows, which stack views do without fighting NSPopover sizing.

- ProxySnapshot is the single source the views render from, so no view
  invents its own loading flag. Five states, and every one carries a word
  beside its dot so meaning is never colour-only.
- PollingCoordinator implements the 002 contract: 5s liveness always, heavy
  aggregation only while the popover is open, 30s backoff after three
  consecutive failures. Cancellation is not treated as a failure.
- Theme derives from gui/src/styles.css but prefers AppKit semantic colours
  where they exist, since those also track increased-contrast and vibrancy.
  Numerics use monospaced digits so polling does not make digits jitter.
- The menu bar glyph is a vector template image and carries state through
  fill and a notch, not colour: a coloured dot in the menu bar is the tell of
  an app that ignores the platform.
- Quota rows show which window each percentage belongs to. Without it, 42%
  of Cursor's API-usage window and 42% of a month look identical.
- A nil percent draws no bar at all, because a zero-width bar reads as
  "0% used" — a different fact from unknown.

Visual verification drove three fixes that code review would not have caught:
the sparkline rendered as wide slabs that read as a progress bar rather than
a chart; the trend was centred and floated away from the columns it belongs
to; and hidden sections left a large empty void because the view kept its
initial 260pt instead of sizing to content. Screenshots of running, stopped,
unauthorized, degraded, and empty were inspected in the real window server.

UI moved into a MenuBarUI library so the visual-QA probe can build the same
surface — an executable target cannot be imported. 55 -> 64 test cases.

* fix(app): fold UI review blockers — states, keyboard, polling, glyph

Adversarial review rendered every state and returned FAIL on 9 findings.

- Stop proxy now confirms first. It interrupts in-flight requests and stops
  launchd, so firing it on a single click was wrong.
- Escape did not work at all: an accessory app never takes key focus, so
  keyDown never arrived. Now activates on open, sets a first responder, and
  installs a scoped key monitor that is removed on close.
- Loading, unauthorized, and degraded were specified but not built. Loading
  shows skeleton rows with disabled chrome; unauthorized has a real Add key
  button; degraded keeps its last-known data with an explicit age plus Retry,
  because stale-but-labelled beats a blank panel.
- Polling split into on-open reads (providers, config) and interval-gated
  aggregation (usage, quotas). Previously every open forced aggregation while
  background ticks fetched on-open data — exactly backwards.
- Refreshes can no longer overlap or outlive a close: one in-flight cycle, a
  generation counter that discards superseded results, and freshness advanced
  only when the aggregation actually completed.
- The at-risk notch never rendered. Stroking with .clear under a .clear
  composite silently did nothing, so a protected and an at-risk proxy showed
  an identical glyph — the state signal was invisible. Carved with even-odd
  winding and verified against a rendered glyph sheet.
- recommendedCommand was decoded but never displayed; the live proxy has been
  advising ocx service install this whole time. Now shown as selectable text,
  alongside a provider summary line.
- Popover height is capped at 480pt with a scrolling body, and scrollers
  appear only on real overflow.
- PollingSuite replaces a test that asserted four constants: gating, cadence,
  backoff, recovery, degraded retention, and observer delivery. 64 -> 73.

UIProbe captures via CGWindowListCreateImage so nothing under app/ constructs
a Process, per the 030 security rule.

* fix(app): make Escape work, top-anchor overflow, split data freshness

Round-2 UI review found 6 defects, all reproduced before folding.

- Escape genuinely did not work. Activating before presentation leaves an
  accessory app's popover without key focus, so no key event ever arrived.
  Activation now happens on the next main-loop turn after show(relativeTo:).
  Verified by synthesizing keycode 53 into the app's own event queue: popover
  shown true before, false after.
- Overflowing content opened scrolled to the bottom, hiding the status line
  and metrics that the urgency order exists to surface. NSScrollView is
  bottom-origin by default; a flipped clip view fixes it.
- Close-then-immediate-reopen dropped the reopen's refresh: the old cycle
  exited on its generation guard while the new one had already been rejected
  by the in-flight lock. Refreshes now queue and drain on every exit path.
- Closing mid-sequence still paid for later requests, and a partial
  aggregation failure re-fetched its healthy sibling every 5 seconds because
  the rate limit keyed on success. Now every request re-checks the cycle, and
  aggregation is limited by attempt.
- Retry opened a browser. Add key and Retry now have separate callbacks.
- Degraded quoted an age derived from the last health probe, so it could
  claim to be showing data it never loaded. healthUpdated and usageUpdated
  are now separate, showsData requires actually-loaded sections, and the
  guidance quotes the data age.

The overflow menu ships Refresh, Open dashboard, and Quit rather than the
sketched Preferences: there is no preferences surface, and a menu item that
opens nothing is worse than its absence. Spec amended to match.

* fix(app): replace NSPopover with a key-capable panel so Escape works

Three rounds of Escape fixes failed because the premise was wrong, not the
implementation. Probing the real delegate from an accessory process:

  popover window in NSApp.windows : absent
  canBecomeKey                    : false
  after NSApp.activate            : appActive=true, isKey=false
  after NSRunningApplication      : appActive=true, isKey=false
  after raising the window level  : appActive=true, isKey=false

macOS does not route key events to a window that cannot become key, so no
activation strategy could ever have delivered Escape. PopoverPanel measures
shown=1 canBecomeKey=1 isKey=1, and Escape closes it.

The panel keeps the parts of the popover contract that matter: transient
dismissal on outside click, dismissal on losing key focus, and
nonactivatingPanel so opening does not steal focus from the user's editor.

Also fixed:
- The success path's generation guard returned without draining a queued
  reopen, so close-then-reopen still dropped its refresh. Every exit path now
  clears the lock and drains.
- On-open reads ran on every 5s liveness tick, turning two rarely-changing
  endpoints into pollers. Now gated on an actual open or manual refresh.
- An already-invalid cycle could consume the aggregation window and make a
  legitimate reopen skip usage and quotas for 60 seconds.
- Removed lastHeavyRefresh and healthUpdated, written but never read.

Four new polling tests: tick-while-open, closed-popover, partial aggregation
failure, and degraded-without-data. 73 -> 77.

* fix(app): give the panel a real surface and keep it alive behind the alert

Round-4 review found two defects introduced by the NSPopover -> NSPanel
amendment. Both are things NSPopover had been providing for free.

- The borderless panel had no background at all. isOpaque=false with a clear
  backgroundColor composited the whole dashboard onto whatever application
  was underneath: labels collided with the app behind it, and contrast
  depended on that app's colours. Content is now wrapped in an
  NSVisualEffectView with .popover material, rounded and clipped.
- Presenting the Stop confirmation made the alert key, which tripped
  resignKey() and dismissed the panel behind it. A user who chose Cancel was
  returned to nothing. isPresentingModal now suspends resign-key dismissal;
  Cancel restores key focus and Confirm dismisses deliberately.

UIProbe missed the first defect because it rendered the controller inside an
ordinary NSWindow, which supplies its own background. It now presents through
the real PopoverPanel over a loud backdrop, so a missing surface cannot hide.
That is twice in this phase that the harness rather than the code was
concealing a defect.

Also: dismiss() is idempotent against a late monitor callback,
debugTogglePanel() is #if DEBUG only, and applicationWillTerminate dismisses
the panel.

* fix(app): let the alert own Escape, and fix measured contrast failures

Round-5 review found two defects, both verified by measurement.

- Escape during the Stop confirmation dismissed the panel and consumed the
  event, leaving the alert stranded with no keyboard way to cancel. The
  monitor now returns the event unchanged while isPresentingModal, so NSAlert
  handles Escape as Cancel.
- Theme.faint used tertiaryLabelColor, which measured 2.01:1 in light and
  2.39:1 in dark against the popover material — far under the 4.5:1 required
  for text. AppKit's tertiary tier is meant for disabled affordances, but it
  was carrying the range heading, metric captions, and quota window labels:
  information the user has to read. Replaced with calibrated tokens plus a
  separate graphMark token held to the 3:1 non-text threshold.

Re-measured from the rendered PNG: 7.27:1 light, 4.98:1 dark. Contrast is now
measured rather than assumed from token names, and UIProbe can force an
appearance without touching system settings.

* fix(app): recalibrate all four text tiers against the rendered material

My round-5 contrast correction was itself wrong. The sampling took the
darkest pixel in a band, which is primary text, not faint — so 7.27:1 and
4.98:1 described a token that was never in question while the actual faint
tier sat at 2.87:1 in dark and the sparkline marks at 1.85:1.

Corrected method: count pixels matching each exact token value in the
rendered PNG, so one tier cannot be measured by accidentally sampling
another. Measured against light (220,219,218) and dark (103,102,102):

  text       12.59 / 5.72   (>= 4.5)
  muted       7.86 / 5.11   (>= 4.5)
  faint       5.48 / 4.89   (>= 4.5)
  graphMark   3.58 / 3.79   (>= 3.0, non-text)

All four pass and text > muted > faint holds in both appearances. The light
inversion the reviewer found — faint outranking muted — is gone.

The dark material is the binding constraint: pure white measures only 5.81:1
against it, so three text tiers have to fit inside a 1.3-point band. That is
why the dark values cluster, and why AppKit's semantic tiers cannot be used
here without silently reintroducing the failure.

* docs(app): note the material pixel variation and the semantic-colour exception

Round-7 review passed. Two documentation nits from it:

- The dark popover material is not perfectly flat: the dominant pixel is
  (102,101,101) while adjacent pixels read (103,102,102). The contrast table
  uses the lighter value (the stricter test) and the 5.81:1 ceiling comes
  from the darker one. Both are now named.
- Theme's header claimed AppKit semantic colours always win, which is true
  for surfaces but is now a deliberate exception for the text tiers.

* feat(app): wire proxy control and provider toggles

Phase 3 (030_phase3_actions.md). The client write methods and the
confirmation sheet already landed in Phase 2 — a Stop button could not ship
without them — so this phase adds what was actually missing: outcome
reporting, the provider toggle UI, and result feedback.

- ActionCoordinator reports what happened rather than what was requested.
  /api/stop answers before it drains and stops launchd on the way, so a 200
  means accepted, not stopped: the coordinator polls until the port stops
  answering and reports requiresManualStart with the command for that
  install. A proxy still answering after 10s is a failure, not a success.
- Provider toggles are optimistic with revert on rejection. The default
  provider's switch is inert and explains why, since the proxy answers 400
  for that case and firing a request that cannot succeed is worse than not
  offering it.
- A result banner reports every write outcome and clears itself, guarded by a
  token so an older timer cannot clear a newer result.
- No failure path quotes a response body; bodies can echo configuration.

The stop timeout test needed an injectable clock, not just a no-op sleeper:
the loop is bounded by a deadline, so skipping the sleep without advancing
time meant it never expired and the test reported success. Recorded in 030
along with the stub's drain-to-refused fallback, which can make an
under-queued test pass for the wrong reason.

Live-verified against the running proxy: anthropic disabled and re-enabled
with the proxy confirming each state, and the default-provider guard refusing
before any request. Proxy state restored afterwards, 10 of 10 enabled. 77 ->
87 tests.

* fix(app): distinguish liveness states and serialize provider writes

Review of ef1c59c5 returned FAIL on 6 findings, all verified against the
proxy source.

- isReachable() treated every non-401 error as "gone", so a 500 or a decode
  failure while polling after /api/stop reported the stop as confirmed while
  an HTTP server was still listening. Replaced with three-state liveness:
  reachable (any HTTP answer proves the port is occupied), refused (the only
  proof the proxy is gone), indeterminate (a timeout proves nothing).
- /api/stop returns success:false when restoreNativeCodex() fails
  (management-api.ts:145-147). The proxy still exits, but native Codex is
  left pointing at a closing port. The body was discarded, so the app said
  "Proxy stopped". Now decodes only the boolean — never the server's message
  — and reports stoppedWithRestoreFailure telling the user to run ocx restore.
- Two rapid toggles could reach the server out of order and leave it opposite
  to the user's last click, since both actors are reentrant across awaits.
  One in-flight write per provider, and the row stays inert until its
  authoritative refresh lands. Pending state survives rebuildRows so a poll
  cannot resurrect the pre-toggle switch.
- A default provider that was already disabled could never be re-enabled: the
  switch was inert whenever isDefault, but the proxy guard fires only when
  disabled is true AND the name matches the default — enabling is valid.
- The "exact body" test encoded its own dictionary rather than reading the
  request, so it would have passed with no body at all. StubProtocol now
  drains httpBodyStream and the test asserts on the decoded actual body.
- Acceptance criterion 1 demanded a live stop while the notes said stop was
  deliberately not run live. Amended with reasoning: stopping the developer's
  proxy is out of bounds, and the branches that matter cannot be produced on
  demand from a healthy proxy.

Also corrected 002 (the success flag was undocumented) and 050's stale
"scroll-free column". 87 -> 93 tests.

* fix(app): only a refused connection proves the proxy stopped

Round-2 review found the three-state liveness contract was still two states
in practice, plus three follow-on defects.

- perform() mapped .timedOut, .networkConnectionLost, .cannotFindHost, and
  .notConnectedToInternet to ProxyError.unreachable, which liveness() then
  read as .refused. So a timeout during the stop poll could still confirm a
  stop while the proxy was running — the exact defect round 1 was meant to
  fix. Added ProxyError.inconclusive; only .cannotConnectToHost becomes
  .refused now. Liveness probes also take a 1.5s timeout so a single probe
  cannot overrun the 10s stop deadline it is supposed to respect.
- rebuildRows() initialised each switch from the server snapshot, so a poll
  landing mid-write snapped the switch back to its pre-toggle value even
  though the row was marked busy. pending now stores the intended state and
  applies it before marking the row busy.
- The post-write refresh coalesced: refresh() queues and returns immediately
  when another cycle holds the lock, so the switch became interactive again
  against pre-write data. Added refreshAndWait().
- 030 still demanded a live stop in its verification line and carried three
  pre-review snippets (void stop(), boolean isReachable() loop, unconditional
  default guard) that would have reintroduced the reviewed defects.

Added liveness classification tests for every URLError code that matters, an
HTTP-answer table (200/401/403/500 all prove the port is occupied), an
undecodable-200 case, and a stop-with-timeout case asserting the inconclusive
message rather than a false success. 93 -> 97 tests.

Also corrected the 002 stop snippet, which showed only the success:true
branch while the prose below it described both.

* fix(app): single-attempt liveness and a real refresh completion signal

Round-3 review returned GO-WITH-FIXES on two Medium blockers.

- liveness() went through the generic send(), so a 401 with a stored key
  triggered the credential retry: a second full timeout spent re-asking a
  question the 401 had already answered, and a failed retry downgraded a
  known-reachable result to indeterminate. It now calls perform() directly —
  one attempt, no retry.
- The stop loop always asked for a 1.5s probe regardless of time remaining,
  so the final probe could overrun the 10s deadline. Each probe is capped to
  min(1.5, remaining) and the loop breaks when nothing is left.
- refreshAndWait() spun on shared booleans with a 5s bound. A legitimately
  slow cycle (providers + config sequentially, plus a due aggregation) can
  exceed that, at which point it returned and the switch became interactive
  against pre-write data — the exact window the method was added to close.
  It now waits on a continuation released when no cycle is running or queued.

Also corrected two stale ProxyError doc comments (timeout is no longer
unreachable, DNS is no longer transport) and the ActionOutcome snippet in 030,
which predated stoppedWithRestoreFailure.

New tests: a 401 with a stored key resolves in one request; the probe honours
a caller-supplied timeout; every stop probe stays within the cap;
refreshAndWait returns only after a cycle published, and survives a failing
cycle without hanging. 97 -> 102.

* test(app): actually exercise the refresh continuation path

Round-4 review found that neither refreshAndWait test entered the code they
were written to protect. Both ran with refreshInFlight == false, so they took
the direct path and never touched completionWaiters, waitForCompletion, or
signalCompletionIfIdle. They would have stayed green if the continuation
never resumed, resumed early, or was deleted.

StubProtocol gained a request gate so a cycle can be held suspended. Two new
tests start a refresh, block it in the stub, call refreshAndWait
concurrently, assert it has NOT returned, then release and assert it does.
One covers a succeeding queued cycle, one a failing cycle.

Sabotage-verified, because a passing test proves nothing about a path it
never takes: removing the resume line made the suite hang until the 120s
timeout rather than pass. Restored, it completes in about 2 seconds.

102 -> 104 tests.

* test(app): make the failing-cycle test actually consume a failure

Round-5 review found the "queued cycle fails" test was re-testing the success
path. With the popover closed a cycle consumes exactly one health response,
and the queue led with three 200s, so the connection-refused responses were
never reached. It would have stayed green if the error exit stopped signaling
waiters.

Two contract details drive the corrected setup: drainPendingRefresh only runs
while the popover is open, and an open cycle consumes health + providers +
config + usage + quotas. So the popover is opened first, then a single gated
200 lets cycle 1 reach the gate, and everything after is a refusal. A new
snapshot.state == .unreachable assertion proves the failure was consumed —
and that assertion is what caught the original defect.

Hardened the gate harness alongside it: setGate/currentGate now go through
the stub's existing lock rather than racing on a bare static, a gateEntered
semaphore lets a test wait for the request to actually arrive instead of
inferring it from a 200ms sleep, and defer releases the gate so a mid-test
failure cannot wedge the suite.

Sabotage results, both recorded in 030 because the second one matters:
removing waiter.resume() entirely hangs the suite, so the gate tests do
depend on the continuation. Removing only the ProxyError signal does not fail
it — a signal trace showed the waiter is protected by several exit paths, so
single-site sabotage is not a valid probe here.

104 tests.

* test(app): deterministic waiter registration and a UI test target

Round-6 review found two ways the suite could pass without proving anything.

- The continuation tests synchronised on a fixed sleep. gateEntered proved
  cycle 1 reached the gate, but nothing proved the waiter had registered
  before the gate was released; under starvation the waiter could start
  afterwards, take the ordinary non-coalesced path, and still satisfy every
  assertion. PollingCoordinator now exposes waiterCount, and the tests poll
  it until registration is observed, then assert it returns to zero.
- No test drove MenuBarUI at all. The Phase 3 behaviours that had actually
  been defects in earlier rounds — optimistic rollback, pending state
  surviving a stale poll, and the direction-sensitive default guard — had no
  regression cover, because MenuBarCoreTests depends only on MenuBarCore.
  Added a MenuBarUITests target with read-only inspection hooks.

Sabotage-verified: reintroducing both original defects failed exactly the two
matching cases and left the other five green. Making the default guard
direction-insensitive failed the disabled-default recovery test; dropping the
intended value in rebuildRows failed the stale-poll test.

Also removed an unnecessary nonisolated(unsafe) on a let constant.

104 core + 7 UI tests.

* chore(app): narrow test hooks to package visibility

Round-7 review passed. Carry-forward items folded now rather than deferred:

- waiterCount and the ProviderListView test hooks are `package` rather than
  `public`. Neither module ships as a library product, so this was never an
  external API risk, but package visibility says what these are: test-only
  access within the package.
- 040's test:macos script runs both suites, and its acceptance criteria now
  state that "build clean" means exit 0 rather than warning-free, since the
  remaining warnings are Command Line Tools search paths from the toolchain.
- 030's stop example carries the remaining-time clamp that shipped.

* feat(release): build and package the macOS companion

Phase 4 (040_phase4_release.md). The app now has a distribution path, which
is what the whole question was about: a menu bar app a user has to compile is
not a shipped app.

- scripts/build-macos-app.sh assembles OpenCodex.app by hand — no Xcode
  project to keep in sync. It stages into a temp directory and moves at the
  end, so an interrupted build cannot leave a half-written bundle that
  launches and misbehaves. Version comes from package.json, so the app can
  never claim a version the release did not ship. UNIVERSAL=1 under Command
  Line Tools refuses with an explanation instead of a linker error.
- scripts/package-macos-release.sh asserts rather than hopes: codesign
  --verify --deep --strict, lipo arch check, ditto archiving (plain zip
  corrupts the signature), an archive-contents assertion, and a SHA-256
  sidecar.
- release.yml gains package-macos and attach-macos. Workflow-level
  permissions drop to {} and each job declares its own, so a new job cannot
  silently inherit a write token or an OIDC credential. package-macos has no
  needs relationship with publish in either direction: a Swift failure must
  never be able to block an npm release.
- ci.yml runs the macOS test and build on macOS runners only, after
  privacy:scan so a credential leak fails before a multi-minute Swift build.
  The path filter gained app/** — without it an app-only change ran no CI.

Verified locally end to end: the bundle builds, passes codesign, launches
with no ATS errors, packages to an 813 KB zip whose checksum verifies, and
survives unpack-and-launch — the path a user actually takes, and the one that
would expose a corrupted signature.

One debugging note recorded in 040: the archive assertion originally used
`unzip -Z1 | grep -Fqx`, which fails under pipefail because grep -q exits on
match and unzip dies on SIGPIPE. It rejected correctly-packaged archives.

* fix(release): env-pass the release input, fix preview versions, guard output

Security review returned FAIL on three findings. The first was caught by the
repository's own regression suite, which is the best possible outcome.

- release.yml interpolated inputs.version directly into run: shell source.
  tests/ci-workflows.test.ts:76-81 rejects exactly this pattern repo-wide as
  script-injection hardening, and the suite was failing. The version now
  reaches the shell through env as RELEASE_VERSION.
- CFBundleVersion accepted prerelease suffixes. Apple restricts that field to
  period-separated integers, so every preview build would have shipped
  invalid metadata. The script now uses the numeric core for CFBundleVersion
  while CFBundleShortVersionString keeps the full human-facing string, and
  MACOS_BUILD_NUMBER (github.run_number in CI) appends a monotonic build
  component. Verified: 2.7.36-preview.1 produces 2.7.36, and 2.7.36.42 with a
  build number.
- The output containment check compared $app_bundle against $output_root,
  both derived from the same variable, so it always passed. OUTPUT_DIR could
  point at /Applications and have an existing bundle recursively removed. The
  destination must now sit under the repository or a temp directory.
  Verified: /Applications is refused, /tmp is allowed.

On Gatekeeper: the reviewer is right that the asset is ad-hoc signed and
spctl rejects it. Developer ID signing plus notarization needs a paid Apple
Developer account and this project has no certificate (verified: zero
Developer ID identities, no Apple secrets in any workflow). Rather than
pretend otherwise, build-macos-app.sh gained an optional MACOS_SIGN_IDENTITY
that switches to hardened-runtime signing, package-macos-release.sh reports
the spctl verdict and fails only when a real identity was claimed and still
rejected, and release.yml wires the secret so adding a certificate becomes
configuration rather than code. 040 documents what ships today and why the
Phase 5 Gatekeeper section is mandatory.

Also corrected the SIGPIPE note in 040: the reviewer reproduced the old
pipeline exiting 0, so it is a race rather than a certainty — which is a
better argument for fixing it, not a weaker one.

* fix(release): honour Apple's actual version limits and drop the phantom secret

Security re-review found my first version fix was still wrong, in a way I
had not read carefully enough.

- CFBundleShortVersionString must be exactly three integers, so a preview
  release was still writing "2.7.36-preview.1" into a field that does not
  accept it. It now gets the numeric core.
- CFBundleVersion accepts ONE TO THREE integers and ignores a fourth. So
  "2.7.36.<run>" provided no additional identity at all — repeated builds of
  the same version compared as identical despite the run number. When CI
  supplies a run number it now becomes the CFBundleVersion outright: a single
  monotonically increasing integer is both valid and genuinely distinguishing.
  Verified: 2.7.36-preview.1 gives short 2.7.36 / build 2.7.36, and with a run
  number, build 1234.
- The output containment check resolved logical paths, so a repository-local
  symlink pointing outside would pass the prefix test and then be deleted for
  real. Paths are now resolved with pwd -P, and a symlinked destination is
  refused outright. Verified: a symlink to a home directory is refused, while
  ordinary paths still build.
- Removed MACOS_SIGN_IDENTITY from release.yml. The reviewer is right that an
  identity name alone cannot sign on a hosted runner — nothing imports the
  certificate and private key, so codesign fails with "no identity found".
  Advertising the secret implied a capability that does not exist. The build
  script keeps the hook for local signing and says so; real CI signing needs a
  protected P12 import, a temporary keychain, notarytool credentials, and
  stapling as one security-reviewed change.

Also updated 040's executable snippets, which still showed the pre-review
version handling and the direct inputs.version interpolation while later
sections described the fixes — a source-of-truth document contradicting
itself is worse than one that is merely incomplete.

* docs(release): sync the Phase 4 plan with what actually shipped

Closure blocker from the security review: 040 is the security-review
artifact, and it still demonstrated the defects the last two rounds fixed.
Copying its workflow example would have reintroduced the repository's
prohibited injection pattern.

Synchronised every stale snippet:
- the tautological output guard is now the physical-path containment check
- pwd gained -P where the implementation has it
- the ad-hoc signing note no longer claims CI may re-sign, which the workflow
  deliberately does not support
- the package job example carries MACOS_BUILD_NUMBER
- the attach step passes RELEASE_VERSION through env instead of interpolating
  inputs.version into run: source
- acceptance criterion 3a describes both Apple limits correctly rather than
  the invalid 2.7.36.<run> form

Also folded the Low finding: the script created the output directory before
validating containment, so a refused path still left a directory behind.
Validation now resolves the physical path by walking up to the nearest
existing ancestor, and mkdir runs only after the check passes. Verified: a
refused path creates nothing, symlinks outside the allowed roots are still
refused, and ordinary builds are unaffected.

* fix(release): normalise .. before the containment check, and cover it

The containment fix was itself bypassable, which the reviewer demonstrated
and I reproduced: resolve_physical walked up to the nearest existing ancestor
and re-appended the missing tail verbatim, so

  <repo>/.ocx-nope/../../outside-probe

resolved to itself, satisfied the prefix check, and mkdir -p then followed
the .. components out of the repository. The build landed outside the
permitted roots, where the destructive replace runs.

The resolver now normalises the collected tail component by component,
dropping "." and popping a level for "..". Verified: the same traversal is
now refused, naming the RESOLVED path, and creates no directory.

Added tests/macos-build-script.test.ts, which runs the real script: outside
paths refused with nothing created, unresolved .. traversal refused,
repository paths allowed, temp allowed.

Writing that test surfaced its own trap worth recording: building the
traversal with path.join() silently normalises the .. away, so the script
never receives the bypass and the test passes against broken code. It is
built by string concatenation instead. Sabotage-verified — reverting the
normaliser fails exactly the traversal case and leaves the other three green.

Also synced 040's snippet, which still showed the plain pwd -P form.

* fix(release): normalise before resolving, and stop the test deleting fixed paths

Second bypass in the same boundary, found by review and reproduced here.

- resolve_physical resolved physically BEFORE normalising, so `..` could reveal
  a symlink that was then never followed: <repo>/.missing/../outward-link
  passed containment while pointing elsewhere. The order is now inverted —
  normalise lexically, then resolve the surviving path component by component
  so a symlink anywhere along it is followed.
- Iteration is over a quoted array. `for part in $tail` word-split, so a
  literal glob such as `rel*` expanded against the filesystem.
- Found while fixing it: `unset 'stack[-1]'` is a bad subscript in bash 3.2,
  which is what macOS ships. It failed silently, so `..` was never applied at
  all and the previous fix only appeared to work. Computes the index instead.

Verified against every construction the reviewer named: a symlink reached
through `..`, a direct outward symlink, a plain `..` traversal, and a literal
glob. Each is refused naming the RESOLVED path, and none creates a directory.

The regression test was itself unsafe: it recursively deleted fixed paths
outside the repository, including <repo-parent>/ocx-escaped-probe, which would
have destroyed unrelated data if anything already lived there. A test for a
safety boundary must not itself be destructive. Every fixture now lives in a
mkdtemp sandbox or carries a pid-and-timestamp suffix, and the suite only
removes what it created. Grew from 4 to 7 cases, adding both symlink forms and
the glob.

Sabotage-verified: restoring the bash 3.2 unset fails exactly the traversal and
symlink cases and leaves the other five green.

* fix(release): refuse symlinks that do not resolve to a directory

Third bypass in this boundary, found by review and reproduced first.

A symlink with a RELATIVE dangling target was joined onto the resolved prefix
without normalising, so `link -> ../../outside` became `<repo>/../../outside`,
satisfied the `<repo>/*` prefix check, and escaped during mkdir -p. Confirmed
by building straight out of the repository before the fix.

Rather than recursively resolve dangling targets with cycle detection, the
script now refuses any symlink that does not resolve to an existing directory.
OUTPUT_DIR has no legitimate reason to pass through one, and a refusal is
easier to reason about than a clever resolver that has now been wrong three
times.

Two test-quality fixes from the same review:

- The outside-path test derived its destination from process.env.HOME. Other
  suites replace HOME with a temp directory, and temp is a permitted root, so
  the script built there and the assertion failed during a full-suite run. It
  now uses a sibling of the repository, which no suite mutates. The full suite
  is green again: 4076 pass / 0 fail.
- The glob test ran the child with cwd at the repository root while the glob
  sat under dist/, so the old unquoted loop had nothing to expand and the test
  would have passed against the broken implementation. It now runs in a
  sandbox that contains a matching entry and asserts the literal-star path was
  used rather than the decoy.

Added a relative-escaping-symlink regression. Sabotage-verified: disabling the
new symlink guard fails exactly the three symlink cases.

040's containment snippet now shows the real implementation, with all four
bypasses recorded as the reason it looks the way it does, and criterion 3b
describes the eight cases plus the two harness traps.

* docs(release): make the Phase 4 containment snippet honest and complete

Review passed the implementation and left one blocker: 040 is the
security-review artifact, and its containment snippet still could not be
trusted.

- It called resolve_physical without defining it, so it was not executable.
  Now explicitly marked ABBREVIATED with the script named as authoritative.
- It omitted the allowed_tmp branch. That is not cosmetic: macOS puts TMPDIR
  under /var/folders, so the documented version would have rejected the
  packaging script's own temporary build root while claiming to describe it.
- Criterion 3b claimed coverage it did not describe. It now enumerates the
  eight cases and all three harness traps — the HOME mutation, the path.join
  normalisation, and the glob cwd — each of which made a test pass against
  broken code at some point.

Also removed the `normalised` variable, which was computed and never read
after the resolver was restructured.

* docs(release): correct two counting errors in the Phase 4 criteria

Review passed. Editorial only: 'Two harness details' introduced three bullets,
and the eight-case list implied every case was a refusal when two are
acceptance cases.

* docs(macos): document the companion and the Gatekeeper first launch

Phase 5 documentation. The guide ships in all five locales and is linked from
the sidebar; docs-site builds 131 pages with all five present.

The Gatekeeper section is the reason this guide is mandatory rather than
nice-to-have. Users WILL see "cannot be opened because the developer cannot be
verified", and the honest explanation is that Developer ID signing plus
notarization needs a paid Apple Developer account the project does not have.
So the guide says that plainly, gives the right-click-Open path and the xattr
alternative, and points at building from source for anyone who wants neither.

The rest documents what the app actually does rather than what a menu bar app
usually does: the monochrome icon states and why colour is not used up there,
the quota row showing the window under most pressure rather than the longest
horizon, why the button says Stop proxy and not Restart, and the polling
cadence — since a companion that hammers your own proxy every five seconds is
a battery complaint waiting to happen.

Also registered app/ in AGENTS.md and structure/00_overview.md. A new
top-level directory that neither file mentions is invisible to the next agent,
and the overview now states the boundary explicitly: the app is a client of
the management API, so a change that needs a new endpoint is a change to the
proxy first.

* docs(macos): correct the API-key claim the app does not implement

Review found the guide describing a flow that does not exist, in all five
locales, and the same overclaim repeated in the #421 closing comment.

Nothing calls Keychain.write. The app can READ a key under
com.opencodex.menubar and retries once with it, but there is no entry UI, and
"Add key…" only opens the dashboard — whose token lives in session storage and
is unreachable from a native app. So a user with a non-loopback proxy stays on
"Needs API key" no matter what the guide promised.

All five locales now say that plainly: the key can be read from the Keychain,
there is no way to enter one yet, a loopback proxy needs none, and native
entry is planned. The uninstall section no longer claims a Keychain entry
exists unless the user created it. Keychain.swift documents the same thing at
the source.

Also folded:
- Bun was missing from the build prerequisites, so a machine with only Command
  Line Tools would hit "command not found" after satisfying the stated
  requirements. Added, with `bash scripts/build-macos-app.sh` as the
  no-Bun path.
- README now names app/ as the source directory, which criterion 2 asked for
  literally and the previous wording only implied.
- "the address it is listening on" was imprecise: the panel shows the loopback
  endpoint the app is using, which is not necessarily the proxy's configured
  bind. Reworded in all five locales.
- Added the System Settings → Privacy & Security → Open Anyway fallback, since
  current macOS does not always offer an Open button in the first dialog.

A corrective note on #421 follows separately — the credit there also needs
fixing, and closing a PR with an inaccurate credit is worse than not crediting
at all.

* docs(macos): stop pointing users at a Keychain item they cannot create

Review found the documented identifier does not match the code: the app
queries service com.opencodex.menubar.apikey with account "default", while the
guide named com.opencodex.menubar. All five locales repeated it, so the
workaround I had just added would have left users exactly where they started.

Rather than publish the exact identifier, the guides now say there is no
supported way to provision the key by hand. That is the honest answer: the
entry is a data-protection Keychain item, which Keychain Access does not
create, so naming the service would send people down a path that does not
work either. A loopback proxy — the default — needs no key, and native entry
is planned.

The uninstall sections no longer describe removing a Keychain item, since the
app stores nothing there today.

Also took the reviewer's suggestion on 050: criterion 1 said "Guide
published", which implied a deployment this phase does not perform. It now
says the source is added and the docs build verified, with publication
following merge and Pages.

* docs(app): align the Keychain comment with what the guides now say

The source comment still told a maintainer that users create the Keychain
item themselves — the exact workaround the guides just stopped publishing,
because every query sets kSecUseDataProtectionKeychain and Keychain Access
does not create data-protection items.

Left as-is it would have reintroduced the invalid advice the next time
someone read the source instead of the guide.

* ci: declare macos-app in the aggregate gate after the dev rebase

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: add usage timeline companion settings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui): companion section in Usage with live timeline preview

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): timeline cache isolation, other-fold aggregation, ocx companion set/reset

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(app): settings-driven menu bar title, today metrics, timeline chart, widget snapshot export

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(widget): WidgetKit extension with small/medium/large families, packaged into OpenCodex.app

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: structure/docs/ci parity for the macOS companion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(widget): NSExtensionMain entry point, family-specific layouts, popover legend/captions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cli): include companion in help banner

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ci): cover companion parity and GUI doctor findings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): defer companion loading and translate French labels

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): surface corrupt companion settings and correct the widget copy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): reset fieldset chrome on the companion controls

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(companion): default the menu bar headline to tokens

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(companion): integer token abbreviation (K/M/B, no decimals)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui): companion install card driven by app presence

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui): scrollable model list with switches for the companion chart

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(app): Liquid Glass surfaces on macOS 26

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): round integer token abbreviations and panel presence age

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(companion): update integer token examples

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): live presence refresh, tokens headline in the small widget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): stable model ordering, tokens-first today row

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(companion): address macOS widget review findings

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(devlog): drop the duplicate _plan copy of the closed macOS unit

The branch opened this unit under devlog/_plan/ before dev published the
same unit as closed at devlog/_fin/260725_macos_menubar_app/ in 8ae52e4291.
The rebase replayed the _plan addition on top of that publication, so the
tree carried both copies: nine files, 2,605 lines, six byte-identical to
their _fin counterparts.

The three that differ are worse than redundant. 003_design_read.md,
010_phase1_core.md and 020_phase2_ui.md keep the decimal token text
(12.4M, 36.5B) that this same pull request corrects to integers in the
_fin copies, so the duplicate contradicted the corrected record two
directories over.

AGENTS.md defines _plan as units still open and _fin as closed work, and
nothing in CI reads devlog/ - the file-size scanner excludes it - so no
gate would have caught this. The _fin copies, including the
051_feature_summary.md this PR adds, remain the record.

* ci(release): gate package-macos on dispatch validation and align the artifact pin

Two defects in the release jobs this pull request adds, both found in
review of the workflow surface.

package-macos had no needs:, so a dispatch that validate-dispatch would
reject still spun up a macOS runner and packaged an asset. Every other
job in the file gates on that validation; this one now does too. The
blast radius was bounded - contents: read, no secrets, and the script's
own version guard - but running at all on a rejected dispatch is not the
design.

The upload step pinned actions/upload-artifact at v5.0.0 while ci.yml
already pins v7.0.1, leaving the repository with two pins for one
action and pairing a v5 upload against the v8 download in attach-macos.
Both now use the SHA ci.yml already trusts, which is also the pairing
actions/download-artifact v8 expects.

---------

Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
)

Consolidates the desktop stack that was carried as a GitHub native stack on top
of the macOS menu bar companion. lidge-jun#5196 landed as 38a5ab9 by squash, which
detached every child in the chain from its base, so the remaining work is
applied here as one branch against the current dev instead of replayed through
bases that no longer exist.

Carries the standalone binary, the Tauri v2 cross-platform tray and webview
shell, the GUI desktop shell integration, the WidgetKit appex bundle, and the
signed desktop packaging for DMG, MSI, AppImage and deb.

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…n#5319)

* Add Claude intercept pair: local CA, CONNECT proxy, TLS listener, server wiring

Claude Code honours HTTPS_PROXY/NODE_EXTRA_CA_CERTS from its settings env, so a
loopback CONNECT proxy plus a locally-signed TLS listener for api.anthropic.com
lets the router see Claude Code's Messages traffic without any ANTHROPIC_BASE_URL
rewrite and without touching the Desktop app's own (first-party) configuration.

- src/claude/intercept/local-ca.ts: ECDSA P-256 CA + leaf issuance with a
  hand-rolled DER encoder (node:crypto only); CA persisted under
  <OPENCODEX_HOME>/claude-intercept with a 0600 key, never installed in an OS store.
- src/claude/intercept/connect-proxy.ts: CONNECT-only loopback proxy; splices
  api.anthropic.com:443 onto the TLS listener, relays other targets blind,
  refuses plain HTTP, loopback targets and oversized heads.
- src/claude/intercept/listener.ts: TLS listener; POST /v1/messages and
  /v1/messages/count_tokens are rewritten onto a loopback origin and dispatched
  to the route table under the new claude-intercept ingress (loopback policy);
  every other path is relayed verbatim to the configured Anthropic upstream.
- src/claude/intercept/settings.ts: ownership-aware apply/inspect/remove of the
  two env keys in Claude Code settings.json (anchored on the CA path).
- src/claude/intercept/runtime.ts + server wiring: enabled by default on a hub,
  proxy port = public port + 100 unless claudeCode.intercept.port is set; bind
  failure degrades to a warning; stop joins both sockets.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Validate claudeCode.intercept in the config schema

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-intercept): keep ephemeral-port servers proxy-free and lift wiring out of index.ts

startServer(0) has no stable port to derive the CONNECT proxy from, so the
intercept pair now stays off unless claudeCode.intercept.port is explicit;
this also keeps in-process test fixtures at their expected listener count.
The wiring moves into src/server/index/claude-intercept-lifecycle.ts and the
inbound-body-limit warning into startup-warnings.ts so src/server/index.ts
stays under its file-size cap.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-intercept): refuse mapped/unspecified loopback CONNECT targets, share the intercepted-path predicate

isLoopbackTarget now checks 127/8, ::1, 0.0.0.0 and :: through a BlockList (which also
matches IPv4-mapped IPv6), *.localhost, and numeric resolver shorthands like 127.1.
serve-options.ts reuses isClaudeInterceptedPath from listener.ts so the two route lists
cannot drift apart.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(claude-desktop): first-party mode as default, gateway profile as explicit opt-in

Add claudeCode.desktopMode (first-party | gateway). First-party keeps Claude
Desktop on claude.ai and writes only HTTPS_PROXY/NODE_EXTRA_CA_CERTS into
Claude Code's settings.json so the Code tab, subagents and the claude CLI go
through the local intercept pair. Gateway keeps the existing 3P profile writer.

- resolveClaudeDesktopMode: explicit > applied gateway fingerprint > first-party,
  so existing gateway installs do not flip on update while new installs get 1P
- resolveClaudeDesktopApplyMode: implied 1P falls back to gateway where the
  intercept pair cannot run (client role / intercept disabled)
- CLI: ocx claude desktop apply [--first-party|--gateway]; legacy shape flags
  imply --gateway; connected clients default to gateway
- API: /api/claude-desktop/apply accepts first-party|gateway (+ legacy shapes),
  status reports mode + firstParty block; native toggle applies resolved mode
  and disable removes both gateway profile and 1P env
- ocx ensure: refresh stale 1P env when ON, remove it when OFF
- modes are mutually exclusive; foreign proxy/CA env is never overwritten

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-desktop): first-party apply replaces an active gateway profile

Switching gateway -> first-party from the GUI/CLI/API previously refused with
gateway_profile_active and required turning the integration off first, while the
docs and the GUI switch note promise a direct replacement in both directions.
The first-party branch now pivots the owned gateway profile back to standard
(removeDesktop3pStandardPivot with replaceWhileEnabled, since the durable switch
stays ON) before writing the env, and fails without writing when the pivot cannot
complete.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-desktop): native toggle enable follows the apply contract for first-party

- The native ON toggle pivots an owned gateway profile to standard (replaceWhileEnabled)
  before writing the first-party env, and persists the desktopMode marker, matching
  POST /api/claude-desktop/apply.
- Gateway apply now reports a failed mode-marker save as saved:false plus a warning
  instead of dropping the result.
- ensure/update warns when an explicit first-party marker contradicts a gateway profile
  still on disk rather than returning silently.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(claude-desktop): drop the gateway apply marker on first-party and record gateway mode from the native toggle

All three mode-marker writers (CLI apply, /api/claude-desktop/apply, native toggle) now share
recordClaudeDesktopMode. Switching to first-party clears desktopProfile.appliedFingerprint/appliedAt
so a lost explicit marker can no longer resolve back to gateway while first-party env is on disk;
the profile assignments stay for a later gateway apply. The native toggle's gateway enable branch
saves desktopMode="gateway" like the apply route does.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(claude-desktop): prove the native toggle writes the gateway marker from an unmarked config

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(gui,docs): Claude Desktop connection-mode selector and first-party docs

Dashboard Desktop tab gains a Connection mode picker (first-party default,
gateway opt-in) that sends the chosen mode with /api/claude-desktop/apply and
shows the intercept proxy state in first-party mode. Strings added to every
locale.

Docs describe both modes, the settings.json env the first-party apply writes,
intercepted routes, the local CA trust boundary, update behavior for existing
gateway installs, and Claude Code CLI compatibility/limitations. Translated
guides get a summary section pointing at the canonical English text.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: avoid literal home path in first-party settings example

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(gui): cover Claude Desktop connection-mode picker

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(gui): move Claude Desktop mode-picker CSS into its own stylesheet

gui/src/styles.css sits at its file-size cap; the picker rules move byte-for-byte
into gui/src/styles/claude-desktop-mode-picker.css, imported from main.tsx.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): keep the Desktop mode picker unresolved until /status answers

The picker no longer pre-checks the first-party default while the status request is
in flight, so a gateway install does not see the wrong radio and badge flash.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(gui): unlock the Desktop mode picker after a confirmed /status failure

A failed /status with no cached status left the picker disabled forever. It now unlocks on
the first-party default once the error is shown, while the current-mode badge and switch
note stay hidden because the real mode is still unknown.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: re-run after macos 2/2 runner hang in launcher --version test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs(claude-desktop): state that Save alone does not switch the connection mode

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
* test(tray): prove hung-probe cleanup behaviorally with a controlled fake CLI child

Follow-up to lidge-jun#5184: replace the placement-blind gate substring assert
and the comment-text assert with a win32-only lifecycle regression test.
A PowerShell driver loads the real probe functions from
windows-tray.ps1 via the AST, stages a real hung child through
Start-StartupHealthProbe, backdates past the 30s timeout, and invokes
the real Update-TrayState ticks:

- Offline: tray stays offline yet terminates the hung child.
- Online (fake /healthz): ticks kill the hung child and launch no
  replacement before the refresh interval (pid file proves 1 launch).

Ablation: deleting the timeout-branch Kill() flips childTerminated to
false, so the test goes red exactly when the behavior regresses.

* test(tray): name the driver safety-net catch for hygiene

* test(tray): harden the probe lifecycle driver and keep merge-time placement cover

- Driver wraps the post-launch lifecycle in try/finally so a throw before
  the fake CLI writes its pid file cannot leak the 120s sleeper; the
  in-hand pid is stopped in finally, verdict evaluation stays before it.
- Driver asserts the child is still alive after the settle wait, so an
  already-exited child cannot vacuous-pass through the exited-probe path.
- Restore a lightweight platform-independent placement assert (timeout
  maintenance must precede the online-only UI branch, line-anchored to
  dodge the inline proxyPid conditional), because the win32-only
  behavioral test does not run on the PR-gated legs.

* test(tray): anchor the placement check to the timeout branch itself
* fix(gui): keep Apple SD Gothic Neo behind San Francisco

* test(gui): guard system font fallback precedence

* fix(gui): preserve product font priority before system fallbacks

* test(gui): cover all named system UI font fallbacks

---------

Co-authored-by: stleamist <2215080+stleamist@users.noreply.github.com>
…s that it accepts it (lidge-jun#5334)

lidge-jun#5213 removed a hostname test that decided the wire role, which was right: a
gateway proxying OpenAI accepts `developer` and the hostname cannot say so. The
replacement default was wrong in the other direction. Forwarding to every
destination assumed each one accepts a role until an operator marks it, so a
gateway that rejects it answered `400 role 'developer' is not allowed` and the
turn never started.

Nothing in this repository could see that. Every test asserted the new default
and passed; what broke was outside the tree. The key is now tri-state and the
unset state is the safe one: absent folds to `system`, `true` records an
upstream that rejects the role, `false` records one that accepts it and the
role is forwarded. Placement is untouched in all three cases, which is the
contract lidge-jun#5213 established and this change preserves.

The regression fixes the gap directly: an undeclared destination must fold, and
the role must still never be read from the hostname. The ordering suites declare
their destinations rather than asserting the default, because they are about
where a reminder sits, not which role carries it.

Co-authored-by: codex <codex@users.noreply.github.com>
…idge-jun#5335)

Co-authored-by: Flowershangfromthebranches <flowershangfromthebranches@users.noreply.github.com>
…veloper role (lidge-jun#5341)

lidge-jun#5334 folds the wire role unless a destination records acceptance. This vector is the one place that asserts the forwarded role, and it lives outside tests/, so the change missed it and dev went red with roles:value_mismatch.

Co-authored-by: codex <codex@users.noreply.github.com>
… the developer role (lidge-jun#5344)

Co-authored-by: codex <codex@users.noreply.github.com>
…developer role (lidge-jun#5346)

Co-authored-by: codex <codex@users.noreply.github.com>
…-jun#5338)

* ci(desktop): keep the release updater key out of the verification build

* test(lab): fold the conformance developer role with the destination default

* docs(lab): describe the folded chat fixture contract

* test(lab): pin the undeclared chat role fixture

* test(lab): defer to the merged developer-role fixture fix

* test(openai-chat): align dangling barriers with role folding

* Revert "test(openai-chat): align dangling barriers with role folding"

This reverts commit 4d42760.

---------

Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
* fix(desktop): keep proc-macro symbols in the release profile

cargo applies profile.release strip to build scripts and proc macros. A stripped proc-macro dylib cannot be loaded by rustc, so the release build failed at ctor_proc_macro with a bare can't-find-crate that named the macro instead of the profile. The dev profile compiled the same graph.

* docs(devlog): lock the desktop stabilization roadmap

* docs(devlog): state per-phase acceptance evidence

* fix(cli): report a dashboard bundle older than its sources

The dashboard is a build artifact served from gui/dist, so a checkout that moves
forward without bun run build:gui keeps serving the previous bundle. Nothing
fails: the proxy answers, the page loads, and every feature added since the last
build is absent, which reads as the feature being broken rather than unbuilt. A
five-day-old bundle hid the whole menu-bar and widget section of the Usage page
that way.

ocx status now compares the newest source mtime under gui/src against the served
bundle and names the rebuild. It reports and never rebuilds: a proxy that
compiled a frontend while starting would trade silent staleness for a slow,
surprising start.

Unknown is not stale. A packaged install ships no gui/src beside the bundle, and
a missing bundle is a separate condition, so neither raises the warning.

* docs(devlog): plan the wp2 build-state guards

* build(desktop): give the local build a path that needs no signing key

tauri build always writes the updater archive, because createUpdaterArtifacts is
true and plugins.updater.pubkey is set, and then refuses to finish without
TAURI_SIGNING_PRIVATE_KEY. Both bundles already exist when that happens, so a
local build reports a failure for a signing step it was never meant to perform
and a wrapper cannot tell it apart from a real one.

bun run build:local turns the artifact off for that invocation instead of
leaving the key required and unmet, so nothing is skipped unsigned. Selecting
bundle targets is not enough: createUpdaterArtifacts is a config flag, so
--bundles app,dmg still produced the updater archive and still failed. The
committed config is unchanged and the release path still refuses to publish an
unsigned updater artifact.

* fix(ci): stop the desktop lockfile shadowing the root one and name the freshness test for its domain

Two failures on the exact head of this branch, both real.

The widget job installs the desktop workspace with --frozen-lockfile on Bun 1.3.14. A bun.lock
written inside desktop/ by a newer Bun shadows the root lockfile for any command run from that
directory, so the job failed with "Unknown lockfile version" followed by "lockfile had changes,
but lockfile is frozen" before it built anything. That file was committed by accident; the root
lockfile is the only one this repository keeps, and .gitignore now says so.

tests/server/gui-bundle-freshness.test.ts was registered as server in both inventories, but the
gui domain seed claims ^(?:dashboard|gui|models|qwen|tencent)-, so resolveTarget answered gui and
the membership oracle reported the file twice - once as a wrong target against the fixture and
once as a seed disagreeing with the table. Renaming it to server-gui-bundle-freshness.test.ts puts
the name in the domain that owns it rather than pinning an override, which is what that guard is
there to prevent.

* test(layout): register the StepFun provider test in both inventories

tests/providers/stepfun-provider.test.ts landed on dev without an entry in either inventory, and
no regex seed resolves its name, so the membership oracle has been failing on dev and on every
branch cut from it since. Registering it under providers restores the gate for everyone rather
than only for this stack.

* Revert "test(layout): register the StepFun provider test in both inventories"

This reverts commit e10b98f.

The same registration landed on dev as lidge-jun#5335 while this stack was in flight, and the rebase kept
both because the two insertions chose different neighbours. Two entries for one key is not a
second registration, it is a JSON object whose last value silently wins, so the duplicate goes
rather than the one already on dev.

---------

Co-authored-by: codex <codex@users.noreply.github.com>
devin-ai-integration Bot and others added 18 commits September 21, 2026 13:06
… console (lidge-jun#5399)

* fix(desktop): make the Windows shell load its own origin and hide the console

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(desktop): let Stop proxy settle before deciding whether the proxy is gone

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(desktop): only trust http for the Windows app origin and log a stuck Stop proxy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: jun <bitkyc08@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…tract, locked resolution, atomic replace (lidge-jun#5400)

* fix(service): one install-state contract for both runtimes

The record shape, the state-path list and the ownership resolution move into a plain-ESM module both runtimes import. They validated the record separately before, and the Node launcher's copy was weaker in two ways that decided authorization: it inspected only the anchor path, and it answered 'known unowned' for any record whose ownership field was absent, including one that fails the contract outright. Reported on PR lidge-jun#5386 by Codex (P2) and CodeRabbit (major, CWE-863).

* fix(service): close the three state-write defects review found

Ownership is now resolved INSIDE the swap, while the anchor lock is held. Resolving beforehand was a lost update the compare-and-swap cannot detect: a takeover landing between the resolution and the base read reaches current, passes the revision check untouched, and is then overwritten by the older claim. Where the anchor and the cross-path resolution still disagree, the higher consent generation wins and an equal generation keeps the anchor.

Each state file is published by writing a sibling temporary file and renaming it. An in-place write truncates first, so an interrupted commit left the anchor empty or half-serialized — which the fail-closed reader introduced in lidge-jun#5386 reports as unknown, blocking start, repair, restart and every update until a takeover install. Windows can refuse the replace transiently, so it retries and then falls back to the in-place write rather than failing an install that already registered.

The lock file carries a token naming its holder. Eviction re-reads it before unlinking and release unlinks only its own instance, so a holder evicted as stale can no longer delete the replacement lock and hand a third writer the pathname. The stale threshold now exceeds the longest legitimate critical section rather than the typical one: on Windows a commit runs hardenSecretPath per path, whose own worst case is far beyond the previous thirty seconds.

The reader and the parser delegate to the shared contract.

* fix(update): share the contract and re-read ownership at each runtime action

The launcher resolves ownership through the shared contract across every state path instead of its own anchor-only reader, and that reader is deleted rather than kept in step by a table of claim shapes — being 'in step' is what it was not.

Both package updaters re-read the claim immediately before the stop rather than trusting a plan formed earlier in the run. The Windows tray handoff spawns children between the two, so a takeover can land in the gap, and stopping a runtime that just changed hands is the failure this lane exists to prevent.

* docs: document runtime ownership, its refusals and the recovery path

The CLI lifecycle reference gains a Runtime ownership section: what the record means, which subcommands refuse under a foreign or unknown owner and which stay open, what the refusal text says, and that ocx service install is the never-gated recovery. Codex flagged the missing page on PR lidge-jun#5386 against the docs rule in src/AGENTS.md.

structure/runtime.md records the shared contract module, the resolve-inside-lock rule, the atomic replace, the lock token, and the residual window that re-reading narrows but does not remove.

* test: cover the shared contract, the atomic replace and the lock token

The launcher's parser is gone, so the table that kept two readers aligned is replaced by cases against the one contract, including the record that fails it while carrying no ownership field — the exact shape the launcher used to call unowned. Adds a legacy-path claim, a cross-path conflict, an unreadable path, the staging-and-rename write path, the lock token's release rule, and the newer-claim tie-break.

* test(update): follow the launcher's state-path list to the shared contract

The launcher no longer spells "service-state.json": the path list moved into the contract both runtimes import, which is the point of the change. The assertion now reads the call that builds that list and the detection it feeds, so it still proves the launcher decides service-installed from the install-state record.

Caught by hosted CI on shard 1/4 at 4017e7d; shards 2/4, 3/4 and 4/4 were green.
lidge-jun#5387)

* fix(gui): replace the platform dialogs the app webview cannot draw

wry implements no WKUIDelegate JavaScript panel methods, so inside the desktop app confirm() returned false without drawing anything, alert() drew nothing and prompt() could not collect input. Thirteen consent gates and seven result reports across the dashboard were therefore inoperative there, including the sidebar stop and refresh orbs: the user clicked, was silently declined, and saw nothing.

Replace all three with in-page surfaces already in the tree. confirmAction and requestTextValue open a real <dialog> built the way admin-token-dialog.ts already builds one, keeping the call-site shape so a refusal still issues no request, and carrying the dismissal, focus-return and validation behaviour of the existing modals. The alert() reports move to ToastNotice, which useCodexRestart now requires its consumer to supply so a forgotten report fails to compile rather than vanishing.

requestTextValue mirrors the alias contract both credential routes enforce, so an over-long or pasted control character is reported beside the field instead of returning an opaque 400. ProxyStopOutcome and CodexRestartOutcome become discriminated unions so a failure always carries its reason.

Models.tsx sits on its recorded line cap, so alias editing moves to a sibling module rather than growing it.

* test(gui): guard the dashboard against the platform dialogs by call form

Matches the global CALL FORM rather than the identifier, because the identifier is legitimate in four places this repository uses: a confirm() method on a session object, the promptForAdminToken helper, a confirm prop, and an executable sample string containing the word. A lexical ban would reject all four.

The scanner masks comments, string literals, template text and regular expressions before matching, keeping template holes visible because interpolated code is executable. Masking regular expressions is not cosmetic: an unmasked /[quote]/ reads as the start of a string literal and swallows the rest of the file, which would turn the guard green by blinding it.

Driven red twice. Against origin/dev it reports all 25 pre-existing call sites with exact file:line, and clears the four legitimate shapes. Against a reintroduced window.confirm and alert in gui/src/lib/desktop-shell.ts it reports both and the tree test fails.

* test(gui): assert the absence of platform dialogs instead of stubbing them in

The GUI tests were the reason CI never saw this defect. codex-stale-banner-dom stubbed confirm() to true and alert() to a no-op, memory-observability-card forced confirmation, app-stop asserted that alert(outcome.message) existed, and four more files answered consent the same way. Each is reasonable alone; together they encoded browser dialogs as available and made a dashboard that could draw none of them look correct.

Every one of those files now installs a throwing trap for confirm, alert and prompt, and answers the real in-page dialog through gui/tests/helpers/action-dialog.ts. Reaching a platform dialog fails the test. New coverage in gui/tests/action-dialogs.test.ts drives the helpers directly: a refusal resolves false and mounts nothing, Escape and the backdrop are refusals rather than unanswered closes, focus returns to the trigger, a destructive action does not put the accepting button under Enter, and a rejected value keeps the dialog open with an accessible report and no request.

Three fixes the review found. The focus capture was duck-typed because instanceof HTMLElement bound every caller to a realm exposing that global, which most of this package's DOM tests do not. Teardown resolves in a finally block, since a dialog left connected is cosmetic but a promise that never settles hangs the handler awaiting it. ProviderModels claims its single flight before awaiting consent: the old gate was synchronous and nothing could interleave with it, and a browser's modal inertness is the platform's courtesy rather than this component's invariant.

The scanner lost several blind spots, each a false negative. It now tracks the previous token rather than the previous character, so a JSX closing tag is not read as a regular expression and an apostrophe in JSX body text is not read as a string; both masked the rest of their line. It also reads confirm?.(x) and (confirm)(x).

* docs(pr-assets): add the in-page consent and text-entry dialogs screenshot

The two surfaces that replaced the platform dialogs the app webview cannot draw: the consent gate behind the sidebar stop orb, and the text entry that replaced window.prompt() for alias editing. Captured against a freshly started proxy with an empty config home, so it carries no account data.

* fix(gui): dismiss an open dialog when its page navigates away, and close the guard's blind spots

Review found two real defects in the dialog helper. A dialog is mounted on <body>, so it outlives the React subtree that opened it: navigating with Back or Forward left it on screen, and accepting it afterwards resumed a closed-over handler against a page the user had already left, which for account removal or device revocation means a destructive action running from a surface they cannot see. Navigation is now a refusal, using the same two events the shell already treats as leaving a page, and both listeners come off when the dialog settles.

Escape moves to the document. With showModal the dialog reports its own cancel event and this never fires; without it the element is merely open, so a dialog-level listener missed Escape as soon as focus sat anywhere else. That fallback path is not a modality boundary and the comment now says so plainly rather than implying a focus trap it does not have: every browser surface the dashboard ships to implements showModal and takes the other branch.

A static review then demonstrated several calls the guard did not report, each a real hole rather than a stylistic gap: window.confirm?.(x), (window.confirm)(x), ((confirm))(x), a call at a statement start after automatic semicolon insertion, export default prompt(x), a postfix ++ making the next slash a division rather than a regular expression, and computed access through globalThis["alert"]. Computed access is matched against the raw source because the quotes that make it work are exactly what the mask removes.

One regression was vacuous: the provider-model dismissal cases answered through the shared click helper, which returns silently when no dialog opens, so they would have passed if Delete and Hide became no-ops — which is the very defect this lane exists for. They now drive the dialog themselves and assert it appeared. The dialog test file also stops installing the HTMLElement global, so it proves the focus check stays realm-independent instead of hiding a reintroduction.

* fix(gui): correct two unrun assertions that hosted CI caught

Both were written under the no-local-execution contract and both were wrong about the environment rather than about the behaviour they describe.

The document-Escape case asserted that this DOM has no showModal. It does, so the dialog always took the modal branch, the document listener was never registered, and the test sat until its deadline. It now deletes showModal from the prototype for the duration, which is the only way to reach the fallback at all, and restores it afterwards.

The parenthesized-callee expectation still named the older match shape. Widening the pattern to accept any number of wrapping parentheses moved the match start to the identifier, so the reported form is confirm)( rather than (confirm)(.

Evidence: run 35547774047 at 683e929 reported 2261 pass / 1 fail in the GUI suite and one failing guard case in shard 1/4, which is exactly these two.

* fix(gui): call the consent gate through a named async handler, not a floating .then

React Doctor blocks on warnings and flagged two: no-floating-then-in-jsx-handler at ProviderSettings.tsx:422 and startup-sections.tsx:201. Both were promise chains I put directly in a JSX handler when converting these two sites away from the synchronous confirm(). A floating .then in a handler has no rejection path, so a throw inside the continuation becomes an unhandled rejection rather than anything the component can see.

Each becomes a named async function the handler calls with void, which is how the rest of this codebase drives async work from an event. Behaviour is unchanged: the account-mode select still restores its visible value on refusal, reading currentTarget before the await because it is null once the handler resumes, and the tray uninstall still runs only on acceptance.

The finding was not reachable from the PR: this workflow keeps the action's comment and inline outputs off for least privilege, so GitHub shows only the exit code. The rules and lines came from the run's job summary.

* fix(gui): separate accepted, rejected and unknown, and bind consent to its subject

Two findings from the external re-audit, both of the same shape: something uncertain was being recorded as something known.

The stop client mapped every transport failure to accepted. A timeout, a connection that never arrived, a user abort and a connection dropped after the server accepted are different events, and collapsing them turned the fact that the user pressed the button into evidence that the server acted. ProxyStopOutcome now answers accepted, rejected or unknown. An unknown is settled by READING the instance again rather than by re-sending: a stop that did arrive would otherwise be repeated against whatever now holds the port. A refused follow-up connection is the evidence that the runtime went away, so that case reports accepted; a proxy that still answers reports unknown with that reason; anything else stays unknown. Disconnecting a client ends no process, so there is nothing to re-read and it stays unknown. A 2xx whose body cannot be parsed is also unknown, because success: false rides in the body that was lost. App reports a refusal in the error tone and an unknown in the warn tone, because degraded is not failed.

The restart consent could outlive its subject. The dialog is asynchronous, so the surface can unmount or the backend base can change while the question is still on screen, and the request then went to the closure's captured target. Consent now carries a cancellation lifetime: a changed base or an unmount withdraws the open dialog, and the confirmed path re-checks that the base it captured is still the current one before sending. confirmAction and requestTextValue accept an AbortSignal for this, and answer a refusal when it is already aborted rather than drawing a question nobody can act on.

Also pins the template-literal distinction the re-audit doubted: a backtick literal's text is prose, and the dashboard ships an executable sample that spells out a prompt call on purpose, but a ${...} hole is executable code. The guard is supposed to report the second and ignore the first, so both are now regression cases.
* fix(integrations): version conjunction selectors

* test(integrations): preserve legacy selector meaning

* fix(integrations): guard selector restore previews

* test(integrations): reach malformed recorded selector

* fix(integrations): validate recorded selector grammar
…be (lidge-jun#5384)

* feat(desktop): one exit path, one startup surface and a real tray probe

Closing the window, the platform quit gesture and the tray's Quit all used to
mean the same thing. There was no ExitRequested handler, so the quit gesture
reached RunEvent::Exit and called CommandChild::kill() on the runtime this app
had started - a SIGKILL on Unix, cutting off the in-flight requests, the
client-configuration restore and the state-file clearing that the CLI's stop
performs, on a keystroke the user reads as "hide". exit.rs now holds the exit,
drains what the app owns and only then lets the process end. An installed
update asks for a coordinated restart down the same drain rather than
restarting straight into the kill, and a runtime counts as stopped only when
the child reports its own exit or the endpoint refuses a connection.

macOS needed one thing more than the handler: Tauri's default menu carries a
predefined Quit wired to Cocoa's terminate:, and the pinned tao implements no
cancellable applicationShouldTerminate, so that Cmd+Q never raised the event at
all. menu.rs rebuilds the default menu with an ordinary item on the same
accelerator, keeping the clipboard items the failure diagnostic needs.

Startup ran inside setup() before any window existed, and the spawn event
stream was destructured into _events and dropped, so a sidecar that exited
immediately looked exactly like a slow one. The window is created and shown
first now, and startup.rs runs the whole sequence inside it - registering,
resolving, probing, attaching or starting, waiting - under one 30-second
deadline, with every probe bounded by the time left rather than by the HTTP
client's own timeout. Registering comes first so a failed start still leaves a
tray to reopen from. The failure state carries a retry, the child's exit code
and a copyable diagnostic; a retry waits on a child that has not exited rather
than racing it, and a spawn cannot interleave with a quit because both take the
same lock.

Tray availability is asked of the session bus: not whether the watcher exists,
which proves nothing, but whether it reports a host registered. The pinned Linux
backend creates an AppIndicator and reports success either way. Where there is
no host, no icon is claimed, the window is shown whatever the launch origin, and
closing it quits through the same drain.

* test(desktop): pin exit ownership, the startup surface and tray availability

These are wiring facts, not behaviour a hosted runner can observe: CI builds the
shell against a zero-byte sidecar and has no graphical session to press Cmd+Q
in, so the ordering and the branches are read out of the source the way the
Start at Login default already is. The no-kill scan enumerates the shell's Rust
files from disk rather than from a list, so a new module cannot opt itself out.

Every assertion was driven red once against the shape it replaces: tray Quit
calling app.exit, a kill in the shell, the predefined macOS Quit, any endpoint
error read as a stopped runtime, a spawn that ignores an exit in flight,
_events discarded, the window shown after the sequence, resolve back in setup(),
a probe bounded only by the client timeout, a page failure that cannot report
itself, the weaker watcher question, a Linux tray assumed before the probe, and
a migration marker claimed before its rewrite succeeded.

* docs(structure): record the desktop shell's exit, startup and tray contracts

INV-DESKTOP-01 and INV-DESKTOP-02 bind the two rules that are easy to regress
silently: what is allowed to end the app, and what counts as a tray. Both state
the no-tray exception rather than claiming a uniform rule, and the shell
document says plainly that an incomplete drain still exits and can leave the
runtime standing.

* fix(desktop): keep the macOS-only menu out of every other platform's build

Hosted CI rejected the first head: the menu module compiled everywhere while
its only caller was macOS-gated, so gesture, QUIT_ID and on_event were dead code
on Linux and clippy -D warnings refused them. The module is now macOS-only, and
the window's close handler routes through exit::gesture instead of repeating the
decision, which gives that function a caller on every platform and leaves one
place where a close and a quit gesture are decided.

Four more things a review round found, none of them visible from behaviour:

The tray verdict was published before the icon existed and was never downgraded
when the build failed, so a close in that window hid into nothing. It is now
published only after a successful install, and a failed install is a session
with no tray.

Registering ran again on every retry, which would have built a second tray icon
with its own refresh loop and its own menu handlers - the app appearing to
duplicate itself each time the user pressed Retry. It now happens once per
process and a retry re-runs only the runtime half.

The exit coordinator held its lock across process creation, which put spawning
in front of the main thread's exit handler; a wedged spawn would have been a
Quit that never answered. The spawn is reserved instead, and a quit arriving in
between is deferred until the child is owned and then drains it.

Every tray menu setter dispatches to the main thread and waits, and the tray is
built on the main thread holding the menu mutex, so calling a setter under that
lock is a cycle. The handles are copied out from under it first.

Registration's session-bus probe and its main-thread callback are also bounded
by the sequence deadline now, so neither can strand the page in a state whose
retry could do nothing.

* test(desktop): follow the shell contracts to where they now live

The close handler, the spawn reservation, the tray verdict and the one-time
registration all moved, so the oracles move with them. Two assertions were also
too weak to bind what they claimed: the no-kill scan now walks the source tree
instead of listing its top level, and the setup-does-nothing check is bounded to
the setup closure rather than running to end of file.

Each literal and each ordering these files assert was checked against the
current source by reading it, not by running them.

* docs(structure): state the tray verdict, registration and spawn rules exactly

The contracts now say when the tray verdict is published rather than implying it
is known up front, that registration happens once per process, that a quit
during a spawn is deferred rather than refused, and why a menu setter is never
called under the menu mutex.

* test(desktop): follow claim_drain into the match that replaced its guard

Hosted CI caught this one: the assertion still looked for the early-return guard
that claim_drain used before it grew a Spawning arm, and the phrase it searched
for had moved to begin_spawn - so a global search for the text found it while
the scoped assertion did not. It now reads the Idle arm itself and pins the
number of places that move the phase to draining.

* feat(desktop): hold this installation's own id and read ownership by lane C's rule

The shared service install state now records who owns the running proxy, and the
claim names the owning installation rather than the user or the machine. So the
app needs a value of its own to compare against: identity.rs mints one into the
app's config directory, once and exclusively, so two launches racing each other
answer to the same id rather than to two - and a second id would find a claim
that is not its own and ask again for consent the user had already given. An id
kept only in the shared record would be whoever wrote it last, which is why D3
accepted two records and the re-consent a lost app-local one forces.

ownership.rs mirrors the claim, the three answers a read can give and the
comparison, all of which src/service/state.ts defines. It does not read the
record: resolving one means reading every state path and failing closed on an
unreadable one, on a corrupt anchor and on paths that disagree, and a second
weaker implementation of a question core already answers is the mistake that
gave discovery.rs its own port guess. The types are the CLI's answer as it will
arrive on the wire, field for field, so lane A's contract fills a hole instead
of reshaping this file.

Until it lands, resolve is unavailable - which is not "nobody owns it", because
the question has not been put - so no takeover is attempted and nothing is
recorded. The registering state and the failure diagnostic say which of the two
it is.

* test(desktop): read both halves of the ownership claim together

The shell's half and src/service/state.ts's half are asserted in one file, so a
change to the owner values, the wire field names, the three resolution kinds or
the comparison rule breaks here rather than leaving the two to disagree
somewhere only a real takeover would reveal. It also pins what the comparison
does not look at: the generation moves on every grant, and comparing it would
make a consent the app already holds look foreign.

* docs(structure): record the app's half of the runtime-ownership claim

Points at the contract lane C published rather than restating it, and says
plainly that an unavailable answer is not an unowned runtime.

* fix(desktop): drain before an update installs, and stop calling a failed drain a drain

Removing the direct kill put the update on a coordinated path only if the
coordination is reached. On Windows it was not: the pinned updater's install
hands off to the installer process and ends this one with process::exit(0), so
the restart asked for after download_and_install() never ran, and the package
was replaced under a runtime still serving out of those files. The order is now
download and signature-check, confirm who owns the running runtime, drain it and
confirm the child is gone, and only then install. A drain that did not complete
refuses the install and leaves the update pending rather than proceeding.

A failed drain was also being recorded as a drain: the same completion path ran
for both, so a stop that was refused or timed out still ended in the exiting or
restarting branch. For a quit that is a defensible trade - refusing to close
when the user asked is worse, and a standing runtime is recoverable. For a
restart it is not the same judgement, because the new app comes back attached to
the old runtime while the user believes they upgraded. DrainFailed and
OwnershipUnknown are now states of their own, a quit proceeds from either, and a
coordinated restart refuses both.

The tray's Stop ran its own drain beside the coordinator, so Stop pressed twice,
Stop then Quit, and Stop during an update were separate executions over one
child. It takes the same phase now, and a quit that lands during a stop is
deferred and run afterwards rather than dropped.

* fix(desktop): confirm the instance before owning it or sending it a credential

Ownership of the running process was a bool set when the child was spawned, and
attaching to a different proxy left it set. A child that dies and an npm service
that takes the port back gives the combination the audit named: the connection
is somebody else's runtime and the flag still says ours, and Stop or Quit then
sends an owner's stop to it. Durable consent and current process ownership are
now separate facts. Consent stays in the recorded claim; ownership is
re-established each time from the pid the endpoint reports, and an answer that
cannot be read leaves the app owning nothing.

The same unauthenticated health body settles who the management token may be
sent to. It carries the marker, the pid and the port, so the client confirms the
instance before the credential rather than sending it to whatever holds the
port, and a request is bound to that pid, that port and the generation it was
authorised under. The client also refuses redirects - the pinned reqwest does
not treat this custom header as sensitive, so it would carry across a hop - and
refuses system proxies. This is the local management client only; the updater's
download client keeps its own policy.

Two smaller ones in the same area. The Windows app origin is allowed: the pinned
Tauri serves the app from tauri.localhost there because wry needs an http
origin, and without it the window's first navigation to its own page went to the
external browser. That exact host with no port, not localhost generally. And the
budget for finding an existing runtime is counted from when probing starts
rather than from process start, so a slow tray or session-bus registration
cannot spend it and turn into "nothing is listening", which starts a second
proxy beside the one already there.

* test(desktop): pin the update order, the failure states and the instance check

The order inside the update, which states a restart refuses, that Stop and Quit
share one execution, that ownership comes from the answering pid, and that the
credential follows the confirmation rather than the other way round. The Windows
origin case asserts what is not allowed as well as what is, since the risk there
is width rather than absence.

* docs(structure): record the update order, the drain verdicts and the client policy

Says which failure a quit tolerates and a restart refuses, why the install waits
for a confirmed stop, and that the management client has a network policy of its
own separate from the updater's download client.

* fix(desktop): let a refused restart be tried again

The new failure states were a dead end. A drain that did not complete left the
coordinator in DrainFailed, and every later claim returned None - so the update
stayed pending in the tray and pressing Install again did nothing, on the one
machine where the user most needs to retry: the one whose runtime would not
stop. A terminal failure is not work in flight, so claiming it again re-enters
the drain. A successful drain still cannot be re-entered, and a quit that
claimed the reason first still wins it, so the retry cannot turn a pending quit
into a restart.

* test(desktop): follow finish_drain into its verdict argument

The call takes the verdict now, so the assertion that still looked for a bare
finish_drain() was stale. Hosted CI caught it, which my own literal scan should
have: the scan used a look-behind, rg's default engine rejects that, and a
rejected pattern produces no output - so the loop ran zero times and reported
clean on every file. It is fixed and now fails loudly if the extraction errors,
and the corrected run over all five files found this one assertion and nothing
else.

* feat(desktop): resolve the runtime through the bundled CLI, and stop guessing

D5. The shell used to answer this itself, in a file called discovery.rs that
read runtime-port.json, fell back to 10100 and started there - so a user with a
configured config.port was started on a port they had not chosen, and the tuned
probe budgets that decision needs were sitting unused one layer down. It asks
ocx resolve --json now and reads one ocx-resolve/1 document.

Liveness keeps its three answers, and the third one is the point. live means
attach as a guest; absent-proven means every recorded and configured endpoint
was definitively dead, and only that authorises starting a runtime. Everything
else is unknown - a non-zero exit, a timeout, output that will not parse, a
schema this shell does not know, a missing binary - and unknown fails the state
with a diagnostic and a retry. It is never read as absence, because that is the
reading that puts a second proxy next to the one already running.

Two things a live verdict does not settle on its own. Core's liveness predicate
accepts a connected client's listener on purpose, so duplicate-start avoidance
can see it, and a caller that needs the management plane has to discriminate on
the role rather than narrow that predicate - this shell needs it, so a client
listener is live and unusable rather than something to attach to. And a runtime
bound somewhere 127.0.0.1 cannot reach is the same kind of answer. Neither is an
absence, so neither authorises a start.

The sequence also stops reporting Ready against an instance it could not
identify. bind returns its answer now instead of swallowing it, and both call
sites fail the state on None: the management token is only ever sent to a bound
instance, so a dashboard there would not load anyway.

* fix(desktop): stop the runtime with the bundled ocx stop, and read what it said

D4. The shell was ending the runtime with a management call from inside the
process it was ending. That cannot own its own teardown: launchd and systemd can
terminate the request handler during self-unload, and the Windows respawn window
can only be verified after the process exits. ocx stop --json runs the real
teardown - the receipt, the drain, the respawn verification, the client-config
restore - and the shell reads the ocx-stop/1 summary instead of inferring an
outcome from an HTTP response.

A stop counts only when five facts hold together. The process exited 0 and the
document says so, through both ok and exitCode, so 1, 79 and 80 are refusals
however the rest reads - taking the summary's word for its own exit status is
taking a claim as its own evidence. runtimeDown has to be true, because a
service that failed while the proxy happened to stop is exactly the case that
may respawn it. And the document has to agree with itself: only a stopped
outcome beside a stopped or orphaned proxy, or not-running beside not-running,
is a runtime that is down. An outcome or proxy state this shell does not know
fails to parse, which is the same answer as a stop that did not happen.

* test(desktop): read both CLI contracts against the CLI that defines them

The schema strings, the status and outcome vocabularies, the three-valued
liveness rule and the stop's accept-set are asserted on both sides in one file,
so a change to either is found here rather than on a user's machine. The
liveness test pins what must never happen as firmly as what must: no path
reaches a spawn without a proven absence, and a live listener this app cannot
manage is neither attached to nor started beside.

* docs(structure): record that the shell resolves and stops through the CLI

What the three liveness answers mean, which one authorises a start, and why a
stop is accepted only on exit 0 with the runtime reported down.

* fix(desktop): follow the stop outcome into its own type

One assertion still compared the summary's outcome to a string after it became
a closed enum, and clippy --all-targets compiles the test target, so the Rust
tests were skipped behind it rather than run. My static pass checked the code
paths and the source oracles and did not re-read the crate's own unit tests
after the type changed; the sweep now looks for any comparison of either typed
field against a string literal, and finds none.

* fix(desktop): merge the app-origin rule rather than pick a side of it

lidge-jun#5399 made the window load its own origin and hid the Windows console, and it
landed on the three files this lane owns. The console attribute in main.rs and
the Stop-settle intent in tray.rs carry through unchanged - the second is now
the coordinator's job, which confirms the stop through the bundled CLI instead
of polling /healthz and reports a stuck one rather than printing to a console
that is no longer there.

The app origin is one function now instead of the two the auto-merge left side
by side. It keeps lidge-jun#5399's contract - the custom scheme everywhere, the http
spelling WebView2 needs, https refused because that is not what the pinned Tauri
serves the app over, and not gated on the platform - and adds this lane's
tightening: no port, because a port means something else is answering rather
than the app. lidge-jun#5399's test asserted through navigation_allowed, which now takes
an AppHandle and cannot be built in a unit test, so its cases moved onto the
helper directly and its loopback-endpoint case is covered by the source oracle.

* fix(desktop): give a foreign runtime its own widget state

`ProxyError::Foreign` was added without updating the widget's match on it.
`widget.rs` compiles only on macOS, so the Linux `desktop shell` job never
sees it and the non-exhaustive match surfaced as a lone E0004 in
`macos widget + bundle`, which then failed the aggregate `ci`.

The new arm is explicit rather than a catch-all. A runtime this app did not
start is a different event from a fault: folding it into `degraded` or
`unreachable` would tell a user whose own npm or CLI runtime holds the port
that something is broken. It gets its own `foreign` state, which the widget
renders in the neutral secondary colour because `tone` does not know the
string. Keeping the match exhaustive also means the next variant added to
`ProxyError` is a compile error here again rather than a silent mislabel.

* test(clients): follow the owner check into the install-state contract

lidge-jun#5400 moved the runtime validation of an ownership claim out of
`src/service/state.ts` into `src/service/install-state-contract.mjs`, and the
receiver changed from `ownership` to `value`. The oracle asserted the old
literal, so it went red on the merge with `dev` while both sides were green
alone — each of the two reads its own half and neither compiles the other.

The assertion now reads both halves of core's answer: the runtime rejection a
record on disk actually meets, and the exported `ServiceOwner` type every
caller is compiled against. Splitting them matters here, because a parse that
accepted a third owner and a type that forbade it would disagree exactly where
a takeover happens, which is the case this file exists to catch.
…imi-for-coding default (lidge-jun#5403)

* release: v2.33.0-preview.20260825

* release: v2.34.0-preview.20260827

* release: v2.36.0-preview.20260829

* fix(release): pass the bump job's permissions through the reusable-workflow call (lidge-jun#3262)

Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died
at startup_failure: a workflow_call cannot grant its callee more than the
calling job holds, and dev-version-bump.yml's job declares contents+pull-
requests write. lidge-jun#3129 wired the call but never dispatched a release, so this
is its first live run. The caller job now declares exactly the callee's two
permissions; no other job in release.yml gains anything.

Co-authored-by: jun <jun@lidge.dev>
(cherry picked from commit 7ce0ba5)

* release: set preview channel version 2.48.0-preview.20260908

* release: set main channel version 2.48.0

* chore(release): promote 2.55.0-preview.20260914 to preview

Promotes the dev product snapshot 62f0222 to the preview train.

The 2.55.0 line carries the lidge-jun#4546 cost-guard work: one send budget per logical request with a
shared final-recovery reserve, zero-is-zero refusals with a typed error rather than a synthetic
502, compact and the Kiro inner retries admitted against that budget, a finite send ceiling per
root workflow with an interactive reserve a fan-out cannot take, and a healthy detour promoted on
transient-hold expiry instead of released cold.

The previous preview tip 2.54.0-preview.20260914 is already tagged and published and is outranked
by v2.54.0, so it could not be re-released; this is a new candidate rather than a re-cut.

* chore(release): promote the verified 2.55.0 product tree to main

Same product tree as preview 7bdd1b2 / 2.55.0-preview.20260914, which published successfully with its registry smoke green. Only package.json version differs.

* fix(kimi): update Kimi coding registry for K2.8 (adjustable thinking, 1M context, current alias default)

- kimi-for-coding is the stable subscription alias Moonshot re-points at each
coding release; it now routes to K2.8 Preview. Live GET /coding/v1/models
lists only kimi-for-coding[-highspeed], k3, k3-256k; the k2.x ids are
retired from the subscription endpoint.
- K2.8 accepts the same adjustable low/high/max thinking ladder as k3
(verified live: 350K-token request accepted at max effort; upstream
rejects beyond 1,048,576 with 'model token limit: 1048576').
- Bump kimi-for-coding context window to the verified 1M ceiling and
advertise text+image input.
- Default kimi / kimi-code presets to kimi-for-coding instead of the
retired kimi-k2.7-code.
- Update provider-registry parity test to match the new verified shape.

* fix(kimi): retire k2.x ids from the coding picker and migrate saved configs to kimi-for-coding

Address review on lidge-jun#5403:

- MODEL_RENAMES gains kimi/kimi-code entries mapping the retired default
  kimi-k2.7-code to the live kimi-for-coding alias, so saved configs keep
  working after Moonshot removed the k2.x ids from the subscription endpoint.
- The kimi/kimi-code presets seed only ids the endpoint still serves (live
  /coding/v1/models: kimi-for-coding, k3). Every preset metadata list is
  live-id only: seeding a retired id there re-armed the rename migration on
  every boot (lidge-jun#5066 shape), because the residue guard cannot skip a list that
  holds the retired id without the live alias.
- KIMI_CODING_MODELS is replaced by KIMI_CODING_LIVE_MODELS built from
  KIMI_CODING_K3_MODELS + KIMI_CODING_K28_MODELS, so a future alias added to
  the K28 constant flows into the picker and every parallel record.
- Parity tests now assert defaultModel is kimi-for-coding for both presets
  (a registry rollback to the retired default would otherwise pass silently).

Verified: bun test on model-rename-migration, provider-registry-parity and
codex-catalog (432 pass), full tests/providers sweep (only pre-existing
proxy-environment timeouts fail, identical on the clean base), tsc clean.

* docs(kimi): document the K2.8 coding refresh in the providers guide

Address the CodeRabbit finding on lidge-jun#5403: the kimi row in the canonical
English providers guide (and the zh-cn translation) now documents the
kimi-for-coding default, the 1M context window, the adjustable
low/high/max ladder (default max), image input, and the automatic
kimi-k2.7-code migration on upgrade. Verified with the required
validation: cd docs-site && bun install --frozen-lockfile && bun run
build (497 pages, exit 0).

* fix(kimi): repair saved K2 coding metadata

* fix(kimi): drop the stale no-reasoning classification even when only the replacement id is present

Address the CodeRabbit finding on the maintainer's 608d7a2: a saved row can
carry kimi-for-coding in noReasoningModels while every retired id is already
gone from the row (the pre-K2.8 registry seeded the alias there). The early
return in dropRenamedIdsFromList required the retired id, so the stale
classification survived and kept the reasoning picker disabled for the live
alias. Proceed when the list contains either id and filter both.

Verified: model-rename-migration + provider-registry-parity 101 pass, tsc
clean; new regression test covers the replacement-id-only row.

* fix(kimi): preserve explicit reasoning overrides

---------

Co-authored-by: JUN <bitkyc08@gmail.com>
Co-authored-by: jun <jun@junui-MacBookPro.local>
Co-authored-by: jun <jun@lidge.dev>
Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com>
Co-authored-by: t <a@b.com>
Co-authored-by: JUN <jun@lidgeai.com>
Co-authored-by: panyuanyuan <panyuanyuan@hetao101.com>
…ations (lidge-jun#5417)

* docs: fold the desktop app into a beta section and record install locations

* docs: resync translated READMEs for desktop beta
…le publish path (lidge-jun#5405)

* feat(release): add the pre-publication asset verifier

The release pipeline verified checksums and generated the updater manifest
inside attach-release, a job that runs after publication and is skipped on
dry-run. The guarantee that gives is "packaging finished before publish";
the guarantee a release needs is "everything about to be published was
verified valid before publish".

desktop/scripts/verify-release-assets.ts is the verification authority.
It derives the expected platform file set from the release workflow's own
packaging matrices and the producer tables (build-standalone targets,
collect-release-assets bundle names), verifies every recorded checksum
against the bytes on disk with the bare-name rule the flat verification
directory requires, verifies every updater signature cryptographically
against the minisign public key pinned in tauri.conf.json (pure Ed25519
"Ed" mode, the form the Tauri bundler emits; the prehashed "ED" mode
fails loudly rather than mis-verifying), generates the updater manifest
and parses it back against the files it names, and writes a
machine-readable receipt that a later stage can require.

bundlesByTarget and platformFiles are exported from their owning scripts,
and the standalone target set, archive naming, and executable naming move
into scripts/standalone-targets.ts, which the builder and the verifier
share — a target added to one side without the other fails verification,
not the release. Unit tests in
release-desktop-scripts.test.ts cover the derivation against the real
workflow, checksum acceptance and the three refusal modes, signature
verification with real Ed25519 fixtures (tampered payload, foreign key
id, unsupported algorithm), and the full flow including the receipt.
They were reviewed statically and are first executed by hosted CI.

* feat(release): verify everything before publication and add a resumable publish path

The pipeline now has a verify-release job between packaging and
publication. It downloads the packaged artifacts, runs the verifier over
them — expected platform set, checksums, updater signatures, manifest
generation and parse-back — and publishes the verified bundle plus the
verification receipt. publish waits for verify-release instead of
verifying nothing, and attach-release downloads the verified bundle and
refuses to upload unless the receipt names this run's version and
commit. Checksum verification and latest.json generation moved out of
attach-release into verify-release, so they now run on dry-run too: a
dry run proves the same chain a real release relies on.

npm and GitHub are not published atomically, so a run that acknowledged
npm publication and failed afterwards needs a path that completes the
GitHub side without republishing. The new resume-after-npm-publish
dispatch input is that path: the preflight requires the version to
already exist on npm and refuses to combine with dry-run, the publish
step skips npm publish while still emitting the publication receipt the
downstream steps gate on, and release creation is idempotent so a
release left behind by the failed run is reused for attachment. A
successful publish records these recovery instructions in the job
summary at the moment they matter.

The workflow-contract tests assert the new ordering graph, the absence
of verification steps in attach-release, the receipt gate's ordering
before the upload, and the recovery branches; the publish-needs
assertion in ci-workflows.test.ts follows the new graph. Release
automation changed, so this carries the explicit security review the
repository requires: no permissions blocks change, no secrets are added
or re-scoped, and verification (commit 1) is reviewable separately from
publication ordering and the recovery input (this commit).
…lacement (lidge-jun#5406)

* fix(service): make ownership state crash-safe and consent-bound

Use one default-home authority with an active-home compatibility mirror, token/PID/process-instance locks, fsynced atomic replacement, mirror-first deletion, and authoritative recovery after partial commits.

Bind ownership grants to the exact approved owner/install/generation/revision and to re-observed managing-CLI compatibility. OpenCodex 2.60.x, unknown managers, and registrations without protocol 1 remain guests.

Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; the included regressions are for hosted CI.

* fix(update): fence replacement and restart with ownership leases

Split package replacement, runtime stop, and service restoration authority. Unknown and desktop ownership now block package replacement; Node and Bun read the same authoritative state observations.

Hold a shared mutation lease from final subject and liveness validation through replacement, and through dashboard restart. Direct bind takes the same lease, while repair children join by an exact live token.

Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier.

* docs(structure): define authoritative ownership and takeover compatibility

Record the authority/mirror commit protocol, consent subject precondition, managing-CLI compatibility floor, independent update authorities, and shared replacement/start lease.

Local structure checks were NOT RUN by instruction; hosted CI is the verifier.

* fix(service): recover incomplete locks without poisoning delegates

Reclaim empty or partial state locks only after the stale grace and dead-PID proof. Canonical delegated mutation tokens are consumed from child environments, cached only while the exact parent lease remains live, and discarded before fresh acquisition.

Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier.

* fix(service): make lease cleanup intent explicit

Keep token-specific stale recovery as the owner of uncertain descriptor, owner-file, directory, and release cleanup paths so deterministic hygiene accepts the deliberate best-effort boundaries.

Local checks were NOT RUN by instruction.

* fix(service): align typed evidence with shared record selector

Cast the service-owned evidence union at the shared plain-ESM selector boundary; both carry the same validated record shape, while TypeScript correctly rejects the missing index signature without the explicit boundary cast.

Local checks were NOT RUN by instruction; this fixes the exact hosted typecheck diagnostic.

* test: isolate corrupt authority and follow shared state paths

Reset the corrupt-authority fixture before exercising valid-authority mirror recovery, and point the updater source oracle at the shared active/default path resolver instead of an inlined filename literal.

Local tests were NOT RUN by instruction; this fixes the exact hosted shard failures.

* test(update): follow the reconciled install-state facade

Point the Node launcher, Bun state reader, and source oracle at the install-state-contract surface landed on dev, while keeping one state-record authority implementation underneath it.

Local checks were NOT RUN by instruction; this fixes the exact hosted shard diagnostic.
…idge-jun#5410)

On Linux, bun run build:local asked for appimage,deb in ONE tauri
invocation. When AppImage bundling failed (linuxdeploy missing a host
dependency), the invocation died and the deb was never attempted — a
contributor following the README got zero artifacts and an error that
named a tool they never invoked (observed on a real GNOME desktop,
devlog plan 260921 / 120_install_verification.md).

Each format now builds in its own invocation, every format is
attempted, and the summary reports each outcome beside the artifacts
that did build; the exit code is non-zero when any requested format
failed. A failing format is retried once with --verbose: at the
bundler's default log level the error is a bare "failed to run
linuxdeploy" with the tool's stderr discarded, and the verbose pass is
the branch where those diagnostics reach the terminal.

The release workflow builds its artifacts on its own runner image and
is untouched.
Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
Co-authored-by: Ingwannu <ingwannu@users.noreply.github.com>
…ater table (lidge-jun#5425)

dev went red at the union of lidge-jun#5405 and lidge-jun#5391: lane F made the deb a second
Linux updater target, so the verifier's derived expected set gained
OpenCodex-<version>-linux-amd64.deb.sig, while the test's hand-written
oracle still described the earlier world where only the AppImage was
signed. Each branch was green alone; the merge was not.

The fix is derivation, not list-keeping. The signed set and the manifest
platform list in the fixture now come straight from platformFiles — the
table that decides which bundles carry the updater key — and the produced
payload list comes from the shared standalone target module and the bundle
table. A future updater target changes both sides of the assertion by
itself. The derivation test keeps its concrete payload anchors (a renamed
or dropped bundle should still fail for a human to review) and asserts the
rule instead of the roster: a bundle's signature is expected exactly when
the updater table names it.

Only the two test oracles changed; the verification ordering (checksums,
signatures and the manifest all precede publication) is untouched.
* test(service): assert ownership parser behavior

* fix(service): fence runtime start and stop ownership

* fix(update): hold runtime authority through replacement

* docs(runtime): record ownership mutation boundaries

* test(cli): follow transactional start prewarm

* test(cli): anchor fenced start refusal

* test(update): assert lock boundary behavior
…e does not fork execPath (lidge-jun#5418)

* fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cli): prove absence on every loopback host and see refusals inside AggregateError

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: retrigger cross-platform run (macos 1/2 shard hit the 20-minute runner timeout)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(cli): keep a mixed aggregate out of the absence proof

---------

Co-authored-by: jun <bitkyc08@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Live verification 260921: k3-256k is the same K3 served under the
explicit ceiling id - the same 988-token scaffold and identity answer as
bare k3 on the same input. The subscription endpoint lists it alongside
kimi-for-coding[-highspeed] and k3, but the opencodex picker and the
expected-prices overlay only knew k3 and k3[1m], so usage logged under
k3-256k showed as unestimable.

- KIMI_CODING_K3_MODELS gains k3-256k, so the picker, context windows
  (262_144, the advertised ceiling), reasoning ladder and locked-parameter
  lists all derive it automatically.
- expected-prices gains kimi/kimi-code entries at the same KIMI_K3 rate
  (input 3 / output 15 / cacheRead 0.3), sourced as verified-derived with
  the live probe note.
- Parity and overlay-membership tests updated for the new id.

Verified: provider-registry-parity + codex-catalog + usage-cost +
model-rename-migration 539 pass, tsc clean.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added enhancement New feature or request intake: hygiene-blocked Deterministic PR hygiene checks failed labels Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Deterministic hygiene checks failed.

  • unsponsored_surface — This changes an authentication, workflow, release-automation, or dependency surface. MAINTAINERS.md requires security review for these; ask a maintainer to apply maintainer-sponsored once they have reviewed it. Paths: .github/workflows/ci.yml, .github/workflows/desktop-installed-gate.yml, .github/workflows/release.yml, .github/workflows/service-lifecycle.yml, package.json, src/cli/account-auth.ts, src/oauth/index.ts, src/oauth/login-cli.ts, src/server/auth-cors.ts, src/server/management-api.ts, src/server/management/oauth-account-routes.ts.

@github-actions github-actions Bot changed the title feat(kimi): register k3-256k in the picker and the price catalog [WRONG BRANCH] feat(kimi): register k3-256k in the picker and the price catalog Sep 21, 2026
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • wrong target branch (main); retarget to dev. UI screenshot required. hygiene: unsponsored_surface.

What to do

  • Retarget this PR to dev — all contributions go to dev.
  • Add a screenshot of the UI change to the PR description.
  • Fix unsponsored_surface — This changes an authentication, workflow, release-automation, or dependency surface. MAINTAINERS.md requires security review for these; ask a maintainer to apply maintainer-sponsored once they have reviewed it. Paths: .github/workflows/ci.yml, .github/workflows/desktop-installed-gate.yml, .github/workflows/release.yml, .github/workflows/service-lifecycle.yml, package.json, src/cli/account-auth.ts, src/oauth/index.ts, src/oauth/login-cli.ts, src/server/auth-cors.ts, src/server/management-api.ts, src/server/management/oauth-account-routes.ts.
  • Tick all four boxes in the PR description once you're done (currently 0/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ⬜ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

0/4 boxes ticked.

Its title has been prefixed with [WRONG BRANCH].
This pull request was already a draft. Its draft status will be preserved after every issue above is resolved.
@yuanyuanlove Tick the boxes once your local CI is green, your branch is on the latest dev commit, and every correct Codex and CodeRabbit finding is resolved.

@github-actions
github-actions Bot marked this pull request as draft September 21, 2026 10:10
@yuanyuanlove
yuanyuanlove force-pushed the feature-20260921-kimi-k3-256k branch from 086a0f5 to 5910394 Compare September 21, 2026 10:16
@yuanyuanlove

Copy link
Copy Markdown
Contributor Author

Closing as duplicate: the same change is already open as #5447 with the correct base branch (dev). This one was mistakenly targeted at main, which pulled the whole dev history into the diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request intake: hygiene-blocked Deterministic PR hygiene checks failed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants