Przeglądaj źródła

refactor: migrate Bun.spawn to Process utility with timeout and cleanup (#14448)

Dax 6 miesięcy temu
rodzic
commit
814c1d398c

+ 7 - 14
packages/opencode/src/bun/index.ts

@@ -4,20 +4,21 @@ import { Log } from "../util/log"
 import path from "path"
 import { Filesystem } from "../util/filesystem"
 import { NamedError } from "@opencode-ai/util/error"
-import { readableStreamToText } from "bun"
+import { text } from "node:stream/consumers"
 import { Lock } from "../util/lock"
 import { PackageRegistry } from "./registry"
 import { proxied } from "@/util/proxied"
+import { Process } from "../util/process"
 
 export namespace BunProc {
   const log = Log.create({ service: "bun" })
 
-  export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
+  export async function run(cmd: string[], options?: Process.Options) {
     log.info("running", {
       cmd: [which(), ...cmd],
       ...options,
     })
-    const result = Bun.spawn([which(), ...cmd], {
+    const result = Process.spawn([which(), ...cmd], {
       ...options,
       stdout: "pipe",
       stderr: "pipe",
@@ -28,23 +29,15 @@ export namespace BunProc {
       },
     })
     const code = await result.exited
-    const stdout = result.stdout
-      ? typeof result.stdout === "number"
-        ? result.stdout
-        : await readableStreamToText(result.stdout)
-      : undefined
-    const stderr = result.stderr
-      ? typeof result.stderr === "number"
-        ? result.stderr
-        : await readableStreamToText(result.stderr)
-      : undefined
+    const stdout = result.stdout ? await text(result.stdout) : undefined
+    const stderr = result.stderr ? await text(result.stderr) : undefined
     log.info("done", {
       code,
       stdout,
       stderr,
     })
     if (code !== 0) {
-      throw new Error(`Command failed with exit code ${result.exitCode}`)
+      throw new Error(`Command failed with exit code ${code}`)
     }
     return result
   }

+ 6 - 4
packages/opencode/src/bun/registry.ts

@@ -1,5 +1,7 @@
-import { readableStreamToText, semver } from "bun"
+import { semver } from "bun"
+import { text } from "node:stream/consumers"
 import { Log } from "../util/log"
+import { Process } from "../util/process"
 
 export namespace PackageRegistry {
   const log = Log.create({ service: "bun" })
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
   }
 
   export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
-    const result = Bun.spawn([which(), "info", pkg, field], {
+    const result = Process.spawn([which(), "info", pkg, field], {
       cwd,
       stdout: "pipe",
       stderr: "pipe",
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
     })
 
     const code = await result.exited
-    const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
-    const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
+    const stdout = result.stdout ? await text(result.stdout) : ""
+    const stderr = result.stderr ? await text(result.stderr) : ""
 
     if (code !== 0) {
       log.warn("bun info failed", { pkg, field, code, stderr })

+ 9 - 3
packages/opencode/src/cli/cmd/auth.ts

@@ -11,6 +11,8 @@ import { Global } from "../../global"
 import { Plugin } from "../../plugin"
 import { Instance } from "../../project/instance"
 import type { Hooks } from "@opencode-ai/plugin"
+import { Process } from "../../util/process"
+import { text } from "node:stream/consumers"
 
 type PluginAuth = NonNullable<Hooks["auth"]>
 
@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
         if (args.url) {
           const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
           prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
-          const proc = Bun.spawn({
-            cmd: wellknown.auth.command,
+          const proc = Process.spawn(wellknown.auth.command, {
             stdout: "pipe",
           })
           const exit = await proc.exited
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
             prompts.outro("Done")
             return
           }
-          const token = await new Response(proc.stdout).text()
+          if (!proc.stdout) {
+            prompts.log.error("Failed")
+            prompts.outro("Done")
+            return
+          }
+          const token = await text(proc.stdout)
           await Auth.set(args.url, {
             type: "wellknown",
             key: wellknown.auth.env,

+ 7 - 2
packages/opencode/src/cli/cmd/session.ts

@@ -6,6 +6,7 @@ import { UI } from "../ui"
 import { Locale } from "../../util/locale"
 import { Flag } from "../../flag/flag"
 import { Filesystem } from "../../util/filesystem"
+import { Process } from "../../util/process"
 import { EOL } from "os"
 import path from "path"
 
@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
       const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
 
       if (shouldPaginate) {
-        const proc = Bun.spawn({
-          cmd: pagerCmd(),
+        const proc = Process.spawn(pagerCmd(), {
           stdin: "pipe",
           stdout: "inherit",
           stderr: "inherit",
         })
 
+        if (!proc.stdin) {
+          console.log(output)
+          return
+        }
+
         proc.stdin.write(output)
         proc.stdin.end()
         await proc.exited

+ 9 - 4
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
 import { tmpdir } from "os"
 import path from "path"
 import { Filesystem } from "../../../../util/filesystem"
+import { Process } from "../../../../util/process"
 
 /**
  * Writes text to clipboard via OSC 52 escape sequence.
@@ -87,7 +88,8 @@ export namespace Clipboard {
       if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
         console.log("clipboard: using wl-copy")
         return async (text: string) => {
-          const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
+          const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
+          if (!proc.stdin) return
           proc.stdin.write(text)
           proc.stdin.end()
           await proc.exited.catch(() => {})
@@ -96,11 +98,12 @@ export namespace Clipboard {
       if (Bun.which("xclip")) {
         console.log("clipboard: using xclip")
         return async (text: string) => {
-          const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
+          const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
             stdin: "pipe",
             stdout: "ignore",
             stderr: "ignore",
           })
+          if (!proc.stdin) return
           proc.stdin.write(text)
           proc.stdin.end()
           await proc.exited.catch(() => {})
@@ -109,11 +112,12 @@ export namespace Clipboard {
       if (Bun.which("xsel")) {
         console.log("clipboard: using xsel")
         return async (text: string) => {
-          const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
+          const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
             stdin: "pipe",
             stdout: "ignore",
             stderr: "ignore",
           })
+          if (!proc.stdin) return
           proc.stdin.write(text)
           proc.stdin.end()
           await proc.exited.catch(() => {})
@@ -125,7 +129,7 @@ export namespace Clipboard {
       console.log("clipboard: using powershell")
       return async (text: string) => {
         // Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
-        const proc = Bun.spawn(
+        const proc = Process.spawn(
           [
             "powershell.exe",
             "-NonInteractive",
@@ -140,6 +144,7 @@ export namespace Clipboard {
           },
         )
 
+        if (!proc.stdin) return
         proc.stdin.write(text)
         proc.stdin.end()
         await proc.exited.catch(() => {})

+ 2 - 2
packages/opencode/src/cli/cmd/tui/util/editor.ts

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
 import { join } from "node:path"
 import { CliRenderer } from "@opentui/core"
 import { Filesystem } from "@/util/filesystem"
+import { Process } from "@/util/process"
 
 export namespace Editor {
   export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -17,8 +18,7 @@ export namespace Editor {
     opts.renderer.suspend()
     opts.renderer.currentRenderBuffer.clear()
     const parts = editor.split(" ")
-    const proc = Bun.spawn({
-      cmd: [...parts, filepath],
+    const proc = Process.spawn([...parts, filepath], {
       stdin: "inherit",
       stdout: "inherit",
       stderr: "inherit",

+ 27 - 30
packages/opencode/src/file/ripgrep.ts

@@ -7,6 +7,8 @@ import { NamedError } from "@opencode-ai/util/error"
 import { lazy } from "../util/lazy"
 import { $ } from "bun"
 import { Filesystem } from "../util/filesystem"
+import { Process } from "../util/process"
+import { text } from "node:stream/consumers"
 
 import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
 import { Log } from "@/util/log"
@@ -153,17 +155,19 @@ export namespace Ripgrep {
         if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
         if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
 
-        const proc = Bun.spawn(args, {
+        const proc = Process.spawn(args, {
           cwd: Global.Path.bin,
           stderr: "pipe",
           stdout: "pipe",
         })
-        await proc.exited
-        if (proc.exitCode !== 0)
+        const exit = await proc.exited
+        if (exit !== 0) {
+          const stderr = proc.stderr ? await text(proc.stderr) : ""
           throw new ExtractionFailedError({
             filepath,
-            stderr: await Bun.readableStreamToText(proc.stderr),
+            stderr,
           })
+        }
       }
       if (config.extension === "zip") {
         const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
@@ -227,8 +231,7 @@ export namespace Ripgrep {
       }
     }
 
-    // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist.
-    // See https://github.com/oven-sh/bun/issues/24012
+    // Guard against invalid cwd to provide a consistent ENOENT error.
     if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
       throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
         code: "ENOENT",
@@ -237,41 +240,35 @@ export namespace Ripgrep {
       })
     }
 
-    const proc = Bun.spawn(args, {
+    const proc = Process.spawn(args, {
       cwd: input.cwd,
       stdout: "pipe",
       stderr: "ignore",
-      maxBuffer: 1024 * 1024 * 20,
-      signal: input.signal,
+      abort: input.signal,
     })
 
-    const reader = proc.stdout.getReader()
-    const decoder = new TextDecoder()
-    let buffer = ""
-
-    try {
-      while (true) {
-        input.signal?.throwIfAborted()
+    if (!proc.stdout) {
+      throw new Error("Process output not available")
+    }
 
-        const { done, value } = await reader.read()
-        if (done) break
+    let buffer = ""
+    const stream = proc.stdout as AsyncIterable<Buffer | string>
+    for await (const chunk of stream) {
+      input.signal?.throwIfAborted()
 
-        buffer += decoder.decode(value, { stream: true })
-        // Handle both Unix (\n) and Windows (\r\n) line endings
-        const lines = buffer.split(/\r?\n/)
-        buffer = lines.pop() || ""
+      buffer += typeof chunk === "string" ? chunk : chunk.toString()
+      // Handle both Unix (\n) and Windows (\r\n) line endings
+      const lines = buffer.split(/\r?\n/)
+      buffer = lines.pop() || ""
 
-        for (const line of lines) {
-          if (line) yield line
-        }
+      for (const line of lines) {
+        if (line) yield line
       }
-
-      if (buffer) yield buffer
-    } finally {
-      reader.releaseLock()
-      await proc.exited
     }
 
+    if (buffer) yield buffer
+    await proc.exited
+
     input.signal?.throwIfAborted()
   }
 

+ 6 - 4
packages/opencode/src/format/formatter.ts

@@ -1,7 +1,8 @@
-import { readableStreamToText } from "bun"
+import { text } from "node:stream/consumers"
 import { BunProc } from "../bun"
 import { Instance } from "../project/instance"
 import { Filesystem } from "../util/filesystem"
+import { Process } from "../util/process"
 import { Flag } from "@/flag/flag"
 
 export interface Info {
@@ -213,12 +214,13 @@ export const rlang: Info = {
     if (airPath == null) return false
 
     try {
-      const proc = Bun.spawn(["air", "--help"], {
+      const proc = Process.spawn(["air", "--help"], {
         stdout: "pipe",
         stderr: "pipe",
       })
       await proc.exited
-      const output = await readableStreamToText(proc.stdout)
+      if (!proc.stdout) return false
+      const output = await text(proc.stdout)
 
       // Check for "Air: An R language server and formatter"
       const firstLine = output.split("\n")[0]
@@ -238,7 +240,7 @@ export const uvformat: Info = {
   async enabled() {
     if (await ruff.enabled()) return false
     if (Bun.which("uv") !== null) {
-      const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
+      const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
       const code = await proc.exited
       return code === 0
     }

+ 10 - 7
packages/opencode/src/format/index.ts

@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
 import { Config } from "../config/config"
 import { mergeDeep } from "remeda"
 import { Instance } from "../project/instance"
+import { Process } from "../util/process"
 
 export namespace Format {
   const log = Log.create({ service: "format" })
@@ -110,13 +111,15 @@ export namespace Format {
       for (const item of await getFormatter(ext)) {
         log.info("running", { command: item.command })
         try {
-          const proc = Bun.spawn({
-            cmd: item.command.map((x) => x.replace("$FILE", file)),
-            cwd: Instance.directory,
-            env: { ...process.env, ...item.environment },
-            stdout: "ignore",
-            stderr: "ignore",
-          })
+          const proc = Process.spawn(
+            item.command.map((x) => x.replace("$FILE", file)),
+            {
+              cwd: Instance.directory,
+              env: { ...process.env, ...item.environment },
+              stdout: "ignore",
+              stderr: "ignore",
+            },
+          )
           const exit = await proc.exited
           if (exit !== 0)
             log.error("failed", {

+ 24 - 24
packages/opencode/src/lsp/server.ts

@@ -4,12 +4,14 @@ import os from "os"
 import { Global } from "../global"
 import { Log } from "../util/log"
 import { BunProc } from "../bun"
-import { $, readableStreamToText } from "bun"
+import { $ } from "bun"
+import { text } from "node:stream/consumers"
 import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
 import { Flag } from "../flag/flag"
 import { Archive } from "../util/archive"
+import { Process } from "../util/process"
 
 export namespace LSPServer {
   const log = Log.create({ service: "lsp.server" })
@@ -133,7 +135,7 @@ export namespace LSPServer {
         )
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
+          await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -263,14 +265,16 @@ export namespace LSPServer {
       }
 
       if (lintBin) {
-        const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" })
+        const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
         await proc.exited
-        const help = await readableStreamToText(proc.stdout)
-        if (help.includes("--lsp")) {
-          return {
-            process: spawn(lintBin, ["--lsp"], {
-              cwd: root,
-            }),
+        if (proc.stdout) {
+          const help = await text(proc.stdout)
+          if (help.includes("--lsp")) {
+            return {
+              process: spawn(lintBin, ["--lsp"], {
+                cwd: root,
+              }),
+            }
           }
         }
       }
@@ -372,8 +376,7 @@ export namespace LSPServer {
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
 
         log.info("installing gopls")
-        const proc = Bun.spawn({
-          cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
+        const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
           env: { ...process.env, GOBIN: Global.Path.bin },
           stdout: "pipe",
           stderr: "pipe",
@@ -414,8 +417,7 @@ export namespace LSPServer {
         }
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
         log.info("installing rubocop")
-        const proc = Bun.spawn({
-          cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
+        const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
           stdout: "pipe",
           stderr: "pipe",
           stdin: "pipe",
@@ -513,7 +515,7 @@ export namespace LSPServer {
         const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "pyright"], {
+          await Process.spawn([BunProc.which(), "install", "pyright"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -746,8 +748,7 @@ export namespace LSPServer {
 
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
         log.info("installing csharp-ls via dotnet tool")
-        const proc = Bun.spawn({
-          cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
+        const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
           stdout: "pipe",
           stderr: "pipe",
           stdin: "pipe",
@@ -786,8 +787,7 @@ export namespace LSPServer {
 
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
         log.info("installing fsautocomplete via dotnet tool")
-        const proc = Bun.spawn({
-          cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
+        const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
           stdout: "pipe",
           stderr: "pipe",
           stdin: "pipe",
@@ -1047,7 +1047,7 @@ export namespace LSPServer {
         const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
+          await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -1094,7 +1094,7 @@ export namespace LSPServer {
         const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
+          await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -1339,7 +1339,7 @@ export namespace LSPServer {
         const exists = await Filesystem.exists(js)
         if (!exists) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
+          await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -1518,7 +1518,7 @@ export namespace LSPServer {
         const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "intelephense"], {
+          await Process.spawn([BunProc.which(), "install", "intelephense"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -1615,7 +1615,7 @@ export namespace LSPServer {
         const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
+          await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,
@@ -1827,7 +1827,7 @@ export namespace LSPServer {
         const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
         if (!(await Filesystem.exists(js))) {
           if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
+          await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
             cwd: Global.Path.bin,
             env: {
               ...process.env,

+ 10 - 4
packages/opencode/src/tool/grep.ts

@@ -1,7 +1,9 @@
 import z from "zod"
+import { text } from "node:stream/consumers"
 import { Tool } from "./tool"
 import { Filesystem } from "../util/filesystem"
 import { Ripgrep } from "../file/ripgrep"
+import { Process } from "../util/process"
 
 import DESCRIPTION from "./grep.txt"
 import { Instance } from "../project/instance"
@@ -44,14 +46,18 @@ export const GrepTool = Tool.define("grep", {
     }
     args.push(searchPath)
 
-    const proc = Bun.spawn([rgPath, ...args], {
+    const proc = Process.spawn([rgPath, ...args], {
       stdout: "pipe",
       stderr: "pipe",
-      signal: ctx.abort,
+      abort: ctx.abort,
     })
 
-    const output = await new Response(proc.stdout).text()
-    const errorOutput = await new Response(proc.stderr).text()
+    if (!proc.stdout || !proc.stderr) {
+      throw new Error("Process output not available")
+    }
+
+    const output = await text(proc.stdout)
+    const errorOutput = await text(proc.stderr)
     const exitCode = await proc.exited
 
     // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)

+ 11 - 12
packages/opencode/src/util/git.ts

@@ -1,5 +1,7 @@
 import { $ } from "bun"
+import { buffer } from "node:stream/consumers"
 import { Flag } from "../flag/flag"
+import { Process } from "./process"
 
 export interface GitResult {
   exitCode: number
@@ -14,12 +16,12 @@ export interface GitResult {
  * Uses Bun's lightweight `$` shell by default.  When the process is running
  * as an ACP client, child processes inherit the parent's stdin pipe which
  * carries protocol data – on Windows this causes git to deadlock.  In that
- * case we fall back to `Bun.spawn` with `stdin: "ignore"`.
+ * case we fall back to `Process.spawn` with `stdin: "ignore"`.
  */
 export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
   if (Flag.OPENCODE_CLIENT === "acp") {
     try {
-      const proc = Bun.spawn(["git", ...args], {
+      const proc = Process.spawn(["git", ...args], {
         stdin: "ignore",
         stdout: "pipe",
         stderr: "pipe",
@@ -27,18 +29,15 @@ export async function git(args: string[], opts: { cwd: string; env?: Record<stri
         env: opts.env ? { ...process.env, ...opts.env } : process.env,
       })
       // Read output concurrently with exit to avoid pipe buffer deadlock
-      const [exitCode, stdout, stderr] = await Promise.all([
-        proc.exited,
-        new Response(proc.stdout).arrayBuffer(),
-        new Response(proc.stderr).arrayBuffer(),
-      ])
-      const stdoutBuf = Buffer.from(stdout)
-      const stderrBuf = Buffer.from(stderr)
+      if (!proc.stdout || !proc.stderr) {
+        throw new Error("Process output not available")
+      }
+      const [exitCode, out, err] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
       return {
         exitCode,
-        text: () => stdoutBuf.toString(),
-        stdout: stdoutBuf,
-        stderr: stderrBuf,
+        text: () => out.toString(),
+        stdout: out,
+        stderr: err,
       }
     } catch (error) {
       const stderr = Buffer.from(error instanceof Error ? error.message : String(error))

+ 71 - 0
packages/opencode/src/util/process.ts

@@ -0,0 +1,71 @@
+import { spawn as launch, type ChildProcess } from "child_process"
+
+export namespace Process {
+  export type Stdio = "inherit" | "pipe" | "ignore"
+
+  export interface Options {
+    cwd?: string
+    env?: NodeJS.ProcessEnv | null
+    stdin?: Stdio
+    stdout?: Stdio
+    stderr?: Stdio
+    abort?: AbortSignal
+    kill?: NodeJS.Signals | number
+    timeout?: number
+  }
+
+  export type Child = ChildProcess & { exited: Promise<number> }
+
+  export function spawn(cmd: string[], options: Options = {}): Child {
+    if (cmd.length === 0) throw new Error("Command is required")
+    options.abort?.throwIfAborted()
+
+    const proc = launch(cmd[0], cmd.slice(1), {
+      cwd: options.cwd,
+      env: options.env === null ? {} : options.env ? { ...process.env, ...options.env } : undefined,
+      stdio: [options.stdin ?? "ignore", options.stdout ?? "ignore", options.stderr ?? "ignore"],
+    })
+
+    let aborted = false
+    let timer: ReturnType<typeof setTimeout> | undefined
+
+    const abort = () => {
+      if (aborted) return
+      if (proc.exitCode !== null || proc.signalCode !== null) return
+      aborted = true
+
+      proc.kill(options.kill ?? "SIGTERM")
+
+      const timeout = options.timeout ?? 5_000
+      if (timeout <= 0) return
+
+      timer = setTimeout(() => {
+        proc.kill("SIGKILL")
+      }, timeout)
+    }
+
+    const exited = new Promise<number>((resolve, reject) => {
+      const done = () => {
+        options.abort?.removeEventListener("abort", abort)
+        if (timer) clearTimeout(timer)
+      }
+      proc.once("exit", (exitCode, signal) => {
+        done()
+        resolve(exitCode ?? (signal ? 1 : 0))
+      })
+      proc.once("error", (error) => {
+        done()
+        reject(error)
+      })
+    })
+
+    if (options.abort) {
+      options.abort.addEventListener("abort", abort, { once: true })
+      if (options.abort.aborted) abort()
+    }
+
+    const child = proc as Child
+    child.exited = exited
+    return child
+  }
+}