Browse Source

Fix/node runtime compat (#304)

* fix: replace Bun-only APIs with cross-runtime compatible alternatives

The OpenCode desktop (Electron) app runs the server in-process using
Node.js, not Bun. The plugin's use of `import { spawn } from 'bun'`,
`Bun.write()`, and `Bun.spawn()` caused immediate import failure in
Node.js with `__require is not a function`.

- Add `src/utils/compat.ts` with `crossSpawn()` and `crossWrite()`
  utilities using `node:child_process` and `node:fs/promises`
- Replace all `import { spawn } from 'bun'` with cross-runtime spawn
- Replace `Bun.write()` with `crossWrite()`
- Replace `Bun.spawn()` with `crossSpawn()`
- Simplify LSP client stream wrappers (Node.js streams used directly)
- Change build target from `--target bun` to `--target node`

`node:child_process` and `node:fs/promises` are fully supported in
both Bun and Node.js, so CLI mode continues to work as before.

Fixes #298

* fix(build): align prepare with publish build

Keep local prepare output consistent with the published build by sharing the same externalized runtime dependencies, and simplify the package scripts so the build steps stay readable.

---------

Co-authored-by: whackur <whackur@gmail.com>
Alvin 3 months ago
parent
commit
b5824e618f

+ 4 - 2
package.json

@@ -36,8 +36,10 @@
     "LICENSE"
     "LICENSE"
   ],
   ],
   "scripts": {
   "scripts": {
-    "build": "bun build src/index.ts --outdir dist --target bun --format esm --packages external && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --packages external && tsc --emitDeclarationOnly && bun run generate-schema",
-    "prepare": "bun build src/index.ts --outdir dist --target bun --format esm --packages external --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/sdk",
+    "build:plugin": "bun build src/index.ts --outdir dist --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/sdk",
+    "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @ast-grep/napi --external @opencode-ai/plugin --external @opencode-ai/sdk",
+    "build": "bun run build:plugin && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",
+    "prepare": "bun run build",
     "contributors:add": "all-contributors add",
     "contributors:add": "all-contributors add",
     "contributors:check": "all-contributors check",
     "contributors:check": "all-contributors check",
     "contributors:generate": "all-contributors generate",
     "contributors:generate": "all-contributors generate",

+ 30 - 9
src/cli/system.ts

@@ -1,5 +1,6 @@
 import { spawnSync } from 'node:child_process';
 import { spawnSync } from 'node:child_process';
 import { statSync } from 'node:fs';
 import { statSync } from 'node:fs';
+import { crossSpawn } from '../utils/compat';
 
 
 let cachedOpenCodePath: string | null = null;
 let cachedOpenCodePath: string | null = null;
 
 
@@ -125,27 +126,47 @@ export async function isOpenCodeInstalled(): Promise<boolean> {
 
 
   for (const opencodePath of paths) {
   for (const opencodePath of paths) {
     if (opencodePath === 'opencode') continue;
     if (opencodePath === 'opencode') continue;
-    if (canExecute(opencodePath, ['--version'])) {
-      cachedOpenCodePath = opencodePath;
-      return true;
+    try {
+      const proc = crossSpawn([opencodePath, '--version'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      await proc.exited;
+      if (proc.exitCode === 0) {
+        cachedOpenCodePath = opencodePath;
+        return true;
+      }
+    } catch {
+      // Try next path
     }
     }
   }
   }
   return false;
   return false;
 }
 }
 
 
 export async function isTmuxInstalled(): Promise<boolean> {
 export async function isTmuxInstalled(): Promise<boolean> {
-  return canExecute('tmux', ['-V']);
+  try {
+    const proc = crossSpawn(['tmux', '-V'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    await proc.exited;
+    return proc.exitCode === 0;
+  } catch {
+    return false;
+  }
 }
 }
 
 
 export async function getOpenCodeVersion(): Promise<string | null> {
 export async function getOpenCodeVersion(): Promise<string | null> {
   const opencodePath = resolveOpenCodePath();
   const opencodePath = resolveOpenCodePath();
   try {
   try {
-    const result = spawnSync(opencodePath, ['--version'], {
-      encoding: 'utf-8',
-      stdio: ['ignore', 'pipe', 'ignore'],
+    const proc = crossSpawn([opencodePath, '--version'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
     });
     });
-    if (result.status === 0) {
-      return result.stdout.trim();
+    const outputPromise = proc.stdout();
+    await proc.exited;
+    if (proc.exitCode === 0) {
+      return (await outputPromise).trim();
     }
     }
   } catch {
   } catch {
     // Failed
     // Failed

+ 46 - 40
src/hooks/auto-update-checker/index.test.ts

@@ -1,12 +1,4 @@
-import {
-  afterEach,
-  beforeEach,
-  describe,
-  expect,
-  mock,
-  spyOn,
-  test,
-} from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 
 
 const logMock = mock(() => {});
 const logMock = mock(() => {});
 
 
@@ -24,6 +16,15 @@ const cacheMocks = {
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
 };
 };
 
 
+const crossSpawnMock = mock((_command: string[]) => ({
+  exited: Promise.resolve(0),
+  exitCode: 0,
+  kill: mock(() => true),
+  stdout: () => Promise.resolve(''),
+  stderr: () => Promise.resolve(''),
+  proc: {} as never,
+}));
+
 mock.module('../../utils/logger', () => ({
 mock.module('../../utils/logger', () => ({
   log: logMock,
   log: logMock,
 }));
 }));
@@ -32,8 +33,13 @@ mock.module('./checker', () => checkerMocks);
 
 
 mock.module('./cache', () => cacheMocks);
 mock.module('./cache', () => cacheMocks);
 
 
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+  crossWrite: mock(() => Promise.resolve()),
+  isBun: false,
+}));
+
 let importCounter = 0;
 let importCounter = 0;
-let bunSpawnSpy: ReturnType<typeof spyOn> | undefined;
 
 
 function createCtx() {
 function createCtx() {
   const showToast = mock(() => Promise.resolve(undefined));
   const showToast = mock(() => Promise.resolve(undefined));
@@ -87,11 +93,20 @@ describe('auto-update-checker/index', () => {
     cacheMocks.resolveInstallContext.mockImplementation(() => ({
     cacheMocks.resolveInstallContext.mockImplementation(() => ({
       installDir: '/tmp/opencode',
       installDir: '/tmp/opencode',
     }));
     }));
+
+    crossSpawnMock.mockReset();
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(0),
+      exitCode: 0,
+      kill: mock(() => true),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+      proc: {} as never,
+    }));
   });
   });
 
 
   afterEach(() => {
   afterEach(() => {
-    bunSpawnSpy?.mockRestore();
-    bunSpawnSpy = undefined;
+    // Mocks are automatically cleared by Bun's test runner between tests
   });
   });
 
 
   test('uses resolved install root for auto-update installs', async () => {
   test('uses resolved install root for auto-update installs', async () => {
@@ -134,14 +149,14 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
 
 
-    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
-      () =>
-        ({
-          exited: Promise.resolve(0),
-          exitCode: 0,
-          kill: mock(() => {}),
-        }) as never,
-    );
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(0),
+      exitCode: 0,
+      kill: mock(() => true),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+      proc: {} as never,
+    }));
 
 
     const { createAutoUpdateCheckerHook } = await import(
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
       `./index?test=${importCounter++}`
@@ -158,7 +173,7 @@ describe('auto-update-checker/index', () => {
       '0.9.11',
       '0.9.11',
       'oh-my-opencode-slim',
       'oh-my-opencode-slim',
     );
     );
-    expect(bunSpawnSpy).toHaveBeenCalledWith(
+    expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
       ['bun', 'install'],
       expect.objectContaining({ cwd: '/tmp/opencode' }),
       expect.objectContaining({ cwd: '/tmp/opencode' }),
     );
     );
@@ -181,15 +196,6 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
     cacheMocks.preparePackageUpdate.mockImplementation(() => null);
     cacheMocks.preparePackageUpdate.mockImplementation(() => null);
 
 
-    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
-      () =>
-        ({
-          exited: Promise.resolve(0),
-          exitCode: 0,
-          kill: mock(() => {}),
-        }) as never,
-    );
-
     const { createAutoUpdateCheckerHook } = await import(
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
       `./index?test=${importCounter++}`
     );
     );
