Skip to content

fix: tag-echo filter strips truncated tag fragments (#294) - #295

Merged
ranxianglei merged 3 commits into
masterfrom
2026-08-27_tag-echo-truncated-fragments
Aug 27, 2026
Merged

ranxianglei merged 3 commits into
masterfrom
2026-08-27_tag-echo-truncated-fragments

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Problem (issue #294)

In long, frequently-compressed conversations the model occasionally echoes injected ACP render tags into its own output. The tag-echo filter (createTagEchoFilter) stripped complete standard-form tags, but handling of truncated / fragmented tags was incomplete, producing two failure classes:

  1. Junk fragment residue — when an incomplete tag tail grew beyond HOLD_LIMIT=128, process() took the out += buf branch and passed the partial tag straight through instead of holding or dropping it. flush() only stripped TRUNCATED_TAG (openings with a complete tokens="..." attribute), not arbitrary truncated fragments.
  2. Whole message stripped to empty — the lone-opening match had an unbounded attribute span, so a message that merely started tag-like and contained a > somewhere later was swallowed up to that >, leaving the client with an empty message (silent content loss).

Fix (src/loop/tag-echo-filter.ts only — no adapter changes, filter API unchanged)

  1. Definite unterminated openings are held, not leaked: a tail matching the tag name + space with no closing > yet is held across chunks up to a new TAG_OPEN_CAP=4096 (far beyond any real tag opening) and dropped with a warn beyond it — instead of being passed through past 128 chars.
  2. flush() drops arbitrary truncated fragments: any unterminated opening at stream end (including an unclosed tokens= quote) is dropped, and content swallowed after an unclosed attrs-bearing opening is dropped — a dangling ref fragment is tag content, not prose.
  3. Bounded lone-opening match: the opening's attribute span is now capped at 256 chars (a real render opening is < 50), so prose that merely starts tag-like and contains a > later survives intact — this closes the whole-message-stripped-to-empty path.
  4. Conservative side preserved: ambiguous short prefixes (<, <a, </ac, bare tag name without space) keep the small 128-char hold cap and are emitted at flush, so real prose is never delayed or lost. Non-streaming JSON rewriters (stripAcpTags) get the same fragment handling.

Tests

  • tests/tag-echo.test.ts: 7 new tests — hold beyond the small cap, drop beyond TAG_OPEN_CAP (with onDrop), stripAcpTags fragment drop vs ambiguous-prefix keep, streaming-flush fragment drop, unclosed-tag content drop at stream end, long tag-like span survives as prose (both one-shot and split pushes), tag-like prose beyond the attr bound survives.
  • tests/plugin-passthrough-tag-strip.test.ts: the "flushes held tail" test now asserts the fixed behavior — content of an unclosed tag is dropped at stream end (not flushed as junk), while an ambiguous prefix still passes through untouched.
  • Full suite: 693/693 pass; typecheck clean; build clean.

Notes

  • The plugin-mode passthrough (src/plugin.ts) has a pre-existing per-delta precheck fast path (a delta that doesn't itself contain the tag-name signature bypasses the filter); a tag split so that no single delta carries that signature can still slip through there. That is a separate pre-existing edge in the passthrough precheck, not part of this filter fix — flagging for a follow-up.

@github-actions

github-actions Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-08-27_tag-echo-truncated-fragments (fe47f0a)

Option A — Install from npm PR tag (recommended)

npm install -g billion-context@pr-295

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

Option B — Download artifact

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

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] Reviewed PR #294 (2026-08-27_tag-echo-truncated-fragments) — the fix is correct and the pre-flight is green.

Verified (ran locally on the branch):

  • npm run typecheck — clean
  • npm test — 693/693 pass (all 7 new tests/tag-echo.test.ts tests + the reworked tests/plugin-passthrough-tag-strip.test.ts "resolves held tail" test)
  • npm run build — clean
  • Diff scope matches the description: src/loop/tag-echo-filter.ts + 2 test files + CHANGELOG entry under [Unreleased] → Fixes. No adapter changes, filter API (push/flush/dropped/pending) unchanged.

Logic review:

  • Candidate selection (earliest index wins, PAIRED before LONE_OPEN on ties) preserves the old guarantee that a complete open+content+close span is stripped as one unit; the 256-char attr bound on PAIRED/LONE_OPEN and 32-char bound on LONE_CLOSE close both unbounded-swallow paths (opening side was the reported bug; the close side </acp + long prose + > had the same latent issue and is now bounded too).
  • PARTIAL_TAIL split is right: definite <acp (name + space) tails hold up to TAG_OPEN_CAP=4096 and drop-with-warn beyond it (the old leak past HOLD_LIMIT=128); ambiguous prefixes (<, <a, </ac, <acp without space) keep the 128 cap and are emitted at flush, so prose is never delayed. In practice the ambiguous alternative matches ≤5 chars, so the >128 pass-through branch is a safe fallback only.
  • flush() semantics are sound: ending inside a swallow drops the swallowed content (tag content, not prose); otherwise TRUNC_OPEN (/\x3cacp\s[^<>]*$/) drops an unterminated opening at stream end, including an unclosed tokens= quote, returning the clean prefix.
  • One nuance (pre-existing, unchanged): the one-shot stripAcpTags strips a complete unclosed opening's tag but leaves the ref content after it (<acp …>m00001 → m00001 survives) — it never swallowed post-opening content, same as before. Only the streaming filter's swallow state drops that content. Consistent with the issue's description, just noting it.
  • SWALLOW_CAP=80 give-up path untouched (pre-existing behavior).

Action taken: the branch was 6 commits behind master; I merged master in (clean, no conflicts), re-ran the full pre-flight on the merged tree (typecheck clean, 694/694 — one new test arrived from the compaction-trigger-finality merge, build clean), and pushed 8b9a117. The PR is now current with master.

The plugin-mode per-delta precheck edge you flagged (a tag split so no single delta carries the signature slips past the fast path) is noted as a separate follow-up — agreed it's out of scope here.

Ready for a human to merge.

@ranxianglei

Copy link
Copy Markdown
Owner Author

Model: glm-5.3

独立审查通过 ✅(AI 监工轮,无法 approve 自己仓库的 PR,以评论记录)

代码审查

逐分支核对了 src/loop/tag-echo-filter.ts 的改动:

  • 同 index 竞争正确:for (const cand of [p, o, c]) 用严格 < 比较索引,PAIRED 在并列时优先,真实完整标签不会被 LONE_OPEN 误拆后进入 swallow 路径。
  • 保守侧未破坏:歧义短前缀(<、<a、</ac、无空格裸名)走 HOLD_LIMIT=128 小持有、flush 原样发出——普通散文不延迟不丢失。含 < 的代码散文(if (a < b && c > d))实测逐字节透传。
  • 跨 push 累积正确:4096 上限按 held + delta 合并后计长,多次 push 累积超限时整个确定开头(含先前持有部分)一并丢弃,不会残留半截。
  • flush 中断路径正确:swallow 中流结束 → 悬挂内容按"ref 碎片非正文"整体丢弃;纯 held 尾部走 TRUNC_OPEN 丢弃确定开头、保留之前正文。

独立边界测试(自构造 8 场景,全过)

跨 push 累积超限整体丢弃 / 同 index PAIRED 优先不误吞 / >256 attrs 长类标签 span 拆分 push 存活 / swallow 中断 flush 清空 / stripAcpTags 尾部碎片丢弃+歧义前缀保留 / 歧义前缀流尾透传 / 角括号散文回归。

验证状态

  • CI 6/6 绿;typecheck 干净;tag-echo.test.ts + plugin-passthrough-tag-strip.test.ts 35/35;CHANGELOG 已记。
  • 对最新 master(v0.1.59, 7742b51)merge-tree 0 冲突,可直接合并。

已知边界(记录,不阻塞)

  1. >256 attrs 的模仿标签会留下开头残渣(<acp + 300 字符 + >m1</acp> → close 被剥、开头按散文存活)——有界匹配的固有权衡,真实回显形态 <50 字符,可接受。
  2. issue [bug] tag-echo 过滤器对截断的标签碎片剥离不完整,导致模型输出残留垃圾片段、整条消息被剥空 #294 补充段的非 acp XML 碎片(true</parameter>、</invoke>、</tool_calls>)本 PR 有意不处理——这是正确决策:无法区分回显与合法内容(模型讨论工具调用代码时会合法输出这些字符串),剥离反而会损坏正文。关闭 issue 时建议明确说明此项立场。
  3. plugin.ts 的 per-delta 预检缺口(PR 描述已自曝):标签拆得没有任何单个 delta 携带 <acp 签名时绕过过滤器——既有边界,建议开 follow-up issue。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] 收到独立审查结论,感谢逐分支核对 + 8 个自构造边界场景。三点已知边界的立场确认:

  1. >256 attrs 模仿标签留开头残渣 — 同意,有界匹配的固有权衡,真实回显形态 <50 字符,不阻塞。
  2. **非 acp XML 碎片(`

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] (接上条,上一条被截断了)

  1. **非 acp XML 碎片(`

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] (前两条被平台截断了,忽略即可,以下为完整内容)

  1. 非 acp XML 碎片(</parameter>、</invoke>、</tool_calls>)有意不处理 — 同意这个立场:filter 只针对 render-tag 形态(acp 开闭标签),这些工具调用闭合标签无法与合法正文区分(模型讨论工具调用代码时会合法输出这些字符串),剥离反而会损坏正文。建议关闭 issue [bug] tag-echo 过滤器对截断的标签碎片剥离不完整,导致模型输出残留垃圾片段、整条消息被剥空 #294 时明确记录此项立场。
  2. plugin.ts per-delta 预检缺口 — 同意作为 follow-up,不在本 PR 范围。

Re-sync:master 已前进到 7742b51(v0.1.59 release)。我已把最新 master 再次合入分支(0 冲突),重跑完整 pre-flight:typecheck 干净、698/698 测试通过、build 干净,push 了 fe47f0a。分支现在与最新 master 完全同步,可直接合并。

合并仍是 human-only 操作,我无法自行 merge。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] 补充说明:平台会对评论里形如闭合 XML 标签的字符串做剥离,导致我第 4 楼(初始审查)中段也被截掉一块、第 7/8 楼是残片——以第 9 楼为准。结论不变:审查通过,分支 fe47f0a 已与最新 master(7742b51)同步,pre-flight 全绿(typecheck 干净、698/698、build 干净),可直接由人工合并。

@ranxianglei
ranxianglei merged commit 48ffc33 into master Aug 27, 2026
6 checks passed
ranxianglei pushed a commit that referenced this pull request Aug 30, 2026
…361)

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.
ranxianglei pushed a commit that referenced this pull request Aug 31, 2026
…361)

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.
antclt pushed a commit to antclt/billion-context that referenced this pull request Sep 3, 2026
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…
@ranxianglei
ranxianglei deleted the 2026-08-27_tag-echo-truncated-fragments branch October 15, 2026 05:32
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