# Technical notes 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. ## Native plugin lifecycle (Option 1) 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 `/bili/`, 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`. ## Proxy reuse and the attach gate (#1225, #1335, #1232, #1660) 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. ## Shared state dir and multi-instance security boundary (#394, #1724) 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/sessions` list / 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. ## Runtime-info protocol (#955) 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//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 `/bili/` 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=`, 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 ` 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=&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 native posture (#964) 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:/bili/`, `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. ## Injection priority — no files unless unavoidable (#535) 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. ## Two compression modes — who executes `compress` 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; `pluginMode` is decided per request (`x-bili-plugin` header) 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. ### Mode switches change the Responses projection — attribution must not blame the host (#2867) 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. ## Responses native chaining is not supported — history must be replayed (#1954) 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 ## Single-writer: who owns which copy (#991) 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). ## Stuck-session self-heal — detection constants and remedies (#2155) 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 **no** `x-bili-plugin` header, declared **no** bili tools on the wire, and produced **no** compression reduction. Remedy: **degrade to proxy mode** — the single `pluginMode` flip (`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` → `degradeAvailable` false) the remedy falls back to **suppress-nudge**. Recovery: the live plugin header (`pluginLaneRestore`, hoisted BEFORE the `pluginMode` flip so the first header-bearing request is already plugin mode — the sticky `pluginAgent` must 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 four `willInjectNudge` gates (`&& !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; `consumeFallback` resets the counter), and the latch expires `SELF_HEAL_SUPPRESS_CAP_MS` (10 min) wall-clock after arming — re-arming then costs a fresh full `SELF_HEAL_WINDOW` of 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 = true` on the **first** hit and the summary is re-requested as SSE — the same learned store as the manual `compress.streamSummary` knob (#2133), auto-armed. The cascade is honored in both directions: an explicit `streamSummary: false` anywhere (global → provider → model) is the operator's opt-out — neither learn path arms and a stale learned flag is ignored (`streamSummaryOff` in `PreflightDeps`). 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 ` 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**. ## Degraded direct fallback — refuse, self-heal, announce (#4516, #3304, #2924, #4079) 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 below `MIN_ASSUMED_WINDOW_TOKENS` (1M, #4079 — declared windows can be wrong-small); bodies in that band are left to preflight. Missing data (no `x-bili-plugin-context-window` header, unreadable body) fails OPEN to the direct send — a local guard must never false-block on its own ignorance. Verdict: 422 `x-bili-gate: oversize-refusal-fallback`, retryable, wire-shaped per upstream dialect, message names floor/core/window and the unavailability reason (`bootstrap-failed` vs `dead` — 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 under `state.spawnWaitMs ?? readyTimeoutMs ?? 15_000`: the pi spawn lane sets `SPAWN_BUDGET_MS + 10 s` so 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.lastUnavailableReason` carries 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). ## Unified ACP tag-echo remediation — the four invariants (#2023/#2066/#2190/#2248) 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): 1. **The thinking channel is byte-verbatim.** Anthropic `thinking`, Google `thought`, OpenAI `reasoning_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 through `createIdentityStreamFilter`, 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. 2. **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. 3. **Model output bound for files is byte-identical (#1039).** Tool-call arguments never enter any filter, whatever bytes they carry. 4. **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). ## Cache-ledger accrual is coverage-conditioned (#2202) 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 `` 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 without `prompt_cache_key`. Retry accepted ⇒ causation proven ⇒ remember the `origin(+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_status` carries a section listing current suppressions; the web panel shows a banner (zh/en) with sessions; scripts read `pckSuppressions` from `GET /__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`. ### #5079: policy reversal — self-stamping is OFF by default, one knob gates every wire 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 for `api.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`. ## Uncovered-history audit — honest minimum verdict (#4331) 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 (the `collectVisible` predicate), 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 TWO `core.processTurn` passes over a `structuredClone(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 below `VIABLE_RANGE_MIN_TOKENS` (200) stay silent. A caller that already ran the normal offer pass may pass it via `normalRanges` (array = trust-and-reuse, even empty; undefined = no caller data, audit runs its own pass) so acp_status pays ONE extra `processTurn`, 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 HISTORY` section (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 via `scope:"uncompressed"` unchanged. - **Tier-2 coverage invariant (reviewed, pinned by P4)**: the audit's covered-set is `coveredMessageIds` = the union of ACTIVE blocks' `effectiveMessageIds` with NO child recursion (kernel/src/state.ts:57-64). That is safe because every block-creation path unions consumed children's `effectiveMessageIds` into 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 of `blocks.payload_json` do NOT have that property and need `directBlockIds` recursion (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). ## Event-loop wedge self-exit and EADDRINUSE holder diagnostics (#4762) 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 every `WATCHDOG_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, then `process.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 of `startServer` (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 inline `eval` code because `dist/index.js` is a single bundle with no sibling files to reference; its only import is a guarded `require("worker_threads")` — the `parentPort` global 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 in `src/server.ts`): at the moment of death, RE-probe the existing listener's `/__bili/health` once (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), else `netstat/lsof`. Logged as `[eaddrinuse-diag]` and written to the spawn port log when `BILI_LAUNCH_TOKEN` is set (this child's stderr is muted into bili.log, #2864). Covers BOTH paths: the pre-engine early twin-collision exit and the post-bind `server.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's `classifySameLanePeer` is 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). ## Dependency audit — zod advisory GHSA-mrhx-c7xc-m3w5 and the jimp nested-zod false positive (#4820) 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.json` `dependencies.zod`, exact pin per KDD #1). Genuinely affected: the vulnerable code is `$ZodArray.handleArrayResult` in `v4/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. Bumped `4.1.8 → 4.6.7` (patched line starts at 4.6.6, which adds an `abortEarly`-gated early break plus a `capSize` registration 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 by `tests/opencode-native-v1.test.ts`. In our tree this copy is consumed only by `src/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/*` declares `zod ^3.23.8`, so npm nests a 3.x copy under every `node_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"` (never `zod/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 `@jimp` ever imports `zod/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. ## Full-resend watermark fastpath (#5403 / #5418) 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-` — 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`, 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_` 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).