From 237692deab89cd54dc2bc683865a26a1af32453f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sat, 25 Jul 2026 07:45:56 +0000 Subject: [PATCH] fix(computer-use): re-enter single worker host --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/package.json | 1 - packages/coding-agent/scripts/build-binary.ts | 1 - packages/coding-agent/scripts/bundle-dist.ts | 6 +- .../coding-agent/scripts/compile-binary.ts | 4 +- packages/coding-agent/src/cli.ts | 7 +++ .../src/computer-worker-process-entry.ts | 8 --- .../__tests__/process-entry-import.test.ts | 59 +++++++++++++++++-- .../src/tools/computer/protocol.ts | 2 + .../src/tools/computer/supervisor.ts | 18 ++++-- .../src/tools/computer/worker-entry.ts | 9 ++- .../coding-agent/src/tools/computer/worker.ts | 10 +--- .../fixtures/computer-worker-bundled-host.ts | 9 +++ ...try.ts => computer-worker-cli-selector.ts} | 5 +- packages/natives/CHANGELOG.md | 4 ++ packages/natives/native/desktop.d.ts | 4 ++ packages/natives/native/desktop.js | 10 ++++ packages/natives/package.json | 4 ++ scripts/ci-release-build-binaries.ts | 5 +- 19 files changed, 124 insertions(+), 44 deletions(-) delete mode 100644 packages/coding-agent/src/computer-worker-process-entry.ts create mode 100644 packages/coding-agent/test/fixtures/computer-worker-bundled-host.ts rename packages/coding-agent/test/fixtures/{computer-worker-process-entry.ts => computer-worker-cli-selector.ts} (66%) create mode 100644 packages/natives/native/desktop.d.ts create mode 100644 packages/natives/native/desktop.js diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d37122d0f..cb278f797 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -33,7 +33,7 @@ ### Fixed - Fixed `todo` calls that omit `op` hard-failing validation ("op must be operation to apply (was missing)"): the tool now validates leniently and infers the op for unambiguous payloads (`list` → `init`, `phase`+`items` → `append`, bare `items` on an empty list → `init`); `op` stays required in the schema, and ambiguous op-less calls surface the schema error as a retryable tool error. -- Fixed Codex subscription and proxy models being sent the unsupported native `{ type: "computer" }` declaration based only on model ID. They now receive the callable function-tool fallback, including after switching from native OpenAI Responses history, while explicit endpoint metadata can still opt into the GA contract. Explicit native Codex replays preserve `computer_call`/`computer_call_output` pairing, and normal CLI startup keeps the native desktop worker graph lazy. +- Fixed Codex subscription and proxy models being sent the unsupported native `{ type: "computer" }` declaration based only on model ID. They now receive the callable function-tool fallback, including after switching from native OpenAI Responses history, while explicit endpoint metadata can still opt into the GA contract. Explicit native Codex replays preserve `computer_call`/`computer_call_output` pairing, and normal CLI startup keeps the native desktop worker graph lazy while packaged workers re-enter the single CLI host entry. - Fixed isolated JavaScript eval subprocesses letting the global fatal-rejection handler race the cell rejection interceptor. A floated promise rejection is now folded into the owning cell result without killing its reusable worker process. - Fixed credential-free web search engines (SearXNG, DuckDuckGo, Google, Startpage, Ecosia, Mojeek, and the Public Web fan-out) returning zero results for queries with `site:` paths (e.g. `site:github.com/owner/repo`) or `inurl:` operators: scraper engines only match `site:` against a bare domain and DuckDuckGo ignores `inurl:` entirely, so such queries silently emptied the result set and fell through to the next provider in the chain. A shared `formatScraperQuery` formatter now structurally demotes path-carrying `site:` and all `inurl:` values to plain search terms (covering OR-grouped and quoted directives) while preserving bare-domain `site:` filters, negated operators, and each engine's supported syntax; the pipeline post-filter still enforces the demoted constraints on returned sources. - Fixed `ast_edit` previews reading like applied edits to the model: the `⟨proposed⟩` badge was TUI-only, so the model-visible result (hashline header + `-`/`+` rows, identical to applied edit output) carried no staged-proposal signal. The preview result now leads with a "Staged as a proposal — files NOT modified yet" notice naming `xd://resolve`/`xd://reject`, the injected resolve reminder names the source tool, and the `ast_edit` tool prompt documents the two-phase flow. diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 5c9db8f7f..f5cab378d 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -106,7 +106,6 @@ "files": [ "src", "dist/cli.js", - "dist/computer-worker-process-entry.js", "dist/*.node", "scripts", "examples", diff --git a/packages/coding-agent/scripts/build-binary.ts b/packages/coding-agent/scripts/build-binary.ts index b507af11d..1fb51fcfa 100644 --- a/packages/coding-agent/scripts/build-binary.ts +++ b/packages/coding-agent/scripts/build-binary.ts @@ -96,7 +96,6 @@ async function main(): Promise { await compileCodingAgent({ repoRoot, entrypoint: path.join(packageDir, "src", "cli.ts"), - workerEntrypoints: [path.join(packageDir, "src", "computer-worker-process-entry.ts")], outfile: outputPath, transformersVersion, target: crossBuild?.target, diff --git a/packages/coding-agent/scripts/bundle-dist.ts b/packages/coding-agent/scripts/bundle-dist.ts index 382a7d473..9cb04169e 100755 --- a/packages/coding-agent/scripts/bundle-dist.ts +++ b/packages/coding-agent/scripts/bundle-dist.ts @@ -78,7 +78,6 @@ async function cleanBundleOutputs(): Promise { .filter( entry => entry === "cli.js" || - entry === "computer-worker-process-entry.js" || entry.endsWith(".node") || entry.endsWith(".js.map"), ) @@ -98,10 +97,7 @@ async function main(): Promise { // 128KiB per-argv-string cap, so it can never be passed as a CLI // `--define` (posix_spawn fails with E2BIG). const output = await Bun.build({ - entrypoints: [ - path.join(packageDir, "src/cli.ts"), - path.join(packageDir, "src/computer-worker-process-entry.ts"), - ], + entrypoints: [path.join(packageDir, "src/cli.ts")], outdir: outDir, target: "bun", external: [...ALWAYS_EXTERNAL, ...RUNTIME_EXTERNAL], diff --git a/packages/coding-agent/scripts/compile-binary.ts b/packages/coding-agent/scripts/compile-binary.ts index 54b6eb1e0..495612041 100644 --- a/packages/coding-agent/scripts/compile-binary.ts +++ b/packages/coding-agent/scripts/compile-binary.ts @@ -10,8 +10,6 @@ export interface CodingAgentCompileOptions { readonly repoRoot: string; /** Absolute CLI entrypoint. */ readonly entrypoint: string; - /** Additional worker modules embedded as independently evaluated process entries. */ - readonly workerEntrypoints?: readonly string[]; /** Absolute standalone executable output path. */ readonly outfile: string; /** Concrete Transformers.js version baked into the tiny-model worker. */ @@ -35,7 +33,7 @@ export async function compileCodingAgent(options: CodingAgentCompileOptions): Pr } try { const output = await Bun.build({ - entrypoints: [options.entrypoint, ...(options.workerEntrypoints ?? [])], + entrypoints: [options.entrypoint], root: options.repoRoot, external: [...COMPILED_EXTERNAL_DEPENDENCIES], define: { diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index cfac661af..a437dee6b 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -32,6 +32,7 @@ import { startJsEvalProcess } from "./eval/js/process-entry"; import type { WorkerInbound as JsWorkerInbound, WorkerOutbound as JsWorkerOutbound } from "./eval/js/worker-protocol"; import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol"; import { smokeTestComputerWorker } from "./tools/computer/supervisor"; +import { startComputerWorker } from "./tools/computer/worker-entry"; if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) { process.stderr.write( @@ -112,6 +113,7 @@ async function runSmokeTest(): Promise { const TINY_WORKER_ARG = "__omp_worker_tiny_inference"; const STATS_SYNC_WORKER_ARG = "__omp_worker_stats_sync"; const TAB_WORKER_ARG = "__omp_worker_tab"; +const COMPUTER_WORKER_ARG = "__omp_worker_computer"; const JS_EVAL_WORKER_ARG = "__omp_worker_js_eval"; const JS_EVAL_PROCESS_ARG = "__omp_worker_js_eval_process"; const STT_WORKER_ARG = "__omp_worker_stt"; @@ -155,6 +157,11 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise { await import("./tools/browser/tab-worker-entry"); return true; } + if (arg === COMPUTER_WORKER_ARG) { + if (parentPort) installWorkerInbox(parentPort); + startComputerWorker(); + return true; + } if (arg === JS_EVAL_WORKER_ARG) { if (parentPort) installWorkerInbox(parentPort); await import("./eval/js/worker-entry"); diff --git a/packages/coding-agent/src/computer-worker-process-entry.ts b/packages/coding-agent/src/computer-worker-process-entry.ts deleted file mode 100644 index 7f55915cf..000000000 --- a/packages/coding-agent/src/computer-worker-process-entry.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { parentPort } from "node:worker_threads"; -import { installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; -import { startComputerWorker } from "./tools/computer/worker-entry"; - -if (!parentPort) throw new Error("computer-worker-process-entry: missing parentPort"); - -installWorkerInbox(parentPort); -startComputerWorker(); diff --git a/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts b/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts index 8481fc79d..c42eefd74 100644 --- a/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts +++ b/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "bun:test"; import * as path from "node:path"; +import * as fs from "node:fs"; import { TempDir } from "@oh-my-pi/pi-utils"; it("imports the CLI entry graph without loading dotenv before profile bootstrap", async () => { @@ -26,7 +27,27 @@ it("imports the CLI entry graph without loading dotenv before profile bootstrap" expect(stderr).toBe(""); }); -it("starts ordinary CLI paths without evaluating the computer worker entry", async () => { +async function pingComputerWorker( + entry: string, + id: string, + argv: string[] = ["__omp_worker_computer"], +): Promise { + const worker = new Worker(entry, { + type: "module", + argv, + }); + const response = Promise.withResolvers(); + worker.addEventListener("message", event => response.resolve(event.data)); + worker.addEventListener("error", event => response.reject(event.error ?? new Error(event.message))); + worker.postMessage({ type: "ping", id }); + try { + return await response.promise; + } finally { + worker.terminate(); + } +} + +it("starts ordinary CLI paths without loading the native computer addon", async () => { const cliPath = path.resolve(import.meta.dir, "../../cli.ts"); for (const args of [ ["--no-addons", cliPath, "--version"], @@ -41,9 +62,9 @@ it("starts ordinary CLI paths without evaluating the computer worker entry", asy } }); -it("dispatches the computer worker through its dedicated process entry", async () => { - const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/computer-worker-process-entry.ts"); - const proc = Bun.spawn([process.execPath, fixture], { +it("dispatches the computer worker through the CLI host selector in a child process", async () => { + const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/computer-worker-cli-selector.ts"); + const proc = Bun.spawn([process.execPath, "--no-addons", fixture], { stdout: "pipe", stderr: "pipe", }); @@ -53,5 +74,33 @@ it("dispatches the computer worker through its dedicated process entry", async ( new Response(proc.stderr).text(), ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toBe('{"type":"pong","id":"computer-process-entry"}\n'); + expect(stdout).toBe('{"type":"pong","id":"computer-cli-selector"}\n'); +}); + +it("loads the computer worker module directly outside a declared CLI host", async () => { + const entry = new URL("../../tools/computer/worker-entry.ts", import.meta.url).href; + const response = await pingComputerWorker(entry, "computer-direct-module", []); + expect(response).toEqual({ type: "pong", id: "computer-direct-module" }); +}); + +it("dispatches the computer worker from a single npm-style host bundle", async () => { + const packageDir = path.resolve(import.meta.dir, "../../.."); + const outDir = fs.mkdtempSync(path.join(packageDir, ".computer-worker-bundle-")); + try { + const output = await Bun.build({ + entrypoints: [path.join(packageDir, "test/fixtures/computer-worker-bundled-host.ts")], + outdir: outDir, + naming: "cli.js", + target: "bun", + external: ["@oh-my-pi/pi-natives"], + define: { "process.env.PI_BUNDLED": JSON.stringify("true") }, + throw: false, + }); + expect(output.logs).toEqual([]); + expect(output.outputs.map(file => path.basename(file.path))).toEqual(["cli.js"]); + const response = await pingComputerWorker(output.outputs[0]!.path, "computer-npm-bundle"); + expect(response).toEqual({ type: "pong", id: "computer-npm-bundle" }); + } finally { + fs.rmSync(outDir, { recursive: true, force: true }); + } }); diff --git a/packages/coding-agent/src/tools/computer/protocol.ts b/packages/coding-agent/src/tools/computer/protocol.ts index 24d7bcd9f..5225b0adc 100644 --- a/packages/coding-agent/src/tools/computer/protocol.ts +++ b/packages/coding-agent/src/tools/computer/protocol.ts @@ -1,5 +1,7 @@ import type { DesktopAction, DesktopCapabilities, DesktopCapture, DesktopSessionOptions } from "@oh-my-pi/pi-natives"; +export const COMPUTER_WORKER_ARG = "__omp_worker_computer"; + export type ComputerWorkerInbound = | { type: "ping"; id: string } | { type: "init"; options: DesktopSessionOptions } diff --git a/packages/coding-agent/src/tools/computer/supervisor.ts b/packages/coding-agent/src/tools/computer/supervisor.ts index 24e0de8a0..03776f122 100644 --- a/packages/coding-agent/src/tools/computer/supervisor.ts +++ b/packages/coding-agent/src/tools/computer/supervisor.ts @@ -1,8 +1,14 @@ import type { DesktopAction, DesktopCapabilities, DesktopCapture, DesktopSessionOptions } from "@oh-my-pi/pi-natives"; import { withTimeout } from "@oh-my-pi/pi-utils/async"; import * as logger from "@oh-my-pi/pi-utils/logger"; +import { workerHostEntry } from "@oh-my-pi/pi-utils/worker-host"; import { ToolAbortError, ToolError } from "../tool-errors"; -import type { ComputerWorkerError, ComputerWorkerInbound, ComputerWorkerOutbound } from "./protocol"; +import { + COMPUTER_WORKER_ARG, + type ComputerWorkerError, + type ComputerWorkerInbound, + type ComputerWorkerOutbound, +} from "./protocol"; const START_TIMEOUT_MS = 10_000; const CLOSE_TIMEOUT_MS = 1_500; @@ -62,11 +68,11 @@ function wrapWorker(worker: Worker): ComputerWorkerHandle { } export function spawnComputerWorker(): ComputerWorkerHandle { - const processEntry = - process.env.PI_BUNDLED === "true" || process.env.PI_COMPILED === "true" - ? new URL("./computer-worker-process-entry.js", import.meta.url).href - : new URL("../../computer-worker-process-entry.ts", import.meta.url).href; - return wrapWorker(new Worker(processEntry, { type: "module" })); + const hostEntry = workerHostEntry(); + const worker = hostEntry + ? new Worker(hostEntry, { type: "module", argv: [COMPUTER_WORKER_ARG] }) + : new Worker(new URL("./worker-entry.ts", import.meta.url).href, { type: "module" }); + return wrapWorker(worker); } interface PendingRequest { diff --git a/packages/coding-agent/src/tools/computer/worker-entry.ts b/packages/coding-agent/src/tools/computer/worker-entry.ts index f31da18e5..2b1e15eed 100644 --- a/packages/coding-agent/src/tools/computer/worker-entry.ts +++ b/packages/coding-agent/src/tools/computer/worker-entry.ts @@ -1,6 +1,6 @@ import { parentPort } from "node:worker_threads"; import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; -import type { ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol"; +import { COMPUTER_WORKER_ARG, type ComputerWorkerInbound, type ComputerWorkerTransport } from "./protocol"; import { ComputerWorkerCore } from "./worker"; export function startComputerWorker(): void { @@ -25,3 +25,10 @@ export function startComputerWorker(): void { new ComputerWorkerCore(transport); } + +// Bun workers report `import.meta.main === false`. The source fallback still +// enters this file directly, while packaged CLI workers carry the selector and +// start the named entry only after installing its inbox. +if (!Bun.isMainThread && !process.argv.includes(COMPUTER_WORKER_ARG) && import.meta.path === Bun.main) { + startComputerWorker(); +} diff --git a/packages/coding-agent/src/tools/computer/worker.ts b/packages/coding-agent/src/tools/computer/worker.ts index 537fc15a9..43741aeca 100644 --- a/packages/coding-agent/src/tools/computer/worker.ts +++ b/packages/coding-agent/src/tools/computer/worker.ts @@ -1,9 +1,5 @@ -import { - type DesktopAction, - type DesktopCapture, - DesktopSession, - type DesktopSessionOptions, -} from "@oh-my-pi/pi-natives"; +import type { DesktopAction, DesktopCapture, DesktopSession, DesktopSessionOptions } from "@oh-my-pi/pi-natives"; +import { createDesktopSession } from "@oh-my-pi/pi-natives/desktop"; import type { ComputerWorkerError, ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol"; export interface NativeDesktopSession { @@ -44,7 +40,7 @@ export class ComputerWorkerCore { constructor( private readonly transport: ComputerWorkerTransport, - private readonly createSession: NativeDesktopSessionFactory = options => new DesktopSession(options), + private readonly createSession: NativeDesktopSessionFactory = createDesktopSession, ) { this.#unsubscribe = transport.onMessage(message => this.#onMessage(message)); } diff --git a/packages/coding-agent/test/fixtures/computer-worker-bundled-host.ts b/packages/coding-agent/test/fixtures/computer-worker-bundled-host.ts new file mode 100644 index 000000000..ca18e05df --- /dev/null +++ b/packages/coding-agent/test/fixtures/computer-worker-bundled-host.ts @@ -0,0 +1,9 @@ +import { parentPort } from "node:worker_threads"; +import { installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; +import { COMPUTER_WORKER_ARG } from "../../src/tools/computer/protocol"; +import { startComputerWorker } from "../../src/tools/computer/worker-entry"; + +if (process.argv[2] !== COMPUTER_WORKER_ARG) throw new Error(`unknown worker selector: ${process.argv[2]}`); +if (!parentPort) throw new Error("computer worker fixture: missing parentPort"); +installWorkerInbox(parentPort); +startComputerWorker(); diff --git a/packages/coding-agent/test/fixtures/computer-worker-process-entry.ts b/packages/coding-agent/test/fixtures/computer-worker-cli-selector.ts similarity index 66% rename from packages/coding-agent/test/fixtures/computer-worker-process-entry.ts rename to packages/coding-agent/test/fixtures/computer-worker-cli-selector.ts index 9b0d92970..9afdeb183 100644 --- a/packages/coding-agent/test/fixtures/computer-worker-process-entry.ts +++ b/packages/coding-agent/test/fixtures/computer-worker-cli-selector.ts @@ -1,10 +1,11 @@ -const worker = new Worker(new URL("../../src/computer-worker-process-entry.ts", import.meta.url).href, { +const worker = new Worker(new URL("../../src/cli.ts", import.meta.url).href, { type: "module", + argv: ["__omp_worker_computer"], }); const response = Promise.withResolvers(); worker.addEventListener("message", event => response.resolve(event.data)); worker.addEventListener("error", event => response.reject(event.error ?? new Error(event.message))); -worker.postMessage({ type: "ping", id: "computer-process-entry" }); +worker.postMessage({ type: "ping", id: "computer-cli-selector" }); try { process.stdout.write(`${JSON.stringify(await response.promise)}\n`); } finally { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index c847094ce..95639b7c0 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added the `@oh-my-pi/pi-natives/desktop` factory entry, which defers native addon loading until a desktop worker initializes its session. + ## [17.1.3] - 2026-07-24 ### Changed diff --git a/packages/natives/native/desktop.d.ts b/packages/natives/native/desktop.d.ts new file mode 100644 index 000000000..8003d7a1e --- /dev/null +++ b/packages/natives/native/desktop.d.ts @@ -0,0 +1,4 @@ +import type { DesktopSession, DesktopSessionOptions } from "./index.js"; + +/** Construct a desktop session, loading the native addon on first use. */ +export declare function createDesktopSession(options: DesktopSessionOptions): DesktopSession; diff --git a/packages/natives/native/desktop.js b/packages/natives/native/desktop.js new file mode 100644 index 000000000..e9bb4dfbb --- /dev/null +++ b/packages/natives/native/desktop.js @@ -0,0 +1,10 @@ +import { loadNative } from "./loader-state.js"; + +/** + * Construct a desktop session without loading the native addon until the + * computer worker receives its initialization message. + */ +export function createDesktopSession(options) { + const { DesktopSession } = loadNative(); + return new DesktopSession(options); +} diff --git a/packages/natives/package.json b/packages/natives/package.json index 4a069cbdd..d81552e56 100644 --- a/packages/natives/package.json +++ b/packages/natives/package.json @@ -62,6 +62,10 @@ ".": { "types": "./native/index.d.ts", "import": "./native/index.js" + }, + "./desktop": { + "types": "./native/desktop.d.ts", + "import": "./native/desktop.js" } } } diff --git a/scripts/ci-release-build-binaries.ts b/scripts/ci-release-build-binaries.ts index 6bc4c0696..7d6d46ed9 100644 --- a/scripts/ci-release-build-binaries.ts +++ b/scripts/ci-release-build-binaries.ts @@ -16,7 +16,6 @@ interface BinaryTarget { const repoRoot = path.join(import.meta.dir, ".."); const binariesDir = path.join(repoRoot, "packages", "coding-agent", "binaries"); const entrypoint = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); -const workerEntrypoint = path.join(repoRoot, "packages", "coding-agent", "src", "computer-worker-process-entry.ts"); const transformersManifest: unknown = createRequire(import.meta.url)("@huggingface/transformers/package.json"); if ( typeof transformersManifest !== "object" || @@ -27,8 +26,7 @@ if ( throw new Error("@huggingface/transformers package manifest has no string version"); } const transformersVersion = transformersManifest.version; -// The computer worker is an independent compiled entry so its native graph is -// evaluated only when the supervisor selects it. Other workers re-enter the CLI. +// Worker threads re-enter the binary's single CLI host entry. const isDryRun = process.argv.includes("--dry-run"); const targets: BinaryTarget[] = [ { @@ -144,7 +142,6 @@ async function buildBinary(target: BinaryTarget): Promise { await compileCodingAgent({ repoRoot, entrypoint, - workerEntrypoints: [workerEntrypoint], outfile: path.join(repoRoot, target.outfile), transformersVersion, target: target.target,