diff --git a/README.md b/README.md index a4278cfd0..1df27ebad 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Pick by your client: | **pi** | [`billion-context-pi`](https://github.com/ranxianglei/billion-context-pi) (in-process extension) | | **opencode 1.x** | `bili plugin install opencode` (self-spawning native plugin — pre-migration [`opencode-acp`](https://github.com/ranxianglei/opencode-acp) sessions keep working, see "OpenCode 1.x") or `bili opencode` (launcher) or standalone `opencode-acp` (in-process extension) | | **opencode 2.0+** | `bili opencode` (built-in V2 plugin — native tools, no separate package) or `bili plugin install opencode` (self-spawning native plugin, no launcher) | -| **omp** | [`billion-context`](https://github.com/ranxianglei/billion-context) via `bili omp` (built-in plugin) | +| **omp** | [`billion-context`](https://github.com/ranxianglei/billion-context) via `bili omp` (built-in plugin) or `bili plugin install omp` (self-spawning native plugin, no launcher) | | **dsh** | `bili dsh` (launcher — full native plugin via `--patch`: tools, session-bound `/acp`, fetch intercept) or `bili plugin install dsh` ≡ `dsh plugin --profile add billion-context` (one unified lane — pnpm-installs the package into each profile so dsh mounts the bundled patch layer; the bili form just drives dsh's own channel per profile and migrates legacy managed blocks) | | **claude** | `bili claude` (launcher) or `bili plugin install claude` (native posture, #964 — managed settings block + session-owned proxy; see the notes below) | | **everything else** (no context hook) | [`billion-context`](https://github.com/ranxianglei/billion-context) — `bili ` (launcher, preferred) or `/bili/` prefix | @@ -185,15 +185,16 @@ Three ways to use it — pick one: - **URL change (persistent):** prefix your client's baseURL with the proxy origin + `/bili/`. -### Option 1 — Native plugin (`bili plugin install pi` / `opencode` / `dsh`) +### Option 1 — Native plugin (`bili plugin install pi` / `omp` / `opencode` / `dsh`) The proxy lives inside the client: install once, then start the client exactly as you always do — no launcher command, no env vars, no fixed port, -no URL edits. Supported today for **pi**, **opencode** (1.x and 2.x) and -**dsh**: +no URL edits. Supported today for **pi**, **omp**, **opencode** (1.x and +2.x) and **dsh**: ```bash bili plugin install pi # registers a "billion-context" entry in pi's settings (npm form when bili itself was npm-installed) +bili plugin install omp # registers an extensions entry in omp's config.yml (~/.omp/agent/config.yml) bili plugin install opencode # registers the plugin in opencode's real config + disables native auto-compaction bili plugin install dsh # runs 'dsh plugin --profile add billion-context' for every existing profile bili plugin remove # undo (dsh removes through the same channel; config snapshots go to .bili-bak) @@ -211,8 +212,8 @@ rewrites model traffic to `/bili/`, registers mode), and binds the `/acp` panel to the current session. It also reports the client's **own model config** to the proxy (runtime-info protocol, #955) so compression budgets use the real window instead of a registry -guess. Opt-out envs: `BILI_NATIVE_PI=0`, `BILI_NATIVE_OPENCODE=0`, -`BILI_NATIVE_DSH=0`. +guess. Opt-out envs: `BILI_NATIVE_PI=0`, `BILI_NATIVE_OMP=0`, +`BILI_NATIVE_OPENCODE=0`, `BILI_NATIVE_DSH=0`. #### Runtime-info protocol (#955) @@ -230,9 +231,10 @@ Resolution order for the window: `anthropic-beta` negotiation > per-request plugin header > runtime-info table (agent+model must match) > launcher env > route config > models.dev registry > built-in table. A reported `maxOutput` only stands in when the request body carries no output budget -of its own. Implementations: `src/agent/pi.ts`, `src/agent/opencode-native.ts` -(v1), `src/agent/opencode-v2.ts`, `src/agent/dsh-native.ts` — other client -integrations should follow the same protocol. +of its own. Implementations: `src/agent/pi.ts` (covers pi and omp), +`src/agent/opencode-native.ts` (v1), `src/agent/opencode-v2.ts`, +`src/agent/dsh-native.ts` — other client integrations should follow the same +protocol. The launcher env tier covers pure-proxy clients (no in-process plugin): `bili ` reads the client's own model config at launch @@ -257,8 +259,8 @@ Notes: to stand down cleanly). - On OpenCode 1.x, pre-migration `opencode-acp` sessions keep working (v1 lane routing, #920) — see "OpenCode 1.x" below. -- `codex` / `omp` have companion installs too (an MCP shell / a thin - extension), but those need a running proxy — they are not native mode. +- `codex` has a companion install too (an MCP shell), but it needs a running + proxy — it is not native mode. - `claude` also has a **native posture** (#964, hybrid): Claude Code has no in-process extension point, so `bili plugin install claude` writes a managed block into `~/.claude/settings.json` (env diff --git a/README.zh-CN.md b/README.zh-CN.md index 0920d4d30..117fa1c8c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -81,7 +81,7 @@ AI 编程助手的通用上下文压缩代理 | **opencode 1.x** | [`billion-context`](https://github.com/ranxianglei/billion-context) —— `bili plugin install opencode`(V1 `.server()` 钩子,与 2.x 同一个包装器)或 `bili opencode`(启动器);[`opencode-acp`](https://github.com/ranxianglei/opencode-acp)(进程内扩展)仍可用 | | **opencode 2.0+** | `bili opencode`(内置 V2 插件 —— 原生工具,无需另装包)或 `bili plugin install opencode`(自拉起原生插件,免启动器) | | **opencode** | [`billion-context`](https://github.com/ranxianglei/billion-context),`bili opencode`(新会话走 bili 代理;现有 [`opencode-acp`](https://github.com/ranxianglei/opencode-acp) 会话继续可用,见下文「OpenCode 1.x」) | -| **omp** | [`billion-context`](https://github.com/ranxianglei/billion-context),`bili omp`(内置插件) | +| **omp** | [`billion-context`](https://github.com/ranxianglei/billion-context),`bili omp`(内置插件)或 `bili plugin install omp`(自拉起原生插件,免启动器) | | **dsh** | [`billion-context`](https://github.com/ranxianglei/billion-context) —— `bili dsh`(启动器,经 `--patch` 注入完整原生插件:工具、会话绑定 `/acp`、fetch 拦截)或 `bili plugin install dsh` ≡ `dsh plugin --profile add billion-context`(统一泳道 —— pnpm 把包装进各 profile、由 dsh 挂载包内 patch 层;bili 形式只是替你按 profile 驱动 dsh 自己的通道,并顺带迁移旧版受管块) | | **claude** | `bili claude`(启动器)或 `bili plugin install claude`(原生姿态,#964 —— 受管 settings 块 + 会话自管代理;见下方"注意") | | **其余所有**(没有上下文 hook) | [`billion-context`](https://github.com/ranxianglei/billion-context) —— `bili `(启动器,优先)或 `/bili/` 前缀 | @@ -113,12 +113,13 @@ npm install -g billion-context -### 方式 1 —— 原生插件(native,`bili plugin install pi` / `opencode` / `dsh`) +### 方式 1 —— 原生插件(native,`bili plugin install pi` / `omp` / `opencode` / `dsh`) -代理住进客户端:装一次,之后照常启动客户端 —— 不用启动器命令、不用环境变量、不用固定端口、不用改 URL。目前支持 **pi**、**opencode**(1.x 与 2.x)、**dsh**: +代理住进客户端:装一次,之后照常启动客户端 —— 不用启动器命令、不用环境变量、不用固定端口、不用改 URL。目前支持 **pi**、**omp**、**opencode**(1.x 与 2.x)、**dsh**: ```bash bili plugin install pi # 在 pi 的 settings 里注册 billion-context 条目(bili 自身为 npm 安装时写 npm 条目) +bili plugin install omp # 在 omp 的 config.yml(~/.omp/agent/config.yml)注册 extensions 条目 bili plugin install opencode # 在 opencode 真实配置里注册插件 + 关闭原生自动压缩 bili plugin install dsh # 对每个已存在的 profile 执行 'dsh plugin --profile add billion-context' bili plugin remove # 卸载(dsh 经同一通道移除;配置快照存 .bili-bak) @@ -126,7 +127,7 @@ bili plugin remove # 卸载(dsh 经同一通道移除;配置快照 dsh 用户可以完全不用 bili:`dsh plugin --profile add billion-context` 正是 `bili plugin install dsh` 按 profile 驱动的那条命令 —— 两种走法终态一致(pnpm 装进 profile、patch 层由 dsh 自己挂载)—— 见下文 dsh 段。 -插件加载时**自拉起自己的代理**(已有健康实例则直接复用 —— 父进程 pid 看门狗在客户端退出时收掉它),把模型流量改写到 `/bili/<上游URL>`,并把 `compress` / `decompress` / `acp_status` 注册为客户端原生工具(plugin 模式),`/acp` 面板绑定当前会话。插件还会把客户端**自己的模型配置**上报给代理(runtime-info 协议,#955),压缩预算用真实窗口而不是注册表猜测。退出开关:`BILI_NATIVE_PI=0`、`BILI_NATIVE_OPENCODE=0`、`BILI_NATIVE_DSH=0`。 +插件加载时**自拉起自己的代理**(已有健康实例则直接复用 —— 父进程 pid 看门狗在客户端退出时收掉它),把模型流量改写到 `/bili/<上游URL>`,并把 `compress` / `decompress` / `acp_status` 注册为客户端原生工具(plugin 模式),`/acp` 面板绑定当前会话。插件还会把客户端**自己的模型配置**上报给代理(runtime-info 协议,#955),压缩预算用真实窗口而不是注册表猜测。退出开关:`BILI_NATIVE_PI=0`、`BILI_NATIVE_OMP=0`、`BILI_NATIVE_OPENCODE=0`、`BILI_NATIVE_DSH=0`。 #### Runtime-info 协议(#955) @@ -137,7 +138,7 @@ dsh 用户可以完全不用 bili:`dsh plugin --profile add billion-con | 逐请求头(门控在 `x-bili-plugin`) | 每次模型请求 | `x-bili-plugin-context-window`、`x-bili-plugin-max-output`、`x-bili-plugin-model` | | `POST /__bili/plugin/runtime-info`(回环地址) | 插件自举 + 模型切换 | `{agent, model, contextWindow?, maxOutput?, baseURL?, source}` | -窗口解析顺序:`anthropic-beta` 协商 > 逐请求 plugin 头 > runtime-info 表(agent+model 必须匹配) > launcher 环境变量 > 路由配置 > models.dev 注册表 > 内置表。上报的 `maxOutput` 仅在请求体自带输出预算缺席时兜底。现有实现:`src/agent/pi.ts`、`src/agent/opencode-native.ts`(v1)、`src/agent/opencode-v2.ts`、`src/agent/dsh-native.ts` —— 其他客户端接入请遵循同一协议。 +窗口解析顺序:`anthropic-beta` 协商 > 逐请求 plugin 头 > runtime-info 表(agent+model 必须匹配) > launcher 环境变量 > 路由配置 > models.dev 注册表 > 内置表。上报的 `maxOutput` 仅在请求体自带输出预算缺席时兜底。现有实现:`src/agent/pi.ts`(覆盖 pi 与 omp)、`src/agent/opencode-native.ts`(v1)、`src/agent/opencode-v2.ts`、`src/agent/dsh-native.ts` —— 其他客户端接入请遵循同一协议。 launcher 环境变量这档覆盖纯代理客户端(无进程内插件):`bili ` 启动时读客户端自己的模型配置(codex 的 `model_context_window` / `model_max_output_tokens`,pi / omp 的 `contextWindow` / `maxTokens`,opencode 的 `limit.context` / `limit.output`,codebuddy 的 `maxInputTokens` / `maxOutputTokens`),经 `BILI_LAUNCHER_MODEL_WINDOWS` / `BILI_LAUNCHER_MODEL_MAX_OUTPUTS` 交给代理(#971)。插件上报 —— 若存在 —— 永远优先于它。 @@ -147,7 +148,7 @@ launcher 环境变量这档覆盖纯代理客户端(无进程内插件):`bili `、`DISABLE_AUTO_COMPACT=1`、`SessionStart` hook),外加同样指向该稳定端口的用户级 MCP shell。hook 在首个模型请求前触发:附着到端口上健康的代理,或拉起一个 pid 看门狗追踪 claude 本身的代理 —— 代理随会话生灭。端口覆盖:`BILI_CLAUDE_NATIVE_PORT` > config `claude.nativePort` > 48787;上游覆盖:`BILI_CLAUDE_UPSTREAM`(或既有 `claude.anthropicBaseUrl`)。`BILI_NATIVE_CLAUDE=0` 退出 —— hook 改为拉起同端口的 **passthrough** 代理(原样转发、关闭压缩)。块是纯 JSON merge/strip:外部键从不触碰,`bili plugin remove claude` 精确还原。装有原生块的机器上 `bili claude` 仍可用 —— 它用自身临时代理覆盖静态 URL,hook 保持休眠。 ### 注入优先级 —— 能不写文件就不写(#535) diff --git a/src/agent/omp-native.ts b/src/agent/omp-native.ts new file mode 100644 index 000000000..13230f800 --- /dev/null +++ b/src/agent/omp-native.ts @@ -0,0 +1,72 @@ +// Native omp mode (#957): the package-installed omp extension bootstraps its +// own proxy — bare `omp` (with this package installed via +// `bili plugin install omp`) gets full plugin-mode compression with NO +// launcher. Same flow as pi-native.ts (#519): +// 1. spawn the package's own proxy (`dist/index.js start`, ephemeral +// port, parent-pid watchdog = this omp process) via ensureProxyRunning — +// a healthy compatible instance is ATTACHED, not doubled; +// 2. patch globalThis.fetch (native-intercept.ts) so model-API requests +// are rewritten to `/bili/` (verified +// patchable under Bun — omp runs on a bundled bun); +// 3. set BILLION_CONTEXT_PROXY so the shared plugin (pi.ts) detects the +// proxy through its existing env fallback — tools, /acp, the compaction +// cancel and the prompt_cache_key stamp all reuse the launcher-mode +// code paths. omp never emits before_provider_headers, so its +// runtime-info report (#955) rides before_provider_request instead. +// Skipped when a proxy is already managed (BILLION_CONTEXT_PROXY set by a +// `bili` MITM launch, or BILI_PROVIDER_REWRITES set by a `bili` /bili/ +// launch) or opted out (BILI_NATIVE_OMP=0). + +import { ensureProxyRunning, LAUNCHER_DEFAULT_HOST } from "../launcher.js"; +import { createBiliPlugin } from "./pi.js"; +import { markNativeHost, nativeBootstrapGate, nativeProxyScriptPath, singleFlight } from "./native-bootstrap.js"; +import { installNativeFetchIntercept, type NativeInterceptState } from "./native-intercept.js"; + +/** Decides whether the native bootstrap should run in this process. */ +export function shouldBootstrapNativeOmp(env: NodeJS.ProcessEnv): boolean { + return nativeBootstrapGate(env, "BILI_NATIVE_OMP"); +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +const state: NativeInterceptState = { origin: undefined, ready: Promise.resolve(undefined) }; + +async function bootstrap(): Promise { + try { + const handle = await ensureProxyRunning( + { host: LAUNCHER_DEFAULT_HOST, port: 0, passthrough: false, debug: false }, + { scriptPath: nativeProxyScriptPath() }, + ); + const origin = handle.origin; + state.origin = origin; + process.env.BILLION_CONTEXT_PROXY = origin; + return origin; + } catch (err) { + console.error(`bili-native: proxy bootstrap failed — model traffic goes direct (uncompressed): ${errMessage(err)}`); + return undefined; + } +} + +const nativeActive = shouldBootstrapNativeOmp(process.env); +if (nativeActive) markNativeHost(process.env, "omp"); + +// node:test imports this module for shouldBootstrapNativeOmp — never +// bootstrap a real proxy from inside a test run. +if (process.env.NODE_TEST_CONTEXT === undefined && nativeActive) { + const start = singleFlight(bootstrap); + state.respawn = start; + state.onGiveUp = () => { + // We wrote BILLION_CONTEXT_PROXY at successful bootstrap. If the proxy + // dies mid-session and the respawn fails, traffic goes direct — clear + // the env so event-time ownership checks (session_before_compact + // cancel, prompt_cache_key stamping) stop claiming compression + // ownership and native compaction comes back with the direct traffic. + delete process.env.BILLION_CONTEXT_PROXY; + }; + state.ready = start(); + installNativeFetchIntercept(state); +} + +export default createBiliPlugin("omp"); diff --git a/src/agent/pi.ts b/src/agent/pi.ts index 51c901a96..194c2fa4d 100644 --- a/src/agent/pi.ts +++ b/src/agent/pi.ts @@ -459,11 +459,25 @@ export function createBiliPlugin(agentOverride?: string, opts?: { retryIntervalM } void registerTools(pi, ctx, state, agent).catch((err: unknown) => console.error(`bili-plugin(${agent}): ${err instanceof Error ? err.message : String(err)}`)); }); + // omp never emits before_provider_headers — where pi stamps the + // x-bili-plugin-* headers and reports runtime info (#955) — so omp's + // report rides this per-request event instead: POST only, deduped per + // model switch, gated on toolsReady like pi's header path (ownership + // claim = ACP tools registered; round 1 rides wire mode). + function reportOmpRuntimeInfo(ctx: Ctx): void { + if (state.toolsReady !== true) return; + const modelId = ctx.model?.id; + if (typeof modelId !== "string" || modelId.length === 0) return; + const window = ctx.model?.contextWindow; + const maxOut = (ctx.model as { maxTokens?: unknown } | undefined)?.maxTokens; + reportRuntimeInfoOnChange(proxyBaseForCtx(ctx), { agent, model: modelId, contextWindow: typeof window === "number" && window > 0 ? Math.floor(window) : undefined, maxOutput: typeof maxOut === "number" && maxOut > 0 ? Math.floor(maxOut) : undefined, baseURL: ctx.model?.baseUrl, source: "client-config" }); + } pi.on("before_provider_request", (event, ctx) => { // omp emits this per model request (but never before_provider_headers); // it doubles as the retry driver when the session_start manifest // fetch raced the proxy startup. Cached by sid, throttled by retryAt. void registerTools(pi, ctx, state, agent).catch((err: unknown) => console.error(`bili-plugin(${agent}): ${err instanceof Error ? err.message : String(err)}`)); + if (agent === "omp") reportOmpRuntimeInfo(ctx); return stampPromptCacheKey(event, ctx, agent); }); pi.on("session_start", (_event, ctx) => { diff --git a/src/plugin-install.ts b/src/plugin-install.ts index ec0550984..e33db1d21 100644 --- a/src/plugin-install.ts +++ b/src/plugin-install.ts @@ -4,7 +4,7 @@ // plugin and proxy always share one version. Every writer backs the target // file up first and is idempotent. Config locations: // pi ~/.pi/agent/settings.json packages: [] -// omp ~/.omp/agent/config.yml extensions: [/dist/agent/omp.js] +// omp ~/.omp/agent/config.yml extensions: [/dist/agent/omp-native.js] // claude `claude mcp add` (user scope; writes ~/.claude.json) // codex ~/.codex/config.toml [mcp_servers.bili] // opencode /opencode.json{c}|config.json (highest-precedence existing; #927) @@ -192,9 +192,11 @@ function piStatus(): string { // — omp ——————————————————————————————————————————————————————————————— // An extensions entry that loads the bili omp plugin (any install): a path -// ending in dist/agent/omp.js. Shared by install/remove/status and the -// launcher's loader check so all four agree on what "installed" means. -const OMP_ENTRY_RE = /[\\/]dist[\\/]agent[\\/]omp\.js$/; +// ending in dist/agent/omp.js (thin launcher-mode form) or +// dist/agent/omp-native.js (self-spawning native form, #957). Shared by +// install/remove/status and the launcher's loader check so all four agree on +// what "installed" means. +const OMP_ENTRY_RE = /[\\/]dist[\\/]agent[\\/]omp(-native)?\.js$/; // Line indices of the `- ` items inside the top-level `extensions:` block. // The block starts at the column-0 `extensions:` key and ends at the first @@ -244,19 +246,17 @@ function ompConfigFile(): string { } function ompExtensionPath(): string { - return path.join(selfPackageRoot(), "dist", "agent", "omp.js"); + return path.join(selfPackageRoot(), "dist", "agent", "omp-native.js"); } function ompEntryValue(line: string): string { return line.replace(/#.*$/, "").trim().replace(/^-\s*/, "").replace(/^["']|["']$/g, "").trim(); } -function ompBlockLoaded(text: string): boolean { - const lines = text.split("\n"); - return ompExtensionItemLines(text).some((i) => { - const v = ompEntryValue(lines[i]!); - return OMP_ENTRY_RE.test(v) && fs.existsSync(v); - }); +// A bili omp entry that actually loads: matches our entry shape AND the target +// file exists on disk (stale entries from a moved install don't count). +function ompEntryLoadable(value: string): boolean { + return OMP_ENTRY_RE.test(value) && fs.existsSync(value); } function ompRemove(): string { @@ -280,13 +280,24 @@ function ompInstall(): string { requireDistFile(entry); fs.mkdirSync(path.dirname(file), { recursive: true }); let text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""; - if (ompBlockLoaded(text)) return `omp: already installed (${file})`; - // Drop any stale copy of OUR exact entry (points at a vanished file) so - // the insert below leaves exactly one copy instead of a duplicate. + // Exactly one bili owner after install (#957): a lone native entry → done. + // Any other bili entry — pre-#957 thin dist/agent/omp.js installs or stale + // duplicates — is dropped and replaced by the insert below, so an old + // install upgrades to native mode on re-install. + let replaced: string[] = []; { const lines = text.split("\n"); - const stale = new Set(ompExtensionItemLines(text).filter((i) => ompEntryValue(lines[i]!) === entry)); - if (stale.size > 0) text = lines.filter((_, i) => !stale.has(i)).join("\n"); + const ours = new Set(); + for (const i of ompExtensionItemLines(text)) { + const v = ompEntryValue(lines[i]!); + if (!OMP_ENTRY_RE.test(v)) continue; + ours.add(i); + if (v !== entry) replaced.push(v); + } + if (ours.size === 1 && [...ours].every((i) => ompEntryValue(lines[i]!) === entry)) { + return `omp: already installed (${file})`; + } + if (ours.size > 0) text = lines.filter((_, i) => !ours.has(i)).join("\n"); } const keyCount = (text.match(/^extensions:/gm) ?? []).length; if (keyCount > 1) throw new Error(`${file}: multiple \`extensions:\` keys — fix the file first, refusing to edit`); @@ -321,7 +332,8 @@ function ompInstall(): string { } backupOnce(file); fs.writeFileSync(file, out); - return `omp: installed -> ${file} extensions += ${entry}`; + const note = replaced.length > 0 ? ` (replaced ${replaced.join(", ")})` : ""; + return `omp: installed -> ${file} extensions += ${entry}${note}`; } function ompStatus(): string { @@ -347,7 +359,9 @@ function ompStatus(): string { * launcher must supply the working plugin itself. */ export function ompPluginLoadedFrom(ompHome: string): boolean { try { - return ompBlockLoaded(fs.readFileSync(path.join(ompHome, "config.yml"), "utf8")); + const text = fs.readFileSync(path.join(ompHome, "config.yml"), "utf8"); + const lines = text.split("\n"); + return ompExtensionItemLines(text).some((i) => ompEntryLoadable(ompEntryValue(lines[i]!))); } catch { return false; } diff --git a/tests/omp-native.test.ts b/tests/omp-native.test.ts new file mode 100644 index 000000000..a0310f33b --- /dev/null +++ b/tests/omp-native.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import factory, { shouldBootstrapNativeOmp } from "../src/agent/omp-native.ts"; +import { nativeProxyScriptPath } from "../src/agent/native-bootstrap.ts"; +import { pluginInstall, pluginRemove, pluginStatusAll, selfPackageRoot, ompPluginLoadedFrom } from "../src/plugin-install.ts"; + +// #957 coexistence: markNativeHost must be set synchronously during module +// evaluation so any in-process bili extension backs off in THIS process. +test("module evaluation marks the process as a native omp host", () => { + assert.equal(process.env.BILLION_CONTEXT_NATIVE, "omp"); +}); + +test("shouldBootstrapNativeOmp: true in a bare host with no bili env", () => { + assert.equal(shouldBootstrapNativeOmp({}), true); +}); + +test("shouldBootstrapNativeOmp: false when the plugin or native mode is opted out", () => { + assert.equal(shouldBootstrapNativeOmp({ BILLION_CONTEXT_PLUGIN: "0" }), false); + assert.equal(shouldBootstrapNativeOmp({ BILI_NATIVE_OMP: "0" }), false); + assert.equal(shouldBootstrapNativeOmp({ BILI_NATIVE_PI: "0" }), true, "another host's opt-out does not affect omp"); +}); + +test("shouldBootstrapNativeOmp: false when a bili launch already owns a proxy", () => { + assert.equal(shouldBootstrapNativeOmp({ BILLION_CONTEXT_PROXY: "http://127.0.0.1:36485" }), false); + assert.equal(shouldBootstrapNativeOmp({ BILLION_CONTEXT_PROXY: " " }), true); + assert.equal(shouldBootstrapNativeOmp({ BILI_PROVIDER_REWRITES: '{"vllm":"http://127.0.0.1:1/bili/http://x"}' }), false); +}); + +test("nativeProxyScriptPath: dist/agent/omp-native.js resolves to the package bin", () => { + const agentFile = path.resolve(path.sep, "opt", "pkg", "dist", "agent", "omp-native.js"); + const resolved = nativeProxyScriptPath(pathToFileURL(agentFile).href); + assert.equal(resolved, path.resolve(path.sep, "opt", "pkg", "dist", "index.js")); +}); + +test("default export is an ExtensionFactory (loaded by omp's extensions loader)", () => { + assert.equal(typeof factory, "function"); +}); + +// — installer: `bili plugin install omp` targets the native entry (#957) —— + +const NATIVE_ENTRY = path.join(selfPackageRoot(), "dist", "agent", "omp-native.js"); + +function withOmpHome(fn: () => void | Promise): Promise { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "omp-native-home-")); + const prev = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = home; + if (!fs.existsSync(NATIVE_ENTRY)) { + fs.mkdirSync(path.dirname(NATIVE_ENTRY), { recursive: true }); + fs.writeFileSync(NATIVE_ENTRY, "// test stub\n"); + } + return Promise.resolve(fn()).finally(() => { + if (prev === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prev; + fs.rmSync(home, { recursive: true, force: true }); + }); +} + +function ompConfigFileIn(home: string): string { + return path.join(home, "config.yml"); +} + +function ompStatusOf(): string { + return pluginStatusAll().find((s) => s.agent === "omp")?.status ?? ""; +} + +test("pluginInstall('omp'): fresh install writes exactly one native entry", () => withOmpHome(() => { + const msg = pluginInstall("omp"); + assert.match(msg, /^omp: installed -> .*extensions \+= .+[\\/]dist[\\/]agent[\\/]omp-native\.js$/); + const file = ompConfigFileIn(process.env.PI_CODING_AGENT_DIR!); + assert.equal(fs.readFileSync(file, "utf8"), `extensions:\n - ${NATIVE_ENTRY}\n`); +})); + +test("pluginInstall('omp'): idempotent — re-install leaves the file untouched", () => withOmpHome(() => { + pluginInstall("omp"); + const file = ompConfigFileIn(process.env.PI_CODING_AGENT_DIR!); + const before = fs.readFileSync(file, "utf8"); + const msg = pluginInstall("omp"); + assert.match(msg, /already installed/); + assert.equal(fs.readFileSync(file, "utf8"), before); +})); + +test("pluginInstall('omp'): migrates a pre-#957 thin dist/agent/omp.js entry to the native one", () => withOmpHome(() => { + const home = process.env.PI_CODING_AGENT_DIR!; + const thin = path.join(home, "old", "dist", "agent", "omp.js"); + fs.mkdirSync(path.dirname(thin), { recursive: true }); + fs.writeFileSync(thin, "// thin\n"); + const file = ompConfigFileIn(home); + fs.writeFileSync(file, `theme: dark\nextensions:\n - ${thin}\n`); + const msg = pluginInstall("omp"); + assert.match(msg, /replaced .*[\\/]dist[\\/]agent[\\/]omp\.js/); + assert.equal(fs.readFileSync(file, "utf8"), `theme: dark\nextensions:\n - ${NATIVE_ENTRY}\n`); +})); + +test("pluginInstall('omp'): collapses duplicate native entries into one", () => withOmpHome(() => { + const home = process.env.PI_CODING_AGENT_DIR!; + const file = ompConfigFileIn(home); + fs.writeFileSync(file, `extensions:\n - ${NATIVE_ENTRY}\n - ${NATIVE_ENTRY}\n`); + pluginInstall("omp"); + assert.equal(fs.readFileSync(file, "utf8"), `extensions:\n - ${NATIVE_ENTRY}\n`); +})); + +test("pluginRemove('omp'): removes both entry forms, keeps foreign entries", () => withOmpHome(() => { + const home = process.env.PI_CODING_AGENT_DIR!; + const thin = path.join(home, "old", "dist", "agent", "omp.js"); + fs.mkdirSync(path.dirname(thin), { recursive: true }); + fs.writeFileSync(thin, "// thin\n"); + const other = path.join(home, "other-ext.js"); + fs.writeFileSync(other, "// other\n"); + const file = ompConfigFileIn(home); + fs.writeFileSync(file, `extensions:\n - ${other}\n - ${thin}\n - ${NATIVE_ENTRY}\n`); + const msg = pluginRemove("omp"); + assert.match(msg, /removed from/); + assert.equal(fs.readFileSync(file, "utf8"), `extensions:\n - ${other}\n`); +})); + +test("pluginStatusAll('omp'): installed for either loadable form, broken for a stale path", () => withOmpHome(() => { + const home = process.env.PI_CODING_AGENT_DIR!; + const file = ompConfigFileIn(home); + const thin = path.join(home, "old", "dist", "agent", "omp.js"); + const stale = "/no/such/dir/dist/agent/omp-native.js"; + fs.mkdirSync(path.dirname(thin), { recursive: true }); + fs.writeFileSync(thin, "// thin\n"); + fs.writeFileSync(file, `extensions:\n - ${stale}\n - ${thin}\n`); + assert.equal(ompStatusOf(), "installed"); + fs.writeFileSync(file, `extensions:\n - ${stale}\n`); + assert.equal(ompStatusOf(), "broken"); + fs.writeFileSync(file, `extensions:\n - ${path.join(home, "foreign.js")}\n`); + assert.equal(ompStatusOf(), "not installed"); +})); + +test("ompPluginLoadedFrom: launcher skips -e only for a loadable entry (either form)", () => withOmpHome(() => { + const home = process.env.PI_CODING_AGENT_DIR!; + const file = ompConfigFileIn(home); + const thin = path.join(home, "old", "dist", "agent", "omp.js"); + fs.mkdirSync(path.dirname(thin), { recursive: true }); + fs.writeFileSync(thin, "// thin\n"); + fs.writeFileSync(file, `extensions:\n - ${thin}\n`); + assert.equal(ompPluginLoadedFrom(home), true, "loadable thin entry counts (pre-#957 installs keep working)"); + fs.writeFileSync(file, `extensions:\n - /no/such/dir/dist/agent/omp-native.js\n`); + assert.equal(ompPluginLoadedFrom(home), false, "stale entry → launcher must supply -e itself"); + fs.writeFileSync(file, `extensions:\n - ${NATIVE_ENTRY}\n`); + if (fs.existsSync(NATIVE_ENTRY)) assert.equal(ompPluginLoadedFrom(home), true, "loadable native entry counts"); +})); diff --git a/tests/plugin-agent.test.ts b/tests/plugin-agent.test.ts index b4d95b939..180385484 100644 --- a/tests/plugin-agent.test.ts +++ b/tests/plugin-agent.test.ts @@ -57,12 +57,14 @@ type FakeProxy = { origin: string; toolCalls: Array<{ conversationId: string; tool: string; args: unknown }>; registers: Array<{ conversationId: string; agent: string; identity: boolean }>; + runtimeInfos: Array>; close(): Promise; }; async function startFakeProxy(opts: { failRegister?: number } = {}): Promise { const toolCalls: FakeProxy["toolCalls"] = []; const registers: FakeProxy["registers"] = []; + const runtimeInfos: FakeProxy["runtimeInfos"] = []; const server = http.createServer((req, res) => { const url = req.url ?? ""; if (url === "/__bili/plugin/manifest") { @@ -104,6 +106,16 @@ async function startFakeProxy(opts: { failRegister?: number } = {}): Promise (body += c)); + req.on("end", () => { + runtimeInfos.push(JSON.parse(body) as Record); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } if (url.startsWith("/__bili/plugin/status")) { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, contextTokens: 1234 })); @@ -115,7 +127,7 @@ async function startFakeProxy(opts: { failRegister?: number } = {}): Promise new Promise((resolve) => server.close(() => resolve())) }; + return { origin, toolCalls, registers, runtimeInfos, close: () => new Promise((resolve) => server.close(() => resolve())) }; } test("shared manifest/tool/status against a fake proxy", async () => { @@ -817,7 +829,7 @@ test("plugin install/remove roundtrips for pi/omp/codex/opencode under a fake HO // omp install/status/remove verify the entry's target exists, so // materialize the (gitignored) dist artifact for this sub-test. - const ompDistFile = path.join(root, "dist", "agent", "omp.js"); + const ompDistFile = path.join(root, "dist", "agent", "omp-native.js"); const createdOmpDist = !fs.existsSync(ompDistFile); if (createdOmpDist) { fs.mkdirSync(path.dirname(ompDistFile), { recursive: true }); @@ -828,7 +840,7 @@ test("plugin install/remove roundtrips for pi/omp/codex/opencode under a fake HO fs.writeFileSync(path.join(home, ".omp/agent/config.yml"), "extensions:\n - /some/other/ext.js\nfirstRunComplete: true\n"); assert.match(pluginInstall("omp"), /installed/); const ompText = fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"); - assert.match(ompText, /extensions:\n - \/some\/other\/ext\.js\n - .*dist[\\/]agent[\\/]omp\.js\nfirstRunComplete: true\n/); + assert.match(ompText, /extensions:\n - \/some\/other\/ext\.js\n - .*dist[\\/]agent[\\/]omp-native\.js\nfirstRunComplete: true\n/); assert.match(pluginInstall("omp"), /already installed/); assert.match(pluginRemove("omp"), /removed/); assert.equal(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), "extensions:\n - /some/other/ext.js\nfirstRunComplete: true\n"); @@ -836,19 +848,19 @@ test("plugin install/remove roundtrips for pi/omp/codex/opencode under a fake HO const noExtYml = "firstRunComplete: true\n"; fs.writeFileSync(path.join(home, ".omp/agent/config.yml"), noExtYml); pluginInstall("omp"); - assert.match(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), /firstRunComplete: true\nextensions:\n - .*omp\.js\n/); + assert.match(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), /firstRunComplete: true\nextensions:\n - .*omp-native\.js\n/); pluginRemove("omp"); const noNlYml = "extensions:"; fs.writeFileSync(path.join(home, ".omp/agent/config.yml"), noNlYml); pluginInstall("omp"); - assert.match(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), /extensions:\n - .*omp\.js\n/); + assert.match(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), /extensions:\n - .*omp-native\.js\n/); pluginRemove("omp"); const colZeroYml = "extensions:\n- /a.js\n- /b.js\nother: 1\n"; fs.writeFileSync(path.join(home, ".omp/agent/config.yml"), colZeroYml); pluginInstall("omp"); - assert.match(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), /^extensions:\n- \/a\.js\n- \/b\.js\n- .*omp\.js\nother: 1\n$/); + assert.match(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), /^extensions:\n- \/a\.js\n- \/b\.js\n- .*omp-native\.js\nother: 1\n$/); pluginRemove("omp"); assert.equal(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), colZeroYml); @@ -857,7 +869,7 @@ test("plugin install/remove roundtrips for pi/omp/codex/opencode under a fake HO assert.throws(() => pluginInstall("omp"), /flow style|inline value/); const quotedYml = `extensions:\n - "${path.join(root, "dist/agent/omp.js")}" # my ext\n`; fs.writeFileSync(path.join(home, ".omp/agent/config.yml"), quotedYml); - assert.match(pluginInstall("omp"), /already installed/); + assert.match(pluginInstall("omp"), /installed.*replaced/); assert.match(pluginRemove("omp"), /removed/); assert.equal(fs.readFileSync(path.join(home, ".omp/agent/config.yml"), "utf8"), "extensions:\n"); }); @@ -1058,7 +1070,7 @@ test("plugin install/remove/status survive a non-object mcp in opencode.json (#8 test("omp plugin: scoped matching, existence check, overlay redirect (issue #392)", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-plugin-omp-")); const root = selfPackageRoot(); - const ompDistFile = path.join(root, "dist", "agent", "omp.js"); + const ompDistFile = path.join(root, "dist", "agent", "omp-native.js"); const createdOmpDist = !fs.existsSync(ompDistFile); if (createdOmpDist) { fs.mkdirSync(path.dirname(ompDistFile), { recursive: true }); @@ -1067,18 +1079,19 @@ test("omp plugin: scoped matching, existence check, overlay redirect (issue #392 try { const stale = "/does/not/exist/dist/agent/omp.js"; - // (a) a same-valued line OUTSIDE the extensions block must not block install + // (a) a stale in-block entry migrates to the native form on install + // (#957); a same-valued line OUTSIDE the extensions block is never touched { const agentDir = path.join(home, "a", ".omp", "agent"); fs.mkdirSync(agentDir, { recursive: true }); fs.writeFileSync(path.join(agentDir, "config.yml"), `extensions:\n - ${stale}\nother:\n - ${stale}\nfirstRunComplete: true\n`); await withEnv({ PI_CODING_AGENT_DIR: agentDir }, async () => { - assert.match(pluginInstall("omp"), /installed/); + assert.match(pluginInstall("omp"), /installed.*replaced/); assert.equal(pluginStatusAll().find((r) => r.agent === "omp")?.status, "installed"); }); assert.match(fs.readFileSync(path.join(agentDir, "config.yml"), "utf8"), - /extensions:\n - \/does\/not\/exist\/dist\/agent\/omp\.js\n - .*dist[\\/]agent[\\/]omp\.js\nother:\n - \/does\/not\/exist\/dist\/agent\/omp\.js\n/); + /extensions:\n - .*dist[\\/]agent[\\/]omp-native\.js\nother:\n - \/does\/not\/exist\/dist\/agent\/omp\.js\nfirstRunComplete: true\n/); } // (b) remove only deletes in-block entries; the out-of-block line stays @@ -1106,7 +1119,7 @@ test("omp plugin: scoped matching, existence check, overlay redirect (issue #392 assert.match(pluginInstall("omp"), /installed/); }); assert.match(fs.readFileSync(path.join(realHome, "config.yml"), "utf8"), - /extensions:\n - .*dist[\\/]agent[\\/]omp\.js\n/); + /extensions:\n - .*dist[\\/]agent[\\/]omp-native\.js\n/); assert.equal(fs.readFileSync(path.join(overlay, "config.yml"), "utf8"), "extensions:\n - /stale/overlay/dist/agent/omp.js\n"); } @@ -1427,3 +1440,56 @@ test("pi agent never stamps prompt_cache_key (it stamps headers instead)", async const out = pi.events.get("before_provider_request")!({ type: "before_provider_request", payload: { messages: [{ role: "user", content: "hi" }] } }, fakeCtx(undefined, "pi-uuid")); assert.equal(out, undefined, "pi agent never stamps the body"); }); + +async function waitForRuntimeInfoCount(proxy: FakeProxy, count: number, timeoutMs = 15000): Promise { + const deadline = Date.now() + timeoutMs; + while (proxy.runtimeInfos.length < count) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${count} runtime-info reports; got ${proxy.runtimeInfos.length}`); + await new Promise((r) => setTimeout(r, 25)); + } +} + +test("#957: omp reports runtime-info via before_provider_request (omp has no header event)", async () => { + const proxy = await startFakeProxy(); + try { + const pi = makeFakePi(); + ompPlugin(pi as never); + const baseModel = { baseUrl: `${proxy.origin}/bili/https://api.example.com/v1` }; + const ctxA = { ...fakeCtx(proxy, "omp-rt-1"), model: { id: "omp-rt-model-a", contextWindow: 200000, maxTokens: 32768, ...baseModel } }; + const ctxB = { ...fakeCtx(proxy, "omp-rt-1"), model: { id: "omp-rt-model-b", contextWindow: 128000, maxTokens: 16384, ...baseModel } }; + await pi.events.get("session_start")!({}, ctxA); + // tools not ready yet → round 1 rides wire mode, no report. The + // manifest fetch cannot complete inside this synchronous block, so + // toolsReady is provably false here. + await pi.events.get("before_provider_request")!({}, ctxA); + assert.equal(proxy.runtimeInfos.length, 0, "no report before toolsReady"); + await waitForTools(pi, 2); + await pi.events.get("before_provider_request")!({}, ctxA); + await waitForRuntimeInfoCount(proxy, 1); + assert.deepEqual(proxy.runtimeInfos[0], { + agent: "omp", + model: "omp-rt-model-a", + contextWindow: 200000, + maxOutput: 32768, + baseURL: `${proxy.origin}/bili/https://api.example.com/v1`, + source: "client-config", + }); + // same model again → deduped, no second POST + await pi.events.get("before_provider_request")!({}, ctxA); + await flush(); + assert.equal(proxy.runtimeInfos.length, 1, "deduped per model switch"); + // model switch → exactly one more report with the new config + await pi.events.get("before_provider_request")!({}, ctxB); + await waitForRuntimeInfoCount(proxy, 2); + assert.deepEqual(proxy.runtimeInfos[1], { + agent: "omp", + model: "omp-rt-model-b", + contextWindow: 128000, + maxOutput: 16384, + baseURL: `${proxy.origin}/bili/https://api.example.com/v1`, + source: "client-config", + }); + } finally { + await proxy.close(); + } +}); diff --git a/tsup.config.ts b/tsup.config.ts index ddb36c3dc..1923925b1 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/mcp.ts", "src/claude-native-bootstrap.ts", "src/agent/pi.ts", "src/agent/pi-native.ts", "src/agent/omp.ts", "src/agent/opencode.ts", "src/agent/opencode-native.ts", "src/agent/dsh-acp.ts", "src/agent/dsh-native.ts"], + entry: ["src/index.ts", "src/mcp.ts", "src/claude-native-bootstrap.ts", "src/agent/pi.ts", "src/agent/pi-native.ts", "src/agent/omp.ts", "src/agent/omp-native.ts", "src/agent/opencode.ts", "src/agent/opencode-native.ts", "src/agent/dsh-acp.ts", "src/agent/dsh-native.ts"], format: ["esm"], target: "node20", platform: "node",