@@ -201,7 +207,7 @@ describe('auto-update-checker/index', () => {
     hook.event({ event: { type: 'session.created', properties: {} } });
     hook.event({ event: { type: 'session.created', properties: {} } });
     await waitForCalls(showToast);
     await waitForCalls(showToast);
 
 
-    expect(bunSpawnSpy).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({
     expect(showToast).toHaveBeenCalledWith({
       body: {
       body: {
         title: 'OMO-Slim 0.9.11',
         title: 'OMO-Slim 0.9.11',
@@ -221,14 +227,14 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
 
 
-    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
-      () =>
-        ({
-          exited: Promise.resolve(1),
-          exitCode: 1,
-          kill: mock(() => {}),
-        }) as never,
-    );
+    crossSpawnMock.mockImplementation(() => ({
+      exited: Promise.resolve(1),
+      exitCode: 1,
+      kill: mock(() => true),
+      stdout: () => Promise.resolve(''),
+      stderr: () => Promise.resolve(''),
+      proc: {} as never,
+    }));
 
 
     const { createAutoUpdateCheckerHook } = await import(
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
       `./index?test=${importCounter++}`
@@ -241,7 +247,7 @@ describe('auto-update-checker/index', () => {
     hook.event({ event: { type: 'session.created', properties: {} } });
     hook.event({ event: { type: 'session.created', properties: {} } });
     await waitForCalls(showToast);
     await waitForCalls(showToast);
 
 
-    expect(bunSpawnSpy).toHaveBeenCalledWith(
+    expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
       ['bun', 'install'],
       expect.objectContaining({ cwd: '/tmp/opencode' }),
       expect.objectContaining({ cwd: '/tmp/opencode' }),
     );
     );

+ 2 - 1
src/hooks/auto-update-checker/index.ts

@@ -1,4 +1,5 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
+import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
 import { preparePackageUpdate, resolveInstallContext } from './cache';
 import { preparePackageUpdate, resolveInstallContext } from './cache';
 import {
 import {
@@ -190,7 +191,7 @@ export function getAutoUpdateInstallDir(): string {
  */
  */
 async function runBunInstallSafe(installDir: string): Promise<boolean> {
 async function runBunInstallSafe(installDir: string): Promise<boolean> {
   try {
   try {
-    const proc = Bun.spawn(['bun', 'install'], {
+    const proc = crossSpawn(['bun', 'install'], {
       cwd: installDir,
       cwd: installDir,
       stdout: 'pipe',
       stdout: 'pipe',
       stderr: 'pipe',
       stderr: 'pipe',

+ 94 - 24
src/hooks/todo-continuation/index.test.ts

@@ -117,7 +117,9 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       const hook = createTodoContinuationHook(ctx);
       const system = { system: ['base'] };
       const system = { system: ['base'] };
 
 
-      await hook.handleMessagesTransform(userMessages('continue previous work', 'sub1', 'explorer'));
+      await hook.handleMessagesTransform(
+        userMessages('continue previous work', 'sub1', 'explorer'),
+      );
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
       await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
 
 
@@ -141,12 +143,18 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
       const system = { system: ['base'] };
 
 
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
-        userMessages('continue with the unfinished work', 'main1', 'orchestrator'),
+        userMessages(
+          'continue with the unfinished work',
+          'main1',
+          'orchestrator',
+        ),
       );
       );
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(system.system.join('\n')).not.toContain(
+        TODO_FINAL_ACTIVE_REMINDER,
+      );
     });
     });
 
 
     test('new requests clear stale pending reminder state', async () => {
     test('new requests clear stale pending reminder state', async () => {
@@ -164,14 +172,20 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('primera request', 'main1', 'orchestrator'),
         userMessages('primera request', 'main1', 'orchestrator'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('segunda request distinta', 'main1', 'orchestrator'),
         userMessages('segunda request distinta', 'main1', 'orchestrator'),
       );
       );
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
 
 
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
 
@@ -194,14 +208,20 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('primera request', 'main1', 'orchestrator'),
         userMessages('primera request', 'main1', 'orchestrator'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('', 'main1', 'orchestrator', [{ type: 'image' }]),
         userMessages('', 'main1', 'orchestrator', [{ type: 'image' }]),
       );
       );
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
 
 
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
 
@@ -234,7 +254,10 @@ describe('createTodoContinuationHook', () => {
           },
           },
         ],
         ],
       });
       });
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
@@ -252,12 +275,16 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       const hook = createTodoContinuationHook(ctx);
       const system = { system: ['base'] };
       const system = { system: ['base'] };
 
 
-      await hook.handleMessagesTransform(userMessages('continue previous work', 'sub1'));
+      await hook.handleMessagesTransform(
+        userMessages('continue previous work', 'sub1'),
+      );
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
       await hook.handleToolExecuteAfter({ tool: 'task', sessionID: 'sub1' });
       await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'sub1' }, system);
 
 
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(system.system.join('\n')).not.toContain(
+        TODO_FINAL_ACTIVE_REMINDER,
+      );
     });
     });
 
 
     test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
     test('known orchestrator sessions still process request boundaries when agent metadata is missing', async () => {
@@ -277,8 +304,13 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
       const system = { system: ['base'] };
 
 
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
       hook.handleChatMessage({ sessionID: 'main1', agent: 'orchestrator' });
-      await hook.handleMessagesTransform(userMessages('new request boundary', 'main1'));
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleMessagesTransform(
+        userMessages('new request boundary', 'main1'),
+      );
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
@@ -297,9 +329,18 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
       const system = { system: ['base'] };
 
 
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
-        userMessages('request boundary', 'main1', 'orchestrator', undefined, 'u1'),
+        userMessages(
+          'request boundary',
+          'main1',
+          'orchestrator',
+          undefined,
+          'u1',
+        ),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleMessagesTransform({
       await hook.handleMessagesTransform({
         messages: [
         messages: [
@@ -338,14 +379,20 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
         userMessages('same text', 'main1', 'orchestrator', undefined, 'u1'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('same text', 'main1', 'orchestrator', undefined, 'u2'),
         userMessages('same text', 'main1', 'orchestrator', undefined, 'u2'),
       );
       );
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
 
 
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
 
@@ -368,7 +415,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('same text', 'main1', 'orchestrator'),
         userMessages('same text', 'main1', 'orchestrator'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleMessagesTransform({
       await hook.handleMessagesTransform({
         messages: [
         messages: [
@@ -388,7 +438,10 @@ describe('createTodoContinuationHook', () => {
       });
       });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, blocked);
 
 
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
 
@@ -412,7 +465,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
         userMessages('first request', 'main1', 'orchestrator', undefined, 'u1'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleMessagesTransform({
       await hook.handleMessagesTransform({
         messages: [
         messages: [
@@ -425,7 +481,9 @@ describe('createTodoContinuationHook', () => {
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(system.system.join('\n')).not.toContain(
+        TODO_FINAL_ACTIVE_REMINDER,
+      );
     });
     });
 
 
     test('does not inject from continuation-like wording alone', async () => {
     test('does not inject from continuation-like wording alone', async () => {
@@ -445,12 +503,18 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
       const system = { system: ['base'] };
 
 
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
-        userMessages('sigue este formato pero empieza de cero', 'main1', 'orchestrator'),
+        userMessages(
+          'sigue este formato pero empieza de cero',
+          'main1',
+          'orchestrator',
+        ),
       );
       );
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
       expect(system.system.join('\n')).not.toContain(TODO_HYGIENE_REMINDER);
-      expect(system.system.join('\n')).not.toContain(TODO_FINAL_ACTIVE_REMINDER);
+      expect(system.system.join('\n')).not.toContain(
+        TODO_FINAL_ACTIVE_REMINDER,
+      );
     });
     });
 
 
     test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
     test('rearms on activity after todowrite even if request wording is continuation-like', async () => {
@@ -472,7 +536,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('finish the previous work', 'main1', 'orchestrator'),
         userMessages('finish the previous work', 'main1', 'orchestrator'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 'main1' });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
@@ -498,7 +565,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
       await hook.handleMessagesTransform(
         userMessages('haz esto', 'main1', 'orchestrator'),
         userMessages('haz esto', 'main1', 'orchestrator'),
       );
       );
-      await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 'main1' });
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
 
       expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
       expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);

+ 9 - 2
src/hooks/todo-continuation/index.ts

@@ -1,6 +1,10 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
 import { tool } from '@opencode-ai/plugin/tool';
 import { tool } from '@opencode-ai/plugin/tool';
-import { SLIM_INTERNAL_INITIATOR_MARKER, createInternalAgentTextPart, log } from '../../utils';
+import {
+  createInternalAgentTextPart,
+  log,
+  SLIM_INTERNAL_INITIATOR_MARKER,
+} from '../../utils';
 import { createTodoHygiene } from './todo-hygiene';
 import { createTodoHygiene } from './todo-hygiene';
 
 
 const HOOK_NAME = 'todo-continuation';
 const HOOK_NAME = 'todo-continuation';
@@ -184,7 +188,10 @@ export function createTodoContinuationHook(
     log: (message, meta) => log(`[${HOOK_NAME}] ${message}`, meta),
     log: (message, meta) => log(`[${HOOK_NAME}] ${message}`, meta),
   });
   });
 
 
-  function inferSessionID(messages: ChatTransformMessage[], index: number): string | undefined {
+  function inferSessionID(
+    messages: ChatTransformMessage[],
+    index: number,
+  ): string | undefined {
     const direct = messages[index]?.info.sessionID;
     const direct = messages[index]?.info.sessionID;
     if (direct) {
     if (direct) {
       return direct;
       return direct;

+ 23 - 11
src/hooks/todo-continuation/todo-hygiene.test.ts

@@ -1,17 +1,19 @@
 import { describe, expect, test } from 'bun:test';
 import { describe, expect, test } from 'bun:test';
 import {
 import {
+  createTodoHygiene,
   TODO_DELEGATION_RESUME_REMINDER,
   TODO_DELEGATION_RESUME_REMINDER,
   TODO_FINAL_ACTIVE_REMINDER,
   TODO_FINAL_ACTIVE_REMINDER,
   TODO_HYGIENE_REMINDER,
   TODO_HYGIENE_REMINDER,
-  createTodoHygiene,
 } from './todo-hygiene';
 } from './todo-hygiene';
 
 
-function createState(overrides?: Partial<{
-  hasOpenTodos: boolean;
-  openCount: number;
-  inProgressCount: number;
-  pendingCount: number;
-}>) {
+function createState(
+  overrides?: Partial<{
+    hasOpenTodos: boolean;
+    openCount: number;
+    inProgressCount: number;
+    pendingCount: number;
+  }>,
+) {
   return {
   return {
     hasOpenTodos: overrides?.hasOpenTodos ?? true,
     hasOpenTodos: overrides?.hasOpenTodos ?? true,
     openCount: overrides?.openCount ?? 1,
     openCount: overrides?.openCount ?? 1,
@@ -83,7 +85,9 @@ describe('todo hygiene', () => {
     await hook.handleToolExecuteAfter({ tool: 'glob', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'glob', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
 
-    expect(system.system.filter((item) => item === TODO_HYGIENE_REMINDER)).toHaveLength(1);
+    expect(
+      system.system.filter((item) => item === TODO_HYGIENE_REMINDER),
+    ).toHaveLength(1);
   });
   });
 
 
   test('injects again on a later round after new activity', async () => {
   test('injects again on a later round after new activity', async () => {
@@ -202,7 +206,10 @@ describe('todo hygiene', () => {
 
 
     hook.handleRequestStart({ sessionID: 's1' });
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'background_output', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({
+      tool: 'background_output',
+      sessionID: 's1',
+    });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'read', sessionID: 's1' });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
 
@@ -223,11 +230,16 @@ describe('todo hygiene', () => {
 
 
     hook.handleRequestStart({ sessionID: 's1' });
     hook.handleRequestStart({ sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
     await hook.handleToolExecuteAfter({ tool: 'todowrite', sessionID: 's1' });
-    await hook.handleToolExecuteAfter({ tool: 'background_output', sessionID: 's1' });
+    await hook.handleToolExecuteAfter({
+      tool: 'background_output',
+      sessionID: 's1',
+    });
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
     await hook.handleChatSystemTransform({ sessionID: 's1' }, system);
 
 
     expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
     expect(system.system.join('\n')).toContain(TODO_FINAL_ACTIVE_REMINDER);
-    expect(system.system.join('\n')).not.toContain(TODO_DELEGATION_RESUME_REMINDER);
+    expect(system.system.join('\n')).not.toContain(
+      TODO_DELEGATION_RESUME_REMINDER,
+    );
   });
   });
 
 
   test('transform lookup failures are best-effort and do not drop later reminders', async () => {
   test('transform lookup failures are best-effort and do not drop later reminders', async () => {

+ 17 - 10
src/hooks/todo-continuation/todo-hygiene.ts

@@ -168,11 +168,14 @@ export function createTodoHygiene(options: Options) {
           reasons: Array.from(pending.get(input.sessionID) ?? []),
           reasons: Array.from(pending.get(input.sessionID) ?? []),
         });
         });
       } catch (error) {
       } catch (error) {
-        options.log?.('Skipped todo hygiene reminder: failed to inspect todos', {
-          sessionID: input.sessionID,
-          tool,
-          error: error instanceof Error ? error.message : String(error),
-        });
+        options.log?.(
+          'Skipped todo hygiene reminder: failed to inspect todos',
+          {
+            sessionID: input.sessionID,
+            tool,
+            error: error instanceof Error ? error.message : String(error),
+          },
+        );
       }
       }
     },
     },
 
 
@@ -212,10 +215,13 @@ export function createTodoHygiene(options: Options) {
         });
         });
       } catch (error) {
       } catch (error) {
         pending.delete(input.sessionID);
         pending.delete(input.sessionID);
-        options.log?.('Skipped todo hygiene reminder: failed to inspect todos', {
-          sessionID: input.sessionID,
-          error: error instanceof Error ? error.message : String(error),
-        });
+        options.log?.(
+          'Skipped todo hygiene reminder: failed to inspect todos',
+          {
+            sessionID: input.sessionID,
+            error: error instanceof Error ? error.message : String(error),
+          },
+        );
       }
       }
     },
     },
 
 
@@ -224,7 +230,8 @@ export function createTodoHygiene(options: Options) {
         return;
         return;
       }
       }
 
 
-      const sessionID = event.properties?.sessionID ?? event.properties?.info?.id;
+      const sessionID =
+        event.properties?.sessionID ?? event.properties?.info?.id;
       if (!sessionID) {
       if (!sessionID) {
         return;
         return;
       }
       }

+ 1 - 2
src/index.ts

@@ -469,8 +469,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         const props = input.event.properties as
         const props = input.event.properties as
           | { info?: { id?: string }; sessionID?: string }
           | { info?: { id?: string }; sessionID?: string }
           | undefined;
           | undefined;
-        const sessionID =
-          props?.info?.id ?? props?.sessionID;
+        const sessionID = props?.info?.id ?? props?.sessionID;
         if (sessionID) {
         if (sessionID) {
           sessionAgentMap.delete(sessionID);
           sessionAgentMap.delete(sessionID);
         }
         }

+ 6 - 3
src/interview/interview.test.ts

@@ -1564,7 +1564,8 @@ describe('interview server port configuration', () => {
     try {
     try {
       const baseUrl = await server.ensureStarted();
       const baseUrl = await server.ensureStarted();
       expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
       expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
-      const port = Number.parseInt(baseUrl.split(':').pop()!, 10);
+      const portStr = baseUrl.split(':').pop();
+      const port = Number.parseInt(portStr ?? '0', 10);
       expect(port).toBeGreaterThan(0);
       expect(port).toBeGreaterThan(0);
     } finally {
     } finally {
       server.close();
       server.close();
@@ -1576,7 +1577,8 @@ describe('interview server port configuration', () => {
     const server = createInterviewServer({ ...noopDeps, port: freePort });
     const server = createInterviewServer({ ...noopDeps, port: freePort });
     try {
     try {
       const baseUrl = await server.ensureStarted();
       const baseUrl = await server.ensureStarted();
-      const port = Number.parseInt(baseUrl.split(':').pop()!, 10);
+      const portStr = baseUrl.split(':').pop();
+      const port = Number.parseInt(portStr ?? '0', 10);
       expect(port).toBe(freePort);
       expect(port).toBe(freePort);
     } finally {
     } finally {
       server.close();
       server.close();
@@ -1587,7 +1589,8 @@ describe('interview server port configuration', () => {
     const server = createInterviewServer({ ...noopDeps, port: 0 });
     const server = createInterviewServer({ ...noopDeps, port: 0 });
     try {
     try {
       const baseUrl = await server.ensureStarted();
       const baseUrl = await server.ensureStarted();
-      const port = Number.parseInt(baseUrl.split(':').pop()!, 10);
+      const portStr = baseUrl.split(':').pop();
+      const port = Number.parseInt(portStr ?? '0', 10);
       expect(port).toBeGreaterThanOrEqual(1);
       expect(port).toBeGreaterThanOrEqual(1);
       expect(port).toBeLessThanOrEqual(65535);
       expect(port).toBeLessThanOrEqual(65535);
     } finally {
     } finally {

+ 14 - 14
src/multiplexer/tmux/index.ts

@@ -2,8 +2,8 @@
  * Tmux multiplexer implementation
  * Tmux multiplexer implementation
  */
  */
 
 
-import { spawn } from 'bun';
 import type { MultiplexerLayout } from '../../config/schema';
 import type { MultiplexerLayout } from '../../config/schema';
+import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
 import type { Multiplexer, PaneResult } from '../types';
 import type { Multiplexer, PaneResult } from '../types';
 
 
@@ -62,14 +62,14 @@ export class TmuxMultiplexer implements Multiplexer {
 
 
       log('[tmux] spawnPane: executing', { tmux, args });
       log('[tmux] spawnPane: executing', { tmux, args });
 
 
-      const proc = spawn([tmux, ...args], {
+      const proc = crossSpawn([tmux, ...args], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
 
 
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
-      const stdout = await new Response(proc.stdout).text();
-      const stderr = await new Response(proc.stderr).text();
+      const stdout = await proc.stdout();
+      const stderr = await proc.stderr();
       const paneId = stdout.trim();
       const paneId = stdout.trim();
 
 
       log('[tmux] spawnPane: result', {
       log('[tmux] spawnPane: result', {
@@ -80,7 +80,7 @@ export class TmuxMultiplexer implements Multiplexer {
 
 
       if (exitCode === 0 && paneId) {
       if (exitCode === 0 && paneId) {
         // Rename the pane for visibility
         // Rename the pane for visibility
-        const renameProc = spawn(
+        const renameProc = crossSpawn(
           [tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
           [tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
           { stdout: 'ignore', stderr: 'ignore' },
           { stdout: 'ignore', stderr: 'ignore' },
         );
         );
@@ -115,7 +115,7 @@ export class TmuxMultiplexer implements Multiplexer {
     try {
     try {
       // Send Ctrl+C for graceful shutdown
       // Send Ctrl+C for graceful shutdown
       log('[tmux] closePane: sending Ctrl+C', { paneId });
       log('[tmux] closePane: sending Ctrl+C', { paneId });
-      const ctrlCProc = spawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
+      const ctrlCProc = crossSpawn([tmux, 'send-keys', '-t', paneId, 'C-c'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
@@ -126,13 +126,13 @@ export class TmuxMultiplexer implements Multiplexer {
 
 
       // Kill the pane
       // Kill the pane
       log('[tmux] closePane: killing pane', { paneId });
       log('[tmux] closePane: killing pane', { paneId });
-      const proc = spawn([tmux, 'kill-pane', '-t', paneId], {
+      const proc = crossSpawn([tmux, 'kill-pane', '-t', paneId], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
 
 
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
-      const stderr = await new Response(proc.stderr).text();
+      const stderr = await proc.stderr();
 
 
       log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
       log('[tmux] closePane: result', { exitCode, stderr: stderr.trim() });
 
 
@@ -164,7 +164,7 @@ export class TmuxMultiplexer implements Multiplexer {
 
 
     try {
     try {
       // Apply the layout
       // Apply the layout
-      const layoutProc = spawn([tmux, 'select-layout', layout], {
+      const layoutProc = crossSpawn([tmux, 'select-layout', layout], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
@@ -175,7 +175,7 @@ export class TmuxMultiplexer implements Multiplexer {
         const sizeOption =
         const sizeOption =
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
           layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
 
 
-        const sizeProc = spawn(
+        const sizeProc = crossSpawn(
           [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
           [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
           {
           {
             stdout: 'pipe',
             stdout: 'pipe',
@@ -185,7 +185,7 @@ export class TmuxMultiplexer implements Multiplexer {
         await sizeProc.exited;
         await sizeProc.exited;
 
 
         // Reapply layout to use the new size
         // Reapply layout to use the new size
-        const reapplyProc = spawn([tmux, 'select-layout', layout], {
+        const reapplyProc = crossSpawn([tmux, 'select-layout', layout], {
           stdout: 'pipe',
           stdout: 'pipe',
           stderr: 'pipe',
           stderr: 'pipe',
         });
         });
@@ -208,7 +208,7 @@ export class TmuxMultiplexer implements Multiplexer {
     const cmd = isWindows ? 'where' : 'which';
     const cmd = isWindows ? 'where' : 'which';
 
 
     try {
     try {
-      const proc = spawn([cmd, 'tmux'], {
+      const proc = crossSpawn([cmd, 'tmux'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
@@ -219,7 +219,7 @@ export class TmuxMultiplexer implements Multiplexer {
         return null;
         return null;
       }
       }
 
 
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
       const path = stdout.trim().split('\n')[0];
       const path = stdout.trim().split('\n')[0];
       if (!path) {
       if (!path) {
         log('[tmux] findBinary: no path in output');
         log('[tmux] findBinary: no path in output');
@@ -227,7 +227,7 @@ export class TmuxMultiplexer implements Multiplexer {
       }
       }
 
 
       // Verify it works
       // Verify it works
-      const verifyProc = spawn([path, '-V'], {
+      const verifyProc = crossSpawn([path, '-V'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });

+ 50 - 38
src/multiplexer/zellij/index.ts

@@ -7,8 +7,8 @@
  * - User stays in their original tab
  * - User stays in their original tab
  */
  */
 
 
-import { spawn } from 'bun';
 import type { MultiplexerLayout } from '../../config/schema';
 import type { MultiplexerLayout } from '../../config/schema';
+import { crossSpawn } from '../../utils/compat';
 import type { Multiplexer, PaneResult } from '../types';
 import type { Multiplexer, PaneResult } from '../types';
 
 
 interface ZellijTabInfo {
 interface ZellijTabInfo {
@@ -119,13 +119,13 @@ export class ZellijMultiplexer implements Multiplexer {
         opencodeCmd,
         opencodeCmd,
       ];
       ];
 
 
-      const proc = spawn([zellij, ...args], {
+      const proc = crossSpawn([zellij, ...args], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
 
 
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
       const paneId = stdout.trim();
       const paneId = stdout.trim();
 
 
       // Accept success if exit code is 0 and we got a valid pane ID
       // Accept success if exit code is 0 and we got a valid pane ID
@@ -143,7 +143,7 @@ export class ZellijMultiplexer implements Multiplexer {
     const originalTab = await this.getCurrentTabId(zellij);
     const originalTab = await this.getCurrentTabId(zellij);
 
 
     // Switch to agent tab
     // Switch to agent tab
-    await spawn([zellij, 'action', 'go-to-tab-by-id', this.agentTabId], {
+    await crossSpawn([zellij, 'action', 'go-to-tab-by-id', this.agentTabId], {
       stdout: 'ignore',
       stdout: 'ignore',
       stderr: 'ignore',
       stderr: 'ignore',
     }).exited;
     }).exited;
@@ -161,21 +161,24 @@ export class ZellijMultiplexer implements Multiplexer {
       opencodeCmd,
       opencodeCmd,
     ];
     ];
 
 
-    const proc = spawn([zellij, ...args], {
+    const proc = crossSpawn([zellij, ...args], {
       stdout: 'pipe',
       stdout: 'pipe',
       stderr: 'pipe',
       stderr: 'pipe',
     });
     });
 
 
     const exitCode = await proc.exited;
     const exitCode = await proc.exited;
-    const stdout = await new Response(proc.stdout).text();
+    const stdout = await proc.stdout();
     const paneId = stdout.trim();
     const paneId = stdout.trim();
 
 
     // Switch back to original tab
     // Switch back to original tab
     if (originalTab) {
     if (originalTab) {
-      await spawn([zellij, 'action', 'go-to-tab-by-id', String(originalTab)], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
+      await crossSpawn(
+        [zellij, 'action', 'go-to-tab-by-id', String(originalTab)],
+        {
+          stdout: 'ignore',
+          stderr: 'ignore',
+        },
+      ).exited;
     }
     }
 
 
     // Accept success if exit code is 0 and we got a valid pane ID
     // Accept success if exit code is 0 and we got a valid pane ID
@@ -195,22 +198,22 @@ export class ZellijMultiplexer implements Multiplexer {
     try {
     try {
       const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
       const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
 
 
-      await spawn([zellij, 'action', 'focus-pane', '--pane-id', paneId], {
+      await crossSpawn([zellij, 'action', 'focus-pane', '--pane-id', paneId], {
         stdout: 'ignore',
         stdout: 'ignore',
         stderr: 'ignore',
         stderr: 'ignore',
       }).exited;
       }).exited;
 
 
-      await spawn(
+      await crossSpawn(
         [zellij, 'action', 'rename-pane', '--name', description.slice(0, 30)],
         [zellij, 'action', 'rename-pane', '--name', description.slice(0, 30)],
         { stdout: 'ignore', stderr: 'ignore' },
         { stdout: 'ignore', stderr: 'ignore' },
       ).exited;
       ).exited;
 
 
-      await spawn([zellij, 'action', 'write-chars', opencodeCmd], {
+      await crossSpawn([zellij, 'action', 'write-chars', opencodeCmd], {
         stdout: 'ignore',
         stdout: 'ignore',
         stderr: 'ignore',
         stderr: 'ignore',
       }).exited;
       }).exited;
 
 
-      await spawn([zellij, 'action', 'write-chars', '\n'], {
+      await crossSpawn([zellij, 'action', 'write-chars', '\n'], {
         stdout: 'ignore',
         stdout: 'ignore',
         stderr: 'ignore',
         stderr: 'ignore',
       }).exited;
       }).exited;
@@ -242,7 +245,7 @@ export class ZellijMultiplexer implements Multiplexer {
       const beforePanes = await this.listPanes(zellij);
       const beforePanes = await this.listPanes(zellij);
 
 
       // Create new tab
       // Create new tab
-      const createProc = spawn(
+      const createProc = crossSpawn(
         [zellij, 'action', 'new-tab', '--name', 'opencode-agents'],
         [zellij, 'action', 'new-tab', '--name', 'opencode-agents'],
         { stdout: 'pipe', stderr: 'pipe' },
         { stdout: 'pipe', stderr: 'pipe' },
       );
       );
@@ -268,7 +271,7 @@ export class ZellijMultiplexer implements Multiplexer {
     tabId: string,
     tabId: string,
   ): Promise<string | null> {
   ): Promise<string | null> {
     const originalTab = await this.getCurrentTabId(zellij);
     const originalTab = await this.getCurrentTabId(zellij);
-    await spawn([zellij, 'action', 'go-to-tab-by-id', tabId], {
+    await crossSpawn([zellij, 'action', 'go-to-tab-by-id', tabId], {
       stdout: 'ignore',
       stdout: 'ignore',
       stderr: 'ignore',
       stderr: 'ignore',
     }).exited;
     }).exited;
@@ -277,10 +280,13 @@ export class ZellijMultiplexer implements Multiplexer {
 
 
     // Restore original tab
     // Restore original tab
     if (originalTab) {
     if (originalTab) {
-      await spawn([zellij, 'action', 'go-to-tab-by-id', String(originalTab)], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
+      await crossSpawn(
+        [zellij, 'action', 'go-to-tab-by-id', String(originalTab)],
+        {
+          stdout: 'ignore',
+          stderr: 'ignore',
+        },
+      ).exited;
     }
     }
 
 
     return panes[0] || null;
     return panes[0] || null;
@@ -291,7 +297,7 @@ export class ZellijMultiplexer implements Multiplexer {
     name: string,
     name: string,
   ): Promise<{ tabId: string; name: string } | null> {
   ): Promise<{ tabId: string; name: string } | null> {
     try {
     try {
-      const proc = spawn([zellij, 'action', 'list-tabs', '--json'], {
+      const proc = crossSpawn([zellij, 'action', 'list-tabs', '--json'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
@@ -299,7 +305,7 @@ export class ZellijMultiplexer implements Multiplexer {
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
       if (exitCode !== 0) return this.findTabByNameText(zellij, name);
       if (exitCode !== 0) return this.findTabByNameText(zellij, name);
 
 
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
 
 
       try {
       try {
         const tabs: ZellijTabInfo[] = JSON.parse(stdout);
         const tabs: ZellijTabInfo[] = JSON.parse(stdout);
@@ -322,7 +328,7 @@ export class ZellijMultiplexer implements Multiplexer {
     name: string,
     name: string,
   ): Promise<{ tabId: string; name: string } | null> {
   ): Promise<{ tabId: string; name: string } | null> {
     try {
     try {
-      const proc = spawn([zellij, 'action', 'list-tabs'], {
+      const proc = crossSpawn([zellij, 'action', 'list-tabs'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
@@ -330,7 +336,7 @@ export class ZellijMultiplexer implements Multiplexer {
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
       if (exitCode !== 0) return null;
       if (exitCode !== 0) return null;
 
 
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
       const lines = stdout.split('\n');
       const lines = stdout.split('\n');
 
 
       for (const line of lines) {
       for (const line of lines) {
@@ -347,15 +353,18 @@ export class ZellijMultiplexer implements Multiplexer {
 
 
   private async getCurrentTabId(zellij: string): Promise<string | null> {
   private async getCurrentTabId(zellij: string): Promise<string | null> {
     try {
     try {
-      const proc = spawn([zellij, 'action', 'current-tab-info', '--json'], {
-        stdout: 'pipe',
-        stderr: 'pipe',
-      });
+      const proc = crossSpawn(
+        [zellij, 'action', 'current-tab-info', '--json'],
+        {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        },
+      );
 
 
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
       if (exitCode !== 0) return null;
       if (exitCode !== 0) return null;
 
 
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
       try {
       try {
         const info = JSON.parse(stdout);
         const info = JSON.parse(stdout);
         return String(info.tab_id);
         return String(info.tab_id);
@@ -369,7 +378,7 @@ export class ZellijMultiplexer implements Multiplexer {
 
 
   private async listPanes(zellij: string): Promise<string[]> {
   private async listPanes(zellij: string): Promise<string[]> {
     try {
     try {
-      const proc = spawn([zellij, 'action', 'list-panes'], {
+      const proc = crossSpawn([zellij, 'action', 'list-panes'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
@@ -377,7 +386,7 @@ export class ZellijMultiplexer implements Multiplexer {
       const exitCode = await proc.exited;
       const exitCode = await proc.exited;
       if (exitCode !== 0) return [];
       if (exitCode !== 0) return [];
 
 
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
       return stdout
       return stdout
         .split('\n')
         .split('\n')
         .slice(1)
         .slice(1)
@@ -396,15 +405,18 @@ export class ZellijMultiplexer implements Multiplexer {
 
 
     try {
     try {
       // Send Ctrl+C for graceful shutdown
       // Send Ctrl+C for graceful shutdown
-      await spawn([zellij, 'action', 'write', '--pane-id', paneId, '\u0003'], {
-        stdout: 'ignore',
-        stderr: 'ignore',
-      }).exited;
+      await crossSpawn(
+        [zellij, 'action', 'write', '--pane-id', paneId, '\u0003'],
+        {
+          stdout: 'ignore',
+          stderr: 'ignore',
+        },
+      ).exited;
 
 
       await new Promise((r) => setTimeout(r, 250));
       await new Promise((r) => setTimeout(r, 250));
 
 
       // Close the pane
       // Close the pane
-      const proc = spawn(
+      const proc = crossSpawn(
         [zellij, 'action', 'close-pane', '--pane-id', paneId],
         [zellij, 'action', 'close-pane', '--pane-id', paneId],
         { stdout: 'pipe', stderr: 'pipe' },
         { stdout: 'pipe', stderr: 'pipe' },
       );
       );
@@ -432,12 +444,12 @@ export class ZellijMultiplexer implements Multiplexer {
   private async findBinary(): Promise<string | null> {
   private async findBinary(): Promise<string | null> {
     const cmd = process.platform === 'win32' ? 'where' : 'which';
     const cmd = process.platform === 'win32' ? 'where' : 'which';
     try {
     try {
-      const proc = spawn([cmd, 'zellij'], {
+      const proc = crossSpawn([cmd, 'zellij'], {
         stdout: 'pipe',
         stdout: 'pipe',
         stderr: 'pipe',
         stderr: 'pipe',
       });
       });
       if ((await proc.exited) !== 0) return null;
       if ((await proc.exited) !== 0) return null;
-      const stdout = await new Response(proc.stdout).text();
+      const stdout = await proc.stdout();
       return stdout.trim().split('\n')[0] || null;
       return stdout.trim().split('\n')[0] || null;
     } catch {
     } catch {
       return null;
       return null;

+ 4 - 7
src/tools/ast-grep/cli.ts

@@ -1,5 +1,5 @@
 import { existsSync } from 'node:fs';
 import { existsSync } from 'node:fs';
-import { spawn } from 'bun';
+import { crossSpawn } from '../../utils/compat';
 import {
 import {
   DEFAULT_MAX_MATCHES,
   DEFAULT_MAX_MATCHES,
   DEFAULT_MAX_OUTPUT_BYTES,
   DEFAULT_MAX_OUTPUT_BYTES,
@@ -107,7 +107,7 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
 
 
   const timeout = DEFAULT_TIMEOUT_MS;
   const timeout = DEFAULT_TIMEOUT_MS;
 
 
-  const proc = spawn([cliPath, ...args], {
+  const proc = crossSpawn([cliPath, ...args], {
     stdout: 'pipe',
     stdout: 'pipe',
     stderr: 'pipe',
     stderr: 'pipe',
   });
   });
@@ -125,11 +125,8 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
   let exitCode: number;
   let exitCode: number;
 
 
   try {
   try {
-    stdout = await Promise.race([
-      new Response(proc.stdout).text(),
-      timeoutPromise,
-    ]);
-    stderr = await new Response(proc.stderr).text();
+    stdout = await Promise.race([proc.stdout(), timeoutPromise]);
+    stderr = await proc.stderr();
     exitCode = await proc.exited;
     exitCode = await proc.exited;
   } catch (e) {
   } catch (e) {
     const error = e as Error;
     const error = e as Error;

+ 2 - 1
src/tools/ast-grep/downloader.ts

@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
 import { homedir } from 'node:os';
 import { homedir } from 'node:os';
 import { join } from 'node:path';
 import { join } from 'node:path';
 import { extractZip } from '../../utils';
 import { extractZip } from '../../utils';
+import { crossWrite } from '../../utils/compat';
 
 
 const REPO = 'ast-grep/ast-grep';
 const REPO = 'ast-grep/ast-grep';
 
 
@@ -96,7 +97,7 @@ export async function downloadAstGrep(
 
 
     const archivePath = join(cacheDir, assetName);
     const archivePath = join(cacheDir, assetName);
     const arrayBuffer = await response.arrayBuffer();
     const arrayBuffer = await response.arrayBuffer();
-    await Bun.write(archivePath, arrayBuffer);
+    await crossWrite(archivePath, arrayBuffer);
 
 
     await extractZip(archivePath, cacheDir);
     await extractZip(archivePath, cacheDir);
 
 

+ 18 - 66
src/tools/lsp/client.ts

@@ -1,10 +1,9 @@
 // LSP Client - Full implementation with connection pooling
 // LSP Client - Full implementation with connection pooling
 
 
+import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process';
 import { readFileSync } from 'node:fs';
 import { readFileSync } from 'node:fs';
 import { extname, resolve } from 'node:path';
 import { extname, resolve } from 'node:path';
-import { Readable, Writable } from 'node:stream';
 import { pathToFileURL } from 'node:url';
 import { pathToFileURL } from 'node:url';
-import { type Subprocess, spawn } from 'bun';
 import {
 import {
   createMessageConnection,
   createMessageConnection,
   type MessageConnection,
   type MessageConnection,
@@ -321,7 +320,7 @@ class LSPServerManager {
 export const lspManager = LSPServerManager.getInstance();
 export const lspManager = LSPServerManager.getInstance();
 
 
 export class LSPClient {
 export class LSPClient {
-  private proc: Subprocess<'pipe', 'pipe', 'pipe'> | null = null;
+  private proc: ChildProcess | null = null;
   private connection: MessageConnection | null = null;
   private connection: MessageConnection | null = null;
   private openedFiles = new Set<string>();
   private openedFiles = new Set<string>();
   private stderrBuffer: string[] = [];
   private stderrBuffer: string[] = [];
@@ -356,15 +355,10 @@ export class LSPClient {
       root: this.root,
       root: this.root,
     });
     });
 
 
-    this.proc = spawn(command, {
-      stdin: 'pipe',
-      stdout: 'pipe',
-      stderr: 'pipe',
+    this.proc = nodeSpawn(command[0], command.slice(1), {
+      stdio: ['pipe', 'pipe', 'pipe'],
       cwd: this.root,
       cwd: this.root,
-      env: {
-        ...process.env,
-        ...this.server.env,
-      },
+      env: { ...process.env, ...this.server.env },
     });
     });
 
 
     if (!this.proc) {
     if (!this.proc) {
@@ -376,45 +370,14 @@ export class LSPClient {
     this.startStderrReading();
     this.startStderrReading();
 
 
     // Create JSON-RPC connection
     // Create JSON-RPC connection
-    const stdoutReader = this.proc.stdout.getReader();
-    const nodeReadable = new Readable({
-      async read() {
-        try {
-          const { done, value } = await stdoutReader.read();
-          if (done) {
-            this.push(null);
-          } else {
-            this.push(value);
-          }
-        } catch (err) {
-          this.destroy(err as Error);
-        }
-      },
-    });
-
+    const stdout = this.proc.stdout;
     const stdin = this.proc.stdin;
     const stdin = this.proc.stdin;
-    const nodeWritable = new Writable({
-      write(chunk, _encoding, callback) {
-        try {
-          stdin.write(chunk);
-          callback();
-        } catch (err) {
-          callback(err as Error);
-        }
-      },
-      final(callback) {
-        try {
-          stdin.end();
-          callback();
-        } catch (err) {
-          callback(err as Error);
-        }
-      },
-    });
-
+    if (!stdout || !stdin) {
+      throw new Error('LSP server process missing stdio streams');
+    }
     this.connection = createMessageConnection(
     this.connection = createMessageConnection(
-      new StreamMessageReader(nodeReadable),
-      new StreamMessageWriter(nodeWritable),
+      new StreamMessageReader(stdout),
+      new StreamMessageWriter(stdin),
     );
     );
 
 
     this.connection.onNotification(
     this.connection.onNotification(
@@ -494,24 +457,13 @@ export class LSPClient {
   }
   }
 
 
   private startStderrReading(): void {
   private startStderrReading(): void {
-    if (!this.proc) return;
-
-    const reader = this.proc.stderr.getReader();
-    const read = async () => {
-      const decoder = new TextDecoder();
-      try {
-        while (true) {
-          const { done, value } = await reader.read();
-          if (done) break;
-          const text = decoder.decode(value);
-          this.stderrBuffer.push(text);
-          if (this.stderrBuffer.length > 100) {
-            this.stderrBuffer.shift();
-          }
-        }
-      } catch {}
-    };
-    read();
+    if (!this.proc?.stderr) return;
+    this.proc.stderr.on('data', (chunk: Buffer) => {
+      this.stderrBuffer.push(chunk.toString());
+      if (this.stderrBuffer.length > 100) {
+        this.stderrBuffer.shift();
+      }
+    });
   }
   }
 
 
   async initialize(): Promise<void> {
   async initialize(): Promise<void> {

+ 91 - 0
src/utils/compat.ts

@@ -0,0 +1,91 @@
+import type { ChildProcess } from 'node:child_process';
+import { spawn as nodeSpawn } from 'node:child_process';
+import { writeFile as fsWriteFile } from 'node:fs/promises';
+
+export const isBun = typeof globalThis.Bun !== 'undefined';
+
+export interface CrossSpawnResult {
+  proc: ChildProcess;
+  /** Collects all stdout into a string */
+  stdout: () => Promise<string>;
+  /** Collects all stderr into a string */
+  stderr: () => Promise<string>;
+  /** Resolves when process exits with exit code */
+  exited: Promise<number>;
+  /** Kill the process */
+  kill: (signal?: NodeJS.Signals | number) => boolean;
+  /** Current exit code or null if running */
+  get exitCode(): number | null;
+}
+
+function collectStream(
+  stream: NodeJS.ReadableStream | null,
+): () => Promise<string> {
+  if (!stream) return () => Promise.resolve('');
+  const chunks: Buffer[] = [];
+  stream.on('data', (chunk: Buffer) => chunks.push(chunk));
+  return () =>
+    new Promise<string>((resolve, reject) => {
+      if (!stream.readable) {
+        resolve(Buffer.concat(chunks).toString('utf-8'));
+        return;
+      }
+      stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
+      stream.on('error', reject);
+    });
+}
+
+/**
+ * Cross-runtime spawn that works in both Bun and Node.js.
+ * API mimics Bun.spawn but uses node:child_process internally.
+ */
+export function crossSpawn(
+  command: string[],
+  options?: {
+    stdout?: 'pipe' | 'inherit' | 'ignore';
+    stderr?: 'pipe' | 'inherit' | 'ignore';
+    stdin?: 'pipe' | 'inherit' | 'ignore';
+    cwd?: string;
+    env?: Record<string, string | undefined>;
+  },
+): CrossSpawnResult {
+  const [cmd, ...args] = command;
+  const proc = nodeSpawn(cmd, args, {
+    stdio: [
+      options?.stdin ?? 'ignore',
+      options?.stdout ?? 'pipe',
+      options?.stderr ?? 'pipe',
+    ],
+    cwd: options?.cwd,
+    env: options?.env as NodeJS.ProcessEnv,
+  });
+
+  const stdoutCollector = collectStream(proc.stdout);
+  const stderrCollector = collectStream(proc.stderr);
+
+  const exited = new Promise<number>((resolve, reject) => {
+    proc.on('error', reject);
+    proc.on('close', (code) => resolve(code ?? 1));
+  });
+
+  return {
+    proc,
+    stdout: stdoutCollector,
+    stderr: stderrCollector,
+    exited,
+    kill: (signal) => proc.kill(signal as NodeJS.Signals),
+    get exitCode() {
+      return proc.exitCode;
+    },
+  };
+}
+
+/**
+ * Cross-runtime file write that works in both Bun and Node.js.
+ */
+export async function crossWrite(
+  path: string,
+  data: ArrayBuffer | Buffer | string,
+): Promise<void> {
+  await fsWriteFile(path, Buffer.from(data as ArrayBuffer));
+}

+ 11 - 11
src/utils/zip-extractor.ts

@@ -1,5 +1,6 @@
+import { spawnSync } from 'node:child_process';
 import { release } from 'node:os';
 import { release } from 'node:os';
-import { spawn, spawnSync } from 'bun';
+import { crossSpawn } from './compat';
 
 
 const WINDOWS_BUILD_WITH_TAR = 17134;
 const WINDOWS_BUILD_WITH_TAR = 17134;
 
 
@@ -16,11 +17,10 @@ function getWindowsBuildNumber(): number | null {
 
 
 function isPwshAvailable(): boolean {
 function isPwshAvailable(): boolean {
   if (process.platform !== 'win32') return false;
   if (process.platform !== 'win32') return false;
-  const result = spawnSync(['where', 'pwsh'], {
-    stdout: 'pipe',
-    stderr: 'pipe',
+  const result = spawnSync('where', ['pwsh'], {
+    stdio: ['ignore', 'pipe', 'pipe'],
   });
   });
-  return result.exitCode === 0;
+  return result.status === 0;
 }
 }
 
 
 function escapePowerShellPath(path: string): string {
 function escapePowerShellPath(path: string): string {
@@ -47,20 +47,20 @@ export async function extractZip(
   archivePath: string,
   archivePath: string,
   destDir: string,
   destDir: string,
 ): Promise<void> {
 ): Promise<void> {
-  let proc: ReturnType<typeof spawn>;
+  let proc: ReturnType<typeof crossSpawn>;
 
 
   if (process.platform === 'win32') {
   if (process.platform === 'win32') {
     const extractor = getWindowsZipExtractor();
     const extractor = getWindowsZipExtractor();
 
 
     switch (extractor) {
     switch (extractor) {
       case 'tar':
       case 'tar':
-        proc = spawn(['tar', '-xf', archivePath, '-C', destDir], {
+        proc = crossSpawn(['tar', '-xf', archivePath, '-C', destDir], {
           stdout: 'ignore',
           stdout: 'ignore',
           stderr: 'pipe',
           stderr: 'pipe',
         });
         });
         break;
         break;
       case 'pwsh':
       case 'pwsh':
-        proc = spawn(
+        proc = crossSpawn(
           [
           [
             'pwsh',
             'pwsh',
             '-Command',
             '-Command',
@@ -73,7 +73,7 @@ export async function extractZip(
         );
         );
         break;
         break;
       default:
       default:
-        proc = spawn(
+        proc = crossSpawn(
           [
           [
             'powershell',
             'powershell',
             '-Command',
             '-Command',
@@ -87,7 +87,7 @@ export async function extractZip(
         break;
         break;
     }
     }
   } else {
   } else {
-    proc = spawn(['unzip', '-o', archivePath, '-d', destDir], {
+    proc = crossSpawn(['unzip', '-o', archivePath, '-d', destDir], {
       stdout: 'ignore',
       stdout: 'ignore',
       stderr: 'pipe',
       stderr: 'pipe',
     });
     });
@@ -96,7 +96,7 @@ export async function extractZip(
   const exitCode = await proc.exited;
   const exitCode = await proc.exited;
 
 
   if (exitCode !== 0) {
   if (exitCode !== 0) {
-    const stderr = await new Response(proc.stderr as ReadableStream).text();
+    const stderr = await proc.stderr();
     throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`);
     throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`);
   }
   }
 }
 }