Skip to content

fix(#453): hard backstop for plugin-mode context overflow - #467

Merged
ranxianglei merged 44 commits into
masterfrom
2026-09-03_fix-453-plugin-overflow-backstop
Sep 3, 2026
Merged

ranxianglei merged 44 commits into
masterfrom
2026-09-03_fix-453-plugin-overflow-backstop

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What & why

#453: in plugin mode (bili pi) bili trusts the ACP agent to run compress. When a weak model ignores the nudge, the payload grows past the window and the upstream 400s on input+output (vLLM rejects an oversized total instead of clamping) — then the client falls back to native compaction and loses all working state (compounds #14).

Proxy mode had preflightCompressIfNeeded as a backstop, but its trigger estimate (estimateCoreMessages) counts message text only — it ignores system + tool definitions and can lag by a turn — so it skipped exactly when the real input crossed the reserved limit. Repro numbers: real input 131745 + completion 131072 = 262817 > 262144 window; the trigger saw ~117k and stayed silent.

Two host-side fixes (both modes)

1. Clamp the outgoing output budget (the hard backstop). In prepareOpenai / prepareResponses, cap max_tokens / max_completion_tokens / max_output_tokens to window − estimated_input − margin. The proxy owns both sides of the sum, so input+output can no longer exceed the window on request-rebuilding upstreams. estimateInputTokens() takes the max of the real previous-turn input and a fresh count that includes system + tools, closing the undershoot that let preflight skip. Anthropic is exempt (its input limit is independent of max_tokens). When the cap would drop below a floor (input alone nearly fills the window) it leaves the budget untouched — that's preflight/self-heal territory, not output starvation.

2. Escalate the nudge (mitigation). Force-inject every turn once usage ≥ EMERGENCY_NUDGE_ESCALATION_PCT (0.70) even when the kernel's cadence/growth gate stays silent. diagNudge now labels these INJECT-ESC instead of logging idle.

Note on the escalation threshold: tracing the kernel's decideNudge showed the pressure branch (usage ≥ nudge.maxContextLimitPct, default 0.75) has no cadence gate — the kernel already force-injects every turn there. So the only cadence-silent zone is below 0.75, which is why the line sits at 0.70 (just under it) rather than the "≥85%" floated in the issue — 85% lands inside the zone where the kernel already injects, making it a no-op. It's a single named constant, trivially tunable. Please confirm you're happy with 0.70 (or whether you'd like it exposed across the three compress config levels).

Tests

New tests/issue453-backstop.test.ts:

  • unit: clampOutputBudget (clamp / fits→no-op / floor→no-op), estimateInputTokens (real-count dominance, turn-1 system+tools count), emergencyNudge (armed→no-op, escalation line, custom line, empty-ranges guard).
  • integration (real proxy + mock upstream): the clamp actually reduces the forwarded max_tokens when a large system prompt pushes true input past the reserved window while preflight skips (mirrors the repro); escalation adds exactly one trailing nudge at 72% vs none at 60%.

Pre-flight: typecheck clean · full suite 924/925 (the single failure is launcher.test.ts resolving a locally-installed codex binary — pre-existing/environmental, fails identically with this change stashed) · build clean · e2e preflight green (full e2e needs a live Responses upstream → runs in CI/manual).

Option 3 from the issue (proxy-side emergency truncation in plugin mode) is deliberately not included — carrier-consistency risk (preflight builds tool-call-less blocks whose acp_summary re-voices as a user message = proxy-mode semantics; the agent may re-send unfolded ranges). Happy to file it as a separate tracking issue if you want it tracked.

ework-agent added 9 commits August 31, 2026 17:29
…, link kind

