Skip to content

feat(pi): native mode — self-spawned per-session proxy via in-process fetch interception (#519) - #524

Closed
ranxianglei wants to merge 1 commit into
2026-09-04_bili-daemon-subcommandfrom
2026-09-04_pi-native
Closed

feat(pi): native mode — self-spawned per-session proxy via in-process fetch interception (#519)#524
ranxianglei wants to merge 1 commit into
2026-09-04_bili-daemon-subcommandfrom
2026-09-04_pi-native

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What

The second half of issue #513 ("探索如何发布为native模式"): a thorough native plugin for pi where the user runs plain pi — no launcher, no env vars — and context compression works transparently. Builds directly on #523 (daemon subcommand); merge #523 first (this PR is based on its branch).

  • src/agent/intercept.ts (new): installFetchInterceptor({proxyOrigin, upstreamOrigins}) wraps globalThis.fetch and rewrites only http(s) requests whose target origin matches the active model's upstream to <proxyOrigin>/bili/<original-url> (the existing zero-config prefix format). Everything else passes through untouched; responses stream back as-is (SSE-safe); reinstall replaces rather than stacks; uninstall restores the original fetch.
  • src/agent/pi.ts: marker-gated native path inside the existing extension factory. With <stateDir>/billion-context/native.json {"pi": true} and no launcher proxy detected, session_start spawns node <pkgroot>/dist/index.js daemon --parent-pid <pi-pid> (package root self-located from the plugin file position — no PATH lookup), parses the single-line JSON handshake, and installs the interceptor for the current model's upstream origin. Launcher-provided proxies always win (byte-identical legacy behavior); no marker → inert; spawn failure → warn + self-disable (never breaks the client). Host ctx data is snapshotted before the first await because pi's ctx getters throw once the session is replaced (ExtensionRunner.assertActive). Interceptor uninstall + daemon SIGTERM on session_shutdown; the parent-pid watcher (Windows 上 launcher 子代理从无优雅退出:stopProxy 直接 TerminateProcess,SIGBREAK-flush 全史 0 次触发,防抖窗状态+进行中轮被拦腰 #414) is the fallback reaper.
  • bili plugin install pi --native / remove: writes/clears the per-agent marker key (other agents preserved, .bili-bak backup); plugin list shows (native); --native rejected for non-capable agents — pi is the only validated host so far.
  • README: "Native mode" section under Running the proxy.

Why this shape

  • pi's official extension hooks can replace the request body but not its URL (before_provider_request result = payload only; before_provider_headers return ignored — verified against earendil-works/pi source), so runtime egress interception is the only way to route an already-running process through a proxy started at session time. Spawn-time launcher wiring (MITM env, overlay homes, -e) by definition cannot apply to a live process.
  • The daemon subcommand + parent-pid reaping + per-session handshake all come from feat: bili daemon subcommand — per-session dynamic-port proxy (#518) #523 unchanged — this PR adds zero proxy-side code except the marker/install plumbing.

Known behavior (documented in README)

Round 1 of each session can outrun daemon startup (~1s) and goes direct to the upstream unproxied; from request 2 on everything flows through the proxy, and since agents resend full history, context tracking self-heals within one turn. Same failure class as the existing launcher-mode manifest race.

Pre-flight

  • typecheck clean; build clean (dist/agent/pi.js 19.9KB)
  • full suite: 971 tests, 969 pass, 1 skip (entry-missing test skips when dist exists), 1 fail = pre-existing env-specific resolveClientCommand: codex/claude resolve to themselves (verified failing identically on pristine master)
  • real-pi e2e (pi 0.83.6, isolated PI_CODING_AGENT_DIR/XDG_STATE_HOME, mock OpenAI upstream): marker present → plain pi -p turn spawns the per-session daemon (dynamic port), round-1 completion goes direct, the SDK retry is intercepted and forwarded through the proxy (forward POST → …/v1/chat/completions, SSE PONG streamed back through it), pi exits 0, daemon reaps itself ≤3s after exit via the parent-pid watcher. Validated against this branch's combined build (feat: bili daemon subcommand — per-session dynamic-port proxy (#518) #523's daemon + this PR's plugin).

Follow-ups (not here): omp native (needs its own fetch-path validation, #520), dsh into PLUGIN_AGENTS (#521), gated e2e harness for the pi-native path.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Reviewing now. Plan: check out 2026-09-04_pi-native (on top of the #523 branch), re-run the pre-flight you listed (typecheck / test / build), and audit the diff against the body's claims (interceptor semantics, marker gating, launcher-wins precedence, spawn-failure self-disable, uninstall/SIGTERM teardown). I'll post a full verdict with evidence here. Note: per repo rules I won't merge — that stays with you, after #523 lands first as you specified.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · qwen3.8-27b

[bot] 🏷 Review complete — LGTM from my side. Every pre-flight claim in the body verified or independently reproduced on 2ba40bf:

Reproduced

  • npm ci → typecheck clean → build clean; dist/agent/pi.js = 19.91 KB (matches claimed 19.9KB)
  • Full suite: 971 tests, 969 pass, 1 skip, 1 fail — exact match with the body. The fail is tests/launcher.test.ts:715 resolveClientCommand: codex/claude resolve to themselves — env-specific (this machine has /usr/bin/codex + /usr/bin/claude symlinks, so the resolver returns full paths instead of bare names). The PR touches neither launcher.ts nor that test, so it's pre-existing by definition. The skip is the entry-missing test (skips when dist/index.js exists, which it did post-build) — both accounted for, zero unexplained failures.

Smoke-tested beyond the suite

  • plugin install pi --native{"pi":true} marker written, (native mode enabled) printed, plugin list shows pi installed (native); plugin remove pi clears the key and leaves .bili-bak. Marker file path is identical on both sides (stateDir() in src/paths.ts includes the billion-context dir, matching the hand-rolled path in src/agent/pi.ts:83-92).
  • node dist/index.js daemon --parent-pid $$: single-line JSON handshake with the exact shape pi.ts parses (origin/port/pid/logPath), health endpoint answers, and killing the parent reaps the daemon ~2s later via the parent-pid watcher (Windows 上 launcher 子代理从无优雅退出:stopProxy 直接 TerminateProcess,SIGBREAK-flush 全史 0 次触发,防抖窗状态+进行中轮被拦腰 #414 fallback confirmed live).
  • Interceptor against real sockets (SSE mock upstream): SSE streams through with content-type preserved; string-URL POST with query rewritten to /bili/<url> preserving path+query; Request-object input reconstructed correctly (body arrived at server); non-matching origin passes through byte-untouched. Reinstall-replaces / uninstall-restores covered by tests/intercept.test.ts.

Code cross-checks

  • stripInheritedProxy is a faithful copy of launcher.ts:1636-1641 (local re-impl justified — keeps the thin agent bundle free of the server graph)
  • packageEntryPath resolves dist/agent/pi.js → dist/index.js; package.json files: ["dist"] ships both, so global-installed plugins work
  • Kill switch consistent end-to-end: detectProxyBase (shared.ts:51) and startDaemon (pi.ts:169-183) both honor BILLION_CONTEXT_PLUGIN=0; NODE_ENV=test guard prevents real spawns in unit tests
  • No double-spawn race (startDaemon check-and-set is synchronous within one handler tick); spawn-failure self-disable is coherent (failed promise stays in state.daemon, no respawn until session_shutdown clears it; nativeRetryAt only bites across a restart boundary)
  • arm() deliberately not chaining registration across the await (stale host refs throw post-session-replacement); re-driven per-request with sticky nativeBase — matches the documented ctx-getter constraint
  • AGENTS.md: version untouched, branch naming + feat: convention OK, no as any/@ts-ignore

Minor (non-blocking): README.zh-CN.md wasn't updated — recent feature PRs (dsh launcher, dsh native /acp) kept it in sync, so the "Native mode" section is English-only.

Open questions (non-blocking, for your awareness): interactive multi-session pi behavior (does /new fire session_shutdown → daemon respawn?) is only covered by reasoning, not by the -p e2e; and the success path (spawn + intercept) rests on your manual real-pi e2e since automated tests cover inert/kill-switch/launcher-wins/entry-missing only — the gated harness is already listed as a follow-up, so fine.

Merge order stands as stated: #523 first, then this. Merging itself is yours — per repo rules I don't merge PRs.

@ranxianglei

Copy link
Copy Markdown
Owner Author

Review vs master 3cd85b6: ⚠️ gated well, but stacked on #523 + shares the interception core with #525 — sequencing + consolidation needed before merge.

Good properties (verified):

  • Opt-in via marker file (bili plugin install pi --native); without it behavior is byte-identical — zero risk to the existing plugin path (agent/pi.ts current master rewrites untouched).
  • Launcher-provided proxy (detectProxyBase) always wins over native spawning — correct priority vs the file-free launch flow.
  • The installInterceptorFor docstring shows real care (ctx getters assert session liveness; upstream snapshotted BEFORE await; refuses plugin ownership when upstream unknown — avoids wedging a session in an uncompressed mode).

Issues:

  1. Stacked on feat: bili daemon subcommand — per-session dynamic-port proxy (#518) #523 (git merge-base confirms) — inherits all of feat: bili daemon subcommand — per-session dynamic-port proxy (#518) #523's drift (duplicate port allocator, pre-fix: instance registry lost-update — per-instance marker files (#527) #528 instance layer, server.ts conflict).
  2. Three-way interception duplication: this PR's src/agent/intercept.ts (114 lines) and feat: dsh 进 PLUGIN_AGENTS——native 插件部署 + 拦截层(native 模式 D) #525's (125 lines) are two independent implementations of the same in-process fetch interception. Landing both forks the core; one must be canonical (探索如何发布为native模式 #513 family decision).
  3. plugin-install.ts diverged from feat: dsh 进 PLUGIN_AGENTS——native 插件部署 + 拦截层(native 模式 D) #525's changes — another reconciliation point.

Recommendation: hold for the #513/#519 family decision; rework on top of a reworked #523; single shared interception core across pi (#519) and dsh (#521).

(tracking: #519 / family #513)

… fetch interception (#519)

Installs a 'thorough native plugin' for pi: the user runs plain 'pi' (no
launcher, no env vars) and context compression works transparently.

- src/agent/intercept.ts: installFetchInterceptor wraps globalThis.fetch and
  rewrites only http(s) requests whose target origin matches the active model's
  upstream to <proxyOrigin>/bili/<original-url> (zero-config prefix format);
  everything else passes through untouched; SSE responses streamed back as-is;
  reinstall replaces (never stacks); uninstall restores the original.
- src/agent/pi.ts: marker-gated native path in the existing extension factory.
  With <stateDir>/billion-context/native.json {"pi":true} and no launcher proxy
  detected, session_start spawns 'node <pkgroot>/dist/index.js daemon --parent-pid
  <pi-pid>' (self-located from the plugin file position — no PATH lookup), parses
  the single-line JSON handshake, and installs the interceptor for ctx.model.baseUrl.
  Launcher-provided proxies always win; no marker -> byte-identical inert behavior;
  spawn failure -> warn + self-disable (never breaks the client). Host ctx data is
  snapshotted before the first await (pi's ctx getters throw once the session is
  replaced). Interceptor uninstalled + daemon SIGTERM'd on session_shutdown; the
  parent-pid watcher is the fallback reaper.
- bili plugin install pi --native / remove: writes/clears the marker (per-agent
  keys, other agents preserved); status rows show '(native)'; --native rejected
  for non-capable agents (only pi is validated).
- Tests: intercept unit matrix (inputs/duplex/exclusions/reinstall/uninstall) and
  native-pi gating tests (inert without marker, kill-switch over marker, launcher
  beats native, missing entry warns). Real-pi e2e validated against a mock
  upstream: round-1 request goes direct (daemon still starting), the SDK retry
  is intercepted and forwarded through the per-session proxy, PONG streams back,
  daemon reaps itself when pi exits.
@ranxianglei
ranxianglei force-pushed the 2026-09-04_bili-daemon-subcommand branch from e515720 to 51547cc Compare September 7, 2026 16:27
@ranxianglei

Copy link
Copy Markdown
Owner Author

Rebased onto the rebased #523 (head 20f2538 = 51547cc + this PR's own delta, cherry-picked 2ba40bf — same stacked structure, now conflict-free).

Pre-flight on 20f2538: typecheck ✓ · 1261/1264 + 1 intentional skip (native-pi.test.ts:170 skips real-daemon spawn when dist/ present) · 2 known env fails in plugin-agent.test.ts, unrelated · build ✓.

Merge #523 first (this branch now includes its rebased head).

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Noted — verifying the rebased head 20f2538 now: stacked structure (parent = rebased #523 head 51547cc, delta identical to 2ba40bf's), then a fresh pre-flight (typecheck / test / build) and a look at the two plugin-agent.test.ts env fails. Evidence here when done.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Rebased head 20f2538 verified — structure and pre-flight both check out, with one small discrepancy in your test numbers:

Stacked structure

  • 20f2538^ = 51547cc (rebased 2026-09-04_bili-daemon-subcommand head) ✓
  • Delta identity: extracted all changed lines (+/, context stripped) from both e515720..2ba40bf and 51547cc..20f2538840 lines, byte-identical. Pure cherry-pick, no drift introduced by the rebase. (Raw git patch-id differs only because hunk context shifted on the newer base, which is expected.)

Pre-flight on 20f2538 (fresh npm ci)

  • typecheck ✓ · build ✓ — note dist/agent/pi.js is now 23.85 KB vs the earlier 19.9KB; that's the rebased base inlining more shared code into the agent bundle, not a change in this PR's delta
  • Suite: 1264 tests → 1262 pass / 1 fail / 1 skip (ran twice, stable)
    • Skip = exactly the one you named: native-pi.test.ts:170 "marker enabled but package entry absent → warn and stay inert" — # SKIP dist/index.js present — would spawn a real daemon
    • Fail = launcher.test.ts: resolveClientCommand: codex/claude resolve to themselves — the same known env-specific failure from round 1 (this box has /usr/bin/codex + /usr/bin/claude symlinks, so the resolver returns full paths). The delta touches neither that file nor the resolver → pre-existing.

Discrepancy (minor): you reported 2 env fails in plugin-agent.test.ts; I see 1 total failure, in launcher.test.ts, and plugin-agent.test.ts passes 35/35 here (ran it standalone too). Your two are presumably specific to your machine's client installs — unrelated to this diff either way, but if you paste the two test names I'm happy to confirm they're the same class.

Verdict unchanged: LGTM, merge #523 first.

@ranxianglei

Copy link
Copy Markdown
Owner Author

Superseded by #706 (spawn-in-plugin form, chosen by the owner in #513): #706 is self-contained on master, needs neither this PR nor #523, and covers all of #519 (fetch interception + plugin glue) with a smaller surface (8 files, no marker/install-flag machinery). Closing to avoid two overlapping implementations of the same feature reaching master. My independent e2e validation of the shared fetch-patch mechanics remains valid evidence for the approach.

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