Mechanism-level details behind the three usage options in the README. The README keeps each option concise; the "how it actually works" material lives here instead of between the options.
Context-overflow recovery distinguishes the upstream context window from the rejected request's total, input-message count, and completion reservation. The parser accepts both the OpenAI/DeepSeek parenthesized split (requested N tokens (M in the messages, C in the completion)) and the SGLang/LiteLLM colon-separated split (requested a total of N tokens: M tokens from the input messages and C tokens for the completion). Explicit input counts feed the existing calibration ring; completion counts arm the existing output-headroom reservation. A window-only or incomplete error provides no invented token measurements. This parsing is shared by plugin and proxy modes and does not change the declared model window.
Fork adoption judges retained compression blocks against the child replay, rather than the source's last active flags. A short source request may temporarily deactivate a fold whose complete original span returns in the child. Fully present retained spans are copied as active blocks; explicitly expanded blocks, consumed tier children, and spans crossing the fork boundary are not independently activated. Tier children accompany an adopted ancestor as inactive records. Planning and adoption never mutate the source session.
At load the plugin spawns its own proxy (or attaches to a healthy
running one — a parent-pid watchdog tears it down when the client exits),
rewrites model traffic to <proxy>/bili/<upstream-url>, registers
compress / decompress / acp_status as native client tools (plugin
mode), and binds the /acp panel to the current session. It also reports
the client's own model config to the proxy (runtime-info protocol,
#955) so compression budgets use the real window instead of a registry
guess. Opt-out envs: BILI_NATIVE_PI=0, BILI_NATIVE_OMP=0, BILI_NATIVE_OPENCODE=0, BILI_NATIVE_DSH=0, BILI_NATIVE_KIMI=0, BILI_NATIVE_HERMES=0, BILI_NATIVE_ZCODE=0.
A native hook may attach to an already-running proxy instead of spawning its own — only when it passes the lifecycle gate below. Reuse is identity-based (#1225): an existing proxy is attached only when it runs the same code (sha256 of the entry script, recorded in the instance file), its lane is compatible — each launcher declares its client's lane, two different declared lanes never share, and an instance without a declared lane is a manually started user-zone daemon, wildcard-compatible on that axis — and it passes the lifecycle gate (§below): an armed parent-pid watchdog (watchdog.armed == true), i.e. it was spawned by a launcher with a parent pid and dies when the last attached session dies, or it is a user-zone instance (no lane, no launch token), which is exempt by definition (#1660). Instances written before #1225 carry no code fingerprint and are therefore never attached: a rebuilt or updated install always starts a fresh proxy on the next launch, so fixes take effect immediately instead of silently serving stale code.
| Listener | Attaches? | Why |
|---|---|---|
| Proxy spawned by this session | ✅ | armed at birth |
| Another session's armed shared proxy (watcher set, #1186) | ✅ | sharing is by design |
Manually started bili start daemon (user zone, #1660) |
🔶 conditional | user-zone instance (no lane, no launch token): an armed one (a managed shared proxy, #1225) is ridden by any compatible client lane; an unarmed manual one is, by default (attachPolicy: own-lane, #2877), NOT ridden by a LANEd hook — it spawns its own instead. Ridden instead under attachPolicy: global-fallback (pre-#2877) or attachExternal=1. You own its lifetime and version either way |
| Unarmed lane'd proxy (crashed-session orphan; pre-#1330 unverifiable) | ❌ loudly refused | lifecycle-drift symptom (#1335): riding it would silently reuse a proxy no live session owns |
The hook probes each candidate's /__bili/health before attaching: armed → attach and register a watcher (unchanged); an armed user-zone candidate attaches regardless of the lane-affinity policy (a managed shared proxy, #1225); an unarmed user-zone (manual) candidate attaches only under attachPolicy: global-fallback (pre-#2877) or attachExternal=1 — by default (own-lane) a LANEd hook spawns its own (#2877); an unarmed lane'd candidate (a crashed session's orphan, or a pre-#1330 build that reports no watchdog field at all — unverifiable, treated as unarmed) is refused loudly, and the session spawns its own proxy in the self-managed port zone (base 18787, or the lane's sticky drift record; armed at birth, dies with the last session, #1186 watcher semantics). Version skew stays fixed: the code-fingerprint check runs first, so a rebuilt or updated install never rides a stale daemon even through the user-zone exemption. Cost: one extra short-lived proxy process per session when nothing attachable exists (session state is shared on disk, so compression continuity is unaffected); the multi-instance warning (#394) becomes correspondingly more common. Escape hatch: native.attachExternal: true in the config file or BILI_NATIVE_ATTACH_EXTERNAL=1 extends attaching to lane'd listeners regardless of watchdog state (including pre-#1330 builds) — you then own those daemons' lifetimes and versions; attachExternal governs only the lifecycle gate for lane'd daemons (it does not open or close user-zone attachment — that axis is attachPolicy); attachUrl (env BILI_MCP_PROXY / --origin) pins an explicit target origin. Explicit user-directed attaches (BILLION_CONTEXT_ATTACH / preset BILLION_CONTEXT_PROXY for kimi/dsh) bypass discovery entirely and are exempt by construction.
Attach discovery is lane-aware across all live instances (#1232): the launcher probes every live entry in the instance registry, not just the single instance file (last-writer-wins — under concurrent multi-client use it can point at another client's proxy), and applies the gate above to every candidate. Among compatible candidates the newest instance with the launcher's own declared lane wins; an instance without a lane (a user-zone daemon) is wildcard-compatible on the lane axis and gate-exempt by default (#1660) — but whether a LANEd hook actually falls back onto it is governed by attachPolicy: own-lane (default) does not ride an unarmed manual daemon (#2877), while global-fallback restores the pre-#2877 wildcard ride. The another bili instance is running warning (#394) is lane-aware too: it fires for same-lane or lane-less coexistence, but stays silent between two different declared lanes, whose session files are disjoint.
A reused proxy keeps its own process-local resolver state (#5222). Attach/reuse hands the session to a long-lived process; after an OS-level network change (WSL/VPN switch, MagicDNS takeover) that process's libuv thread pool — the one shared by dns.lookup AND undici's default connector for outbound connects — can wedge so every lookup times out inside it while other processes on the same machine resolve fine. Restarting the host (e.g. DSH web) does NOT refresh it: the instance registry silently re-adopts the same pid. Remedies: restart the PROXY process (kill the pid recorded in ~/.local/state/billion-context/instances/), or route egress through an upstream proxy (-F / BILI_UPSTREAM_PROXY, which skips local-DNS admission entirely). Since #5222 tunnel admission also self-defends: when the main-pool lookups exhaust their retries, ONE probe on a fresh worker-thread pool runs before the unresolvable verdict, so a wedged proxy stops false-denying healthy destinations (admitted via the worker result, with a loud warn log telling the operator to restart the proxy for full recovery — outbound connects still share the wedged pool until then). Phase 2 (owner decision 2026-10-31: detect + surface, never self-restart) adds a persistent-wedge detector fed by the admission MAIN-pool outcomes only (the worker probe mitigates admission but does not heal the pool): only THROWING lookups count as failures (an authoritative empty answer proves the resolver reachable), and the gate — at least two distinct hosts this process previously resolved OK, each now failing continuously for ≥5 min (WEDGE_MIN_HOSTS / WEDGE_SUSTAIN_MS in src/resolver-wedge.ts; tracker FIFO-capped at 256 user-supplied hosts) — drives the web panel banner, the additive resolverWedge field on /__bili/status + /__bili/overview (null while healthy), an acp_status alarm line, and a warn log line re-logged every 10 min while the episode persists; process behavior is unchanged.
Every bili instance on a host reads and writes the same per-host
storage: the XDG data dir (~/.local/share/billion-context/ — session
records, CCR content-store, prefix-affinity) plus the state dir
(~/.local/state/billion-context/ — log, instance registry). The control
plane is lane-aware (#1232:
attach discovery and the #394 coexistence warning both respect declared
lanes), but the data plane is not partitioned — there is no per-session
owner and no per-lane isolation on disk. Two consequences follow:
- Cross-instance session visibility. Each instance's Web UI
(
__bili/sessionslist / detail / logs) re-scans the whole shared store, so any instance reachable over loopback can enumerate and read any session — raw messages, content-store payloads, compressed blocks — created by any other instance/lane on that host. - Restart drain race. On a restart the new process hydrates the store before the old one finishes flushing, so last-writer-wins can drop the old process's final writes: lost tail updates and a provider prefix-cache bust (the outbound body diverges from what the provider had cached). The #1724 mitigations: the #405 snapshot-counter guard (rejects stale session writes), the prefix-affinity union-on-write guard (#1737: one instance's flush never clobbers a sibling chain), and the self-restart ordering fix (#1742: durable state is flushed to disk before the replacement spawns). Host-driven restarts (dsh et al., #991) still rely on these data-layer guards, since their kill/spawn order is not bili-controlled.
Security posture: the shared-state surface is protected only by the admin endpoint's loopback gate (non-loopback source addresses are refused) plus filesystem permissions on the user's home tree (both dirs live under $HOME) — there is no per-session authorization. For a single-user host that is sufficient. On a multi-user host it is not: any local account able to reach the proxy's loopback port can read every session of every user. Such hosts must partition their per-host storage (per-user/per-lane subdirectories) — the root fix named in #1724 (direction #1), still open as an architecture decision; Web UI scoping (#1724 direction #4) reduces cross-instance browsing but does not change this boundary.
A native plugin lives inside the client process, so it can read the model config the client itself will use. It pushes that truth to the proxy on two channels, and the proxy prefers it over the models.dev registry / built-in table in the context-window chain:
| Channel | When | Fields |
|---|---|---|
Per-request headers (gated on x-bili-plugin) |
every model request | x-bili-plugin-context-window, x-bili-plugin-max-output, x-bili-plugin-model |
POST /__bili/plugin/runtime-info (loopback) |
plugin bootstrap + any reported-config change | {agent, model, contextWindow?, maxOutput?, baseURL?, conversationId?, source} |
Resolution order for the window: anthropic-beta negotiation > per-request
plugin header > runtime-info > launcher env > route config > models.dev
registry > built-in table. The runtime-info step reads the per-agent
entry when the request carries an x-bili-plugin header (agent+model must
match); requests without one resolve the conversation-scoped entry
recorded with a conversationId, keyed by the same conversation signal the
session binds on (client conversation header, custom session header, or the
body's prompt_cache_key) — model must match either way (#1531: omp stamps
prompt_cache_key but no plugin header, and main/subagent sessions share
the agent name while running different models). A reported maxOutput only
stands in when the request body carries no output budget of its own.
Implementations: src/agent/pi.ts (covers pi and omp),
src/agent/opencode-native.ts (v1), src/agent/opencode-v2.ts,
src/agent/dsh-native.ts, src/kimi/native-mcp.ts (bootstrap-time report
only — kimi's provider custom_headers are static, so per-request headers
would go stale on model switch), hermes-plugin/__init__.py (Python plugin:
per-request headers via an llm_request middleware, max output and the host's
per-model context window — read from hermes' own
HERMES_HOME/context_length_cache.yaml, fail-open on any miss (#3261) —
captured by a pre_api_request hook) — other client integrations should
follow the same protocol.
DSH maxOutput reporting is declaration-gated — an undeclared custom model empties the whole ceiling chain (#2904). src/agent/dsh-native.ts forwards maxOutput only when the host's resolveModelInfo(provider, model) returns a positive defaultMaxTokens, and DSH populates that only when the model item explicitly declares maxTokens — provider-level output defaults are never reported. For a user-added custom model with no declared maxTokens, defaultMaxTokens is undefined, so BOTH runtime-info channels come back empty. With no models.dev entry and no bili-configured output either, the four-source ceiling chain (#1840) resolves to nothing, and restoreOutputBudget lifts a client-starved budget to the session high-water mark — which on a long session has decayed through small positives to a poisoned low value. Every turn then runs reasoning-only and stops at length/max-tokens; switching to a model WITH a known ceiling masks it (the high-water is floored up to the ceiling), so the switch is a trigger, not a cause. The real fix home is DSH-side: report its configured provider/model output default instead of undefined (the immediate user workaround is setting maxTokens on the model item). bili deliberately does not guess a quota floor (#1842); instead #2904 surfaces the degraded state persistently on the /acp panel, the Web UI session page (data source /__bili/sessions/<id>/detail), and /__bili/plugin/status?conversationId= (outputBudgetDegraded) — clearing automatically once a healthy value or ceiling arrives — and extends degenerateTurnWarning to length/max-output terminations so the truncation correlates with its cause in the log.
omp: the prompt_cache_key stamp is withheld when OMP's Cowork transport owns egress (#2583/#3556). In native omp mode the stamped prompt_cache_key is the only per-request session signal, and its sole consumer is bili's own fetch-layer interceptor (it rewrites model URLs to <proxy>/bili/<url> and strips the field before forwarding). Recent OMP builds ship a "Cowork transport" (introduced 2026-07-30, can1357/oh-my-pi 642892ae; proxy handling e22a5ff2) that sends default anthropic/anthropic-messages requests over node:https DIRECTLY — they never touch globalThis.fetch unless an explicit proxy env var (PI_PROXY / PI_PROXY_ANTHROPIC) pushes them back onto Bun's fetch. Since #3556 the bili omp launcher closes this gap itself: unless the user has set PI_PROXY / PI_PROXY_ANTHROPIC, it exports PI_PROXY_ANTHROPIC=<proxy origin>, so on the default launcher lane Cowork rides the compressible lane (the stamped key consumed and stripped by the proxy) instead of dialing direct. The withhold also gates the cert-MITM lane — #3556 hoisted it above the destination-routed check, where a nested placement skipped it entirely. Stamping there would ride the field verbatim into Anthropic's strict schema (HTTP 400 prompt_cache_key: Extra inputs are not permitted). The stamp gate (stampPromptCacheKey → ompCoworkTransportBypassesFetch in src/agent/pi.ts) mirrors OMP's own per-model egress selection: proven-direct traffic is unstamped, with one loud warning per process per model. Consequence: those direct requests go uncompressed and anonymous to the proxy (identity/panel/billing degrade — the same trade as #1403); every other lane (launcher /bili/ rewrite, MITM whitelist, other providers/apis, proxy-configured hosts) is unchanged. Known residual: a NO_PROXY/no_proxy rule matching the model host silences OMP's proxy handling even when PI_PROXY / PI_PROXY_ANTHROPIC are set — the request then dials direct while still stamped (the same 400 class); remove such hosts from NO_PROXY instead. The reverse asymmetry is fail-safe: OMP's shape-based fetch fallbacks (Request-object input, non-record headers, unsupported body) route back onto globalThis.fetch even with no proxy envs, where a withheld stamp only degrades identity and never hard-fails. Full coverage would require a host-side change (route Cowork egress through globalThis.fetch) — boundary note in #2583.
The launcher env tier covers pure-proxy clients (no in-process plugin):
bili <client> reads the client's own model config at launch
(model_context_window / model_max_output_tokens for codex,
contextWindow / maxTokens for pi / omp, limit.context / limit.output
for opencode, maxInputTokens / maxOutputTokens for codebuddy) and hands
it to the proxy via BILI_LAUNCHER_MODEL_WINDOWS / BILI_LAUNCHER_MODEL_MAX_OUTPUTS
(#971). A plugin report — when present — always outranks it.
Before the first model request there is no session yet, so the /acp panel
probes GET /__bili/plugin/status?conversationId=<agent>&fallback=latest,
which answers from the runtime table (phase: "pre-first-request") instead
of 404ing — the reported config is visible immediately, and the real session
takes over once traffic lands.
Claude Code has no in-process extension point, so bili plugin install claude writes a managed block into ~/.claude/settings.json (env
ANTHROPIC_BASE_URL=http://127.0.0.1:<port>/bili/<upstream>,
DISABLE_AUTO_COMPACT=1, and a SessionStart hook) plus the same
user-scope MCP shell as before. The hook (fired before claude's first model
request) resolves its port like every lane (#1660): an explicit pin
(BILI_CLAUDE_NATIVE_PORT > config claude.nativePort) launches
strict-port on that exact port (a squatter is refused loudly, #964
preserved); otherwise it rides the self-managed zone — the lane's sticky
record else base 18787 — non-strict, with the child's EADDRINUSE +1
ladder resolving collisions and the settled port recorded sticky. One
exception to the ladder (#1723): when the holder of the lane's port is a
same-lane instance running a different build (the upgrade-restart
overlap — the old version still draining), the child waits for it to release
(up to 5s) and rebinds the same port instead of drifting; a holder that
never leaves exhausts the wait and gets the plain ladder as before. After the
proxy is up the hook re-pins the managed ANTHROPIC_BASE_URL to the live
origin each session (repinClaudeManagedBaseUrl), so a hopped port
self-heals on the next launch and the baked URL never stays desynced from
the running proxy. Upstream override: BILI_CLAUDE_UPSTREAM (or the
existing claude.anthropicBaseUrl config). Install no longer persists
claude.nativePort. Opt out with BILI_NATIVE_CLAUDE=0 — the hook then
brings up a passthrough proxy on the same resolved port (verbatim
forward, compression off) so claude keeps working. The block is pure JSON
merge/strip: foreign keys are never touched, bili plugin remove claude
restores exactly. bili claude still works on a machine with the native
block installed — it overrides the static URL with its own proxy and the
hook stays dormant.
bili never owns user data: every launched client runs on its real home, so
runtime writes land where the user expects them. When pointing a client at the
proxy, the launcher picks by priority — env vars first (proxy/CA envs for
hermes/dsh/codex; the BILI_PROVIDER_REWRITES URL manifest for pi/omp,
consumed by their extension's registerProvider at load), then CLI flags or
extension APIs (codex -c key=value, opencode plugin), and generated files
last — today only opencode's temp opencode.json (deleted on exit) and dsh's
loopback exception: dsh's fetch stack bypasses proxy envs for loopback targets
unconditionally, so local upstreams keep the persistent ~/.dsh-bili overlay
rewrite until dsh gains a settings-path env or an upstream loopback opt-out.
Overlay dirs created by older versions are left in place and never merged back
into the real home.
The proxy runs in one of two modes, and the mode decides who executes
compress, which in turn decides how the summary travels to the model (the
"carrier"). This distinction is the root of #377.
Launcher / plugin mode (bili pi, bili codex, …) |
Proxy mode (plain client → /bili/) |
|
|---|---|---|
| Client | ACP-native agent with the bili extension (pi/omp) | Any OpenAI/Anthropic client, no extension |
Who executes compress |
The agent (pi runs it locally) | The proxy (server-side compress loop) |
compress tool call in the re-sent history? |
Yes — part of the agent's own conversation | No — ephemeral proxy-loop traffic |
| Preflight blocks (no tool call)? | Last-resort backstop — the agent normally compresses on its own compress calls, but src/preflight.ts still fires (in both modes) when the input alone exceeds the window (#470) |
Yes — src/preflight.ts compresses behind the client's back |
| Summary carrier on the wire | the compress tool call |
an acp_summary user message |
| System messages on the wire | always exactly 1 (client + prompt) | always exactly 1 (client + prompt) — summaries ride on user messages |
| SGLang "single system" 400 (#377) | cannot happen | cannot happen (summaries are user messages, not system) |
Proxy-injected compress tools |
none — the agent registers the 4 ACP tools natively | the 4 context tools (when enabled) |
| Proxy-injected nudge | yes — the agent has no nudge channel of its own, so the proxy-side nudge is the proactive compression trigger (preflight alone only fires at the hard limit; #451) | yes (when enabled) |
Why the carriers differ. In plugin mode the agent owns compression: the
compress call + result live in the agent's own history and are re-sent every
turn, so the summary rides on the tool call and the agent's view never renders
the kernel's acp_summary fallback (billion-context-pi src/messages.ts
skips acp_summary_*). In proxy mode the client is not ACP-native, so the
proxy executes compress server-side; the tool call never enters the client's
history, and preflight blocks have no tool call at all — so the kernel's
acp_summary message is the only carrier. The kernel renders it as role
system, but strict OpenAI-compatible backends (SGLang) require exactly one
system message at index 0, so systemToUser (src/util.ts) re-voices it as a
user message, leaving it at its anchor position. This keeps the head system
message (the prefix-cache anchor) byte-stable across compress turns, so a new
block does not invalidate the whole-conversation prefix.
Why user, not system or a forged tool call. A mid-stream system
message is what SGLang rejects (#377). A forged compress tool call would be
the "pure" carrier, but in proxy mode it requires fabricating an
assistant tool_calls + user tool_result pair by id, declaring the tool in
the request, and handling preflight blocks that have no authentic call — far
more invasive than re-voicing a standalone note. A user message is allowed
anywhere in the conversation, so it is the minimal change that satisfies both
SGLang's one-system rule and prefix-cache stability. The accepted trade-off:
a summary is a stand-in for the folded history, and re-voicing it as a user
turn is a semantic mismatch the model tolerates (it is clearly marked
[Compressed conversation section]).
Do the two modes coexist?
- Same proxy instance: yes, by design. One proxy serves plugin and plain
clients at once;
pluginModeis decided per request (x-bili-pluginheader) and bound per session (session.metadata.pluginAgent). The launcher reuses a running proxy. - Same session: the mode is sticky. A session created in plugin mode stays plugin mode (metadata inheritance); a plain session can only be upgraded to plugin mode if a plugin request arrives with a matching conversation id (the header outranks) — and never downgraded. In practice a plain→plugin upgrade requires the plugin client's conversation id to match an existing plain session id, which doesn't happen (each client generates its own id).
- Cross-mode block hazard: theoretical only. It would require the same
conversation id to span a mode switch. plugin→proxy is safe (the tool call is
in the shared history); proxy→plugin could orphan proxy-created block
summaries (their tool call isn't in the agent's history and the agent's view
skips
acp_summary) — but that needs the id match above, which doesn't occur.
Verifying that a compression actually landed. After executing compress,
the proxy emits a confirmation marker (📦 [ACP] Compressed …) as plain
assistant text — but under sustained context pressure a model was observed
writing that marker format itself without ever calling the tool (#717): 17
fake "compressions" over ~2 hours while real usage climbed to 89%. A marker
line visible in the transcript is therefore not proof of persistence — verify
with acp_status (block count increased, compressible-range start advanced)
before trusting it. As a backstop, the proxy strips any marker-shaped line the
model emits on its own and logs a [marker-echo] warning, and both the nudge
and the injected prompt state explicitly that markers are proxy-emitted only.
On the Responses lane the two modes also project mid-history system/developer
items differently: plugin mode keeps them in place (__bili_inplace_sysdev,
#1638 prefix-cache churn fix) — as of #3227 so do Codex clients in native proxy
mode (UA-detected via isCodexClient); other proxy clients keep the hoist into
the head developer block (#1085 hoist-and-anchor). A self-heal degrade-to-proxy
(#2155) therefore
re-projects the SAME incoming history into different forwarded bytes — the
forwarded-system fingerprint (#2350) flips even though the host changed nothing,
and a naive ledger would book that under "host rewrote the system prompt".
Fix: the ledger now pairs the projection fingerprint (systemFp) with a
mode-independent canonical fingerprint of the raw incoming system/developer
stream (canonicalSysFp, role + content + order, captured before any
projection). At settle time: projection flipped AND canonical stable → cause
projection (its own dimension: pf flag, projSwitches counters, cold window,
report section — the proxy's own doing); canonical also changed → cause
prompt (a genuine host-side rewrite, as #2350 intended); canonical changed
alone (e.g. a mid-history edit while in plugin mode, which never touches the
head block) is recorded observationally (cf) without a billing bucket. Unknown
canonical sides never classify (legacy reading). Each sample additionally carries
its mode (pm) and prepare-responses logs one transition line with the reason
(self-heal degrade / restore / header absent). Only hash, length, mode and reason
are recorded — never the prompt body. No wire behavior changed: both projections
stay exactly as before; the switch is now recorded instead of misattributed.
The native-lane takeover works by patching globalThis.fetch in the host process and rewriting model URLs to {proxy}/bili/{original-URL}. That patch covers exactly ONE thing: calls through the current global fetch. A model client that captured a fetch reference before the plugin installed, uses a non-fetch transport (undici dispatcher, raw http, WebSocket), or runs its turns in another JS realm or process bypasses the patch entirely — and the bypass is invisible BY CONSTRUCTION: the request never reaches the proxy, so bili.log shows nothing for that turn (no session, no forward, no gate refusal). Assignment-based displacement by third parties is different and is NOT silent: since #1158 the guarded accessor logs every foreign install and re-chains it as downstream (logging caps past 16 installs; routing authority never leaves the guard). If the host property is non-configurable or BILI_RECLAIM_FETCH_PATCH=0, the classic direct install applies instead — that posture now logs a one-time warning naming the risk and the /bili/ remedy (#5907).
Because /bili/{absolute-upstream-URL} is just a URL prefix, ANY client whose base URL you control can use it directly — no hook required. Pointing a provider baseURL at http://<proxy-host>:<port>/bili/<upstream-base> is the supported shape for remote agents (configuration.md, host) and the recommended fallback when a lane's hook demonstrably misses turns: compression, billing, gate refusals and logging all apply identically, because it is the same channel the hook rewrites to. Caveat: direct clients carry no plugin attribution headers, so their sessions land in the anonymous (pfa-*) chain and are sized by the window-resolution ladder like any unattributed traffic (#5907).
The Responses-wire adapter (prepareResponses in src/server.ts, buildRequest
in src/loop/adapter-responses.ts) is a stateless replay compressor: it
treats parsed.input as the complete conversation, compresses that, and then
drops previous_response_id unless ACP_KEEP_RESPONSE_ID=1. It keeps no
response-lineage store, so it cannot materialize the history a native-chaining
continuation references.
OpenAI's native chaining (store:true + previous_response_id, server-held
history) and bili's client-replayed model are mutually exclusive paradigms. A
delta-only continuation (just the new turn + previous_response_id, no full
replay) therefore reaches the upstream with neither the reference nor the
referenced turns — prior context is silently lost upstream while the request
still returns 200. That is the mechanism behind #1954.
What ships here (approach A — visibility only). When a non-empty
previous_response_id is stripped, both paths now emit a warn naming the id
and stating the consequence. The trigger keys off the id alone, not off
explicit store:true, because Responses stores responses by default — an omitted
store still leaves the referenced response resolvable upstream, so judging on
store would miss the common case. Approach A changes no forwarded byte and
closes nothing: the delta request still loses its history and still returns 200.
It only stops the loss from being silent.
Observation, not proof. Current builds/tests show codex sends store:false
and never sets previous_response_id, so this is a no-op for codex today. That
is an observation from the existing E2E + traffic, not proof that no client ever
chains — the E2E does not cover the delta-chaining shape. codex is OpenAI's own
CLI against a Responses API that natively supports chaining, so a future version
switching to native continuation to save tokens is a reasonable evolution; until
then this stays a latent high-severity gap.
Safe options for a chaining client today: resend the full input/output
history (bili then compresses/forwards normally), or set ACP_KEEP_RESPONSE_ID=1
to preserve the id — but under KEEP the referenced history lives server-side and
bili's compression/prefix-cache accounting cannot see it. The real fix
(materialize lineage before compression) is tracked as feature #1973. Official
migration guide: https://developers.openai.com/api/docs/guides/migrate-to-responses
Every bili presence on a machine has exactly one writer — the thing that installed it is the thing that updates it, and nothing else ever overwrites that copy in place:
| Lane | Copy lives in | Updated by |
|---|---|---|
global bili |
npm global (npm i -g billion-context) |
bili update / background auto-update |
| pi | pi's package manager (npm form) | pi update — bili never overwrites it |
| opencode | opencode's plugin dir | opencode's plugin manager — bili never overwrites it |
| dsh | each profile's pnpm store | a periodic check re-runs dsh's plugin channel per profile — driven by the global bili self-update or by the profile copy's own proxy when the global isn't running (dsh-market installs, #1196); manual: dsh plugin add billion-context@latest. pnpm's hardlinked store must never be copied over in place |
| omp / claude / codex / kimi / zcode | no copy — entries point at the global bili install | they update together with the global copy |
| hermes | ~/.hermes/plugins/billion-context/ (copied files + bili.json sidecar pointing at the global dist) |
bili plugin update hermes re-copies the files; the sidecar tracks the global install |
This is enforced in code, not just convention: the self-updater
(src/update.ts → hostManagedInstall) detects install dirs under a pnpm
virtual store (.pnpm) or a host agent tree (pi / opencode / dsh / kimi /
omp homes) and skips them; installViaTarball refuses them structurally
so direct callers cannot corrupt a store either. Mixing commands is fine
(dsh plugin add ≡ bili plugin install dsh — same channel, same records);
mixing writers is what the guard forbids. bili plugin update [client]
is the one command that drives every lane through its own owner and prints
the per-lane update path (bili plugin list shows the same per-lane channel).
Three failure shapes share one detector family in src/session-self-heal.ts,
evaluated once per request (after prepare, on the nudge-carrying lanes only;
bypass and side lanes never earn credit):
- D2 zombie plugin lane — the plugin was uninstalled (or its MCP child
died) but already-open client windows keep the session plugin-bound
(sticky
metadata.pluginAgent,src/server.ts). Signal: 5 consecutive rounds (SELF_HEAL_WINDOW) that were nudged, carried nox-bili-pluginheader, declared no bili tools on the wire, and produced no compression reduction. Remedy: degrade to proxy mode — the singlepluginModeflip (pluginAgent !== undefined && !pluginLaneDegraded(session)) re-injects the ACP tools wire-side, re-arms the compress rewriter, CCR stamping and absorb, and keeps the nudge (the tools are back). When wire injection is unavailable (compress.injectTool=false→degradeAvailablefalse) the remedy falls back to suppress-nudge. Recovery: the live plugin header (pluginLaneRestore, hoisted BEFORE thepluginModeflip so the first header-bearing request is already plugin mode — the stickypluginAgentmust NOT be used as the restore signal or the degrade clears one round after arming). The binding is kept, never erased. - D1 nudge idle (non-plugin generalization) — 5 consecutive nudged rounds
with zero reduction and zero bili tool uses. "Nudged" means the text was
ACTUALLY injected — since #3004 the decide gate's verdict is the delivery
fact (
prepared.nudgeOutcome === "injected"); a vetoed arm credits no idle streak (the pre-gate expression armed suppression on channels the model never received). Remedy: suppress the nudge at all fourwillInjectNudgegates (&& !nudgeSuppressed(session), covering the emergency path too). Two bounds keep the suppression from latching forever (#3004): the decide ladder's fallback escape still punches through ONE legacy advisory while suppressed (the failure-recovery path outranks suppression;consumeFallbackresets the counter), and the latch expiresSELF_HEAL_SUPPRESS_CAP_MS(10 min) wall-clock after arming — re-arming then costs a fresh fullSELF_HEAL_WINDOWof idle rounds. Recovery: any compression reduction or bili tool use lifts the suppression early. - D3 gateway-killed summaries — a non-streaming preflight summary that
dies with HTTP 524/504 (Cloudflare class) is not retried in place (the old
transient-retry path burned ~100 s per attempt against a dead gateway);
instead the session learns
metadata.preflightStreamSummary = trueon the first hit and the summary is re-requested as SSE — the same learned store as the manualcompress.streamSummaryknob (#2133), auto-armed. The cascade is honored in both directions: an explicitstreamSummary: falseanywhere (global → provider → model) is the operator's opt-out — neither learn path arms and a stale learned flag is ignored (streamSummaryOffinPreflightDeps).
Observability: session.metadata.selfHeal = { detected, action, since } is
exposed in /__bili/sessions, badged in the web UI ("heal"), and every
transition logs one [self-heal] line (detect + recovery). bili plugin remove <client> best-effort queries the live proxy (instance file →
GET /__bili/sessions, 2.5 s timeout, silent on failure) and prints a note
when sessions of that client were active within the last 10 minutes.
When a native lane's proxy is unavailable, model traffic falls back to direct
sends (directSendOrRefuse, src/agent/native-intercept.ts). That path is a
degraded mode with three contracts, all shared by every lane (the gateless
lanes pi/omp/opencode-V1 had none of them before #4516 — a 1.06M-token raw
history rode straight to the provider):
- Size arm — before ANY degraded send, the body is priced with the same
doom math as dsh's gate (
bodyProvablyOverWindow): stage 1 is the raw byte floor vs the window, stage 2 strips structural media bytes (#4111 direction: media-heavy bodies whose core TEXT fits are NOT refused). The bar never assumes a window belowMIN_ASSUMED_WINDOW_TOKENS(1M, #4079 — declared windows can be wrong-small); bodies in that band are left to preflight. Missing data (nox-bili-plugin-context-windowheader, unreadable body) fails OPEN to the direct send — a local guard must never false-block on its own ignorance. Verdict: 422x-bili-gate: oversize-refusal-fallback, retryable, wire-shaped per upstream dialect, message names floor/core/window and the unavailability reason (bootstrap-failedvsdead— the latter only after the lane held an origin and lost it). - Self-heal — a failed bootstrap no longer means
direct-for-process-lifetime. Each request that finds no live origin fires
the owner's
respawn(singleFlight — concurrent requests share ONE attempt) under a 15 s cooldown, then holds out for the in-flight attempt understate.spawnWaitMs ?? readyTimeoutMs ?? 15_000: the pi spawn lane setsSPAWN_BUDGET_MS + 10 sso a slow cold start (#4515) is waited out instead of refused at the generic cap. Recovery needs no host restart. - Announce — every TRANSITION into/out of the degraded state logs one
line to bili.log (proxy recovered / goes direct / refused), not one line
per request;
state.lastUnavailableReasoncarries WHY into the refusal wording so the user can tell "still starting" from "died".
The opencode V2 resolver lanes (createLiveOriginResolver) keep their own
probe-driven retry; this contract covers the plain intercept path they do
not. Attribution reporting into the panel remains dsh-only (see #4516).
One PR folds the three remediation legs together (refs-run residue #2023, degenerate-close stripping + egress audit net #2190, emission drop + signed-thinking verbatim #2066) under a single set of hard rules that govern EVERY text-rewriting surface (loop adapters, plugin passthrough, JSON strip functions, exit audits):
- The thinking channel is byte-verbatim. Anthropic
thinking, Googlethought, OpenAIreasoning_content/reasoning, and Responses reasoning summaries ride as-is — echo-shaped reasoning included. Rationale: reasoning replay (DeepSeek-style) and signature verification validate these bytes; any rewrite desyncs them and bricks the session (#1960/KDD#10). The adapters route these fields throughcreateIdentityStreamFilter, and the strip functions (stripOpenaiChatText,stripResponsesText) leave them untouched (defense in depth). NOTE: this REVERSES the pre-#1881-era reasoning-echo strip — the thinking channel is no longer cleaned. - Model prose is manageable. Render-tag echoes, marker lines, bili-internal text, degenerate open/close residue, and (when absorb-instructed) whole tool-call emissions are stripped from the visible text channel only.
- Model output bound for files is byte-identical (#1039). Tool-call arguments never enter any filter, whatever bytes they carry.
- User-sent bytes are never rewritten. A verbatim echo of the user's own fragment survives even when stripping is armed (#463 exemption, keyed on the request text, not shape).
The egress audit net (#2248) stays log-only: raw exits (unrecognized frames,
parse failures) forward as-is and log [tag-echo] raw exit so new leak shapes
become visible without changing the wire. Pinned by
tests/unified-acp-invariants.test.ts (eight tests, both lanes).
The ledger books each request against every elapsed fold
(requestsAfter += 1 per sample), and the panel turns that counter into
netSaved = Σ (S − σ) × requestsAfter. Booking is a claim that this
request benefited from the fold — which is only true while the fold's
covered bytes actually ride the resent wire. When a host removes them
outside bili's knowledge (dsh native compaction, #2193), the old counter
kept climbing on a destroyed substrate: the #2193 incident showed
netSaved at 355M while 1510 covered ids were permanently gone.
The fix is writer-side, at the claim site: reconcileFoldCoverage
already inspects every inbound pass for exactly this evidence, so it now
also records per-block presence into
session.metadata["foldCoverageByBlock"] — {p, r, t, e} per blockId
(present verbatim / reclaimed via re-anchor / total covered / ever-present
latch). The ledger links each fold to its block with a sparse
LedgerFold.bid (set once in recordCacheFoldsFromBlocks, the single
choke point for both the eager proxy path and the lazy plugin-mode path),
and scales the increment by the measured fraction (p + r) / t:
- 1 — full coverage, or no usable record → byte-identical to the pre-#2202 counter (healthy sessions and all legacy entries);
- f — partial coverage → proportional accrual;
- 0 — verified coverage-lost → accrual frozen until coverage returns.
Two classes are self-calibrated by the e latch: a fold whose covered ids
were ever seen present is verifiable, so total absence means loss; a fold
whose ids never appeared post-creation is structurally unverifiable
(view-folding hosts that resend folded views instead of raw originals) and
keeps status-quo booking rather than being silently zeroed. Coverage-lost
is live-computed (e === 1 && p + r === 0), so recovery resumes accrual
with no sticky state; one warn/info log fires per flip
(foldCoverageLostLogged dedup key).
Evidence hygiene mirrors the #1195 guard: side-request-shaped passes (< 10 inbound messages — title-gen, WebSearch refinement) take NO evidence at all (no records, no backbone roll, no drift-streak movement, no resets), because a short pass would skew the next pass's alignment and manufacture phantom total-loss.
Panel presentation keeps lost folds out of the headline number: the web sessions view shows them as separate labeled segments (row tooltip, overview sub-line, detail stat) — frozen pre-loss savings stay visible, post-loss growth stops.
OpenAI chat-wire prompt_cache_key: stamp everywhere by default, protect strict upstreams with an A/B probe (#2218/#2645/#3556)
The dsh plugin lane's one identity signal readable by body-keyed relays is the chat body's prompt_cache_key (the winning conversation header x-bili-plugin-conversation is bili-internal and never travels under that name; header-reading relays get x-session-id). Two opposing populations meet there: strict direct APIs reject unknown body params with a 400 — and strictness is per MODEL on the SAME origin (#2645: one NVIDIA-gateway model accepts the key, its sibling rejects it) — while account pools, including remote relay pools, read the key as their prefix-cache routing signal, so dropping it anywhere silently degrades those sessions' cache affinity (the #1403 class inverted). No destination-shape test can arbitrate: a loopback-only gate (the #2649 attempt) fixes the strict APIs but strips the key from the two biggest pool populations (docker-bridge/LAN/VPS/domain-name pools).
Settled design = stamp-by-default + empirical self-heal (probe-and-remember):
- Stamp (
src/session-id.ts→resolveRelayAffinityPck): the internal conversation header present ⇒ stamp<conversation id>on EVERY destination; a client-supplied key passes through verbatim until its pair is ablated, then it is withheld too (so later turns don't re-pay a 400 round trip). Unparseable origins fail open (stay stamped). Zero config surface — no new field, env var, or CLI flag. - Probe (
src/server/relay.ts, same buffered-body seam as the #552 compat ladder): the FIRST 400 on an egress body carrying the key re-sends the identical payload once withoutprompt_cache_key. Retry accepted ⇒ causation proven ⇒ remember theorigin(+model)pair in process memory (src/pck-probe.ts) — in-memory ONLY, no persistence, restart re-probes. Retry also failed ⇒ NOT this field's fault ⇒ discard the retry, forward the original 400 untouched, learn nothing (a 400 has many causes; only the A/B verdict may ablate). One probe per request, ever. - Granularity: origin+model, mirroring #2645's measured strictness — one strict model on a shared pool origin leaves sibling models stamped.
- Visibility (owner requirement): first ablation logs a WARN naming origin/model, the affected session(s), and the account-pool caveat explicitly ("if this destination is your relay/account pool, its prefix-cache affinity may degrade");
acp_statuscarries a section listing current suppressions; the web panel shows a banner (zh/en) with sessions; scripts readpckSuppressionsfromGET /__bili/status. All process-local; a restart clears and re-probes. - Modes: only the plugin lane (registered agent / dsh plugin, internal conversation header present) ever stamps; plain proxy mode stays pure passthrough in both directions, byte-for-byte unchanged.
Tests: tests/issue2645-pck-probe.test.ts (strict upstream self-heals turn 1, remembers turn 2, lenient pool never probes, poisoned upstream forwards the 400 and learns nothing, client-owned keys drop after ablation, model-granular pairs) + the rewritten matrix in tests/issue2218-relay-affinity-signal.test.ts.
Owner ruling (supersedes the #2649 stamp-by-default adjudication): MANY services reject a request carrying this field outright, and that population cannot be enumerated up front — so bili no longer writes its own prompt_cache_key unless asked. The gate is the EXISTING promptCache.routing knob (ACP_PROMPT_CACHE_ROUTING, or the web UI quick-config switch), now governing bili's own emission on BOTH wires at once — no new config surface:
"disabled"(NEW DEFAULT; anything unrecognized parses here): bili never writes its own key anywhere. Client-supplied keys are untouched by this gate — they forward verbatim in every mode (the A/B probe-and-remember above still withholds a client key whose origin+model pair has proven to reject it)."auto": exactly the pre-#5079 behavior — chat wire stamps on every plugin-lane request, Responses wire injects only forapi.openai.com."enabled": stamp/inject on every supporting lane (Responses wire: any host).
Bili-owned vs client-owned on the chat wire: on a plugin lane a body key equal to the bound conversation id can only be bili's own machinery echoing back (the identity promotion that would make a client key win requires the absence of the internal conversation header), so it counts as bili's own emission and is stripped under "disabled"; any other value stays client-owned and forwards. Responses wire mirror: an explicit body key equal to the session id under a non-promoted identity is bili-owned (the omp/dsh extension stamps it); a promoted identity means the client supplied it.
Upgrade notice (owner requirement): instances running "disabled" surface a one-time notice — web panel banner (dismissible, persisted per instance in state dir upgrade-notices.json), an acp_status line, and a startup log line — naming the populations who should flip back on ("auto"/"enabled"): relays such as workbuddy panel/hub and account pools, whose prefix-cache affinity degrades when traffic routes randomly without the key. Direct official-API users ignore it. Dismissal never changes the routing value.
Tests: tests/issue5079-pck-default-off.test.ts (parse fallbacks, both-wire matrices incl. bili-owned echo rows, notice module, live-proxy egress assertions for default-off/auto pairs) + the updated matrix in tests/issue2218-relay-affinity-signal.test.ts.
The "no compressible ranges remain — the context is already at its minimum" stop signal was structurally unverifiable: it fired whenever the PRE-FOLD OFFER was exhausted (postCompressTail, #2404 pinning), but nothing ever compared the union of active-block ranges against the session's ref set. A live ZCode session showed the failure at scale — 138 mid-history refs (~65K tok, ~16% of context) in no active block, largest run m00312–m00411 (100 msgs), while the receipt declared the global minimum three times in a row. The kernel DOES find such spans when they are offered (verified by repro on master); what was missing was a GLOBAL coverage audit plus a verdict that only claims "minimum" when one holds.
Design (src/uncovered.ts, host-side only, no kernel change):
uncoveredHistoryRuns(): walk the message array for entries outside every active block's coverage (thecollectVisiblepredicate), group contiguous runs, then classify each run: skip if any current offer covers part of it (the model can act on it now); otherwise diff TWOcore.processTurnpasses over astructuredClone(state)— normal config vs relaxed{preserveRecentMessages: 0, preserveRecentTokens: 0}— and treat refs offered only in the relaxed pass as zone-hidden (inside the protected recent window). The empirical zone diff deliberately re-uses the kernel's own protected-zone rules instead of copying them, so it self-maintains as those rules evolve (over-approximation errs toward under-reporting). Runs belowVIABLE_RANGE_MIN_TOKENS(200) stay silent. A caller that already ran the normal offer pass may pass it vianormalRanges(array = trust-and-reuse, even empty; undefined = no caller data, audit runs its own pass) so acp_status pays ONE extraprocessTurn, not two. Whole function is advisory: any throw degrades to "no runs found" and never breaks receipts or status.- acp_status gains an additive
UNCOVERED HISTORYsection (top-5 runs by tokens, rest counted) listing mass that sits outside every active block AND is absent from the live Compressible-ranges list above — including isolated sub-gate runs between covered blocks that no merge will ever fold (the permanent-hole class of #4331 / #847). Named for visibility, explicitly not as fold suggestions; rows drill down viascope:"uncompressed"unchanged. - Tier-2 coverage invariant (reviewed, pinned by P4): the audit's covered-set is
coveredMessageIds= the union of ACTIVE blocks'effectiveMessageIdswith NO child recursion (kernel/src/state.ts:57-64). That is safe because every block-creation path unions consumed children'seffectiveMessageIdsinto the new parent (kernel/src/unit-projection.ts:178-183) and deactivates the children (kernel/src/compress.ts:1578-1581) — a T2/T3 parent therefore carries its full distilled span in the state model, and hydration preserves it (whole state JSON). DB-side walks ofblocks.payload_jsondo NOT have that property and needdirectBlockIdsrecursion (the reason an earlier independent attempt carried one); the state model does not. - postCompressTail: on clean whole-offer exhaustion with no tier distillation, the audit runs before the verdict. Runs found AND totalling ≥
UNCOVERED_RECEIPT_FLOOR_TOKENS(10K, receipt-only) → honest text naming them ("not at its minimum", pointer to acp_status). Below the floor — or with no runs — the verbatim #1387 contract string fires byte-stable, exactly as on master: small declined leftovers must not re-nag on every fold (pi #521 rule), while material leaks override it. The floor is deliberately NOT applied to the acp_status section: an explicit inspection surface is not a nag surface, and option 1 of #4331 specified no size gate there. The 10K total is a product value pending owner ack (matches the reviewer's independent pick).
Behavior change (disclosed): old = unconditional "already at its minimum" on offer exhaustion; new = that claim only when the global audit verifies it AND the uncovered mass clears the 10K receipt floor — below the floor the receipt is byte-identical to master (so tests/compress-success-tail.test.ts stays at master bytes, zero diff), and its #2404/#1387 sub-floor residue fixtures now double as the floor's pins. Sub-gate residue became visible in acp_status (previously invisible on every surface). No config/env surface added; nudge prompt text untouched (kernel-owned). Known limitation left as a separate observation: the receipt's remaining-calc removes a WHOLE offered batch when any submitted span overlaps it, so a partially-folded merged batch can under-report rather than over-report.
Tests: tests/issue4331-uncovered-history.test.ts (hole session → honest verdict + named span; drained session → verbatim contract string; sub-floor silence; tier precedence; tier-2 distillation coverage pin — three T1s distilled into one T2 parent via the real host path must not leak child spans into the audit; both acp_status directions; formatter caps) + tests/compress-success-tail.test.ts UNCHANGED from master (its < 10K-residue fixtures pin the floor), tests/issue2029-status-baseline-provenance.test.ts (every status-reader kernel call feeds the provenance baseline), tests/issue847-compress-failure-guards.test.ts (sub-gate residue visible in the diagnostic, still never offered).
A long-running standalone proxy (v0.1.208 field report: dsh-harness lane, last log line a 48 MiB session's fold-reconcile re-anchor) blocked its main event loop for 15+ minutes while STILL HOLDING the listen socket — HTTP died, the socket stayed open. External-harness children (no BILI_LAUNCH_TOKEN) fail fast on EADDRINUSE by design (src/listen-policy.ts), so every ~70 s respawn died listen failed: EADDRINUSE … exiting before persist until someone manually killed the zombie. Two gaps: nothing could make a wedged proxy yield its port, and the death message blamed "another process" without ever checking whether that other listener was actually SERVING — worse, a wedged zombie that intermittently drains its accept queue passes the boot-time twin health probe, so the child exited on the "serving instance keeps serving" hint while that instance served nothing.
Design:
- Watchdog (
src/event-loop-watchdog.ts): a separate worker thread OWNS the clock — the worker's own event loop keeps ticking even when the main thread is permanently blocked, which is the whole point. Main posts a heartbeat everyWATCHDOG_HEARTBEAT_MS(5 s); the worker checks every 5 s: gap ≥WATCHDOG_SOFT_WARN_MS(30 s) → ONE forensic warn line (uptime/RSS); gap ≥WATCHDOG_HARD_TIMEOUT_MS(300 s) → FATAL line to bili.log + stderr, thenprocess.exit(1)(exit mode). Thresholds are hard-coded by design (the config surface is owner-gated): legitimate big compressions finish in seconds-to-minutes and yield the loop between chunks, so a CONTINUOUS five-minute silence is only ever a deadlock. Armed at the ENTRY ofstartServer(before the twin probe, engine/storage init and hydrate all run under it — a wedge can arrive during startup, and #4762's own repro class is sync-I/O hotspots) — standalone processes ONLY; plugin-injection mode never arms (it would kill the HOST process). Forensic lines route to bili.log whenever a file leg is configured; the worker's STDERR leg ships only when stderr is a TTY or there is no file leg at all — a blocking write into a full, undrained pipe never fires an error event (#5012 class), so a worker writing its FATAL line into exactly that dead pipe would wedge ITSELF instead of tripping the exit; the gate is structural, not defensive. The worker is unref'd so the safety net can never hold a clean natural process exit hostage. The worker runs as inlineevalcode becausedist/index.jsis a single bundle with no sibling files to reference; its only import is a guardedrequire("worker_threads")— theparentPortglobal does NOT exist in eval workers (ReferenceError, verified on Node 22). Worker-spawn failure degrades to unwatched with one warn and never breaks startup (availability over guardrail). - Holder diagnostics (
src/port-owner.ts+ both EADDRINUSE death paths insrc/server.ts): at the moment of death, RE-probe the existing listener's/__bili/healthonce (2 s budget; wildcard binds probe through a loopback origin —holderProbeOrigin). Answered → the hint states the peer IS serving and the spawner should attach to it; no answer → the hint states the holder appears wedged or dead and names the concrete release:taskkill /PID N(Windows) /kill N(POSIX) when a pid is known (responder pid beats the stale registry entry; falls back to the same-lane registry pid), elsenetstat/lsof. Logged as[eaddrinuse-diag]and written to the spawn port log whenBILI_LAUNCH_TOKENis set (this child's stderr is muted into bili.log, #2864). Covers BOTH paths: the pre-engine early twin-collision exit and the post-bindserver.on("error")terminal branch. - Why re-probe instead of trusting the boot-time verdict: the early twin check (
findVerifiedSameLaneTwin) runs seconds before bind, and the late path'sclassifySameLanePeeris registry-only (pid alive, NO health check). A wedge developing in between — exactly the #4762 shape — flips the truth under both. And one shallow probe cannot distinguish slow-alive from wedged-but-draining (the zombie answered some probes), which is why the wording says "appears wedged" and leaves the kill decision to the operator/spawner rather than acting on it. - Out of scope (documented): a parent-side force-kill after repeated EADDRINUSE belongs to the SPAWNER (the DeepSeek Harness), not this repo — the diagnostic line now tells it exactly what to do.
Behavior change (disclosed): OLD = a wedged standalone proxy lived forever holding its port and every respawn died with an opaque EADDRINUSE; NEW = continuous main-loop silence ≥ 5 min ⇒ one warn at 30 s, one FATAL log line, then process.exit(1) so a spawner's normal respawn recovers the port, plus a serving/wedged adjudication + kill guidance appended to every terminal EADDRINUSE message. No new config/env/CLI surface — thresholds are module constants. Plugin-mode behavior is unchanged (watchdog not armed there).
Tests: tests/event-loop-watchdog.test.ts (verdict boundary table incl. warn-once semantics; default pin; REAL-worker wedge integration — a synchronous spin blocks the main loop past the hard threshold, the trip fires, both forensic lines land in the log file with the [v=…] tag; healthy stream never trips; spawn-failure seam degrades cleanly) + tests/port-owner.test.ts additions (wildcard-origin rewrite; serving/wedged/anonymous hint wording; live-responder pid beats stale registry pid; dead holder reads as not-serving; pre-registration "starting" reads as not-serving).
A fresh-install npm audit reports one high-severity advisory, GHSA-mrhx-c7xc-m3w5 (CVE-2023-54404, CVSS 7.5 High, availability/DoS only — no confidentiality or integrity impact), against two different chains that must be handled differently:
- Direct
zod(package.jsondependencies.zod, exact pin per KDD #1). Genuinely affected: the vulnerable code is$ZodArray.handleArrayResultinv4/core/schemas.js, which accumulates every per-element validation issue with no cap or early termination when validating an oversized array against an unconstrained array schema. Bumped4.1.8 → 4.6.7(patched line starts at 4.6.6, which adds anabortEarly-gated early break plus acapSizeregistration that 4.1.8 lacks). The pin stays EXACT (not a range) so npm can dedupe to one shared instance with the opencode host — the KDD #1 interop guarantee — and is covered bytests/opencode-native-v1.test.ts. In our tree this copy is consumed only bysrc/agent/opencode-native.ts(OpenCode V1 plugin tools, lazy opaque import); it is not loaded in the Pi/proxy lanes. - Jimp's nested
zod@3.x(optionalDependencies.jimp; each@jimp/*declareszod ^3.23.8, so npm nests a 3.x copy under everynode_modules/@jimp/*/node_modules/zod). This is a false positive for how we use jimp, established at source level rather than assumed from the semver range: the vulnerable function exists only in zod's v4 implementation (v4/core/schemas.js) and never in v3;import "zod"in a 3.25.x install resolves to the v3 core; and@jimp/*import plain"zod"(neverzod/v4). So the bug is unreachable through jimp. We therefore do NOT force-override jimp's zod 3→4 (a cross-major override with no patched 3.x to converge onto, risking jimp's own validation semantics); we track upstream instead. Reachability limits stay explicit: jimp is optional and lazy-loaded (src/image-rescale.ts), degrading to pass-through when absent, reachable only during image processing. Boundary: the bundled v4 preview inside a 3.25.x tarball is itself pre-fix — if a future@jimpever importszod/v4, re-evaluate this conclusion.
A hard CI/runtime audit gate is deliberately deferred: it would stay red because of the jimp chain unless we add an exception, and any exception must be narrowly scoped, explained, and revisitable — never a blanket suppression. Revisit once the direct-zod bump has landed and upstream jimp is tracked.
Plugin-lane hosts (the dsh threads model and any client that re-sends the ENTIRE history plus the new tail on every request) used to pay, per turn, a full JSON.parse of the whole body, a full openaiToCore conversion, and a cross-turn pin of the resulting AST in session.lastMessages (snapshotMessages). At the #5403 scale (20k messages, ~81 MiB inbound per turn) that pair is what drove the JavaScript heap past its cap six times in two hours.
The lane now carries a watermark. Every successful plugin-lane chat-completions response stamps x-bili-pf-seq with the server-measured element count T of that request's messages array. T is measured by src/server/fastpath-scan.ts, a single-pass structural scanner (character walk with a string-state machine): it validates enough JSON to be trusted while allocating only span offsets — no AST, no full parse. The client side is ONE state machine (src/agent/pf-channel.ts) fed at ONE chokepoint: the globalThis.fetch intercept in src/agent/native-intercept.ts stamps x-bili-pf-count (and the v2 tail-ids below) on every plugin-lane model request — the /bili/-routed branch, the raw-provider-URL rewrite branch (attach mode, V1 fetch patch), and the direct-to-proxy self branch (hosts whose provider URL dials the proxy root straight, e.g. dsh profile bundles; this one was added after a containerized gate run found that lane silently never seeded the channel) — and learns the stamp back from every successful response, so no lane ever re-implements the handshake.
Relationship to the incremental lane (#3248/#4491) — SAFE TO RUN TOGETHER, they never double-process: both mechanisms attack the same pain point (plugin hosts re-sending full history) but partition by REQUEST SHAPE per turn. The dsh shim's divert only engages for ANTHROPIC-WIRE bodies (wireMessagesLookAnthropic; OpenAI tool shapes role:"tool"/tool_calls always ride full resend, #3433) — and #5403's shape is exactly the opposite wire (chat-completions / openai profile), where the incremental lane never diverts at all. So the two lanes are complements, not competitors: anthropic-wire plugin sessions are served by the incremental lane; OpenAI chat-completions plugin sessions by the watermark fastpath. A single request walks EXACTLY ONE path (the shim chooses per turn); server-side, any request carrying the x-bili-plugin-turn marker skips watermark evaluation entirely (no mixed stamping — the incrementalTurn row of the gate matrix), and diverted responses never carry x-bili-pf-seq (they carry x-bili-applied-seq instead). The synthetic re-entry request the lane builds carries an explicit header allowlist (PASSTHROUGH_HEADERS) with no pf-* names, so handshake headers cannot leak via that path either. Residual harmless case: in a mixed session a stale x-bili-pf-count may accompany one non-diverted turn — it costs at most ONE verification round, then the response re-stamps the truth.
Identity channel v2/v3 (the owner ladder msgid > order > hash, #11): a bare count pins the boundary only by POSITION — a client that rewrites elements while keeping the count constant stays invisible. When OUR shim is in the loop, the request additionally carries x-bili-pf-tail-ids: the host-side message id of element K−1 (the previous turn's anchor) plus every NEW element — “几个,不是几十个”. Hosts do not put ids in chat-completions bodies, but the injected extension reads them from host context (per-element rules shared with the incremental envelope, #4491) — so no host release is needed. Wire form is per-segment percent-encoding (ids.map(encodeURIComponent).join(",")): separator commas stay literal, commas inside an id travel as %2C, so the server's split-then-decode is unambiguous for any id alphabet. No alignment proofs (owner ruling 2026-11-02): hosts compress and REWRITE their histories, so proof-gating fights reality and killed the channel — msgids are unique forever, ORDER binds them inside the verified delta, and the byte-level head check is the boundary truth. Capture is therefore PER-SLOT: an element with no usable host id (native-compaction summary acp_summary_*, persona sidecar, foreign shape, duplicate id) rides the unknown-slot placeholder ? (%3F on the wire) instead of poisoning its neighbours; only a wholly unresolvable list clears storage. Server verdicts: bad length / duplicate real id / undecodable segment ⇒ UNcredited silent degrade of THIS turn — never toward the breaker (shim↔proxy drift self-heals through the legacy path + restamp); prevId ≠ stored anchor ⇒ UNcredited, checked ONLY when BOTH sides are known ids — a ? on either side means no identity claim (no penalty). Absent header ⇒ pure count mode; every pre-v2 behavior is byte-for-byte unchanged. Lane coverage: the channels are protocol-unified — lanes differ ONLY in the thin capture adapter that resolves THIS request's host ids (owner rule: between clients, "where the ids are read" may differ, nothing else): dsh reads them off the host's dispatch context per slot (pfSlotIdsOf — ? placeholders, above), pi walks the session branch entries against the outgoing body family-by-family (anthropic laneMsgIdsForWire, openai-chat laneMsgIdsForOpenAiWire), opencode reuses the validated list its messages-transform hook already aligns for the incremental envelope (#4491), omp the fetch-layer alignment of the branch entries the host stashes in before_provider_request against the ACTUALLY-sent body (ompTurnMsgIds — the same copy the envelope rides) on anthropic-wire turns only: its strict count+role proof cannot bridge the config-assembled leading system run, and a failure-clear there would ERASE the shared hook's list for the same conversation id (the hook runs first, this planner last) — openai-wire turns are covered by the shared laneMsgIdsForOpenAiWire resolver alone (#2583 per-model egress). The pi/opencode/omp walkers are all-or-nothing and a failed resolution CLEARS the stored list (for them a failure means the POSITIONAL knowledge itself is missing, so clearing is the honest move) — an unresolvable request degrades to count mode rather than riding a stale anchor; dsh's per-slot capture is exempt (above). One protocol note: chat-completions bodies start with system|developer elements assembled from config/state that own NO branch entry; the openai resolver covers that leading run with position-pinned synthetic tokens (bili-ps-<i> — stable per slot, charset-safe, cannot collide with an 8-hex uuid entry id), so the chain stays alive instead of dying on the first system element, and a moved slot breaks the anchor exactly like a real edit (uncredited, self-healing).
A turn is a HIT when: the window origin K is settled and the in-process seed (previous turn's canonical cores, module-level WeakMap<Session, …>, deliberately outside the Session object so the panel breakdown stays honest about what is session truth) was stamped at exactly that count under the same model. v4 count relaxation (owner ruling 2026-11-02): once a turn arrives with a shape-valid x-bili-pf-tail-ids list whose width matches the server's own bookkeeping (len = T − K + 1, re-verified SERVER-side), K comes from the LEDGER (seed.stampT) and the echoed x-bili-pf-count demotes to diagnostic — a missing or stale/desynced echo no longer produces a miss (the decision string leaves a trace as hit(no-count) / hit(count-off=N), never breaker fuel); without such a list the pre-v4 ladder runs byte-for-byte unchanged (echo must equal stampT, strictly less than T). The first HEAD_CHECK_ELEMENTS (8) elements re-convert to a byte-identical system run, and the window head is not a system/developer element (a hoisted system would desync reconcileSystemAnchor). On a hit the pipeline receives [seed cores] + [freshly parsed window cores [K..T)] instead of a full parse, and snapshotMessages pins ONLY the tail window. Window cores are re-keyed against the prefix because openaiToCore mints cluster ids per intra-call occurrence order (h_<sha256-16> bases with _N suffixes) and stamps wireIndex from the array position it was given — a repeated content base must continue its ordinal from the stashed prefix, or the ledger's identity contract breaks; with K=1 an un-rekeyed window would even MERGE two distinct wire messages (prune extends first-user-pin runs on equal wireIndex).
Fail-soft discipline (anti-deviation lock): the legacy pipeline code is UNTOUCHED — this feature only swaps the inputs the caller feeds it, and ANY doubt (no/bad/stale stamp — unless a ledger-valid tail-id list carries the request, v4 above; count regression ahead, zero growth T === K keeping no-progress even with a ledger-valid 1-element list (an empty window would pin an empty lastMessages baseline), model switch, head drift, hoist hazard, broken scan, armed breaker) falls back to the untouched full-parse path for that turn, which then RE-STAMPS the freshly measured T so a stale client converges within one round-trip. Five consecutive CREDITED misses arm a breaker that holds the lane hit-less for one hour — held turns keep scanning and re-stamping (a stampless hold would strand the client's watermark forever; that deadlock was found and pinned by unit tests before shipping). Credited means the miss was provably not our fault (client lied or rewrote); unseeded/no-progress misses are free.
Declared trust boundary (§7.4 — human review required, no auto-merge): a client that edits PREFIX BYTES while keeping the element count constant is NOT detected — except at the anchor, where v2 compares the carried prevId against the stored one and a re-minted id costs one uncredited miss plus self-heal. That trade is the entire price of skipping byte-level re-verification; it cleared the §7.4 human review and therefore ships as an EXPERIMENTAL grey rollout — ON by default for roughly 5% of installs (owner ruling 2026-11-02: small cohort before GA, one-time web intro notice visible to every install until closed, plus a tri-state quick switch) — with the A/B golden pinning outbound byte-equality and a per-install opt-in/opt-out (rollout.features.pluginFullResendWatermark: true|false / BILI_ROLLOUT_PLUGIN_FULL_RESEND_WATERMARK=1|0). Destructive rewrites (announced compaction, truncation, reorders) change the count and are caught before any stale core could feed the kernel — under v4 the catch moves from the client echo to the width arithmetic itself: a shrunken prefix demands a non-positive width no id list can satisfy, so the credited ahead / seed-stale verdicts fire exactly as pre-v4.
Honest limits: (1) once a multimodal tail (content array containing image_url) lands in the stash, the lane degrades to full legacy turns for the remainder of that history — the kernel renders user content-array images differently when the message is the LAST element versus mid-history, so a frozen stash cannot reproduce legacy bytes position-independently; text workloads (the #5403 reporter's shape) are unaffected. (2) The head fragment is re-parsed (8 elements) each hit solely for the system-run check. (3) Both x-bili-pf-* headers are stripped upstream (UPSTREAM_HOP_HEADERS) — protocol headers never leak into model traffic.
Measured (provenance: tests/issue5403-watermark-fastpath.test.ts, hermetic dual-proxy A/B on the same corpus): hit turns skip the full parse/conversion/pin entirely; resident lastMessages 8684 B → 1210 B (7.2×) on an 8-turn corpus including a multipart image landing turn; outbound bytes byte-identical to legacy modulo kernel time="…" attributes across all turns; x-bili-pf-* absent from every captured upstream request, both phases. Unit coverage: tests/fastpath.test.ts (full verdict ladder incl. breaker TTL re-arm), tests/fastpath-scanner.test.ts (adversarial JSON table, depth cap, surrogate pairs, 50k-element scale), tests/lane-decisions-matrix.test.ts (gate row matrix, both directions).