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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,28 @@ bili --no-auto-update # disable self-update for this run

Flags override env vars and the config file. `bili --help` lists them all.

### Per-session proxy (`bili daemon`)

Agent-side plugins can start a dedicated, isolated proxy per session instead of
sharing one long-lived instance:

```bash
bili daemon --parent-pid <agent-pid>
# stdout: {"origin":"http://127.0.0.1:<port>","port":<port>,"pid":<pid>,"logPath":"…"}
# exit 0 on success; non-zero + stderr explanation on failure
```

- The port is allocated dynamically (ephemeral bind-0 probe). An explicit
`--port` / `ACP_PORT` is preferred first, with automatic fallback on conflict.
- A fresh instance is **always** started — it never attaches to an existing
proxy, so sessions stay isolated from each other.
- With `--parent-pid` (or an inherited `BILI_PARENT_PID`), the proxy stops
itself when that process exits — the same mechanism `bili <client>` uses.
Without it, a warning is printed and the proxy keeps running until killed.
- Concurrency-safe: daemons handshakes through a per-session result file, so
parallel daemons never clobber each other. The global instance file is still
written (last-writer-wins) for MCP-shell/install discovery.

### Remote agents (`--host`)

By default the proxy binds `127.0.0.1` and only accepts loopback
Expand Down
46 changes: 37 additions & 9 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { checkForUpdate, startAutoUpdate } from "./update.js";
import { resolveProxy } from "./upstream-proxy.js";
import { runMcpStdio } from "./mcp.js";
import { PLUGIN_AGENTS, isPluginAgent, pluginInstall, pluginRemove, pluginStatusAll, type PluginAgent } from "./plugin-install.js";
import { runLaunch, runTestPi, isLaunchClient, type ClientName } from "./launcher.js";
import { runDaemon, runLaunch, runTestPi, isLaunchClient, type ClientName } from "./launcher.js";
import { exportSession } from "./export.js";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -70,8 +70,11 @@ Usage:
bili test pi non-polluting pi smoke test through the proxy
bili export [session] [--full] list sessions / export one as a Markdown handoff
(--full includes original messages; --output FILE)
bili update check for & install a newer version now
bili plugin install <agent> install the thin plugin into a host (pi/omp/
bili update check for & install a newer version now
bili daemon [--parent-pid N] start a per-session proxy on a dynamic port
(always a fresh instance; prints one JSON line
{origin,port,pid,logPath} to stdout, exit 0)
bili plugin install <agent> install the thin plugin into a host (pi/omp/
claude/codex/opencode; original backed up once)
bili plugin remove <agent> remove it again
bili plugin list show install status for every host
Expand Down Expand Up @@ -109,9 +112,11 @@ Options (override config file / env):
--mitm-domain <domain> extra MITM domain (repeatable; launcher only)
--config <FILE> path to config JSON (default: XDG location)
--debug verbose logging
--passthrough forward without compression
--no-passthrough force compression on (overrides config)
--no-auto-update disable background self-update this run
--passthrough forward without compression
--no-passthrough force compression on (overrides config)
--no-auto-update disable background self-update this run
--parent-pid <N> host pid to watch for auto-stop (daemon only;
also honored from BILI_PARENT_PID env)

Config: ${defaultConfigFile()}
Set port/host/debug/providers/compress/autoUpdate there. See README §Configuration.
Expand All @@ -121,7 +126,7 @@ Docs: https://github.com/ranxianglei/billion-context
`;

type Parsed = {
command: "start" | "update" | "help" | "version" | "launch" | "test" | "export" | "plugin-register" | "mcp" | "plugin";
command: "start" | "update" | "help" | "version" | "launch" | "test" | "export" | "plugin-register" | "mcp" | "plugin" | "daemon";
client?: ClientName;
clientArgs: string[];
mitmDomains: string[];
Expand All @@ -130,6 +135,7 @@ type Parsed = {
exportOutput?: string;
exportFull?: boolean;
registerConversationId?: string;
parentPid?: number;
pluginAction?: "install" | "remove" | "list";
pluginAgent?: PluginAgent;
};
Expand All @@ -143,6 +149,7 @@ export function parseArgs(argv: string[]): Parsed {
const mitmDomains: string[] = [];
let exportSelector: string | undefined;
let registerConversationId: string | undefined;
let parentPid: number | undefined;
let exportOutput: string | undefined;
let exportFull = false;
let pluginAction: Parsed["pluginAction"];
Expand Down Expand Up @@ -189,6 +196,15 @@ export function parseArgs(argv: string[]): Parsed {
mitmDomains.push(val);
break;
}
case "--parent-pid": {
const val = argv[++i];
if (val === undefined || !/^\d+$/.test(val) || Number(val) <= 0) {
console.error(`bili: ${a} requires a positive integer pid`);
process.exit(2);
}
parentPid = Number(val);
break;
}
case "--full":
exportFull = true;
break;
Expand Down Expand Up @@ -248,6 +264,8 @@ export function parseArgs(argv: string[]): Parsed {
command = command === "help" || command === "version" ? command : "start";
} else if (cmd === "update") {
command = "update";
} else if (cmd === "daemon") {
command = "daemon";
} else if (cmd === "export") {
command = "export";
exportSelector = positional[1];
Expand Down Expand Up @@ -292,11 +310,11 @@ export function parseArgs(argv: string[]): Parsed {
}
}

return { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, pluginAction, pluginAgent };
return { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, parentPid, pluginAction, pluginAgent };
}

export async function main(): Promise<void> {
const { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, pluginAction, pluginAgent } = parseArgs(process.argv.slice(2));
const { command, client, clientArgs, mitmDomains, overrides, exportSelector, exportOutput, exportFull, registerConversationId, parentPid, pluginAction, pluginAgent } = parseArgs(process.argv.slice(2));
if (command === "help") {
process.stdout.write(HELP);
return;
Expand Down Expand Up @@ -411,6 +429,16 @@ export async function main(): Promise<void> {
await runLaunch({ client: client!, clientArgs, mitmDomains, overrides });
return;
}
if (command === "daemon") {
// Merge overrides into env BEFORE spawning: the proxy child inherits
// this process's env, and the generic merge below runs only on the
// server path (which this branch returns ahead of).
for (const [k, v] of Object.entries(overrides)) {
if (v !== undefined) process.env[k] = v;
}
await runDaemon({ overrides, mitmDomains, parentPid });
return;
}

for (const [k, v] of Object.entries(overrides)) {
if (v !== undefined) process.env[k] = v;
Expand Down
153 changes: 132 additions & 21 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ export interface LaunchOptions {
* BILI_LAUNCHER_MODEL_WINDOWS so the nudge denominator matches the
* client's real window instead of the built-in table guess. */
modelWindows?: Record<string, number>;
/** Skip the attach-to-existing step: always spawn a fresh, owned
* instance (per-session isolation for `bili daemon`, #518). */
fresh?: boolean;
/** Per-session handshake file: passed to the child as BILI_RESULT_FILE;
* the child writes its bind record there after a successful listen and
* this caller polls ONLY that file (concurrent daemons must not clobber
* each other's handshake through the single global instance file). */
resultFile?: string;
/** Host process to watch via BILI_PARENT_PID (#414 parent-gone reaping).
* undefined → this process (launcher semantics); null → no watcher env
* at all (daemon without --parent-pid; defaulting to the daemon's own
* pid would suicide the proxy within 2s). */
parentPid?: number | null;
}

export interface ProxyHandle {
Expand Down Expand Up @@ -1409,6 +1422,24 @@ async function probeExistingInstance(
return inst;
}

/** Bind-0 probe: ask the kernel for an ephemeral port on `host`. The port
* may be stolen before the proxy child binds it — the child's EADDRINUSE
* retry (#407) plus the launch-token handshake reporting the REAL origin
* make the race harmless. */
export function allocateDynamicPort(host = LAUNCHER_DEFAULT_HOST): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once("error", reject);
srv.listen(0, host, () => {
const addr = srv.address();
srv.close(() => {
if (addr && typeof addr === "object") resolve(addr.port);
else reject(new Error("could not allocate a free port"));
});
});
});
}

export function findFreePort(preferred: number, host = LAUNCHER_DEFAULT_HOST): Promise<number> {
const tryBind = (port: number): Promise<boolean> =>
new Promise((resolve) => {
Expand All @@ -1417,20 +1448,7 @@ export function findFreePort(preferred: number, host = LAUNCHER_DEFAULT_HOST): P
srv.once("listening", () => srv.close(() => resolve(true)));
srv.listen(port, host);
});
return tryBind(preferred).then((free) => {
if (free) return preferred;
return new Promise<number>((resolve, reject) => {
const srv = net.createServer();
srv.once("error", reject);
srv.listen(0, host, () => {
const addr = srv.address();
srv.close(() => {
if (addr && typeof addr === "object") resolve(addr.port);
else reject(new Error("could not allocate a free port"));
});
});
});
});
return tryBind(preferred).then((free) => (free ? Promise.resolve(preferred) : allocateDynamicPort(host)));
}

export function pickEphemeralPort(host = LAUNCHER_DEFAULT_HOST): Promise<number> {
Expand Down Expand Up @@ -1484,11 +1502,14 @@ export async function ensureProxyRunning(

// #394/#417: a healthy proxy with a compatible config is SHARED, not
// doubled — two concurrent launches of the same client would otherwise
// spawn two writers over one sessions dir.
const existing = await probeExistingInstance(readInstance, fetchHealthInfo);
if (existing && instanceCompatible(existing, opts)) {
console.error(`bili: attaching to running proxy at ${existing.origin} (pid ${existing.pid})`);
return { origin: existing.origin, port: existing.port, attached: true };
// spawn two writers over one sessions dir. fresh (#518 daemon) skips this:
// per-session instances must never attach to someone else's proxy.
if (!opts.fresh) {
const existing = await probeExistingInstance(readInstance, fetchHealthInfo);
if (existing && instanceCompatible(existing, opts)) {
console.error(`bili: attaching to running proxy at ${existing.origin} (pid ${existing.pid})`);
return { origin: existing.origin, port: existing.port, attached: true };
}
}

// #407: no probe-release-rebind. The child binds the preferred port
Expand All @@ -1515,7 +1536,8 @@ export async function ensureProxyRunning(
env: {
...stripInheritedProxy(process.env),
BILI_LAUNCH_TOKEN: launchToken,
BILI_PARENT_PID: String(process.pid),
...(opts.parentPid === null ? {} : { BILI_PARENT_PID: String(opts.parentPid ?? process.pid) }),
...(opts.resultFile ? { BILI_RESULT_FILE: opts.resultFile } : {}),
...(opts.mitmDomains && opts.mitmDomains.length
? { BILI_MITM_DOMAINS: opts.mitmDomains.join(",") }
: {}),
Expand Down Expand Up @@ -1545,11 +1567,17 @@ export async function ensureProxyRunning(
};
});

// With a resultFile the child reports into OUR file (BILI_RESULT_FILE),
// so concurrent daemons never clobber each other's handshake through the
// single global instance file (#518).
const readHandshake = (): ReturnType<typeof readProxyInstanceFile> =>
opts.resultFile ? readProxyInstanceFile(opts.resultFile) : readInstance();

const deadline = now() + SPAWN_WAIT_MS;
while (now() < deadline) {
if (childExit) break;
await sleepImpl(HEALTH_POLL_INTERVAL_MS);
const inst = readInstance();
const inst = readHandshake();
if (isProxyInstanceFile(inst) && inst.launchToken === launchToken) {
if (await probeHealth(inst.origin, fetchImpl)) {
return { origin: inst.origin, port: inst.port, child, logPath };
Expand Down Expand Up @@ -1597,6 +1625,89 @@ export function stopProxy(handle: ProxyHandle): void {
} catch {}
}

export interface DaemonParams {
overrides: Record<string, string | undefined>;
mitmDomains?: string[];
/** Host agent's pid for BILI_PARENT_PID reaping (#414). Omitted → the
* child gets NO watcher env (never defaults to this process: the daemon
* exits right away and would suicide the proxy within 2s). */
parentPid?: number;
}

export interface DaemonResult {
origin: string;
port: number;
pid: number;
logPath?: string;
}

export async function spawnDaemonProxy(
opts: {
host: string;
port?: number;
passthrough: boolean;
debug: boolean;
mitmDomains?: string[];
parentPid?: number | null;
resultFile?: string;
},
deps: LauncherDeps = {},
): Promise<DaemonResult> {
const port = opts.port ?? (await allocateDynamicPort(opts.host));
const handle = await ensureProxyRunning(
{
host: opts.host,
port,
passthrough: opts.passthrough,
debug: opts.debug,
mitmDomains: opts.mitmDomains,
fresh: true,
resultFile: opts.resultFile,
parentPid: opts.parentPid,
},
deps,
);
if (!handle.child || handle.child.pid === undefined) {
throw new Error("bili daemon: proxy handle has no child pid");
}
return { origin: handle.origin, port: handle.port, pid: handle.child.pid, logPath: handle.logPath };
}

export async function runDaemon(params: DaemonParams, deps: LauncherDeps = {}): Promise<void> {
const host = params.overrides.ACP_HOST?.trim() || LAUNCHER_DEFAULT_HOST;
const rawPort = params.overrides.ACP_PORT ?? process.env.ACP_PORT;
const port = rawPort && rawPort.trim() ? parsePort(rawPort) : undefined;
const passthrough = params.overrides.ACP_PASSTHROUGH === "1";
const debug = params.overrides.ACP_DEBUG === "1";

const envParent = parseInt(process.env.BILI_PARENT_PID ?? "", 10);
const hostPid = params.parentPid ?? (!Number.isNaN(envParent) && envParent > 0 ? envParent : undefined);
if (hostPid === undefined) {
console.error("bili daemon: no --parent-pid given — the proxy keeps running after this command exits (no auto-stop)");
}

const resultFile = path.join(os.tmpdir(), `bili-daemon-${randomUUID()}.json`);
try {
const res = await spawnDaemonProxy(
{ host, port, passthrough, debug, mitmDomains: params.mitmDomains, parentPid: hostPid ?? null, resultFile },
deps,
);
try {
fs.unlinkSync(resultFile);
} catch {}
// Single-line JSON on stdout is a machine contract (#518) — keep stdout
// free of anything else. exitCode (not process.exit) so the pipe flushes.
process.stdout.write(`${JSON.stringify(res)}\n`);
process.exitCode = 0;
} catch (err) {
try {
fs.unlinkSync(resultFile);
} catch {}
console.error(`bili daemon: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
}
}

export function runClient(
cmd: string,
args: string[],
Expand Down
Loading
Loading