Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions devlog/2026-09-04_dsh-native-plugin/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# REQ — dsh 进 PLUGIN_AGENTS:native 插件部署 + fetch 拦截层(#521)

来源: issue #521(#513 native 模式任务 D,owner 已确认方案)。

## 目标

1. `bili plugin install dsh`:把 cordis 插件(dist/agent/dsh-acp.js)部署进真实
DSH_HOME 的每个 profile(`<dshHome>/profiles/*/cordis.patch.yml`),替代目前
仅 launcher `--patch` overlay 注入的路径。remove/status 同步支持,幂等、先备份。
2. fetch 拦截层集成进 dsh-acp 插件:dsh 的 LLM 流量走 pi-ai 的裸 `fetch` 调用
(已对安装的 dsh 0.1.1-rc.2 验证无捕获引用),包装 globalThis.fetch,仅把
origin == 活动上游 origin 的请求改写到 `http://127.0.0.1:<port>/bili/<origURL>`;
其余请求原样透传。代理不可达时该请求降级直连,绝不打断会话。
上游 origin 解析顺序(issue 指定):settings baseURL ?? env DEEPSEEK_BASE_URL
(official deepseek 路由默认 https://api.deepseek.com)。
3. 代理自举:插件在 apply() 时确保代理运行——有 BILLION_CONTEXT_PROXY env 直接用
(launcher 路径不变),否则 execFile 子命令 `bili daemon --fresh --json
--parent-pid <dsh pid>`:动态端口(listen(0),经 instance-file 握手回传真实
origin/pid/logPath)、独立 session instance file(不污染全局发现文件,避免
#394 双写告警风暴)、BILI_PARENT_PID=dsh pid(复用 #414 父进程监视器实现
dsh 退出后代理自动回收)。
4. wire mode 保持不变:dsh 无客户端 conversation id,压缩由代理端执行;/acp
命令继续走 fetchStatusLatest,输出新增一行拦截状态信息。

## 非目标

- 不实现 ACP 工具透传 / x-bili-plugin 头(那是 B/C 任务)。
- 不改压缩管线、不改两种压缩模式的既有行为。
- 不向 spawn 的代理传 modelWindows(registry fallback 生效,后续跟进项)。

## 验收

- typecheck / 全量单测 / build 通过。
- 真实 dsh e2e:裸 `dsh --profile headless "task"`(不经 bili dsh)→ 插件拉起
动态端口代理 → LLM 流量经 /bili/ 改写(代理日志可见)→ 长 prompt 触发压缩 →
/acp 渲染面板 → dsh 退出后代理被回收(≤2s)。
- `bili plugin install dsh && bili plugin status && bili plugin remove dsh` 全流程
幂等且可回退(.bili-bak)。
- launcher 兼容:native 安装后 `bili dsh` 不再写 overlay patch(防双注册),
未安装时行为与现状完全一致。
49 changes: 49 additions & 0 deletions devlog/2026-09-04_dsh-native-plugin/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# WORKLOG — dsh native plugin deployment + fetch interception (#521)

Branch: `2026-09-04_dsh-native-plugin` · base: master @ baf1ac5 (v0.1.83)

## What shipped

### 1. `bili plugin install dsh` (src/plugin-install.ts)
- `"dsh"` added to `PLUGIN_AGENTS`; install/remove/status operate on **every** profile dir under `$DSH_HOME/profiles/*` (dirs containing `cordis.yml` or `package.json`; `node_modules` skipped).
- Entry = `- insert:\n - name: <file:// URL of dist/agent/dsh-acp.js>` merged into each profile's `cordis.patch.yml` (replaces bare `[]`, appends otherwise). Idempotent; stale entries (target file missing) dropped, including their parent `- insert:` group only when no other items remain; `.bili-bak` backup per file (once); throws when zero profiles exist (hint: run `dsh` once first).
- Exported `dshNativeInstalled(dshHome?)` — true when ANY profile carries a live entry.
- Verified live on the real DSH_HOME: install → both profiles patched → remove → restored (`plugin list`: not installed).

### 2. Launcher compat (src/launcher.ts)
- dsh wiring now: `const dshAcpPatch = dshNativeInstalled(dshHomeDir) ? undefined : writeDshAcpPatch(dshHomeDir);` — the legacy `--patch` overlay is skipped when the native entry is present (any-profile rule avoids double-registration of the same module via both layers). Legacy path untouched for fresh machines.
- `LaunchOptions` gains `forceFresh / parentPid / instanceFile / autoUpdateOff`; `proxyStartArgs` pushes `--no-auto-update` for session proxies (no mid-session self-upgrade).

### 3. Per-session proxy: `bili daemon --fresh --json --parent-pid N` (src/cli.ts, src/server.ts, src/config.ts)
- Spawns a detached proxy on **port 0** (ephemeral), writes the bound origin only to a session instance file (env `BILI_INSTANCE_FILE`), prints `{origin, port, pid, logPath}` JSON to stdout, deletes its session file on exit.
- server.ts: with `BILI_INSTANCE_FILE` set, the global instance file and `instances.json` registry are skipped (avoids #394 dual-writer noise across many sibling session proxies); shutdown clears the session file too. Parent-gone reaping (#414 mechanism) terminates the proxy ≤2s after the agent exits.
- config.ts: port validation now accepts `0` (dynamic assignment; real port learned from the handshake). `server.ts` already anticipated port 0; only the validator blocked it.

### 4. In-process fetch interception (src/agent/intercept.ts, new — shared primitive for B/C/D)
- `installFetchInterceptor({ resolveTargets })` wraps `globalThis.fetch`. Rewrites ONLY requests whose origin equals the active upstream origin → `<proxyOrigin>/bili/<full URL>`. Everything else passes through untouched. Network failure (TypeError, non-abort) on a rewritten call retries once direct (degrade, never break the request). Stats: `{rewritten, passthrough, degraded}`. `uninstall()` restores the previous fetch.
- Targets resolve ASYNC per call through an 8s gate that closes once bootstrap knows them; while unknown, calls pass through (a dead/unstarted proxy must never hang or break traffic).

### 5. dsh-acp rewrite (src/agent/dsh-acp.ts) + settings reader (src/agent/dsh-settings.ts, new)
- Bootstrap in `apply()`: interceptor installed **synchronously**; target resolution async — env `BILLION_CONTEXT_PROXY` wins (launcher mode: attach), otherwise spawn `bili daemon` (child gets `BILI_PARENT_PID`). Upstream origin resolved per issue spec: settings provider `baseURL` ?? `llm-deepseek.baseURL` ?? env `DEEPSEEK_BASE_URL` ?? official DeepSeek default (official route only).
- Wire mode unchanged: no ACP tools, no conversation id; `/acp` still uses `fetchStatusLatest` and now reports the routing head (`billion-context: <upstream> → <proxy>`).
- Under `bili dsh`, the overlay-wrapped settings baseURL equals the proxy origin → interceptor sees equal origins → pure passthrough (zero double-proxying by construction).

## Bugs found & fixed during e2e
1. **Port 0 rejected** by config validation → spawned proxy died before bind. Fixed in `src/config.ts` (+ test update in tests/fix-config-session.test.ts).
2. **First-fetch race**: interceptor was installed only after async bootstrap finished, so the agent's first LLM fetch went direct. Fixed with sync install + async per-call target resolution (regression test: fetch fired while spawn pending waits, then lands rewritten).
3. Latent health-probe dependency cycle (gate awaited setup; setup awaited health through the gated wrapper) → settled routing *before* the health check.

## Pre-flight
- `npm run typecheck` — clean.
- `npm test` — **1052 tests, 1051 pass, 1 fail**: pre-existing env-dependent `resolveClientCommand: codex/claude resolve to themselves` (tests/launcher.test.ts) — this sandbox has a local codex binary; passes on CI runners without one. Unrelated to these changes.
- `npm run build` — OK; `dist/agent/dsh-acp.js` 12.56 KB (bundles intercept + dsh-settings; tsup entries unchanged).

## E2E (real dsh 0.1.1-rc.2, bare `dsh --profile headless`, NO `--patch`)
Ran against an isolated DSH_HOME copy (this sandbox's `~/.dsh/sessions` is root-owned and breaks bare dsh regardless of bili) and a real upstream:
1. Smoke task round-tripped end-to-end through the intercepted proxy (exit 0).
2. Rewrite proven against a mock upstream: mock saw only unwrapped `/v1/chat/completions`; with a non-SSE mock body, dsh rendered the proxy's own error string — proxy definitively in the path.
3. Compression pipeline engaged on real dsh traffic: with a 32K route cap (`ACP_PROVIDERS` compress.modelContextLimit) and a ~35.6K-token prompt, bili logged the session, ran preflight, and fail-fast 502'd with the correct adjudication ("nothing left to fold" — a single-message prompt has no compressible history). Full multi-turn compress cycles are covered by the unit suite + `tests/e2e/e2e-codex.test.ts`; headless dsh is one-shot so it cannot produce multi-turn history in a single invocation.
4. Reaping: `parent-gone` ≤2s after every dsh exit; no orphaned proxies.
5. Launcher path: `bili dsh` attach-mode verified by construction (§5) + unit tests.

Known limitations (documented, not fixed): `/acp` live rendering needs an interactive TUI (unit-covered instead); single-shot compression cycle (above); sandbox argv limit ≈128KB caps headless prompt size.
Loading
Loading