Преглед на файлове

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 месеца
родител
ревизия
b5824e618f

+ 4 - 2
package.json

@@ -36,8 +36,10 @@
     "LICENSE"
   ],
   "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:check": "all-contributors check",
     "contributors:generate": "all-contributors generate",

+ 30 - 9
src/cli/system.ts

@@ -1,5 +1,6 @@
 import { spawnSync } from 'node:child_process';
 import { statSync } from 'node:fs';
+import { crossSpawn } from '../utils/compat';
 
 let cachedOpenCodePath: string | null = null;
 
@@ -125,27 +126,47 @@ export async function isOpenCodeInstalled(): Promise<boolean> {
 
   for (const opencodePath of paths) {
     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;
 }
 
 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> {
   const opencodePath = resolveOpenCodePath();
   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 {
     // 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(() => {});
 
@@ -24,6 +16,15 @@ const cacheMocks = {
   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', () => ({
   log: logMock,
 }));
@@ -32,8 +33,13 @@ mock.module('./checker', () => checkerMocks);
 
 mock.module('./cache', () => cacheMocks);
 
+mock.module('../../utils/compat', () => ({
+  crossSpawn: crossSpawnMock,
+  crossWrite: mock(() => Promise.resolve()),
+  isBun: false,
+}));
+
 let importCounter = 0;
-let bunSpawnSpy: ReturnType<typeof spyOn> | undefined;
 
 function createCtx() {
   const showToast = mock(() => Promise.resolve(undefined));
@@ -87,11 +93,20 @@ describe('auto-update-checker/index', () => {
     cacheMocks.resolveInstallContext.mockImplementation(() => ({
       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(() => {
-    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 () => {
@@ -134,14 +149,14 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
     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(
       `./index?test=${importCounter++}`
@@ -158,7 +173,7 @@ describe('auto-update-checker/index', () => {
       '0.9.11',
       'oh-my-opencode-slim',
     );
-    expect(bunSpawnSpy).toHaveBeenCalledWith(
+    expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
       expect.objectContaining({ cwd: '/tmp/opencode' }),
     );
@@ -181,15 +196,6 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
     cacheMocks.preparePackageUpdate.mockImplementation(() => null);
 
-    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
-      () =>
-        ({
-          exited: Promise.resolve(0),
-          exitCode: 0,
-          kill: mock(() => {}),
-        }) as never,
-    );
-
     const { createAutoUpdateCheckerHook } = await import(
       `./index?test=${importCounter++}`
     );
@@ -201,7 +207,7 @@ describe('auto-update-checker/index', () => {
     hook.event({ event: { type: 'session.created', properties: {} } });
     await waitForCalls(showToast);
 
-    expect(bunSpawnSpy).not.toHaveBeenCalled();
+    expect(crossSpawnMock).not.toHaveBeenCalled();
     expect(showToast).toHaveBeenCalledWith({
       body: {
         title: 'OMO-Slim 0.9.11',
@@ -221,14 +227,14 @@ describe('auto-update-checker/index', () => {
     checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
     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(
       `./index?test=${importCounter++}`
@@ -241,7 +247,7 @@ describe('auto-update-checker/index', () => {
     hook.event({ event: { type: 'session.created', properties: {} } });
     await waitForCalls(showToast);
 
-    expect(bunSpawnSpy).toHaveBeenCalledWith(
+    expect(crossSpawnMock).toHaveBeenCalledWith(
       ['bun', 'install'],
       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 { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import { preparePackageUpdate, resolveInstallContext } from './cache';
 import {
@@ -190,7 +191,7 @@ export function getAutoUpdateInstallDir(): string {
  */
 async function runBunInstallSafe(installDir: string): Promise<boolean> {
   try {
-    const proc = Bun.spawn(['bun', 'install'], {
+    const proc = crossSpawn(['bun', 'install'], {
       cwd: installDir,
       stdout: 'pipe',
       stderr: 'pipe',

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

@@ -117,7 +117,9 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       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.handleChatSystemTransform({ sessionID: 'sub1' }, system);
 
@@ -141,12 +143,18 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
 
       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);
 
       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 () => {
@@ -164,14 +172,20 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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.handleMessagesTransform(
         userMessages('segunda request distinta', 'main1', 'orchestrator'),
       );
       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.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
@@ -194,14 +208,20 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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.handleMessagesTransform(
         userMessages('', 'main1', 'orchestrator', [{ type: 'image' }]),
       );
       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.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.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
@@ -252,12 +275,16 @@ describe('createTodoContinuationHook', () => {
       const hook = createTodoContinuationHook(ctx);
       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.handleChatSystemTransform({ sessionID: 'sub1' }, system);
 
       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 () => {
@@ -277,8 +304,13 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
 
       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.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
@@ -297,9 +329,18 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
 
       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.handleMessagesTransform({
         messages: [
@@ -338,14 +379,20 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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.handleMessagesTransform(
         userMessages('same text', 'main1', 'orchestrator', undefined, 'u2'),
       );
       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.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
@@ -368,7 +415,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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.handleMessagesTransform({
         messages: [
@@ -388,7 +438,10 @@ describe('createTodoContinuationHook', () => {
       });
       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.handleChatSystemTransform({ sessionID: 'main1' }, allowed);
 
@@ -412,7 +465,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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.handleMessagesTransform({
         messages: [
@@ -425,7 +481,9 @@ describe('createTodoContinuationHook', () => {
       await hook.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
       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 () => {
@@ -445,12 +503,18 @@ describe('createTodoContinuationHook', () => {
       const system = { system: ['base'] };
 
       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);
 
       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 () => {
@@ -472,7 +536,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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.handleChatSystemTransform({ sessionID: 'main1' }, system);
 
@@ -498,7 +565,10 @@ describe('createTodoContinuationHook', () => {
       await hook.handleMessagesTransform(
         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);
 
       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 { 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';
 
 const HOOK_NAME = 'todo-continuation';
@@ -184,7 +188,10 @@ export function createTodoContinuationHook(
     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;
     if (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 {
+  createTodoHygiene,
   TODO_DELEGATION_RESUME_REMINDER,
   TODO_FINAL_ACTIVE_REMINDER,
   TODO_HYGIENE_REMINDER,
-  createTodoHygiene,
 } 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 {
     hasOpenTodos: overrides?.hasOpenTodos ?? true,
     openCount: overrides?.openCount ?? 1,
@@ -83,7 +85,9 @@ describe('todo hygiene', () => {
     await hook.handleToolExecuteAfter({ tool: 'glob', sessionID: 's1' });
     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 () => {
@@ -202,7 +206,10 @@ describe('todo hygiene', () => {
 
     hook.handleRequestStart({ 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.handleChatSystemTransform({ sessionID: 's1' }, system);
 
@@ -223,11 +230,16 @@ describe('todo hygiene', () => {
 
     hook.handleRequestStart({ 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);
 
     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 () => {

+ 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) ?? []),
         });
       } 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) {
         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;
       }
 
-      const sessionID = event.properties?.sessionID ?? event.properties?.info?.id;
+      const sessionID =
+        event.properties?.sessionID ?? event.properties?.info?.id;
       if (!sessionID) {
         return;
       }

+ 1 - 2
src/index.ts

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

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

@@ -1564,7 +1564,8 @@ describe('interview server port configuration', () => {
     try {
       const baseUrl = await server.ensureStarted();
       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);
     } finally {
       server.close();
@@ -1576,7 +1577,8 @@ describe('interview server port configuration', () => {
     const server = createInterviewServer({ ...noopDeps, port: freePort });
     try {
       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);
     } finally {
       server.close();
@@ -1587,7 +1589,8 @@ describe('interview server port configuration', () => {
     const server = createInterviewServer({ ...noopDeps, port: 0 });
     try {
       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).toBeLessThanOrEqual(65535);
     } finally {

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

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

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

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

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

@@ -1,5 +1,5 @@
 import { existsSync } from 'node:fs';
-import { spawn } from 'bun';
+import { crossSpawn } from '../../utils/compat';
 import {
   DEFAULT_MAX_MATCHES,
   DEFAULT_MAX_OUTPUT_BYTES,
@@ -107,7 +107,7 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
 
   const timeout = DEFAULT_TIMEOUT_MS;
 
-  const proc = spawn([cliPath, ...args], {
+  const proc = crossSpawn([cliPath, ...args], {
     stdout: 'pipe',
     stderr: 'pipe',
   });
@@ -125,11 +125,8 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
   let exitCode: number;
 
   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;
   } catch (e) {
     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 { join } from 'node:path';
 import { extractZip } from '../../utils';
+import { crossWrite } from '../../utils/compat';
 
 const REPO = 'ast-grep/ast-grep';
 
@@ -96,7 +97,7 @@ export async function downloadAstGrep(
 
     const archivePath = join(cacheDir, assetName);
     const arrayBuffer = await response.arrayBuffer();
-    await Bun.write(archivePath, arrayBuffer);
+    await crossWrite(archivePath, arrayBuffer);
 
     await extractZip(archivePath, cacheDir);
 

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

@@ -1,10 +1,9 @@
 // LSP Client - Full implementation with connection pooling
 
+import { type ChildProcess, spawn as nodeSpawn } from 'node:child_process';
 import { readFileSync } from 'node:fs';
 import { extname, resolve } from 'node:path';
-import { Readable, Writable } from 'node:stream';
 import { pathToFileURL } from 'node:url';
-import { type Subprocess, spawn } from 'bun';
 import {
   createMessageConnection,
   type MessageConnection,
@@ -321,7 +320,7 @@ class LSPServerManager {
 export const lspManager = LSPServerManager.getInstance();
 
 export class LSPClient {
-  private proc: Subprocess<'pipe', 'pipe', 'pipe'> | null = null;
+  private proc: ChildProcess | null = null;
   private connection: MessageConnection | null = null;
   private openedFiles = new Set<string>();
   private stderrBuffer: string[] = [];
@@ -356,15 +355,10 @@ export class LSPClient {
       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,
-      env: {
-        ...process.env,
-        ...this.server.env,
-      },
+      env: { ...process.env, ...this.server.env },
     });
 
     if (!this.proc) {
@@ -376,45 +370,14 @@ export class LSPClient {
     this.startStderrReading();
 
     // 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 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(
-      new StreamMessageReader(nodeReadable),
-      new StreamMessageWriter(nodeWritable),
+      new StreamMessageReader(stdout),
+      new StreamMessageWriter(stdin),
     );
 
     this.connection.onNotification(
@@ -494,24 +457,13 @@ export class LSPClient {
   }
 
   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> {

+ 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 { spawn, spawnSync } from 'bun';
+import { crossSpawn } from './compat';
 
 const WINDOWS_BUILD_WITH_TAR = 17134;
 
@@ -16,11 +17,10 @@ function getWindowsBuildNumber(): number | null {
 
 function isPwshAvailable(): boolean {
   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 {
@@ -47,20 +47,20 @@ export async function extractZip(
   archivePath: string,
   destDir: string,
 ): Promise<void> {
-  let proc: ReturnType<typeof spawn>;
+  let proc: ReturnType<typeof crossSpawn>;
 
   if (process.platform === 'win32') {
     const extractor = getWindowsZipExtractor();
 
     switch (extractor) {
       case 'tar':
-        proc = spawn(['tar', '-xf', archivePath, '-C', destDir], {
+        proc = crossSpawn(['tar', '-xf', archivePath, '-C', destDir], {
           stdout: 'ignore',
           stderr: 'pipe',
         });
         break;
       case 'pwsh':
-        proc = spawn(
+        proc = crossSpawn(
           [
             'pwsh',
             '-Command',
@@ -73,7 +73,7 @@ export async function extractZip(
         );
         break;
       default:
-        proc = spawn(
+        proc = crossSpawn(
           [
             'powershell',
             '-Command',
@@ -87,7 +87,7 @@ export async function extractZip(
         break;
     }
   } else {
-    proc = spawn(['unzip', '-o', archivePath, '-d', destDir], {
+    proc = crossSpawn(['unzip', '-o', archivePath, '-d', destDir], {
       stdout: 'ignore',
       stderr: 'pipe',
     });
@@ -96,7 +96,7 @@ export async function extractZip(
   const exitCode = await proc.exited;
 
   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}`);
   }
 }