feat: rerouted worker subprocesses through the bundled CLI host entrypoint

- Rerouted sync, tab, js-eval, and tiny workers to re-enter CLI modes via `__omp_*` selectors.
- Adjusted `cli.ts` startup to dispatch worker entrypoints before parsing and exit 1 on uncaught errors.
- Bundled CLI as `dist/cli.js` in prepack, switching `omp` binary and published files.
- Removed explicit Bun `--compile` worker entrypoints from build/release scripts in favor of host-entry dispatch.
- Added `declareWorkerHostEntry()` and `workerHostEntry()` environment helpers and `PI_COMPILED` binary detection.
This commit is contained in:
can1357
2026-06-10 03:55:08 +02:00
parent f638a5b3e0
commit dc5c93462f
18 changed files with 339 additions and 193 deletions
+8 -7
View File
@@ -29,16 +29,17 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr
- **Class privacy**: use ES `#private` fields; leave externally accessible members bare. **No `private`/`protected`/`public` keyword on fields or methods**, except on **constructor parameter properties** where TypeScript requires it (e.g. `constructor(private readonly session: ToolSession)`).
- **Promises**: use `Promise.withResolvers()` instead of `new Promise((resolve, reject) => ...)`.
- **Prompts**: never build prompts in code (no inline strings, template literals, or concatenation). Prompts live in static `.md` files; use Handlebars for dynamic content. Import them via `import content from "./prompt.md" with { type: "text" }` — not `readFile`.
- **Worker scripts**: spawn workers with the dev/compile-safe hybrid pattern. `with { type: "file" }` only copies the entry as a raw asset and does **not** bundle its imports — workers crashed silently in compiled binaries on every prior incarnation of that pattern (issues #1011, #1027). Use this shape instead:
- **Worker scripts**: workers re-enter the CLI entrypoint; never spawn separate worker entry modules. `cli.ts` declares itself as the worker host at startup (`declareWorkerHostEntry()` from `@oh-my-pi/pi-utils/env`) and dispatches hidden argv selectors (`__omp_stats_sync_worker`, `__omp_tab_worker`, `__omp_js_eval_worker`, `--tiny-worker`) before loading the command registry. Spawn sites use:
```ts
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
const worker = isCompiledBinary()
? new Worker("./packages/<pkg>/src/<worker>.ts", { type: "module" })
import { workerHostEntry } from "@oh-my-pi/pi-utils";
const hostEntry = workerHostEntry();
const worker = hostEntry
? new Worker(hostEntry, { type: "module", argv: ["__omp_<name>_worker"] })
: new Worker(new URL("./<worker>.ts", import.meta.url).href, { type: "module" });
```
The literal in the compiled branch is what Bun's `--compile` static analyzer needs to discover the worker — its path is **`--root`-relative** (repo root, since `build-binary.ts` passes `--root ../..`), so it must start with `./packages/...`. The `new URL` form in the dev branch keeps spawns portable across cwds.
In addition, every worker entry **MUST** be listed as an extra `--compile` entrypoint in `packages/coding-agent/scripts/build-binary.ts`. Without that the analyzer sees the literal but the worker never gets emitted into bunfs. The three current entries (`sync-worker.ts`, `tab-worker-entry.ts`, `worker-entry.ts`) live there as the working reference.
Validate any new worker with the dedicated smoke probe: `omp --smoke-test` spawns the stats sync worker, pings it, and exits — it's wired into `ci:test:smoke` and `scripts/install-tests/run-ci.sh` so binary, source-link, and tarball installs all exercise it. Add a sibling smoke if the new worker is on a different module graph.
When the process was started from the omp CLI — source `cli.ts`, npm-bundle `dist/cli.js`, or compiled binary — `workerHostEntry()` is `Bun.main` and the worker re-enters the single entry module, so no per-worker `--compile` entrypoints or bundle entries exist. Outside a CLI host (`bun test`, SDK embedding, standalone `omp-stats`) it returns `null` and the direct-module fallback loads the worker source. New worker kinds MUST add their selector to the dispatch table in `cli.ts` and keep the fallback branch.
History: `with { type: "file" }` only copied the entry as a raw asset (workers crashed silently in compiled binaries — issues #1011, #1027), and the later literal-path + extra-entrypoint pattern required keeping spawn literals and two build scripts in sync (issue #1150). The repro tests for those issues now pin the worker-host contract instead.
Validate any new worker with the dedicated smoke probe: `omp --smoke-test` spawns the stats sync worker and the tiny-model subprocess, pings them, and exits — it's wired into `ci:test:smoke` and `scripts/install-tests/run-ci.sh` so binary, source-link, and tarball installs all exercise it. Add a sibling smoke if the new worker is on a different module graph.
## Bun Over Node
+3
View File
@@ -6,12 +6,15 @@
- New `omp usage` command: a detailed per-account breakdown of provider usage limits (bars, windows, reset times, plan metadata) covering every stored credential — accounts with no usage endpoint are listed as "no usage data" rows. Each provider section ends with per-window capacity stats ("need: 5h → 3 of 5 accounts"). Flags: `--provider` to filter, `--json` for the broker-shaped report payload, and `--redact` to mask account emails/ids down to a two-char anchor plus a minimal middle-out differentiator (`ca*9*`) for screenshot-safe sharing.
- Startup hangs are now self-diagnosing (speculative fix for the "zero output, hangs even on `omp -h`" report class): a watchdog prints a stderr line every 10s naming the deepest in-flight startup phase (via `logger.openSpanPath()`) until a mode runner takes over, pausing around legitimate interactive waits (fork/move prompts, the `--resume` session picker); `PI_DEBUG_STARTUP` is restored as streaming synchronous `[startup]` phase markers covering command-module imports and the native addon load, which the post-startup `PI_TIMING` tree structurally cannot show for a hang; and waiting on piped-stdin EOF announces itself after 1s instead of blocking silently.
- npm installs now execute a prebundled single-file entry: `bin.omp` points at `dist/cli.js` (built by `scripts/bundle-dist.ts` during `prepack`, ~18MB minified, natives/transformers/mupdf external), cutting npm-install cold start by roughly 3x versus transpiling the raw TypeScript graph per launch; `src/**` stays published for SDK consumers and worker fallbacks
### Changed
- Cached custom model alias maps and built them lazily on first custom model reference lookup, avoiding unnecessary startup model-registry initialization
- Cached resolved auth broker configuration and snapshot reads for the process lifetime so repeated startup paths reuse the same `OMP_AUTH_BROKER_*` resolution instead of re-running config/token discovery
- Reused task-agent discovery results for repeated `TaskTool.create` calls in the same working directory to avoid repeated plugin scans during subagent startup
- Worker threads (stats sync, browser tab, JS eval) and the tiny-model subprocess now re-enter the CLI entrypoint with hidden argv selectors (`__omp_*`, `--tiny-worker`) via the declared worker-host entry (`workerHostEntry()`), collapsing the per-distribution spawn branches; outside a CLI host (bun test, SDK embedding) spawn sites fall back to loading the worker module directly, and both binary build scripts dropped their per-worker `--compile` entrypoint lists
- The CLI entry no longer top-level-awaits `runCli` — the floating call reports rejections to stderr and exits 1, keeping the entry module CJS-lowerable and the bundle parse-friendly
- Tightened the system prompt and tool prompts: deduped restated warnings (bash "catch yourself" list, search/find shell-fallback recaps, read instruction/critical overlap, the AST metavariable primer duplicated across both ast tool descriptions), factored the repeated repo-default clause in the `gh` search ops, dropped a dead `rsed` reference and an internal `tool-timeouts.ts` pointer, and pruned internal mechanism the agent can't act on (screenshot temp-file/downscaling pipeline, browser spawn lifecycle, `gh` "replaces former op" history and run-watch grace period, output-minimizer heuristics, BM25 ranking name, `task.maxConcurrency` pointer)
- Extended the prompt-efficiency pass to the full prompt surface (subagent/plan-mode/notice/title/commit system prompts, agent definitions, goals, memories, review and autoresearch prompts): RFC-keyed prescriptive prose, fixed garbled grammar and a stale `<PLAN_TITLE>` placeholder in the plan-approval reminder, deduped intra-file restatements, and corrected the `todo` op table's claim that `rm` requires a `task`/`phase` (bare `rm` clears the whole list)
- Replace tool prompt no longer recommends `sed -i`/`cat`-heredoc commands that the bash interceptor blocks; its bash-alternatives table now only lists non-intercepted commands
+4 -2
View File
@@ -28,7 +28,7 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"bin": {
"omp": "src/cli.ts"
"omp": "dist/cli.js"
},
"scripts": {
"build": "bun scripts/build-binary.ts",
@@ -40,7 +40,7 @@
"fmt": "biome format --write . && bun run format-prompts",
"format-prompts": "bun scripts/format-prompts.ts",
"generate-docs-index": "bun scripts/generate-docs-index.ts",
"prepack": "bun scripts/generate-docs-index.ts",
"prepack": "bun scripts/generate-docs-index.ts && bun scripts/bundle-dist.ts",
"generate-template": "bun scripts/generate-template.ts"
},
"dependencies": {
@@ -87,6 +87,8 @@
},
"files": [
"src",
"dist/cli.js",
"dist/*.node",
"scripts",
"examples",
"README.md",
+18 -23
View File
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
import * as path from "node:path";
const packageDir = path.join(import.meta.dir, "..");
const repoRoot = path.join(packageDir, "..", "..");
const outputPath = path.join(packageDir, "dist", "omp");
// Transformers.js is an optional, native-heavy dependency that is never bundled
@@ -19,9 +20,13 @@ function shouldAdhocSignDarwinBinary(): boolean {
return process.platform === "darwin";
}
async function runCommand(command: string[], env: NodeJS.ProcessEnv = Bun.env): Promise<void> {
async function runCommand(
command: string[],
env: NodeJS.ProcessEnv = Bun.env,
cwd: string = packageDir,
): Promise<void> {
const proc = Bun.spawn(command, {
cwd: packageDir,
cwd,
env,
stdout: "inherit",
stderr: "inherit",
@@ -55,19 +60,8 @@ async function main(): Promise<void> {
"--external",
"mupdf",
"--root",
"../..",
"./src/cli.ts",
// Worker entrypoints. Bun's `--compile` discovers the literal in
// `new Worker("…", …)` at each spawn site, but only actually
// emits the worker into the bunfs root when it is listed here as
// an explicit additional entry. Paths are relative to this
// script's cwd (packages/coding-agent) and the `--root` above
// (../..) makes them appear inside the binary at
// `/$bunfs/root/packages/<pkg>/src/<worker>.js`, which is
// exactly what the literals at the spawn sites resolve to.
"../stats/src/sync-worker.ts",
"./src/tools/browser/tab-worker-entry.ts",
"./src/eval/js/worker-entry.ts",
".",
"./packages/coding-agent/src/cli.ts",
// Legacy pi-* extension compat entrypoints served by
// `legacy-pi-compat.ts`. These are reached via computed bunfs paths
// (which `--compile`'s static analyzer cannot trace), so each must be
@@ -77,17 +71,18 @@ async function main(): Promise<void> {
// breaks the CLI entry when the same package's barrel appears as an
// extra entrypoint (issue #1474), so legacy `pi-coding-agent` imports
// resolve through `legacy-pi-coding-agent-shim.ts` instead.
"../agent/src/index.ts",
"../natives/native/index.js",
"../tui/src/index.ts",
"../utils/src/index.ts",
"./src/extensibility/typebox.ts",
"./src/extensibility/legacy-pi-ai-shim.ts",
"./src/extensibility/legacy-pi-coding-agent-shim.ts",
"./packages/agent/src/index.ts",
"./packages/natives/native/index.js",
"./packages/tui/src/index.ts",
"./packages/utils/src/index.ts",
"./packages/coding-agent/src/extensibility/typebox.ts",
"./packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts",
"./packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts",
"--outfile",
"dist/omp",
"packages/coding-agent/dist/omp",
],
buildEnv,
repoRoot,
);
// Bun 1.3.12 emits a truncated Mach-O signature on darwin builds.
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bun
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
const packageDir = path.join(import.meta.dir, "..");
const outDir = path.join(packageDir, "dist");
const cliPath = path.join(outDir, "cli.js");
const shebang = "#!/usr/bin/env bun\n";
async function runCommand(command: string[]): Promise<void> {
const proc = Bun.spawn(command, {
cwd: packageDir,
stdout: "inherit",
stderr: "inherit",
});
const exitCode = await proc.exited;
if (exitCode !== 0) throw new Error(`Command failed with exit code ${exitCode}: ${command.join(" ")}`);
}
async function ensureShebang(): Promise<void> {
const text = await Bun.file(cliPath).text();
if (text.startsWith(shebang)) return;
const withoutExisting = text.startsWith("#!") ? text.slice(text.indexOf("\n") + 1) : text;
await Bun.write(cliPath, shebang + withoutExisting);
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
}
async function cleanBundleOutputs(): Promise<void> {
// dist/ is shared with the dev binary (dist/omp); only remove this
// script's own outputs (entry bundle + copied native assets).
let entries: string[];
try {
entries = await fs.readdir(outDir);
} catch (err) {
if (isEnoent(err)) return;
throw err;
}
await Promise.all(
entries
.filter(entry => entry === "cli.js" || entry.endsWith(".node") || entry.endsWith(".js.map"))
.map(entry => fs.rm(path.join(outDir, entry), { force: true })),
);
}
async function main(): Promise<void> {
const start = Bun.nanoseconds();
await cleanBundleOutputs();
await runCommand([
"bun",
"build",
"--target=bun",
"--outdir",
"dist",
"--minify-whitespace",
"--minify-syntax",
"--keep-names",
"--external",
"mupdf",
"--external",
"@oh-my-pi/pi-natives",
"--external",
"@huggingface/transformers",
"--define",
'process.env.PI_BUNDLED="true"',
"./src/cli.ts",
]);
await ensureShebang();
const stat = await fs.stat(cliPath);
const elapsedMs = (Bun.nanoseconds() - start) / 1_000_000;
process.stdout.write(
`Bundled coding-agent CLI to dist/cli.js (${formatBytes(stat.size)}) in ${elapsedMs.toFixed(0)}ms\n`,
);
}
await main();
+69 -5
View File
@@ -14,9 +14,9 @@ try {
* CLI entry point — registers all commands explicitly and delegates to the
* lightweight CLI runner from pi-utils.
*/
import { type CliConfig, run } from "@oh-my-pi/pi-utils/cli";
import type { CliConfig } from "@oh-my-pi/pi-utils/cli";
import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@oh-my-pi/pi-utils/dirs";
import { commands, isSubcommand } from "./cli-commands";
import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/env";
if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
process.stderr.write(
@@ -27,6 +27,12 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
process.title = APP_NAME;
// Declare this module as the worker-host entry: Worker threads and worker
// subprocesses re-enter `Bun.main` with a hidden argv selector instead of
// loading separate worker entrypoints (single-entry contract across source,
// npm bundle, and compiled binary).
declareWorkerHostEntry();
async function showHelp(config: CliConfig): Promise<void> {
const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
const { getExtraHelpText } = await import("./cli/args");
@@ -54,6 +60,45 @@ async function runSmokeTest(): Promise<void> {
process.stdout.write("smoke-test: ok\n");
}
const TINY_WORKER_ARGS = new Set(["--tiny-worker", "__tiny_worker"]);
const STATS_SYNC_WORKER_ARG = "__omp_stats_sync_worker";
const TAB_WORKER_ARG = "__omp_tab_worker";
const JS_EVAL_WORKER_ARG = "__omp_js_eval_worker";
async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
if (arg === STATS_SYNC_WORKER_ARG) {
// The sync worker handles messages via `self.onmessage`, assigned during
// this *async* dynamic import. Bun flushes the worker's initial message
// buffer when the entry module's top-level evaluation finishes — before
// this dispatch completes — so anything the parent posted right after
// spawning (the smoke ping, the first parse request) would be dropped.
// Park early events and replay them once the module's handler is live.
// (The tab/eval workers are immune: `parentPort.on("message")` queues
// until a listener attaches.)
const scope = globalThis as unknown as { onmessage: ((event: MessageEvent) => void) | null };
const pending: MessageEvent[] = [];
const buffer = (event: MessageEvent): void => {
pending.push(event);
};
scope.onmessage = buffer;
await import("@oh-my-pi/omp-stats/sync-worker");
const handler = scope.onmessage;
if (handler && handler !== buffer) {
for (const event of pending) handler.call(scope, event);
}
return true;
}
if (arg === TAB_WORKER_ARG) {
await import("./tools/browser/tab-worker-entry");
return true;
}
if (arg === JS_EVAL_WORKER_ARG) {
await import("./eval/js/worker-entry");
return true;
}
return false;
}
/**
* Hidden subcommand that boots the tiny-model worker inside this process
* over the parent's IPC channel. The agent's main process spawns the same
@@ -90,11 +135,16 @@ async function runTinyWorker(): Promise<void> {
};
},
});
const keepalive = setInterval(() => {}, 2 ** 30);
// Parent went away (crashed, SIGKILL, etc.) — commit suicide so we don't
// linger as an orphan. SIGKILL via `process.kill` keeps us symmetrical
// with the parent's hard-kill on shutdown: skip every JS/native finalizer.
process.on("disconnect", () => shutdown());
await shuttingDown;
try {
await shuttingDown;
} finally {
clearInterval(keepalive);
}
process.kill(process.pid, "SIGKILL");
}
@@ -104,10 +154,17 @@ export async function runCli(argv: string[]): Promise<void> {
await runSmokeTest();
return;
}
if (argv[0] === "--tiny-worker") {
if (TINY_WORKER_ARGS.has(argv[0] ?? "")) {
await runTinyWorker();
return;
}
if (await runWorkerEntrypoint(argv[0])) {
return;
}
const [{ run }, { commands, isSubcommand }] = await Promise.all([
import("@oh-my-pi/pi-utils/cli"),
import("./cli-commands"),
]);
// --help and --version are handled by run() directly, don't rewrite those.
// Everything else that isn't a known subcommand routes to "launch".
const first = argv[0];
@@ -120,4 +177,11 @@ export async function runCli(argv: string[]): Promise<void> {
return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp });
}
await runCli(process.argv.slice(2));
// Floating call instead of top-level await: TLA forces `--bytecode` (CJS
// lowering) builds to fail, and the entrypoint needs nothing after this.
// The catch mirrors what an unhandled TLA rejection produced: error dump to
// stderr, exit code 1. Success paths resolve without touching the exit code.
runCli(process.argv.slice(2)).catch((err: unknown) => {
process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
process.exit(1);
});
@@ -1,13 +1,10 @@
import { isCompiledBinary, logger, Snowflake } from "@oh-my-pi/pi-utils";
import { logger, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
import type { ToolSession } from "../../tools";
import { ToolAbortError, ToolError } from "../../tools/tool-errors";
import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
import { WorkerCore } from "./worker-core";
// Worker entry. See `tab-supervisor.ts` for the rationale behind the
// literal-string + `new URL(import.meta.url)` hybrid: the literal is what
// Bun's `--compile` bundler discovers, the `new URL` form is what makes dev
// runs portable across cwds. The worker is registered as an additional
// `--compile` entrypoint in `scripts/build-binary.ts`.
// Coding-agent binary/bundle workers route through the CLI entrypoint with a
// hidden argv mode, so compiled/npm builds only need one JavaScript entry.
import type {
JsDisplayOutput,
RunErrorPayload,
@@ -384,8 +381,9 @@ async function raceWithTimeout<T>(promise: Promise<T>, timeoutMs: number, reason
async function spawnJsWorker(): Promise<WorkerHandle> {
try {
const worker = isCompiledBinary()
? new Worker("./packages/coding-agent/src/eval/js/worker-entry.ts", { type: "module" })
const hostEntry = workerHostEntry();
const worker = hostEntry
? new Worker(hostEntry, { type: "module", argv: ["__omp_js_eval_worker"] })
: new Worker(new URL("./worker-entry.ts", import.meta.url).href, { type: "module" });
return wrapBunWorker(worker);
} catch (err) {
@@ -110,9 +110,26 @@ function bunfsPath(...segments: string[]): string {
return path.join(BUNFS_PACKAGE_ROOT, ...segments);
}
function resolveBundledSelfPackageRoot(): string | undefined {
if (!process.env.PI_BUNDLED) return undefined;
try {
return path.dirname(Bun.resolveSync("@oh-my-pi/pi-coding-agent/package.json", import.meta.dir));
} catch {
return undefined;
}
}
const BUNDLED_SELF_PACKAGE_ROOT = resolveBundledSelfPackageRoot();
function sourceShimPath(file: string): string {
return BUNDLED_SELF_PACKAGE_ROOT
? path.join(BUNDLED_SELF_PACKAGE_ROOT, "src", "extensibility", file)
: path.resolve(import.meta.dir, "..", file);
}
const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
? bunfsPath("coding-agent", "src", "extensibility", "typebox.js")
: path.resolve(import.meta.dir, "../typebox.ts");
: sourceShimPath("typebox.ts");
// Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
// the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
@@ -124,7 +141,7 @@ const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
// against the bundled pi-ai package.
const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT
? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-ai-shim.js")
: path.resolve(import.meta.dir, "../legacy-pi-ai-shim.ts");
: sourceShimPath("legacy-pi-ai-shim.ts");
// The coding-agent's own `./src/index.ts` cannot be listed as an extra
// `bun --compile` entrypoint alongside the CLI entry without breaking binary
@@ -133,7 +150,7 @@ const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT
// avoids that collision while re-exporting the canonical package surface.
const LEGACY_PI_CODING_AGENT_SHIM_PATH = BUNFS_PACKAGE_ROOT
? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-coding-agent-shim.js")
: path.resolve(import.meta.dir, "../legacy-pi-coding-agent-shim.ts");
: sourceShimPath("legacy-pi-coding-agent-shim.ts");
// Package-root overrides. Shim entries are always applied because they replace
// (or augment) the canonical surface even in non-compiled installs. The bunfs
+26 -11
View File
@@ -1,5 +1,5 @@
import * as path from "node:path";
import { $env, isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
import { $env, isBunTestRuntime, isCompiledBinary, logger, workerHostEntry } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import { settings } from "../config/settings";
import { tinyModelDeviceSettingToEnv } from "./device";
@@ -108,17 +108,28 @@ function tinyWorkerEnv(): Record<string, string> {
for (const key in overlay) merged[key] = overlay[key];
return merged;
}
interface TinyWorkerSpawnCommand {
cmd: string[];
cwd?: string;
}
/**
* Resolve the argv used to relaunch the agent CLI into tiny-worker mode. In a
* compiled binary the entry point is the binary itself; in dev/source the
* spawned `bun` needs the absolute path to `cli.ts` so it can resolve module
* imports against the on-disk source tree.
* Resolve the command used to relaunch the agent CLI into tiny-worker mode.
* In a compiled binary the entry point is the binary itself (no script arg).
* Otherwise re-enter the declared worker-host entry (source cli.ts or
* npm-bundle cli.js) with a cwd-relative script path — Bun's subprocess IPC
* is more reliable that way than with an absolute `.ts` entry under
* `bun test` — and fall back to this package's own `src/cli.ts` when no host
* entry is declared (bun test, SDK embedding).
*/
function tinyWorkerSpawnCmd(): string[] {
if (isCompiledBinary()) return [process.execPath, TINY_WORKER_ARG];
const cliPath = path.resolve(import.meta.dir, "..", "cli.ts");
return [process.execPath, cliPath, TINY_WORKER_ARG];
function tinyWorkerSpawnCmd(): TinyWorkerSpawnCommand {
if (isCompiledBinary()) return { cmd: [process.execPath, TINY_WORKER_ARG] };
const hostEntry = workerHostEntry();
if (hostEntry) {
return { cmd: [process.execPath, path.basename(hostEntry), TINY_WORKER_ARG], cwd: path.dirname(hostEntry) };
}
const packageRoot = path.resolve(import.meta.dir, "..", "..");
return { cmd: [process.execPath, "src/cli.ts", TINY_WORKER_ARG], cwd: packageRoot };
}
interface SpawnedSubprocess {
@@ -143,8 +154,10 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess {
const inbound = new Set<(message: TinyTitleWorkerOutbound) => void>();
const errors = new Set<(error: Error) => void>();
const intentionalExit = { value: false };
const spawnCommand = tinyWorkerSpawnCmd();
const proc = Bun.spawn({
cmd: tinyWorkerSpawnCmd(),
cmd: spawnCommand.cmd,
cwd: spawnCommand.cwd,
env: tinyWorkerEnv(),
stdin: "ignore",
stdout: "ignore",
@@ -175,7 +188,9 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess {
});
// Don't keep the parent event loop alive on account of an idle worker; the
// agent dispose path calls `terminate()` explicitly when shutting down.
proc.unref();
// Bun's test runner can starve IPC delivery for unref'd subprocesses, so
// keep it referenced only under tests that assert the ping/pong contract.
if (!isBunTestRuntime()) proc.unref();
return { proc, inbound, errors, intentionalExit };
}
@@ -1,4 +1,4 @@
import { getPuppeteerDir, isCompiledBinary, logger, Snowflake } from "@oh-my-pi/pi-utils";
import { getPuppeteerDir, logger, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
import type { Page, Target } from "puppeteer-core";
import { callSessionTool } from "../../eval/js/tool-bridge";
import type { ToolSession } from "../../sdk";
@@ -18,14 +18,8 @@ import type {
WorkerOutbound,
} from "./tab-protocol";
// Worker entry. The literal string in `new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", …)`
// below is what Bun's `--compile` static analyzer needs to bundle the worker
// (registered as an additional entrypoint in `scripts/build-binary.ts`); in
// dev we resolve the same source via `import.meta.url`. Replaces the older
// `with { type: "file" }` pattern, which only copied the entry as a raw
// asset and could not resolve the worker's relative imports inside a
// compiled binary (issue #1011 was a false-positive fix — the regression
// test only checked emission, not actual worker startup).
// Coding-agent binary/bundle workers route through the CLI entrypoint with a
// hidden argv mode, so compiled/npm builds only need one JavaScript entry.
interface WorkerHandle {
send(msg: WorkerInbound, transferList?: Transferable[]): void;
@@ -518,8 +512,9 @@ async function raceWithTimeout<T>(
async function spawnTabWorker(): Promise<WorkerHandle> {
try {
const worker = isCompiledBinary()
? new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", { type: "module" })
const hostEntry = workerHostEntry();
const worker = hostEntry
? new Worker(hostEntry, { type: "module", argv: ["__omp_tab_worker"] })
: new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
return wrapBunWorker(worker);
} catch (err) {
@@ -16,63 +16,35 @@ import * as path from "node:path";
* relative imports inside the compiled binary, so the worker still failed
* to load (issue #1027 was the same root cause, retriggered).
*
* The working pattern documented in AGENTS.md is a two-part contract:
*
* 1. `spawnTabWorker` branches on `isCompiledBinary()` and uses a literal
* string path under `--compile`. Bun's `--compile` analyzer discovers
* that literal at the `new Worker("...", ...)` call site. The path is
* `--root`-relative (`./packages/coding-agent/src/...`) because the
* build script passes `--root ../..`.
* 2. `scripts/build-binary.ts` lists the worker as an explicit additional
* `--compile` entrypoint. Without this, Bun sees the literal at the
* spawn site but never emits the worker module into bunfs.
*
* Either half alone is insufficient — both must agree on the exact path.
* Runtime end-to-end coverage lives in `omp --smoke-test` (via the stats
* sync worker). This test is the cheap static contract that catches an
* accidental regression of either half in code review / CI.
* The current contract is simpler: when the process was started from the omp
* CLI (source, npm bundle, or compiled binary), spawn sites re-enter the
* declared worker-host entry — `new Worker(workerHostEntry(), { argv })` — and
* the CLI dispatches the hidden argv selector. Outside a CLI host (bun test,
* SDK embedding) they load the worker module directly. No separate worker
* module is ever bundled or listed as a `--compile` entrypoint.
*/
describe("issue #1011 — tab worker entry must survive `bun build --compile`", () => {
describe("issue #1011 — tab worker must re-enter the CLI entrypoint", () => {
const packageDir = path.resolve(import.meta.dir, "..");
const supervisorPath = path.join(packageDir, "src/tools/browser/tab-supervisor.ts");
const buildBinaryPath = path.join(packageDir, "scripts/build-binary.ts");
// `--root` is `../..` from packages/coding-agent, so the literal that
// matches at runtime inside the compiled bunfs is repo-relative.
const compiledLiteral = "./packages/coding-agent/src/tools/browser/tab-worker-entry.ts";
// The build script's cwd is packages/coding-agent, so its entrypoint
// path is package-relative.
const buildEntrypoint = "./src/tools/browser/tab-worker-entry.ts";
const workerArg = "__omp_tab_worker";
it("tab-supervisor uses the isCompiledBinary() hybrid spawn pattern with a static literal", async () => {
it("tab-supervisor re-enters the worker-host entry with the argv selector", async () => {
const source = await Bun.file(supervisorPath).text();
// The exact literal at the `new Worker(...)` call must be present
// and discoverable to Bun's `--compile` static analyzer.
expect(
source.includes(`new Worker("${compiledLiteral}"`),
`tab-supervisor.ts must spawn the worker with the literal "${compiledLiteral}" so Bun's --compile analyzer can embed it`,
source.includes("workerHostEntry()"),
"tab-supervisor.ts must spawn via the declared worker-host entry",
).toBe(true);
// And the dev-mode branch should keep the portable import.meta.url
// form so spawns work outside of compiled binaries too.
expect(source).toContain(`argv: ["${workerArg}"]`);
expect(
/new Worker\(\s*new URL\("\.\/tab-worker-entry\.ts",\s*import\.meta\.url\)/.test(source),
"tab-supervisor.ts must keep a `new URL('./tab-worker-entry.ts', import.meta.url)` branch for dev/source spawns",
).toBe(true);
// And the branching must come from `isCompiledBinary()` — not, say,
// a hard-coded check, an env var, or a renamed helper.
expect(
source.includes("isCompiledBinary()"),
"tab-supervisor.ts must select the spawn pattern via isCompiledBinary()",
source.includes('new URL("./tab-worker-entry.ts", import.meta.url)'),
"tab-supervisor.ts must keep the direct-module fallback for non-CLI hosts",
).toBe(true);
});
it("build-binary.ts lists tab-worker-entry as an explicit --compile entrypoint", async () => {
it("build-binary.ts no longer lists tab-worker-entry as a separate --compile entrypoint", async () => {
const source = await Bun.file(buildBinaryPath).text();
expect(
source.includes(`"${buildEntrypoint}"`),
`scripts/build-binary.ts must include "${buildEntrypoint}" as an explicit --compile entrypoint so Bun emits the worker into bunfs`,
).toBe(true);
expect(source).not.toContain("./src/tools/browser/tab-worker-entry.ts");
});
});
@@ -16,52 +16,36 @@ import * as path from "node:path";
* the worker module was never emitted into bunfs and the runtime tried to
* bundle it on the fly, which fails in `$bunfs`.
*
* The contract from AGENTS.md is symmetric: **every** worker spawned via
* the `isCompiledBinary()` hybrid pattern must be listed as an extra
* `--compile` entry in **both** scripts. This test pins that contract for
* the release script; the dev script is covered by `issue-1011-repro` for
* the tab worker entry. Runtime coverage lives in `omp --smoke-test`,
* which the release-binary CI step now invokes.
* The current contract is simpler: every Worker re-enters the CLI entrypoint
* and selects its worker body via `WorkerOptions.argv`, so release builds no
* longer need to list the worker modules as extra `--compile` entrypoints.
* Runtime coverage lives in `omp --smoke-test`.
*/
describe("issue #1150 — release-build script must list all worker --compile entrypoints", () => {
describe("issue #1150 — release/dev builds route workers through the CLI entrypoint", () => {
const repoRoot = path.resolve(import.meta.dir, "../../..");
const ciScriptPath = path.join(repoRoot, "scripts/ci-release-build-binaries.ts");
const devScriptPath = path.join(repoRoot, "packages/coding-agent/scripts/build-binary.ts");
// Repo-root-relative literals — both the runtime `new Worker(...)`
// spawn site and the `--compile` entry must use this exact string for
// Bun's static analyzer to match them up.
// Repo-root-relative CLI literal — every runtime worker spawn site uses this
// same entry plus a hidden argv selector.
const workerEntrypoints = [
"./packages/stats/src/sync-worker.ts",
"./packages/coding-agent/src/tools/browser/tab-worker-entry.ts",
"./packages/coding-agent/src/eval/js/worker-entry.ts",
];
it("scripts/ci-release-build-binaries.ts lists every worker as an explicit --compile entrypoint", async () => {
const source = await Bun.file(ciScriptPath).text();
it("release/dev build scripts do not list worker modules as explicit --compile entrypoints", async () => {
const releaseSource = await Bun.file(ciScriptPath).text();
const devSource = await Bun.file(devScriptPath).text();
for (const entry of workerEntrypoints) {
expect(
source.includes(`"${entry}"`),
`scripts/ci-release-build-binaries.ts must include "${entry}" as a --compile entrypoint so Bun emits the worker into bunfs in the published binary`,
).toBe(true);
expect(releaseSource).not.toContain(`"${entry}"`);
}
});
it("packages/coding-agent/scripts/build-binary.ts lists every worker as an explicit --compile entrypoint", async () => {
// Dev script's cwd is packages/coding-agent and its `--root ../..`
// resolves to repo root, so its entry strings are package-relative
// (not repo-relative) but produce the same bunfs path.
const devEntrypoints = [
for (const entry of [
"../stats/src/sync-worker.ts",
"./src/tools/browser/tab-worker-entry.ts",
"./src/eval/js/worker-entry.ts",
];
const source = await Bun.file(devScriptPath).text();
for (const entry of devEntrypoints) {
expect(
source.includes(`"${entry}"`),
`packages/coding-agent/scripts/build-binary.ts must include "${entry}" as a --compile entrypoint so dev binaries match release binaries`,
).toBe(true);
]) {
expect(devSource).not.toContain(`"${entry}"`);
}
});
});
@@ -15,22 +15,30 @@
* the original crash again.
*/
import { describe, expect, it } from "bun:test";
import {
createTinyTitleSubprocess,
smokeTestTinyTitleWorker,
TINY_WORKER_ARG,
} from "@oh-my-pi/pi-coding-agent/tiny/title-client";
import * as path from "node:path";
import { createTinyTitleSubprocess, TINY_WORKER_ARG } from "@oh-my-pi/pi-coding-agent/tiny/title-client";
describe("issue #1606 — tiny model lives in an isolated subprocess", () => {
it("ping/pongs through the spawned worker subprocess and tears it down cleanly", async () => {
// `smokeTestTinyTitleWorker` is the runtime probe wired into
// `omp --smoke-test`: it spawns the worker subprocess via
// `Bun.spawn`, sends a ping over the IPC channel, awaits the pong,
// then SIGKILLs the child. If anyone reverts the worker to an
// in-process `new Worker(...)` thread or drops the `--tiny-worker`
// CLI dispatch, the spawn either picks up the wrong entrypoint or
// the ping never round-trips, and this test fails.
await expect(smokeTestTinyTitleWorker({ timeoutMs: 15_000 })).resolves.toBeUndefined();
// `omp --smoke-test`. Run it in a child Bun process instead of this
// Bun-test worker: the test runner owns its own IPC channel and can
// starve nested Bun subprocess IPC on some Bun builds.
const repoRoot = path.resolve(import.meta.dir, "../../..");
const script =
'const { smokeTestTinyTitleWorker } = await import("@oh-my-pi/pi-coding-agent/tiny/title-client"); await smokeTestTinyTitleWorker({ timeoutMs: 15000 });';
const proc = Bun.spawn([process.execPath, "-e", script], {
cwd: repoRoot,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
expect(`${stdout}${stderr}`).toBe("");
expect(exitCode).toBe(0);
}, 30_000);
it("CLI dispatches the flag that `title-client.ts` passes to the spawned child", async () => {
@@ -39,7 +47,7 @@ describe("issue #1606 — tiny model lives in an isolated subprocess", () => {
// `argv` and there is no fallback path that "re-routes" the worker
// on misnamed flags. Pin the spelling on both ends.
const cliSource = await Bun.file(new URL("../src/cli.ts", import.meta.url)).text();
expect(cliSource).toContain(`argv[0] === "${TINY_WORKER_ARG}"`);
expect(cliSource).toContain(`"${TINY_WORKER_ARG}"`);
expect(cliSource).toContain("runTinyWorker");
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- The session-sync worker re-enters the host CLI entry (`workerHostEntry()` + `__omp_stats_sync_worker` argv selector) when running inside omp — source, npm bundle, or compiled binary — and keeps loading its own `sync-worker.ts` module directly for standalone `omp-stats`, bun test, and SDK hosts
## [15.1.6] - 2026-05-19
### Fixed
+15 -19
View File
@@ -1,5 +1,5 @@
import * as fs from "node:fs";
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
import { workerHostEntry } from "@oh-my-pi/pi-utils";
import {
getRecentErrors as dbGetRecentErrors,
getRecentRequests as dbGetRecentRequests,
@@ -24,15 +24,10 @@ import {
} from "./db";
import { getSessionEntry, listAllSessionFiles, type ParseSessionResult } from "./parser";
import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker";
// Worker entry. Bun's `--compile` bundler statically discovers the string
// literal in `new Worker("./packages/stats/src/sync-worker.ts", …)` below and
// emits the worker as an additional entrypoint (registered in
// `packages/coding-agent/scripts/build-binary.ts`). In dev runs we resolve
// the same source file through `import.meta.url`, so the literal only has to
// be valid relative to the `--root` directory (repo root). Importing the
// source as `with { type: "file" }` is NOT sufficient — that copies the file
// as a raw asset and does not bundle the worker's relative imports, so the
// worker would crash on first `import` (issue #1011, PR #1027).
// Coding-agent binary/bundle workers route through the CLI entrypoint with a
// hidden argv mode, so the compiled binary and npm bundle only need one
// JavaScript entry. Standalone source `omp-stats` keeps using this package's
// own sync-worker source file.
import type { BehaviorDashboardStats, DashboardStats, MessageStats, RequestDetails } from "./types";
/**
@@ -89,17 +84,18 @@ interface WorkerHandle {
}
/**
* Create a fresh sync worker. In a `--compile` binary the literal-string
* specifier is what Bun's static analyzer needs (the file is also listed as
* an additional `--compile` entrypoint in
* `packages/coding-agent/scripts/build-binary.ts`). In dev runs we resolve
* the source URL via `import.meta.url` so the worker survives `cwd` changes
* by callers.
* Create a fresh sync worker. When the process was started from a
* self-dispatching CLI entry (omp in source, npm-bundle, or compiled form),
* re-enter that entry with a worker argv selector; otherwise (standalone
* omp-stats, bun test, SDK embedding) load the worker module directly, so this
* package keeps zero runtime dependency on `@oh-my-pi/pi-coding-agent`.
*/
function createSyncWorker(): Worker {
return isCompiledBinary()
? new Worker("./packages/stats/src/sync-worker.ts", { type: "module" })
: new Worker(new URL("./sync-worker.ts", import.meta.url).href, { type: "module" });
const hostEntry = workerHostEntry();
if (hostEntry) {
return new Worker(hostEntry, { type: "module", argv: ["__omp_stats_sync_worker"] });
}
return new Worker(new URL("./sync-worker.ts", import.meta.url).href, { type: "module" });
}
function spawnWorker(): WorkerHandle {
+2
View File
@@ -5,6 +5,7 @@
- Restored `PI_DEBUG_STARTUP` streaming startup markers: `logger.time` now writes a synchronous `[startup] <op>:start` / `:done` / `:fail` stderr line per phase (independent of `PI_TIMING`), so a startup that hangs hard still names the phase it is stuck in — the `PI_TIMING` tree only prints after startup completes and is structurally unable to diagnose a hang. The CLI runner emits `cli:load:<name>` markers around each lazily-imported command module for the same reason.
- Added `logger.openSpanPath()`: ops of the currently-open timing-span chain (root → deepest), used by the coding agent's startup watchdog to name the in-flight phase of a stalled startup.
- Added `declareWorkerHostEntry()` / `workerHostEntry()` (env): self-dispatching CLI entrypoints declare `Bun.main` as the worker host so worker spawn sites can re-enter the single entry module with `WorkerOptions.argv` selectors across source, npm-bundle, and compiled distributions
### Changed
@@ -14,6 +15,7 @@
### Fixed
- Fixed `prompt.format()` so ASCII symbol replacements such as `-->` and `!=` still run on lines containing a closing HTML comment token when not inside a comment
- `isCompiledBinary()` now also honors a define-folded `process.env.PI_COMPILED` (only `Bun.env` was checked), so builds that constant-fold `process.env` keep compiled-binary detection without relying on `import.meta.url` bunfs markers
- `omp <cmd> --help` now loads only the requested command module instead of the entire command table, so an unrelated command whose import graph hangs or crashes can no longer take down every per-command help invocation.
## [15.10.8] - 2026-06-09
+21 -1
View File
@@ -156,11 +156,31 @@ export function isBunTestRuntime(): boolean {
* first for cheap fast-path detection.
*/
export function isCompiledBinary(): boolean {
if (Bun.env.PI_COMPILED) return true;
if (process.env.PI_COMPILED || Bun.env.PI_COMPILED) return true;
const url = import.meta.url;
return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
}
/**
* Main-module path declared by self-dispatching CLI entrypoints — entries
* whose top-level argv handling routes hidden `__omp_*` worker selectors.
* Worker spawn sites re-enter this module via `new Worker(entry, { argv })`,
* so every distribution (source, npm bundle, compiled binary) needs exactly
* one JavaScript entrypoint. Never set under `bun test`, SDK embedding, or
* standalone package bins — those hosts load worker modules directly.
*/
let workerHostMain: string | null = null;
/** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
export function declareWorkerHostEntry(): void {
workerHostMain = Bun.main;
}
/** Main-module path of the self-dispatching CLI host, or null outside it. */
export function workerHostEntry(): string | null {
return workerHostMain;
}
const TRUTHY: Dict<boolean> = {
"1": true,
Y: true,
+5 -16
View File
@@ -14,20 +14,10 @@ interface BinaryTarget {
const repoRoot = path.join(import.meta.dir, "..");
const binariesDir = path.join(repoRoot, "packages", "coding-agent", "binaries");
const entrypoint = "./packages/coding-agent/src/cli.ts";
// Worker entrypoints. Bun's `--compile` static analyzer discovers the
// literal in `new Worker("…", …)` at each spawn site, but only actually
// emits the worker into the bunfs root when it is also listed here as an
// explicit additional entry. Paths are repo-root-relative (matching
// `--root .` below) so the workers land at
// `/$bunfs/root/packages/<pkg>/src/<worker>.js`, which is exactly what the
// literals at the spawn sites resolve to. Keep this in sync with the dev
// script at `packages/coding-agent/scripts/build-binary.ts`; the
// `issue-1150-repro` test pins both halves of the contract.
const workerEntrypoints = [
"./packages/stats/src/sync-worker.ts",
"./packages/coding-agent/src/tools/browser/tab-worker-entry.ts",
"./packages/coding-agent/src/eval/js/worker-entry.ts",
];
// Legacy extension shims and package barrels in the build argv below are still
// explicit `--compile` entrypoints because they are reached via computed bunfs
// paths. Worker threads spawn `new Worker(Bun.main, { argv })` — they re-enter
// the binary's own entry module — so no separate worker modules are compiled.
const isDryRun = process.argv.includes("--dry-run");
const targets: BinaryTarget[] = [
{
@@ -120,7 +110,7 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
console.log(`Building ${target.outfile}...`);
await embedNative(target);
if (isDryRun) {
console.log(`DRY RUN bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --keep-names --define process.env.PI_COMPILED="true" --root . --external mupdf --target=${target.target} ${entrypoint} ${workerEntrypoints.join(" ")} --outfile ${target.outfile}`);
console.log(`DRY RUN bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --keep-names --define process.env.PI_COMPILED="true" --root . --external mupdf --target=${target.target} ${entrypoint} --outfile ${target.outfile}`);
return;
}
@@ -146,7 +136,6 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
"--target",
target.target,
entrypoint,
...workerEntrypoints,
"--outfile",
target.outfile,
],