Follow-up to #382 (Windows overlay symlink fix), addressing code review:
- mergeSqliteSet: adjudicate the whole set by the MAIN db's mtime (winner's
  set moves as a unit) instead of per-member mtime — per-member could splice
  a newer main db with a newer WAL from the other side (salt/page mismatch ->
  WAL discarded -> overlay session's uncheckpointed writes lost). Orphan sets
  (no main db) are stale residue, discarded, never moved individually.
- sqliteSetMembers: include -journal (rollback-journal mode).
- conflict renames (mergeSqliteSet + mergeOverlayEntry): non-colliding name
  (.bili-conflict, .1, .2, ...) so a retry never clobbers the prior round's loser.
- linkOverlayEntry: explicit symlink kind (win32 dir -> junction, file -> file)
  so a dir target is never a file-tag reparse point.

3 new tests; 871 pass, typecheck + build green.
…deactivating them (#395)

omp's in-session native compaction re-sends a shortened history (its summary +
retained tail) without rotating the session id, so bili's per-sid registry
reuses stale state and syncBlocks silently deactivates every block whose raw
ids left the history — making decompress permanently unreachable and decoupling
acp_status from the real visible context (the ledger break #19 shelved).

Wire the omp session_compact hook bili was ignoring:
- agent plugin (pi/omp) POSTs /__bili/plugin/compact on session_compact
- proxy marks a compaction boundary; the next processTurn degrades the
  active->inactive blocks into an auditable pre-compaction archive
- acp_status lists archived blocks with reason; decompress returns an explicit
  error instead of silent failure or wrong-block content
- prune byRaw/byRef to live raw ids at the boundary (stops the #390 additive
  leak); nextIndex is left alone so a freed ref slot is never re-allocated onto
  a retained tail's live tag
…ponse.failed (#440)

The loop-level responses adapter (src/loop/adapter-responses.ts) emitted a
response.failed with no preceding response.created when the upstream stream
truncated (200 + early EOF) before response.created reached the client. An
orphan response.failed crashes strict clients (codex) — the same class as the
anthropic message_start gap fixed in #413.

Two emit paths were affected, not just emitError:
- 0-event/partial EOF routes through emitCompletion's failed branch (the
  responses parseStream yields a fallback done:failed on truncation, unlike the
  anthropic adapter), so the common case never hit emitError.
- emitError covers HTTP-error / empty-body paths.

Both now track whether response.created was forwarded (createdForwarded, set in
round 1) and synthesize one before emitting response.failed, reusing the
forwarded created id when present so created/failed stay id-consistent.

Tests: tests/loop-responses-orphan.test.ts (0-event EOF, truncation-after-created,
emitError synthesis). All fail on the pre-fix adapter, pass after.
The `bili pi` launcher never disabled pi's own context compaction, so pi's
native summarizer (shouldCompact, enabled by default) could fire ahead of /
alongside bili's ACP compression and lossily rewrite the context bili is
managing — losing the detailed history.

The pi overlay (<piHome>-bili) is now ALWAYS built when the real pi home
exists and always carries a settings.json with compaction.enabled=false
(merged over the user's real settings.json, which is never mutated). This
makes bili's ACP compression the sole compression path under `bili pi`,
mirroring how codex/claude already get their native auto-compact aligned.

- refreshOverlayHome / mergeOverlayEntry now accept a SET of generated files
  (models.json + settings.json) so both stay out of the real-home symlink and
  merge-back sets.
- models.json is still rewritten for HTTP/wrapped-HTTPS upstreams when present.
- preparePiHttpRewrite returns undefined only for a missing real home.
… echo (#462)

plugin-mode openai-chat SSE strip (pipePluginChatWithStrip/processOpenai) latched droppedText per-field, so a chunk whose delta carried e.g. content='real answer' plus a pure render-tag echo in reasoning_content was dropped whole, losing the real content. Now the chunk is dropped only when EVERY managed text field strips to empty (keptText guard); the existing non-text payload check (finish_reason/tool_calls) is unchanged.
Requests that skip compress injection (OpenAI title-gen exclusion, ACP_NO_INJECT_TOOL, #353 stop_sequences classifier bypass) piped upstream SSE straight through to the client. A model echoing history-borne ACP render tags into prose leaked them to the client TUI, where they re-enter the conversation via replay and amplify (same mechanism as #457/#459).

Route those non-injected streaming responses through the same tag-strip pipes as plugin mode (#452), with no session passed: usage accounting must stay off here, or a title-gen call's tiny input_tokens would clobber lastInputTokens and break compression triggering for the main conversation.

- pipePluginChatWithStrip: session now optional (signature reordered protocol-before-session); usage sample applied only when a session is present
- pipePluginResponsesWithStrip: session now optional, same guard
- server.ts !useRewriter branch: strip pipe for streaming requests whose upstream response is text/event-stream; everything else stays raw pipeThrough as before
- tests/proxy-noinject-tag-strip.test.ts: anthropic injectTool:false echo split across deltas stripped; tag-free stream byte-identical; openai title-gen stream stripped with [DONE]/finish_reason intact
Plugin mode trusted the ACP agent to run compress; when a weak model ignored
the nudge the payload grew past the window and the upstream 400'd on
input+output (vLLM rejects an oversized total instead of clamping). Proxy mode
had preflight as a backstop, but its trigger estimate (estimateCoreMessages)
ignores system+tools and can lag by a turn, so it missed the overflow.

Two host-side fixes, both modes:

- Clamp the forwarded output budget (max_tokens / max_completion_tokens /
  max_output_tokens) to window - estimated_input - margin so input+output can
  never exceed the window on request-rebuilding upstreams. Anthropic is exempt
  (its input limit is independent of max_tokens). estimateInputTokens takes the
  max of the real previous-turn input and a fresh count that includes system +
  tool definitions, closing the undershoot that let preflight skip.
- Escalate the nudge: force-inject every turn once usage >= EMERGENCY_NUDGE_
  ESCALATION_PCT (0.70) even when the kernel's cadence/growth gate stays silent.
  The kernel already auto-injects above nudge.maxContextLimitPct (default 0.75),
  so the line sits just under it to fill the pre-limit silent climb. diagNudge
  now labels forced injections INJECT-ESC instead of logging "idle".

tests/issue453-backstop.test.ts: unit coverage of all three helpers plus two
end-to-end proxy tests (clamp reduction mirroring the repro's undershoot;
escalation-vs-control nudge contrast). typecheck/test/build green; e2e
preflight green (full e2e needs the Responses upstream, runs in CI).
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-03_fix-453-plugin-overflow-backstop (ba857b2)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-467

Each push to this PR publishes a new version under the pr-467 npm tag.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr467.tgz
npm install -g package

This comment is automatically updated on each push.

ework-agent and others added 3 commits September 3, 2026 01:13
A small model (e.g. MiniMax-M3 via a gateway) can write a tool call as plain
text - echoing the tool-call XML template from the context - instead of
emitting a real tool block. With no tool block the agent ends the turn early
and shows success, but the tool never ran (the "fake completion" from #371,
root-cause follow-up to #361).

Add a proxy-side detect + retry fallback:
- Detect: a response carrying a complete tool-call structure (an opening
  invoke-style tag, or 2+ distinct closing tags) AND no real tool block for
  the wire protocol (Anthropic tool_use / OpenAI tool_calls / Responses
  function_call). Reuses PR #365's containsToolCallXmlFragment detector; the
  structural guard avoids false-positives on legit prose that merely mentions
  a single closing tag.
- Retry: re-request upstream with a corrective hint appended as a trailing
  user message. Bounded per turn (max retries) and per session
  (session.metadata.fakeCompletionStreak, resets on a clean turn).

Opt-in (disabled by default): the fallback must buffer the whole response
before the client sees it (a fake completion is only knowable at end-of-stream),
which defeats incremental streaming. That cost is only worth paying for the
low-frequency case, so it is opt-in via BILI_FAKE_COMPLETION_RETRIES (default
0 = pre-#371 passthrough).

Tests: tests/fake-completion.test.ts (21) - detection per protocol, the
structure guard, hint injection per protocol, and full-proxy integration
(fake -> retry -> real tool_use; retry cap via streak; legit prose no-retry).
…gs are stripped

Upstreams streaming a model-imitated render tag in tokenizer-sized
fragments (delta.content: '\x3cac','p tokens','...', ...) never trip
containsRenderTagText in any single chunk, so the per-chunk fast-path
gates in pipePluginChatWithStrip / pipePluginResponsesWithStrip skipped
the filter and the tag leaked verbatim to the client (observed live on
Zhipu glm-5.3 through bili 0.1.71 plugin mode; captured raw SSE shows
per-character tag deltas and zero strip drops for that session).

Add mayStartRenderTag() (full detect OR end-anchored PARTIAL_TAIL) and
use it at the three per-delta gates (openai content/reasoning fields,
anthropic text/thinking deltas, responses output_text.delta). Pure-prose
chunks keep byte-identical passthrough; only chunks carrying a complete
tag or ending with a tag head (<, </, <a, <ac, <acp attrs, </acp) engage
the state machine. Wire-mode adapters already push every delta and were
unaffected.
…trip

fix(#468): engage tag-echo filter on tag-head tails so micro-fragmented tags are stripped
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 独立审核完成,pre-flight 全部复核通过。结论:无阻塞项,等你 merge。两个待拍板点见文末。

独立复核结果(branch 2026-09-03_fix-453-plugin-overflow-backstop @ 55a1232,base master@ac4fbcb)

  • typecheck ✅ · build ✅
  • full suite 924/925:唯一失败是 tests/launcher.test.ts 的 resolveClientCommand: codex/claude resolve to themselves(codex binary 本地解析)。已在干净 master@ac4fbcb worktree 上对照运行——同样失败,确认 pre-existing/environmental,与本变更无关。
  • package.json / package-lock.json 对 master 零 diff → version 字段未动,符合 §4。
  • e2e 需要 live Responses upstream,留 CI/manual-dispatch 跑。

代码级复核要点

  1. clamp(硬 backstop):nativeWindow 在 output-headroom 预留之前捕获(src/server.ts:1236),公式 window − input − max(2048, 5%·input);cap < 1024(floor)或 fits → 原样不动;Anthropic exempt;只 clamp 客户端显式传入的 numeric budget。OpenAI 优先 max_completion_tokens 回落 max_tokens;Responses 用 max_output_tokens,compaction-trigger 请求跳过(保住 "must be last input item")。集成测试复现原场景:core estimate ~48k < reserved 60k 使 preflight skip、true input > 60k 时,forwarded max_tokens 确实被压低,对照组不受影响。
  2. estimateInputTokens:取 max(上一轮真实 input, 含 system+tools 的新鲜估计) —— 精确补上 preflight「只数 message text」的 undershoot,即 plugin mode: no hard backstop when the agent ignores the compress nudge — context grows past the window and upstream 400s #453 repro 里「trigger 看到 ~117k 保持沉默」的那个洞。
  3. escalation nudge:emergencyNudge 仅在 kernel 沉默 且 compressibleRanges 非空时覆盖(不推翻 kernel 有意的 empty-ranges suppression);willInjectNudge 在三处 prepare site 都 OR 进去,实际 payload 注入确实由它 gate(不是只打日志)。我独立 trace 了 acp-kernel decideNudge:pressure 分支(usage ≥ nudge.maxContextLimitPct = 0.75)没有 cadence gate(dist/index.js:2554-2572),cadence gate 只存在于 T2/T3 growth 分支(:2579/:2586)→ 0.70 正好落在 0.75 以下的沉默区,而 0.85 如描述所说会是 no-op。阈值论证成立。
  4. 两种模式都受益:shouldInject 不受 pluginMode gate(只有 injectTools 受,server.ts:1586/:1740),plugin/proxy 模式都能触发 escalation;注入仍走既有的 ephemeral trailing-user-message 路径,不动 carrier 语义(与 0.1.69 回归: plugin 模式 nudge 注入被 !pluginMode 静默禁用 — INJECT 日志照打但从不投递, 自动压缩只剩 preflight 应急 #451/fix(#451): restore plugin-mode nudge injection + honest diagNudge log #456 一致)。
  5. diagNudge 现在区分 INJECT-ESC / ARMED-SUPPRESSED / idle,escalation 路径可观测。

两个待你拍板

  1. escalation 阈值 0.70:我的建议是先保持单个 named constant(现状)。理由:这个值与 kernel 的 0.75 pressure line 强耦合——暴露到三级 compress config 后,用户很容易把它配到 ≥0.75 变成 no-op 而不自知。等出现真实的调参需求再提升为 config 项不迟。
  2. Option 3(plugin-mode proxy-side emergency truncation):按你在 plugin mode: no hard backstop when the agent ignores the compress nudge — context grows past the window and upstream 400s #453 的指示已单独开 tracking issue:track: plugin-mode proxy-side emergency truncation (last-resort backstop, deferred from #467) #470(https://github.com/ranxianglei/billion-context/issues/470),carrier-consistency 风险与设计问题清单已写入正文。不需要的话直接关即可。

PR: #467 (mergeable · 等你 merge)

ranxianglei and others added 14 commits September 3, 2026 01:23
Resolve the single textual conflict: the per-delta fast-path gate is now
mayStartRenderTag (#469) and carries the keptText latch from #462.
…-field-drop

fix: keep sibling text when a mixed chunk's other field is a pure tag echo (#462)
…e-tag-strip

fix(#460): strip render-tag echoes from non-injected proxy-mode chat SSE
…#469 merge

pipePluginChatWithStrip is (stream, res, protocol, session?, log?). The two
#468 micro-fragment tests were written before #466 reordered that signature
and still pass (stream, res, session, "anthropic"), so protocol received a
Session object, protocol === "anthropic" was false, and the anthropic
fixture ran through processOpenai. The render tag survived and
npm test fails on master at "plugin anthropic passthrough strips a tag
streamed in tokenizer-sized fragments (#468)":

  actual: 'ok <acp tokens="247" type="text">m00042</acp> tail'
  expected: 'ok  tail'

Tests are not in tsconfig.build.json, so typecheck cannot see the swap.

The openai twin passed only by accident: protocol === "anthropic" is false
for a Session object, and the false branch is processOpenai.
…-order

test: fix pipePluginChatWithStrip call order after #466 signature swap
…mpress injection

#466 closed the non-injected chat SSE hole. Three siblings were still handing
the model's echoed render tags back to the client untouched:

- native Responses compaction (prepared.resetAfterSuccess) forwarded
  await pipeThrough(toClient, res) with no filter. A compaction turn is by
  definition the compression-triggered one, so its context necessarily carries
  render tags — its echoed prose is the likeliest leak of any Responses stream.
- non-injected NON-STREAMING JSON fell to the bare pipeThrough at the end of
  forward(). The compress loop's JSON rewriters strip echoes on every injected
  round; the non-injected twin must not skip it.
- rewriteResponsesJsonResponse (src/stream-responses.ts) was the only JSON
  rewriter without the strip: stream.ts strips content[].text, stream-openai.ts
  strips message.content, this wire did nothing, and its !converted early return
  previously returned a tag-bearing body verbatim.

Fixes:

- server.ts: the resetAfterSuccess branch pipes text/event-stream bodies
  through pipePluginResponsesWithStrip (no session, so usage accounting stays
  off); the ladder gains an application/json branch that runs pipePluginJson.
- stream-responses.ts: probe JSON.stringify(b.output); when it carries a render
  tag, log a tag-echo warning and stripResponsesText(b) BEFORE the compress
  loop, so the synthesized compress note is never edited and the strip also
  covers the early return.
- plugin.ts: pipePluginJson takes an optional session so proxy mode can reuse
  the plugin-mode pipe; usage accounting and markDirty stay behind `session &&`.

Tests (tests/proxy-noinject-tag-strip.test.ts, tests/tag-echo.test.ts): native
Responses compaction SSE stripped with response.completed intact, tag-free
compact stream byte-identical, non-streaming openai/responses JSON stripped,
tag-free JSON byte-identical, and the two rewriteResponsesJsonResponse cases —
plain echo, plus the differential fixture proving the injected compress note
survives while echoed sibling prose strips to the clean text.
src/stream.ts rewriteSseStream and src/stream-openai.ts rewriteOpenaiSseStream have
zero production call sites: server.ts imported rewriteSseStream but never called it,
and the live proxy SSE path is runCompressLoop + pickAdapter (src/server.ts), whose
three adapters already pass text through createTagEchoFilter. They survived only
because five test files drove them directly, which is why #460 and #462 both tripped
over them — the cited "warn-only, then emitEvent(ev)" code is unreachable.

Removed: rewriteSseStream and its private machinery (routeEvent, emitToolReplacement,
buildStopReasonRewrite, parseSseEvent, emitEvent, BlockState, NOOP, SseEvent) plus the
now-unused normalizeSseLineEndings import; rewriteOpenaiSseStream and its private
machinery (newState, routeOpenaiEvent, buildOpenaiTail, extractDataLine, StreamState).
Kept: executeAnthropicProxyTool (used by rewriteJsonResponse), applyRanges (preflight,
compress-loop-responses, loop/core, stream-openai), refNum, type RewriteCtx, and both
JSON rewriters — all live.

Tests: deleted the five suites that existed only to feed the dead rewriters
(tests/proxy-anthropic-stream, tests/proxy-sse, tests/sse-fixtures — the latter two
were near-identical copies — and the two "OpenAI SSE rewriter" cases in tests/basic and
tests/proxy-basic) plus the tests/fixtures/*.sse payloads they alone consumed. Flagged
in the PR: fixture-level OpenAI passthrough-integrity coverage was bound to the removed
implementation; the live buffer-to-finish adapters carry their own coverage in
tests/loop-adapters.test.ts.

Verification: typecheck clean; npm test GREEN apart from the two known env failures.

Four gaps remained after #460/#466, all on proxy-mode paths that bypass the compress loop:

- G1: native Responses compaction turns (resetAfterSuccess) still pipeThrough UNSTRIPPED.
  That turn is by definition the compression-triggered one, so its context necessarily
  carries render tags — highest echo risk of any Responses stream. Now routed through
  pipePluginResponsesWithStrip when the upstream answers text/event-stream.
- G2: rewriteResponsesJsonResponse (src/stream-responses.ts) never stripped render tags,
  while the anthropic/openai JSON rewriters do. stripResponsesText now runs before the
  compress loop, so the synthesized note is never edited and the no-compress early
  return is covered. stripItemContent also learned to strip a top-level item text, not
  only content[].text.
- G3: non-injected NON-STREAMING JSON (title-gen, ACP_NO_INJECT_TOOL, #353 bypass) still
  pipeThrough raw. Now routed through pipePluginJson, whose session became optional so
  usage accounting stays off — a tiny title-gen input_tokens would otherwise clobber
  lastInputTokens through the last-wins mergeUsageSample and break compression
  triggering.
- fast-path hole (#460 family, not recorded anywhere yet): the plugin passthrough pipes
  skip the tag filter when a chunk holds no visible tag and nothing is pending. A render
  tag whose OPENING straddles a chunk boundary — chunk one ends `...<ac`, chunk two
  starts `p tokens="177" type="text">m00155</acp>` — matches no detector on any single
  chunk, so the filter was never fed, never went pending, and the whole tag echoed to
  the client. endsPartialTag keeps a chunk-ending tag-like tail out of the fast path;
  the filter holds it and re-emits on the next push or at flush(), so prose is neither
  delayed nor lost.

Verification: typecheck clean; npm test 906/908 (the two failures are the known
network/launcher env cases, identical on master); E2E codex core 4-phase and the
E2E_FORGE=1 native-compact phase both GREEN against the local sglang upstream.

PR #464 shipped the mixed-chunk fix but referenced #462 everywhere (test names, code comment); the bug it fixes is #463. Also fixes the two test call sites that kept the pre-reorder pipePluginChatWithStrip argument order once #466's signature landed.

plugin-mode openai-chat SSE strip (pipePluginChatWithStrip/processOpenai) latched droppedText per-field, so a chunk whose delta carried e.g. content='real answer' plus a pure render-tag echo in reasoning_content was dropped whole, losing the real content. Now the chunk is dropped only when EVERY managed text field strips to empty (keptText guard); the existing non-text payload check (finish_reason/tool_calls) is unchanged.

Requests that skip compress injection (OpenAI title-gen exclusion, ACP_NO_INJECT_TOOL, #353 stop_sequences classifier bypass) piped upstream SSE straight through to the client. A model echoing history-borne ACP render tags into prose leaked them to the client TUI, where they re-enter the conversation via replay and amplify (same mechanism as #457/#459).

Route those non-injected streaming responses through the same tag-strip pipes as plugin mode (#452), with no session passed: usage accounting must stay off here, or a title-gen call's tiny input_tokens would clobber lastInputTokens and break compression triggering for the main conversation.

- pipePluginChatWithStrip: session now optional (signature reordered protocol-before-session); usage sample applied only when a session is present
- pipePluginResponsesWithStrip: session now optional, same guard
- server.ts !useRewriter branch: strip pipe for streaming requests whose upstream response is text/event-stream; everything else stays raw pipeThrough as before
- tests/proxy-noinject-tag-strip.test.ts: anthropic injectTool:false echo split across deltas stripped; tag-free stream byte-identical; openai title-gen stream stripped with [DONE]/finish_reason intact

release v0.1.71

fix: strip plugin-mode render-tag echoes on chat-completions/anthropic passthrough (#206 parity)
release v0.1.70

fix(#451): restore plugin-mode nudge injection + honest diagNudge log
fix(#422): refresh post-compress re-request view so the model sees the fold
0.1.69 (225ceb4) gated all three nudge injection sites by !pluginMode on
the premise that "in plugin mode the agent owns compression". The agent
plugins (pi/omp) are pure protocol clients with NO nudge channel of their
own, so the gate silently disabled proactive compression in plugin mode —
leaving only preflight at the hard limit — while diagNudge kept logging
"INJECT" (the log lied about delivery, same class as #413).

- Drop !pluginMode from the anthropic/openai/responses nudge gates. The nudge
  is an ephemeral trailing user message (not persisted, never enters the
  agent's re-sent history, prefix-cache-anchor safe), so it belongs in BOTH
  modes. Tool injection stays gated by !pluginMode (the agent provides tools).
- diagNudge now takes the actual willInject decision and prints INJECT only
  when the nudge truly reaches the upstream payload, else ARMED-SUPPRESSED.
- README two-modes table: split the tools/nudge row (tools = none in plugin
  mode, nudge = yes in both modes).
- tests/plugin-nudge-injection.test.ts: regression guard asserting the nudge
  is a trailing user message in BOTH proxy and plugin mode when armed
  (verified to fail 30!==31 against the old gate).

typecheck / test(901) / build green.

release v0.1.69

fix(#437): make upstream timeout idle-based so long healthy streams aren't truncated
fetchWithTimeout used a single TOTAL timer (UPSTREAM_TIMEOUT_MS = 10min)
that only cleared once the body was fully consumed. A healthy stream that
legitimately runs longer than 10 minutes (e.g. a DeepSeek generation via
bili dsh) was aborted at the 10-minute mark mid-stream: the for-await loop
threw, emitStreamError wrote 'stream error' + finish, and the client saw the
conversation truncated.

Re-arm the timer on every response-body chunk (via an idle-wrapped body), so
the timeout is IDLE-based during streaming: healthy long streams survive,
while a genuinely stuck stream (no chunk for timeoutMs) still trips the abort.
Time-to-first-byte bounding is unchanged. clearTimer/externalSignal semantics
are preserved and callers see an identical Response shape (status/headers/body).

fix(#429): openai chat — write merged terminal chunk once, don't leak finish_reason
Two independent defects in the openai adapter's parseStream, both reachable only
when the upstream merges delta.content and finish_reason into the SAME terminal
chunk (MiniMax-M3, kimi-k3, ...). The client's check is unconditional: any chunk
carrying content after finish_reason is set throws
"OpenAI Chat received content after the finish reason".

Defect 1 (single round, real tool call): the `if (finishReason)` block yielded
the raw chunk as meta but did not `continue`, so control fell through to the text
branch and the SAME bytes were written a second time. Add the `continue`.

Defect 2 (multi round, proxy tool): a round that only called proxy tools is not
terminal (core.ts reRequest) and emits no completion, but its merged chunk still
reached the wire carrying finish_reason, so round 2's text landed after the finish.

Invariant enforced: a verbatim passthrough chunk must never carry finish_reason and
must be written at most once; the sole authoritative completion is the final
round's emitCompletion — except a real-tool-call round (suppressCompletion), where
the verbatim terminal chunk IS the completion and keeps finish_reason, written once.

- stripFinishReasonChunk(): blank finish_reason on a passthrough chunk
- reasoning/text/meta branches: strip when the chunk carries finish_reason
- sawRealToolCall branch: keep verbatim + continue (no double-write)

Tests: tests/openai-finish-reason-dup.test.ts (4 cases: both defects + text-only
and reasoning regression guards). Full suite 896/0, typecheck + build clean.

fix(#377): re-voice mid-stream acp_summary as user messages (user-carrier)
README "Two compression modes" table: add a row for proxy-injected tools +
nudge (plugin mode: none; proxy mode: the 4 context tools + nudge). AGENTS.md
item 6: "suppresses tool injection" -> "suppresses tool + nudge injection".

The nudge (a trailing user message prompting compression) was not gated by
!pluginMode, so in plugin mode the proxy could inject a redundant nudge even
though the agent owns compression (its own overflow self-heal). Gate all three
nudge injection sites (anthropic/openai/responses) by !pluginMode, matching the
injectTools gating, so plugin mode is fully clean: no tool injection, no nudge
injection, summary carrier = the agent's own compress tool call.

Replace hoistMidSystemMessages (hoisted mid-stream system messages to the head
as separate system messages) with systemToUser (converts them to role "user",
leaving them at their anchor position).

Why: strict OpenAI-compatible backends (SGLang) require exactly ONE system
message at index 0 and reject a mid-conversation or second system message with
400 "System message must be at the beginning" (#377). The kernel renders each
compressed block's summary as role "system" anchored mid-conversation; in proxy
mode (plain client) the compress tool call is ephemeral and preflight blocks
have none, so the acp_summary is the only carrier. Re-voicing it as a "user"
message satisfies SGLang's one-system rule AND keeps the head system message
(the prefix-cache anchor) byte-stable across compress turns — a new block no
longer invalidates the whole-conversation prefix.

In plugin/launcher mode the summary carrier is the compress tool call (in the
agent's own re-sent history), so the kernel's acp_summary is stripped by
stripKernelSummaries and systemToUser is a no-op there.

- src/util.ts: hoistMidSystemMessages -> systemToUser
- src/server.ts, src/loop/adapter-openai.ts: call sites + two-mode comments
- README.md: new "Two compression modes" section (carriers, why they differ,
  why user not system/tool-call, coexistence determination)
- AGENTS.md: Key Design Decisions item 6 (two modes) + Before Making Changes
  item 4 (every wire-touching change must be reasoned in BOTH modes)
- tests: new system-to-user.test.ts (7 tests), updated e2e-mid-system.test.ts
  (summaries must ride on user messages, not leak into system), deleted
  hoist-mid-system.test.ts

release v0.1.68

fix: omp plugin install/remove idempotency (scoped match, existence check, overlay redirect)
fix: stateless decompress — pure retrieval, no deactivate, cache retained (#400)

fix: /bili/ tunnel destination admission + management-plane tunnel block (#409)
The /bili/<absolute-url> zero-config branch forwarded to ANY destination
with no host/scheme admission — including the proxy's own /__bili/
management plane (the tunnel's inner connection originates from the proxy,
so the loopback admin gate passes; PoC in #409), link-local metadata
addresses, and, on --host 0.0.0.0 deployments, anything reachable from
this machine: a LAN peer could PUT /__bili/config over the tunnel and
rewrite upstream routing to exfiltrate API keys, despite the
'management stays loopback-only' promise. The CONNECT side already had
its gates (mitm.ts) — this adds the /bili/ counterpart:

- src/tunnel-guard.ts: destination × client admission. Self (same port on
  any local address incl. 127/8) and link-local/metadata (literal or via
  DNS resolution of the destination name) are ALWAYS denied. Loopback/
  private destinations pass for loopback clients (the self-hosted
  upstream case the launcher httpRewrites flow depends on: sglang
  127.0.0.1:8199, ollama 11434) and are denied for remote clients unless
  listed in BILI_TUNNEL_ALLOWED_HOSTS (host or host:port). Public passes.
- forward() stamps x-bili-tunnel: 1 on every tunnel forward; /__bili/*
  rejects any request carrying it — the management plane stays
  unreachable through the tunnel even when self-detection misses (NAT
  hairpin, DNS-rebinding race between check and connect, chained bili).
- Startup logs the allowlist when set (the #405 silent-knob lesson).

Verified: typecheck, 891/891 tests (10 new), build; integration tests
reproduce the #409 PoC (403) and confirm 169.254.169.254 is denied
before any socket, local loopback upstreams still proxy, and the marker
reaches the upstream.

feat: instance governance — attach, liveness, handshake, graceful exit, atomic state (#417)
CI caught the stale assertion: win32 stopProxy is intentionally a no-op
(child.kill is TerminateProcess, zero flush); the test now pins that
contract on both platforms instead of assuming POSIX behavior.

L1 identity: proxy-origin file upgraded to JSON {origin, instanceId, pid,
startedAt, config fingerprint, launchToken} with atomic writes and legacy
plain-string read compatibility; /__bili/health echoes instanceId/pid/startedAt
(#403 read side).

L2 attach-or-spawn (#394): ensureProxyRunning attaches to a healthy,
config-compatible instance instead of doubling writers over one sessions dir;
stateDir instances.json registry prunes dead pids and warns loudly on dual
live instances.

L3 handshake (#407): no more probe-release-rebind TOCTOU — the child self-
binds, retries EADDRINUSE (+1 up to 16, then ephemeral) when launched with
BILI_LAUNCH_TOKEN, and reports the real origin via the instance file; forced
--debug on spawned proxies removed (12MB/day logs).

L4 graceful exit (#414): launcher children watch BILI_PARENT_PID and run the
flush path when the parent dies (Windows TerminateProcess never flushed);
stopProxy is a no-op kill on win32 (the watcher owns shutdown) and leaves
attached instances alone.

L5 state hygiene (#406, #403 write side): plugin-conversations.json gets
tmp+rename atomic writes, a dirty flag (dying paths no longer rewrite the
table), and corrupt-byte preservation + warning instead of silent zeroing;
plugin install refuses to freeze dead/unverifiable origins into client
configs (BILI_MCP_PROXY still wins).

L6 overlay antidote (#410): mergeOverlayEntry threads the generatedFile
exclusion through directory recursion (nested models.yml can no longer be
promoted into the real home); dead proxy URLs baked into a real models.yml
are unpacked mechanically on launch (live origins kept).

Three defects in the omp plugin idempotency checks (issue #392):

1. Whole-file, unscoped line matching: install/remove/status compared
   every line of config.yml against the entry value, so a same-valued
   line under any other key was counted as installed (blocking a needed
   install) or deleted (corrupting unrelated config). Matching is now
   scoped to the top-level extensions: block via ompExtensionItemLines().

2. No existence check, disagreeing with the loader: the launcher's
   ompPluginLoadedFrom() uses a suffix match + fs.existsSync, but
   install/remove used exact-string compare with no existence check.
   A stale entry (target file gone) was reported 'installed' while the
   launcher treated it as unloaded. All four paths now share OMP_ENTRY_RE
   + ompBlockLoaded(); status additionally reports 'broken' for a stale
   entry.

3. PI_CODING_AGENT_DIR drift to the overlay: inside a 'bili omp' shell the
   env points at the <home>-bili overlay whose config.yml can be a stale
   copy (merge conflict leaves a .bili-conflict). ompConfigFile() now
   detects the -bili overlay, redirects to the real home, and prints a
   note about which file was actually touched.

Tests: existing roundtrip fixtures now materialize the gitignored dist
artifact (the existence check needs it); new test covers all four
acceptance criteria (out-of-block line doesn't block install, remove
preserves out-of-block lines, overlay redirect leaves overlay untouched,
stale entry reports 'broken').

release v0.1.67

fix: Windows overlay symlink EPERM fallback + fail-loudly + SQLite set merge
Unprivileged Windows processes lack SeCreateSymbolicLinkPrivilege, so
fs.symlinkSync throws EPERM and refreshOverlayHome's bare catch{} swallowed
every failure while still returning true — the bili omp/pi/hermes/dsh overlay
ended up hollow (only the generated config + pid marker, zero links), so the
client read an empty home and launched fresh/unconfigured every time (the
Windows root cause of #289).

- linkOverlayEntry: on EPERM/EACCES/EINVAL fall back per entry type — dir ->
  symlink 'junction' (no privilege needed), file -> hardlink (same volume) ->
  copy. The overlay now works without Developer Mode.
- refreshOverlayHome: track link success/failure per entry; when the overlay
  comes out hollow (no entry resolvable) log a loud warning and return false
  so the launcher aborts instead of pointing the client at an empty home.
  Partial failures are reported, not silently dropped.
- mergeSqliteSet: move the SQLite three-piece set (.db/.db-wal/.db-shm) as a
  unit with two-phase rollback — a locked/half-migrated set can no longer
  corrupt the real home db.
- A write-through hardlink (nlink>1, same dev+ino as the real file) is
  re-pointed on refresh, never merged back (data-loss guard).

Tests: 6 new cases in tests/launcher.test.ts simulate the Windows EPERM
condition on Linux by denying fs.symlinkSync for non-junction links.
typecheck / test (868) / build all green.

release v0.1.66

feat: surface compress rejection reasons to the model (#362 ③) + acp-kernel 0.0.47

- parseCompressInput now returns {ranges, diagnostics} so callers can
  see WHY entries were dropped (kernel 0.0.47 invalidReasons)
- applyRanges failure receipt includes kind + dropped count + per-entry
  reasons (max 8, 200 chars each) — the model sees exactly which entry
  was rejected and why, instead of blind-retrying the same bad shape
- bump acp-kernel to 0.0.47 (persist retry backoff + spill files +
  tmp-name-per-attempt; exact pin per AGENTS.md)
- .gitignore: tmp/ (e2e sandbox dirs leaked into a commit earlier today)

fix: warn on tool-call XML fragments + close-side truncated acp echo (#361)
fix: surface raw-dump failures and rate-limit telemetry path noise (#362)
Detect the non-acp tool-call XML close-fragment family (</invoke>,
</tool_calls>, </parameter>, antml: namespace, JSON-escaped forms) that a
model may echo from the context — the same source as acp tag echo that
produces a "fake completion" (tool call written as text, no tool_use block).
Warn-only, NOT stripped: these fragments are indistinguishable from
legitimate prose discussing tool-call code (see #295 review), so stripping
would corrupt real content. A once-per-stream warn in the server streaming
loop makes the leak attributable in bili.log.

Also: close-side truncated acp tag handling (TRUNC_CLOSE) mirroring
TRUNC_OPEN; fix dangling RENDER_TAG_DETECT reference (regression); tests.

A varying query (?ts=...) would split one endpoint into unbounded keys,
defeating the suppression and growing the map without bound.

- Raw dumps (INCOMING/REQ/RES/SSE) that fail now log a rate-limited warn (first failure immediate, repeats suppressed within a 60s window, cumulative count) instead of failing silently. A stopped raw/ dump (disk full, locked dir) was previously invisible.
- The 'unrecognized path' telemetry warn (client event-report endpoints forwarded unchanged) is now rate-limited per path (first 3 at warn, one 'suppressed' notice, then silent) instead of logging every hit (~20k lines in one user's capture).
- No change to forwarding behavior; observability only.

typecheck clean; 845 tests pass; build success.

release v0.1.65

fix: omp pck stamp for chat-completions with max_tokens; anthropic wire consumes + strips pck (#268)
fix: 404 unknown /__bili/ paths + actionable MITM CA-rejection warning (#346)
fix: actionable no-valid-ranges compress error + defensive object tool args
fix: hoist mid-conversation system messages to the leading prefix on the OpenAI wire (#355)
fix: pass through Claude auto-mode classifier requests untouched (#353)
docs: align per-model context declaration precedence with actual resolution order
fix: per-route model context declaration outranks the models.dev registry (#344)
fix: show real CA path in web UI + Windows ~ expansion doc note (#342)
release v0.1.64

fix: default upstream proxy to direct; add gost-style -F forward flag (#346)
Address the two non-blocking review nits on #357:
- help/PR copy advertised gost's --forward but only -F is implemented
  (per the 'only -F for now' decision); drop --forward from the help text.
- CHANGELOG/PR claimed 'config' proxy wins regardless of mode, but a
  top-level proxy config still requires mode auto/manual (pre-existing);
  correct the claim to only -F / BILI_UPSTREAM_PROXY.

- Unset upstreamProxyMode now resolves to "direct" (matches the web UI's
  直连(默认) and ZCode's default) instead of falling through to env/system
  proxy auto-detection. Set mode "auto" to follow the system/env proxy; an
  explicit proxy (-F / BILI_UPSTREAM_PROXY / config) still wins regardless.
- Add `-F <url>` (gost-style --forward) to set the upstream proxy for a run;
  works for launcher cmds (before the client name) and `start`. HTTP/HTTPS
  only for now (SOCKS5 is a follow-up).
- Log the resolved upstream-proxy decision once per unique (host, proxy,
  source) — `[upstream-proxy] <host> via <proxy> (source=<source>)` — with
  non-public hosts masked (#255), so a silently-picked env proxy is visible.
- Fix the misleading `bili pi --mitm-domain ...` help example (that form
  forwarded the flag to the client; the working form is `bili --mitm-domain ... pi`).

Tests: default-direct (unset mode ignores HTTPS_PROXY), auto-mode opt-in
still follows the env proxy, and parseArgs -F placement (before/after client,
start cmd, composition).

CONFIGURATION.md claimed the per-model context declaration is primary and the
models.dev registry is only a fallback for undeclared models. The code
(src/server.ts native-window resolution) actually checks the WARM registry
(peekWindow) BEFORE the per-model declaration (configuredWindow), so for a
relay/private host whose model is listed in the registry, the registry value
silently outranks the user's declaration. This mismatch is what made the
#351 user's 200K declaration get ignored in favor of the registry's 1M.

Document the real resolution order and steer users to
compress.modelContextLimit (highest-priority source, always wins) for
reliably pinning the effective window. Behavior-preserving (docs only).

#349: when the model emits an empty compress call ({} -> missing-content)
the proxy returned 'Check your startId/endId parameters' — misleading, since
the real problem is no content at all. Replace with an actionable message
that tells the model to re-issue with a non-empty content array so it can
self-correct on the re-request round.

Also harden the OpenAI adapter: fn.arguments was only accepted as a string;
a non-standard upstream emitting it as an object was silently dropped
(-> same missing-content failure). JSON.stringify object args now.

typecheck clean; 824/824 tests pass (incl. new #349 regression); build ok.

The CONNECT e2e suite covered the MITM happy path, the idle-tunnel timeout,
and the remote policy, but never a blind tunnel to a non-whitelisted host
passing bytes both ways — the exact path ZCode's official site (zcode.z.ai)
uses. Add it: a local TCP server stands in for the upstream, and the test
asserts the round-trip plus the new 'tunnel ... established (blind TCP)' log.
Confirms the tunnel itself is sound, so the #346 zcode.z.ai failure is on the
ZCode/network side, not a bili tunnel bug.

tunnelThrough only logged on connect-timeout/failure/close, so a tunneled
host (e.g. ZCode's official site zcode.z.ai) that connects but then fails at a
higher layer left the log completely blind. Log the established tunnel,
mirroring doMitm's 'tunnel established' line but distinguishable (blind TCP,
not decrypted).

test: widen e2e mock pacing to keep streaming-span margin on slow CI runners

fix: generate real changelog in GitHub Releases via generate_release_notes

release v0.1.63

feat: codex compact family — combined #322/#323/#324/#325/#317/#319 + UA unification
codex 0.147 ships MCP tools to the model as a namespace tool type;
self-hosted servers (sglang/vllm/ollama/llama.cpp) do not parse it, so
the injected bili MCP tools become silently invisible and the model
fumbles for them. Users had no way to discover BILI_LAUNCHER_PLUGIN=0.

launcherInjectMcp now falls back to wire-injected flat tools when the
codex upstream host is loopback/RFC1918/ULA/link-local/.local and
BILI_LAUNCHER_PLUGIN is unset; BILI_LAUNCHER_PLUGIN=1 forces plugin
mode. claude is unaffected (it flattens MCP tools itself).

ework's pass (656f90d..ccd7036) and my pass (d4fe1ba) fixed the same
findings concurrently. This reconciles them, best mechanism per finding:

- MAJOR (summary loss after trigger-form forge): hybrid. The echoed
  fc_bili_ item is REPLACED by a summary-carrying user message
  (history-borne: kernel-compressible, kept by codex's user-message
  retention) — and ework's forge-time capture into
  session.metadata.codexForgedSummaries stays as the fallback: when the
  echo does NOT come back, the captured summaries are re-injected into
  the developer message. Exactly one path is active per turn (no
  duplication), and neither an echoed-item loss nor unbounded permanent
  injection can drop or bloat the handoff.
- tail-window cross-merge: keep ework's offset-0 guard (a head-to-head
  match is never a truncation reattach) over the byte-size heuristic.
- endpoint compact: strip-on-all-paths (ework) + all-or-nothing state
  commit with restore on gate-fail/rebuild-failure/error (mine).
- codexTurnIdentity: validated 'user' turns fall through to the legacy
  precedence chain (stronger headers may own the id).
- pending plugin register can no longer be claimed by a subagent thread.
- codex window clamp exempts an operator's explicit
  compress.modelContextLimit (operator tuning is never floored/clamped).
- trigger-form forge honors parsed.stream (JSON when not streaming) and
  mints usage from max(lastInputTokens, payload estimate, 1).
- isCompactionTrigger reuses hasCompactionTrigger.

823/823 unit tests; full e2e incl. forge + pre-forge deep recall pass
against local sglang.

prepareResponsesCompact mutated state and then returned the original (unstripped) body on gate-fail/catch — the echoed fc_bili_ item reached upstream. Strip before the early return so pass-mode, gate-fail, and catch paths all forward a clean body.

Tail-window reattach now excludes offset 0: a head-to-head match is either a full-prefix case (step 1) or a distinct conversation sharing a templated head — never a truncation reattach (the retained suffix of a truncation starts strictly inside the stored chain). Closes the cross-merge of two anonymous conversations with identical templated heads.

The trigger-form forge put block summaries into the opaque compaction item, but the kernel deactivates blocks whose messages vanish from the truncated replay (syncBlocks keeps a block only while one of its effectiveMessageIds or its acp_summary_ message is present) — so everything that lived only in compressed blocks disappeared from the model's view on the first post-forge turn.

Fix: capture the active blocks' summaries in the kernel's render format at forge time and at echo-strip time (upgrade path for sessions forged by an older build) into session.metadata.codexForgedSummaries (persisted, exact-text dedup), and re-inject them into the developer message on every /responses turn.

Tests: unit coverage for renderForgedSummary/mergeForgedSummaries; mock-upstream integration test asserting the post-forge turn strips the echoed fc_bili_ item and re-injects the summary as a developer message; e2e forge phase now also recalls a sentinel that was compressed pre-forge (archive #6, not in codex's retained tail).

# Conflicts:
#	src/server.ts

Model: glm-5.3 — test: e2e suite — real codex through bili (warmup/load/compress/purity/forge)

fix: warn when running process is stale relative to on-disk install (#327)

The /bili/ prefix section had no Claude Code entry. Add it (claude's undici
fetch ignores HTTPS_PROXY, so the /bili/ URL form via ANTHROPIC_BASE_URL is
the only manual option) plus a note that manual mode must set
CLAUDE_CODE_AUTO_COMPACT_WINDOW itself — the bili claude launcher does this
automatically (PR-D) but manual /bili/ users do not get the injection.
claude clamps the value down to its perceived model window, so over-setting
is safe. EN + zh-CN.

When BILI_CODEX_COMPACT=intercept, the client is codex (UA codex_cli_rs/),
and the ACP state is healthy (transform ok + steady-state < 90% of window +
an active block to hand off), bili forges a success response for codex's
native compaction requests and never contacts upstream — a deterministic
handoff to the ACP state:

- trigger form (input ends with compaction_trigger): forged 2-frame SSE
  (response.output_item.done with one {type:compaction, id:fc_bili_*,
  encrypted_content:bili:acp:<summary>} + response.completed with honest
  usage). Matches codex's collect_compaction_output contract (exactly one
  compaction item + response.completed; all other events ignored).
- endpoint form (POST /responses/compact): forged JSON {output:[...]} where
  output is the rebuilt history (recent items + ACP summary), matching
  codex's CompactHistoryResponse parser.

Otherwise (kill-switch off by default, ACP not keeping up >= 90%, or nothing
compressed yet) the request passes through to upstream and native compaction
backstops (rebased by the PR-C semantic gate). Bili-generated compaction
items (fc_bili_ prefix / bili:acp: sentinel) are stripped from subsequent
requests so codex's echoed item does not double-count.

Pre-flight: typecheck clean; 752/752 tests (7 unit + 5 e2e new); build ok;
forge code verified inlined in dist.

The rebase marker fired on ANY 2xx upstream response, so a failed,
incomplete, or truncated compaction stream scheduled a rebase on top of a
compaction that never happened (the #249 false-rebase bug): bili discarded
valid blocks and mis-tracked the ledger.

- new src/stream-terminal.ts: terminal-state observer for Responses
  responses. SSE: response.completed -> completed, response.failed /
  response.incomplete -> failed, no terminal event before EOF -> unknown.
  JSON: output array -> completed, error field -> failed, unparseable /
  empty / oversized -> unknown. unknown is the safe default (no rebase;
  next request self-heals via kernel message-id deactivation).
- server.ts: both markNativeCompactionBoundary call sites now gate on
  observed terminal === completed. 2xx empty body no longer rebases.
- trigger-path hardening in prepareResponses: a trailing
  compaction_trigger request is detected BEFORE injection — no compress
  system prompt, no compress tools, renderTags none, compressInjected
  false (response flows through plain passthrough instead of the
  rewriter), and resetAfterSuccess is set so its terminal state gates
  the rebase. Nudge skip now keys off the same pre-injection detection.
- tests: 16 unit tests for the observer + e2e through the proxy covering
  completed / failed / truncated / 2xx-error-JSON / empty-body /
  JSON-output and the clean-forward assertions; finality-test control
  moved to a separate session (a completed trigger now rebases the
  compacting session, legitimately disarming its nudge).

Tail-pinned matching only reattached when the client retained <=
TAIL_WINDOW (8) items; a rolling-window client keeping 20-30 items
orphaned on every slide. Search the incoming's leading window across the
stored chain's per-item hashes at any offset (<= 57 offsets x 8 compares,
trivial). MAX_STORED_ITEMS 64 -> 128 so deeper windows stay searchable.

guardian_review / guardian_classifier (Feature string) / memory_consolidation
threads previously fell back to the legacy chain and landed on the root
session — the exact #150 isolation violation. Invert the discrimination:
only "user" joins the root session; every other thread_source with a valid
thread_id cross-check partitions by thread-id.

A client that drops its oldest messages (truncation) previously failed the
full-depth prefix match and was orphaned into a fresh pfa- session, stranding
the old session's compression blocks. resolve() now falls back to a
tail-window comparison: the incoming's leading items are matched against each
stored chain's trailing items (position-independent per-item hashes), so a
truncated replay reattaches its original session and the kernel reconciles by
message id (verified: blocks deactivate gracefully on truncation and revive on
full replay, no throw).

New pfa- sessions also record a best-effort lineage (discarded candidates +
reason truncated/forked) in session metadata for UI/debug only — never used
for matching. Live-edge overwrite semantics unchanged.

- src/prefix-affinity.ts: TAIL_WINDOW/MIN_TAIL_MATCH/MAX_STORED_ITEMS/
  MIN_FORK_PREFIX; perItemHashes + lcpLength; resolve() gains tail-window
  reattach + lineage; note() stores per-item hashes; AnonymousAffinity gains
  via + itemHashes + lineage.
- src/server.ts: tail-window reattach log line; note() 4th arg; metadata
  records via + lineage.
- tests/prefix-affinity.test.ts: note() call sites updated to 4 args.
- tests/tail-window-reconcile.test.ts (new): tail-window reattach, prefix/new
  regressions, fork + truncated lineage, and a kernel reconcile anchor test.

Independent of PR-A (codex turn-metadata, #317). Pre-flight: typecheck OK,
746/746 tests, build OK.

Codex (>=0.147) reuses the root session-id header on subagent threads, so
the legacy identity chain mapped every thread of one task onto a single
session — subagents inherited the root's compression state, breaking #150's
isolation (a guardian subagent must read the user's original authorization
verbatim, never a compressed summary).

Add codexTurnIdentity(): when x-codex-turn-metadata is present and
cross-checked (metadata.thread_id === thread-id header), partition by
thread_source — "user" keeps the session-id header (current root
semantics, stable across turns), "subagent" gets its own thread-id (fresh
independent state per thread). Untrusted metadata (absent / unparseable /
mismatched / unknown thread_source) falls through to the legacy chain
unchanged, so no-metadata clients (omp pck, etc.) see zero regression.

The trusted id enters the verbatim session chain directly (no hashing, no
new dimensions) and bypasses subagentNamespace, leaving the kernel's
empty-instructions non-anchoring path untouched for metadata-less clients.

Tests: unit coverage for both thread_source values, cross-check mismatch,
JSON parse failure, unknown thread_source, missing headers, omp pck chain
unaffected; plus a server-level test proving subagent and root threads land
in distinct isolated sessions.

docs: add issue-work delivery requirements to AGENTS.md

release v0.1.62

feat: #189 compress observability + gated staged/tail-bias steering (smooth transition)
fix(logger): rotated log file orphan inode — reopen or degrade to stderr-only (#210)
release v0.1.61

fix: preflight compress fails fast on upstream errors instead of forwarding over-window payloads (#301)

fix: honor anthropic-beta context-1m to size the context window
fix: detect chained bili instances via x-bili-hop marker (#300)
fix: adopt complete terminal usage atomically in anthropic adapter (stitched-stream double-count)
release v0.1.60

feat: anonymous prefix-affinity session resolution (#309)

Clients that cannot send any identity signal (no headers, no body
session_id / prompt_cache_key) still replay their full history every
request. Resolve them by longest-prefix hash-chain affinity instead of
the #286 hard 400: h_i = sha256(h_{i-1} || msg_i) over the client-raw
messages, matched against per-partition (protocol | upstream | auth)
stored chains, fork-safe (divergent histories split on their next
request), deterministic ids (pfa-<hash16>) so identical replays
reattach after restart. Degenerate requests (empty / system-only)
keep the explicit 400. Logging on every resolution (session, match
depth, partition, tail hash).

- Observability (always on): applyRanges now records the last successful
  compress (shrink ratio, fold point, blocks, tokens) on the session and logs
  an [acp-compress-obs] line per compress.
- 3007 correlation: the replay/preflight retry callbacks now append a
  [after compress: shrink N% foldPoint=X ...] suffix so a transient upstream
  rejection is correlated with the rewrite that preceded it.
- Staged compression + prefix survival (gated behind BILI_MAX_SHRINK_PER_COMPRESS):
  when a single compress shrinks context more than the configured fraction, the
  tool result steers the model toward smaller, TAIL-biased ranges (keep the
  stable prefix intact), and the nudge text gains a smooth-transition guidance
  block. Off by default (legacy behavior).
- 10 new tests in tests/compress-observability.test.ts.

fix: tag-echo filter strips truncated tag fragments (#294)
When an Anthropic-format request carries an anthropic-beta header with a
larger-context beta (e.g. context-1m-2025-08-07), the client has negotiated a
1M-token upstream window. The proxy now sizes its own context window to match
(1,000,000), overriding the model-table default (200K for claude).

The beta is the most direct per-request evidence of the window the upstream
will serve, so it is resolved at the top of the native-window cascade and
re-read on every request (the header may appear/disappear between requests of
the same session). This fixes EMERGENCY / nudge / usage bands firing against
the wrong denominator — e.g. 261K input was 131% of 200K (EMERGENCY) when it
was really 26% of the 1M window the client negotiated.

- anthropicBetaContextWindow(): parses context-Nm betas (N x 1,000,000),
  largest wins.
- e2e: EMERGENCY suppressed at 261K input under the 1M beta; fires at the same
  input without the beta (200K). Plugin effectiveContextLimit is 1M with the
  beta and drops to 200K when the header disappears (per-request).

When two bili instances are chained (A forward -> B), both run the
compression pipeline on the same request, double-compressing and
corrupting session state (root cause of #292).

- Outbound: stamp x-bili-hop: <instance-uuid> on every request this
  instance actually processed (main forward + preflight summarization +
  compress-loop calls via shared headers). A passthrough forward does NOT
  claim processing, so a dumb-proxy bili in front of a processing one is
  unaffected; any inbound marker keeps propagating down the chain.
- Inbound: if a request already carries x-bili-hop, an upstream bili already
  processed it. Skip ALL processing (no tool/tag injection, no acp-loop, no
  session state) and pass it through verbatim, logging a prominent [warn]
  naming the upstream instance (self-loop vs foreign chain).
- e2e: two tests — B in isolation with a foreign marker (body reaches the
  LLM byte-identical, no tool/tag injection, marker propagated), and the full
  A->B chain (single-layer processing, B warns, one LLM request, marker is
  A's not B's).

Clients never send this header, so single-instance use is unaffected.

release v0.1.59

test: normalize macOS /var->/private/var tmpdir paths in launcher rewrite tests
fix: recognize Codex context-overflow errors and learn conservative window when unparseable
release v0.1.58
fix: bili claude/codex auto-inject native MCP tools by default (#290)
refs #280

On macOS os.tmpdir() returns /var/folders/..., which is a symlink to
/private/var/folders/.... fs.realpathSync in the pi/omp http-rewrite
tests resolves through it, so the strictEqual path asserts compared a
realpath'd left side against an un-realpath'd right side and failed.
Canonicalize the mkdtempSync result up front; no-op on Linux.

Full suite 687/687 on Linux.

fix: keep Codex compaction_trigger final when injecting the compress nudge
docs: how to open PRs without the gh CLI (credential + REST API)

Documents the exact procedure the environment actually supports: no gh
binary, but git push works via the credential helper and the same
credential opens PRs through the GitHub REST API. This unblocked #282
(analysis was complete, PR creation was not).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

fix: floor fallback-derived context window at 100k after output-headroom reservation
fix: resolve registry window for relay hosts via provider-prefixed key scan
fix: body dumps off by default (ACP_DUMP_BODY) + reuse log-mask in dumps (#276)

feat: session id is the client-provided conversation value verbatim; 400 without identity (#286)
- dsh: force PI_CACHE_RETENTION=long so pi-ai stamps prompt_cache_key
  (the dsh session uuid) on every request; the prompt_cache_retention
  side-effect field is stripped proxy-side before forwarding upstream.
- hermes: write a bili-session-identity provider plugin into the
  ~/.hermes-bili overlay — it re-registers the bundled custom profile
  (plus custom:<name> for every proxied provider) with the real hermes
  session id as prompt_cache_key. Skipped when ~/.hermes already has a
  plugins/ dir (never write through the overlay symlink).
- fix: prepareHermesHome now strips YAML quotes before matching api
  values — quoted endpoints previously failed to rewrite silently.
- prompt_cache_retention stripped on the openai and responses paths
  (OpenAI-host-only directive; third-party upstreams may reject it).

Close #290. BILI_LAUNCHER_PLUGIN now defaults to on for claude/codex
(both flags verified on claude 2.1.227 / codex 0.147.0 and soaked since
08-16); set BILI_LAUNCHER_PLUGIN=0 to force pure wire mode.

Strong-signal conversation identities (codex session-id/thread-id,
claude x-claude-code-session-id, opencode x-opencode-session,
Responses body session_id, prompt_cache_key) now derive the session id
from hash(protocol|conversation) only. The 4-dim hash
(protocol|upstream|credential|conversation) is retained for weak
signals (content fingerprints), where the credential/upstream
dimensions provide collision isolation.

Rationale: the credential dimension is a rotating OAuth bearer for
Codex/ChatGPT and the upstream dimension changes when users switch
relays — either change orphaned all compression state mid-session
(#280). The client's native session id is the only invariant bound to
the conversation.

One-time cost: existing persisted sessions are re-keyed once (old
records orphaned), accepted per issue discussion.

refs #280

Codex's native remote-compact request ends with a compaction_trigger
input item that the upstream requires to be the FINAL input item. The
compress nudge injection appended a user message after it, so any
nudge-armed session that hit a native compact got
400 "The 'compaction_trigger' item must be the final input item."
and the pre-sampling compact (and the turn) failed.

Skip the nudge when the final input item is a compaction_trigger —
the native compact IS the compression, and the developer-message /
tool injections (head of input) are unaffected.

Test: Responses e2e — trailing compaction_trigger stays final with no
nudge appended; control request keeps the nudge. Full suite 662/662.

closes #280

After a macOS sleep/wake (or any rebind), a restored session starts with
lastInputTokens=0, so the nudge is blind and only the preflight fit check
can catch an oversized replay. Two gaps left the Codex path stuck:

- Codex's overflow error (code context_window_exceeded / "ran out of room
  in the model's context window") matched none of CONTEXT_OVERFLOW_PATTERNS,
  so no self-heal ever armed and the same oversized payload was re-sent
  forever.
- Even when the error is recognized, Codex's body carries no window number,
  so parseOverflowWindow found nothing and the floor stayed 0.

Now: both Codex markers are recognized, and when no window is parseable the
proxy learns a conservative window from the rejected payload's own size
(only-shrink-never-grow). The next turn's self-heal re-centers the limit
and preflight compresses the history below it before forward.

Tests: 2 unit (Codex error recognition) + Codex e2e self-heal (400 without
window number -> learned window -> preflight fold on the retry) + new
Responses-protocol e2e for the issue's suggested restore scenario (fresh
session, lastInputTokens=0, raw history over the window -> preflight
compresses before forward). Full suite: 661/661 pass.

- Decouple all plaintext body dumps (dumps/req-*.json, raw/*-REQ.txt,
  raw/*-RES.txt, raw/*-INCOMING.txt, req-*-REREQUEST.json) from --debug:
  they are now opt-in via ACP_DUMP_BODY=1, so `bili <client>` users
  (launcher hardcodes debug:true) no longer leak conversation bodies to
  disk by default.
- Reuse maskHeadersForLog from src/log-mask.ts for dump header filtering:
  now covers cookie/set-cookie (the old /key|auth|token/i regex missed
  them) and non-public hosts.
- Mask the upstream URL in raw/*-REQ.txt (maskUrlForLog) and the incoming
  path in raw/*-INCOMING.txt (maskUrlsInText) so non-public hosts don't
  land in dump files.
- INCOMING dump now honors ACP_RAW_DUMP_DIR (was hardcoded to stateDir/raw).
- Add tests/dump-mask.test.ts: dumps off by default under --debug; with
  ACP_DUMP_BODY=1 dumps carry no credentials and no non-public host; dumps
  work without --debug.

tunnelThrough logged err.message verbatim; OS/undici error text embeds
the endpoint ("connect ECONNREFUSED 10.0.0.5:8443", "getaddrinfo
ENOTFOUND relay.internal"), re-leaking the host the template just
masked. Add maskHostInText() to log-mask.ts and apply it to the two
tunnel error log lines; formatUpstreamError now reuses the same helper
(also fixes the bracketed/bare IPv6 mismatch in its scrub).

+2 tests: unit coverage of maskHostInText, e2e CONNECT tunnel failure
asserting no raw target host in any captured log line.

bili.log and the launcher tmp log (bili-proxy-${port}.log) received
plaintext secrets and non-public upstream endpoints:

- 'forward GET/POST -> <upstreamUrl>' logged the full upstream URL on
  every request (always-on, not debug-gated)
- '-> upstream headers:' / '<- upstream response headers:' logged
  authorization / x-api-key / cookie / set-cookie values verbatim
- formatUpstreamError embedded the raw upstream URL and the failing
  host (ECONNREFUSED 192.168.x.x, ENOTFOUND relay.internal)
- mitm/CONNECT logs exposed tunneled upstream host:port

New src/log-mask.ts:
- credential headers (key|auth|token|cookie) -> '<masked N chars>'
- non-public API hosts -> '<private-host>' (port kept); well-known
  public endpoints (openai, chatgpt, anthropic, deepseek, googleapis,
  azure, mistral, groq, ...) stay verbatim for debuggability
- userinfo/query/hash stripped from logged URLs (key-leak vectors)
- error message/address fields scrubbed of non-public hostnames

debug: true in the launcher is intentionally kept (per #255 triage);
dump-file gating and GC are tracked in separate issues.

release v0.1.57

fix: identity plugin binding survives model switches across sessions

fix: preflight-compress oversized context before forward on model switch
fix: stamp type:message on type-less Responses input items (omp wire form)
fix: omp chat-completions sessions get pck identity + /acp panel

release v0.1.56

fix: plugin passthrough review follow-ups (non-streaming strip parity, stream-cut tail flush, multi-line data, malformed parts)

regression: #257 omp native plugin mode + #258 drop whitespace-only Responses items

estimateCoreMessages/splitChunks/summary-cost used estimateTokensFast
(chars/4), which undercounts CJK ~4x. On a stable CJK session the loop's
fit-check therefore saw an undersized payload and broke without
compressing, forwarding an oversized body -> upstream 400. Use the
kernel's CJK-aware defaultCountTokens and floor the loop on the session's
measured lastInputTokens (which also covers system prompt + tool defs).

Adds a CJK e2e test (red before, green after). 633/633 pass.

A failed identity register left state.sid set, so the early-return guard
blocked every retry — the session stayed in wire mode (duplicate ACP tools)
for its whole life. Don't mark the sid done until the register succeeds, and
guard the tool loop with a separate toolsFor flag so a retry re-fetches the
manifest without re-registering tools. Make the retry interval injectable
(createBiliPlugin opts.retryIntervalMs) so the test can actually assert the
post-throttle retry.

omp 17.x mounts extension tools that omit loadMode under xd:// devices
(invisible to the main turn); declare loadMode:"essential" so the four
ACP tools reach the model natively. omp emits no before_provider_headers,
so bind the conversation via the launcher identity register (#162 path)
once tools are ready — bound sessions run in plugin mode with wire
injection suppressed. before_provider_request doubles as the retry
driver. Full native roundtrip e2e-verified against omp 17.3.8.

fix: net post-compress usage credit + hand launcher context windows to the proxy (#252)

release v0.1.55

merge: joint regression branch for PR#246 + PR#248 (dsh launcher + omp zero-config)

omp does not ship the bili plugin; the launcher now rides -e dist/agent/omp.js
when the config carries no loadable bili entry (mirrors pi, PR#227). omp keeps
wire-injected tools (its fork never surfaces extension tools to the model and
emits no before_provider_headers); the injected plugin provides the native /acp
command. Server log now prints the effective injectTools flag, not the raw one.

Stateless full-history replay clients without conversation headers (omp
without its plugin) minted a new session every request: the kernel identity
chain hashes the ENTIRE input array, which changes as the conversation
grows. New session per request meant the nudge always evaluated 0 tokens
(usage lands after the first forward), so 90%+-full contexts were never
compressed and the upstream affinity churned every turn. prompt_cache_key —
the client's own stable conversation id — now replaces only that
fingerprint; headers, body session_id and previous_response_id still win.

bili dsh now splices a --patch overlay (~/.dsh-bili/.bili-acp.patch.yml)
into dsh's argv, inserting dist/agent/dsh-acp.js (a cordis command plugin,
same shape as dsh-command-compact) into whatever profile boots: a real
/acp slash command on the web/tui surfaces.

- src/agent/dsh-acp.ts: /acp → status panel (fallback=latest), else
  armed-but-idle info (#235 wording), else unreachable error
- src/agent/shared.ts: +fetchStatusLatest (conversationId=dsh placeholder;
  server rejects the empty string)
- src/launcher.ts: +writeDshAcpPatch (independent of the settings rewrite,
  exists with zero custom providers) +dshArgsWithPatch (prepend / after
  'web' / skip for 'plugin' & --dump-default-config)
- tests: launcher 85 (incl. argv assertions in runLaunch dsh e2e),
  tests/dsh-acp.test.ts 4 outcomes; 610/610

This reverts commit a2af88ffda51fa94082067e8e49cb4672bd17537.

- client-config: resolveDshHome / parseDshSettingsYaml / readDshConfig
- launcher: dsh route discovery, prepareDshHome overlay (settings.yaml line
  rewrite, CRLF-preserving, siblings symlinked), runLaunch branch wiring
  BILLION_CONTEXT_PROXY + DEEPSEEK_BASE_URL (built-in deepseek-official route)
- fix(openai adapter): emitCompletion usage numeric fields ?? 0 — undefined
  prompt_tokens/completion_tokens serialized away and strict clients (dsh
  mapUsage) compute NaN → non-JSON-serializable session events
- cli help, README/CONFIGURATION (en+zh), 7 new tests

release v0.1.54

[Fix] 限制 Responses round-2 重映射 message ID 长度

Sanitize over-long input item ids before the projection reads them so
conversations stuck on 400 recover on the next request. Complements the
root-cause fix (hashId remapping) in this branch.

fix: surface truncated upstream streams instead of silent end_turn

fix: allow remote clients when bound to a non-loopback host (#240)

- setupMitm: new allowRemoteClients flag; remote CONNECT accepted only for
  whitelisted model hosts (blind tunnels stay loopback-only — no open relay)
- server: derive the flag from --host; honest 0.0.0.0 display + [security]
  warning on non-loopback binds
- tests: #77 case extended to the full remote policy matrix
- docs: README/CONFIGURATION remote-agents section (en+zh)

Follow-up to #221 (PR #222): the reporter still sees the agent stop
while outputting a long conclusion. If the relay drops the SSE stream
mid-generation (before message_delta/message_stop), the loop received
no done event, finishReason stayed undefined, and emitCompletion
defaulted it to a clean end_turn — the client then ended the turn
mid-text with no indication anything went wrong, which reads as the
agent silently stopping.

Detect the abnormal termination (no done event in the round) and emit
an explicit [acp-proxy: upstream stream truncated (...)] error block
plus an error-level log, so truncation is visible and diagnosable
instead of masquerading as a normal turn end.

Refs https://github.com/ranxianglei/billion-context/issues/221

release v0.1.53

docs: README header badges + centered install command (match billion-context-pi)

release v0.1.52

fix(agent): /acp shows armed/idle info instead of warning before first request
A 404 from the status endpoint on a live proxy means the conversation
has sent no model request yet (e.g. /acp right after bili pi starts) —
not an error. Probe the manifest for liveness+version and show an info
notice; keep the old warning only when the proxy is unreachable.

release v0.1.51

fix: legacy billion-context-pi entry wrongly suppressed launcher -e injection
isPiEntry (PR #227) matched legacy npm:billion-context-pi entries as
'installed', so piPluginInstalled() returned true and the launcher
skipped -e — but that legacy package usually is not installed, leaving
pi with NO plugin at all (/acp gone). Add isBiliPiEntry() which only
matches billion-context itself; install/remove keep the broad isPiEntry
for legacy cleanup.

docs: recover deleted README usage detail into CONFIGURATION (en+zh)
fix: persistent overlay homes for pi/omp/hermes launchers (session history loss)

# Conflicts:
#	README.md

# Conflicts:
#	src/launcher.ts

docs: align README with the zh edition, drop plugin-install section
Review of the first overlay commit found three paths where overlay-created
data could be silently destroyed instead of merged back into the real home:

- a regular FILE shadowing a real-home entry was rm'd without merging
  (client atomic-write-over-symlink lost its newer content)
- the dir merge was shallow: new files under subdirs that exist on both
  sides were skipped and then deleted
- a type clash (real-home file vs overlay dir, or vice versa) aborted the
  merge silently while the caller still rm'd the shadow subtree

mergeOverlayDir is replaced by a recursive mergeOverlayEntry that preserves
both sides (mtime-newer-wins for files, losers kept as
<name>.bili-conflict) and reports failure; the caller now removes the
shadow only when the merge fully succeeded, otherwise it is kept in place
with a warning. Also add a .bili-launch.pid marker that warns when two bili
launches share one overlay (each launch rewrites the generated config with
its own proxy origin).

Regression tests for all three loss paths plus the pid marker.

- English restructured to mirror README.zh-CN.md: Quickstart two ways
  (launcher / URL change), Option 1 Launcher (intro + usage block only),
  Option 2 URL change, no plugin-mode deep-dive, no mode matrix, no
  redirect/CA or discovery tables, no temp-copy details, no subagent/
  export sections.
- Drop the zh plugin-install note: bili pi now auto-injects the native
  plugin via -e when not installed (#227), so install is never required.
- Status: mock test count 146 -> 500+.

fix: bili plugin install pi replaces legacy billion-context-pi entries
fix(loop): strip model-emitted render tags to break #206 echo feedback loop
The pi/omp/hermes launchers built their /bili/-rewritten config inside a
mkdtemp temp home (PI_CODING_AGENT_DIR / HERMES_HOME) and rm -rf'd it when
the client exited. Clients persist absolute session paths derived from that
temp home into shared state (e.g. omp ~/.omp/agent/terminal-sessions/pts-*
resume pointers), and any file the client created directly inside the temp
home (agent.db, models.db, ...) was destroyed on exit — making omp session
history invisible and leaking temp dirs when the process was killed.

Replace temp homes with stable persistent overlays next to the real home
(~/.omp/agent-bili, ~/.pi/agent-bili, ~/.hermes-bili): every launch
refreshes entry symlinks (dropping dead ones, merging overlay-created
entries that shadow real-home entries back into the real home) and rewrites
the generated config atomically. The overlay is never deleted, so paths
stayed resolvable across runs.

opencode/claude/codex launchers are unaffected (env/temp-file only).

- fix filter false-swallow after paired tags (wasPaired check)
- containsRenderTagText: detect JSON-escaped open tag with attrs
- non-stream gates use containsRenderTagText (catch bare lone tags)
- openai rewriteContentChunk preserves sibling choices
- add e2e tests (real server, compress round-trip) and non-stream tests

Models sometimes imitate the render tags injected into compressed history
and emit them in visible output. The client stores the echoed tags, replays
them next turn, and the imitation amplifies into unbounded repetition (#206).

Strip render tags from outgoing text in all three wire adapters (streaming
and non-streaming) with a streaming-safe filter; underscore-namespaced
text-protocol triggers and ordinary prose pass through untouched.

launcher pi branch prepends pi -e <dist/agent/pi.js> only when the user
has NOT run plugin install pi (mutually exclusive: pi dedups extensions
by resolved path, packages entry vs -e file would double-register).
Uninstalled users now get the native tool panel and /acp out of the box,
matching bili omp / bili opencode behavior.

isPiEntry now recognizes the legacy billion-context-pi npm form, dev
checkout paths, and Windows backslash paths, so install leaves exactly
one live bili plugin instead of stacking the old 0.1.x package next to
the new one. piStatus reports strictly on the current install root.

release v0.1.50
docs: cover all six launcher clients + backfill changelog 0.1.41-0.1.50
README keeps only the upstream-proxy (GFW) subsection; the reference
material (config file, top-level keys, providers, compression tuning,
env vars) lives in the dedicated CONFIGURATION docs. English README
mirrors the zh restructure (drop env-var table, Two ways, fix heading).

Removed detailed environment variable configuration section from the README file.

Removed duplicate sections and clarified usage instructions for the proxy and client configurations.

feat(launcher): add hermes to the bili launcher (/bili/ rewrite, no MITM)
fix(openai-sse): settle tool calls at finish, not per-chunk (name-split regression)

fix: survive upstream rejection of replayed thinking blocks in compress loop
PR #190 added fetchWithRetry (transient 429/5xx/risk-control replay retries,
BILI_REPLAY_RETRY_MAX opt-out) to the re-request path. Keep that machinery
and layer the #221 fix on top: a deterministic 4xx that survives transient
retries triggers one degraded retry with loop thinking blocks stripped
before the error is surfaced.

After a proxy-tool round (compress/decompress/acp_status/search_context),
the loop rebuilds the conversation and re-requests upstream. The rebuilt
assistant message replays thinking blocks collected from the intercepted
turn. Two failure modes halted the agent right after the ACP marker
(upstream issue #221, MiniMax M3 via Anthropic-protocol relay):

- signature-less thinking (relay never sends signature_delta) cannot be
  replayed on Anthropic-protocol endpoints that validate -> 400
- even signed thinking replay is rejected by some relays/converter
  backends

Both made the loop emit an error and end_turn, so the agent stopped
executing right after the ACP tool result.

Fixes:
- proactively drop signature-less thinking segments from the replay
  (they are already streamed to the client verbatim in round 1)
- if the re-request is rejected with a 4xx, retry once with all
  loop-injected thinking blocks stripped (degraded retry, at most one
  per turn) before surfacing the error

Fixes https://github.com/ranxianglei/billion-context/issues/221

fix(loop): auto-retry acp-loop replay on transient upstream rejections (#189)
release v0.1.49

fix(config): correct stale context limits + registry-first resolution + Web UI parse-error guard

- CONTEXT_LIMIT_TABLE: deepseek 64K -> 128K (V2-era value caused constant
  emergency compression out of the box); add minimax -> 204.8K
- registry: map api.minimax.chat / .com / .io so provider-qualified keys
  like minimax/MiniMax-M2.1 resolve; new sync peekRegistryContext()
- server: native-window order is now plugin header -> warm registry cache
  -> user route declaration -> built-in table -> async registry fallback.
  A stale table can no longer lock out fresh registry data; cold start
  still never blocks on a fetch.
- web/api: GET /__bili/config surfaces parseError for an unparseable
  config; PUT returns 409 instead of silently rebuilding the file from {}
  (which wiped every field the JSON loader could not read) — reported on
  #212 as 'config overwritten after npm install'
- web/client: toast the parseError path
- tests: +8 (registry unit tests; broken-config PUT guard)

release v0.1.48

fix(plugin): stamp x-bili-plugin only after tools are registered
Review follow-up: the graceful-degradation claim (dead manifest endpoint
-> permanent wire mode) had no test. Drive session_start against a fake
proxy that 404s every request and assert the plugin stays silent.

The header tells the proxy the client owns the ACP tools natively (skip
wire injection). Stamping it synchronously while registerTools() is still
fetching the manifest sent the first provider request out tool-less in
one-shot runs. Gate the stamp on toolsReady; until then the request
rides the proxy's wire mode, and a dead manifest endpoint degrades
gracefully instead of stranding the session.

fix(responses): remap round-2 message lifecycle so codex stops dropping text deltas
fix(launcher): never reuse a proxy port — always spawn a fresh instance

release v0.1.47

feat(launcher): add opencode as a launch client + --bin flag

process.argv[1] stays at the global bin symlink (~/.local/.../bin/bili),
so path.dirname(argv[1]) pointed into the bin dir and the existsSync
guard silently skipped dist/agent/opencode.js (and dist/mcp.js in the
MCP configs). Resolve via selfPackageRoot() (import.meta.url-based,
symlink-safe through Node's realpath) instead.

Verified via the global bili: opencode run now loads the bili plugin
('[bili-opencode] plugin active') and the session log shows
dist/agent/opencode.js loading.

- src/agent/opencode.ts: 1.8KB plugin shipped as dist/agent/opencode.js;
  registers the acp command via the config hook, renders the proxy's
  buildStatusPanel through an ignored no-reply chat message. Active only
  under BILLION_CONTEXT_PROXY (launcher), no-op otherwise.
- prepareOpencodeHttpRewrite(pluginPath?) injects the plugin's absolute
  path into the throwaway OPENCODE_CONFIG copy so bili opencode needs no
  install step and never touches the real opencode.json.
- handlePluginStatus(..., fallbackLatest): ?fallback=latest resolves an
  unknown c…
…letion resolution

Merging #378 with post-#466 master left the #460 strip branch reading
upstream.body, which resolveFakeCompletion has already consumed when
BILI_FAKE_COMPLETION_RETRIES > 0 — the client would hang on an empty
response. Read responseBody instead (identical to upstream.body when
retries are disabled).
…etry

feat: detect + retry fake completions (tool call written as text, #371)
…end retry to responses-shaped truncation

- core.ts: wrap the two reasoning yield sites (ev.raw / emitReasoning) in
  fwd() — a round-1 truncation after reasoning-only output was treated as
  zero-side-effect and retried, duplicating reasoning bytes already sent
  to the client (openai reasoning_content has no preceding meta chunk)
- core.ts: ParsedStreamEvent done gains truncated?: boolean; the retry
  predicate accepts (!sawDone || truncatedDone) so adapters that surface
  truncation as a synthetic failed done (responses parseStream fallback)
  also get the zero-side-effect retry — previously the retry never fired
  for responses streams despite #441's premise
- adapter-responses.ts: the !terminalKind fallback done is marked
  truncated: true (upstream-explicit response.failed stays non-retryable)
- tests: loop-truncation-reasoning (red on pre-fix core), orphan T1/T2
  stub fetch (retry now fetches), T4 retry-success
…on-retry

fix: acp-loop zero-side-effect truncation retry + well-formed anthropic error stream (#413)
…tream.body

With BILI_FAKE_COMPLETION_RETRIES>0 and a streaming request, the #378
buffering block consumes upstream.body before the ladder. The new G3
application/json branch piped upstream.body — a drained stream — so the
client received an empty body. Read responseBody instead, matching the
SSE strip branch and the bare pipeThrough fallback.

Includes a red-then-green regression test driving the real proxy harness
(fake-completion enabled + stream:true + JSON answer).
fix: strip render-tag echoes on the no-inject outbound paths (#460 residual); drop dead SSE rewriters
…eview-fixes

fix: #381 review — SQLite set adjudication, -journal, conflict rename, link kind
…rip list + handlePluginCompact); add compact endpoint test
…t-archive

fix: archive blocks across omp native compaction instead of silently deactivating them (#395)
…-compaction

fix: bili pi launcher disables pi's native auto-compaction (#447)
@ranxianglei

Copy link
Copy Markdown
Owner Author

Full review + merged-tree verification (branch updated to ba857b2 with master merged in — the base was 43 commits behind: v0.1.72/73/74, #396, #421, #448; textually clean, verified semantically below).

Verified on the merged tree

  • typecheck ✓, full suite 961/963 (2 = the known permanent plugin-agent.test.ts env failures, reproduce on clean master), issue453-backstop 10/10 ✓
  • E2E (codex → real responses upstream, 4-phase) ✓ 263s — this touches the request pipeline (prepareOpenai/prepareResponses), so per AGENTS.md the E2E gate applies; it passes on the merged tree
  • nativeWindow is captured at server.ts:1248 before the reserveOutputHeadroom mutation — confirmed it is the raw window, not window−maxOutput
  • emergencyNudge feeds the real injection (if (willInjectNudge && turn.nudge) → trailing user message), not just the diagNudge log ✓
  • Anthropic exempt from the clamp — consistent with shouldReserveOutputHeadroom (Anthropic enforces its input budget independently of max_tokens)
  • Responses clamp skipped on isCompactionTrigger ✓ (native compaction needs its full summary budget)
  • escalation line 0.7 sits below the kernel's 0.75 force-inject line — fills exactly the cadence-silent zone from the plugin mode: no hard backstop when the agent ignores the compress nudge — context grows past the window and upstream 400s #453 log

Minor notes (none blocking)

  1. No numeric max_tokens in the request → no clamp. Deliberate (don't invent a budget the client didn't set); plugin agents always send the field, and vLLM fills its own default otherwise. Worth remembering if a future client omits it on a hard-limit upstream.
  2. Stale-fat lastInputTokens right after a fold: max() keeps the pre-fold count for one turn → overestimated input → cap may dip below OUTPUT_CLAMP_FLOOR → clamp silently no-ops that turn. Documented as preflight/self-heal territory; the next real usage report corrects the estimate. Conservative direction — fine.
  3. The late-added nudge user message isn't counted in the input estimate (~few hundred tokens; OUTPUT_CLAMP_MIN_MARGIN=2048 covers it).
  4. Cosmetic: the prepareOpenai ternary line in handle() picked up one extra leading space in the diff.

Verdict: LGTM — merge gate cleared (CI will re-run on the updated head; E2E + full suite already verified on the exact merged state). Merge is human-only as always.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 收到。已在本沙箱对 ba857b2 做了独立复核(结构检查 + fast gates);E2E 沙箱内跑不了(需要 live upstream),以你的 263s green + CI re-run 为准。

独立复核结果(ba857b2,本沙箱)

  • 结构:branch head = ba857b2,origin/master(cdbf2bc)确为其祖先(merge 完成,43 commits 补上);对 master 的 diff 仍只有 src/server.ts(+85/-6)+ tests/issue453-backstop.test.ts;package.json/lockfile 零 diff(0.1.74 版本号来自 master,本分支未动 version 字段)
  • typecheck ✅ · full suite 962/963 —— 唯一失败是 not ok 381 resolveClientCommand: codex/claude resolve to themselves(本沙箱 codex binary 解析问题)。刚在干净 master@cdbf2bc worktree 上对照重跑:同样失败(launcher.test.ts 115/116)→ pre-existing/environmental。你那边 961/963 的 2 个 plugin-agent.test.ts env failure:失败集合因机器而异,但两边都是 environmental、均与本 PR 无关
  • issue453-backstop 10/10 ✅

note 4 精确确认(逐行测量过,和你描述的略有出入)

  • src/server.ts:1280 ? prepareOpenai:缩进 28→29,与同级 sibling :1281(: responsesCompact,仍 28)差出 1 格 —— 这才是真正的回归点
  • 附带发现::1283 : prepareResponses 也从 30→31 了,但它同级 sibling :1282(? prepareResponsesCompact)在 master 上本来就是 31(master 此处原本就是 31/30 不齐的),所以这一行改完反而和 sibling 对齐了
  • 结论:真正要清理的只有 line 1280 开头多出的那一个空格

notes 1–3 同意,无补充异议:无 numeric 字段不 clamp = 有意保守(不发明 client 没设的 budget);stale-fat lastInputTokens 走 floor no-op,方向安全(宁可不 clamp 也不基于过期数据 clamp 错);late nudge 的 token 量被 MIN_MARGIN=2048 覆盖。

处理建议

这个空格我不建议现在再推 commit:你已对 ba857b2 宣布 merge gate cleared(含 exact merged state 的 E2E),动 head 就得重新确认一轮,而它零语义影响。我的建议:按 ba857b2 原样 merge。若你想顺手清掉,说一声我就推一个 whitespace-only commit(删那一个空格),CI 自然 re-run,零语义 diff。

Merge 照旧 human-only。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Agreed — merge ba857b2 as-is. Thanks for the precise line-level measurement on note 4 (1280 off-by-one-space is the only real artifact; 1283 incidentally re-aligned with its pre-existing 31/30 sibling). Not worth invalidating the verified head over one space — it can be folded into the next content commit touching that ternary block. No further commits to this branch from my side. Merge is human-only.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 收到,我这边无后续动作。

一个 merge 前的小提醒:PR 标题的 fix(#453) 不匹配 GitHub closing keyword 模式(需要 fixes #453 这类形式),body 里也没有 —— 所以 merge 后 #453 不会被自动关闭,会保持 open。merge 完手动关一下,或者跟我说一声我来关。

@ranxianglei
ranxianglei merged commit c2f0229 into master Sep 3, 2026
6 checks passed
@ranxianglei
ranxianglei deleted the 2026-09-03_fix-453-plugin-overflow-backstop branch October 15, 2026 05:32
cskwork pushed a commit to cskwork/billion-context that referenced this pull request Oct 21, 2026
…anglei#470)

The preflight trigger and post-fold fit gates estimated the payload with
estimateCoreMessages (messages only), but the billed input also carries the
system prompt and tool definitions (~10-20k on agent clients plus the
proxy-injected compress prompt ~1.9k and ACP tools ~0.5k). Consequences:

- the trigger fired late: text alone "fit" the window while the real input
  already overflowed it (guaranteed upstream 400 on strict relays);
- the post-fold fit gates could pass payloads whose billed input still
  exceeded the window.

ranxianglei#467 fixed this class of under-count only for the output clamp
(estimateInputTokens); this applies the same term to preflight, the
plugin-mode last resort.

Changes:
- src/server.ts: new estimateWireOverhead(protocol, body) counts
  system/instructions text (anthropic string/array, openai hoisted
  system/developer messages, responses instructions) + tools JSON from the
  rebuilt wire body via defaultCountTokens. Unparseable -> 0. Text-only, so it
  never double-counts image tokens.
- preflightCompressIfNeeded: trigger + both post-fold fit gates add the term;
  the fold loop receives it as PreflightDeps.wireOverhead so currentTokens /
  payloadEstimate floors include it and the loop keeps folding until the true
  billed input fits.
- README.md: two-mode table plugin-mode preflight cell said "No"; preflight
  runs unconditionally in both modes as the last-resort backstop.

Tests: new tests/wire-overhead.test.ts (6 unit tests); new e2e proving
envelope-driven overflow folds + forwards; affected fixtures re-sized to sit
under honest accounting. typecheck + build clean; full suite green except one
pre-existing env-dependent failure unrelated to this change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant