summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock10
-rw-r--r--package.json2
-rw-r--r--packages/opencode/package.json4
-rw-r--r--packages/opencode/src/util/process.ts4
-rw-r--r--packages/sdk/js/package.json9
-rw-r--r--packages/sdk/js/src/process.ts31
-rw-r--r--packages/sdk/js/src/server.ts39
-rw-r--r--packages/sdk/js/src/v2/server.ts39
8 files changed, 103 insertions, 35 deletions
diff --git a/bun.lock b/bun.lock
index 1a16a3769..88de0e4b8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -355,7 +355,7 @@
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
- "cross-spawn": "^7.0.6",
+ "cross-spawn": "catalog:",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "catalog:",
@@ -410,7 +410,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
- "@types/cross-spawn": "6.0.6",
+ "@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
@@ -463,9 +463,13 @@
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.13",
+ "dependencies": {
+ "cross-spawn": "catalog:",
+ },
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
+ "@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
@@ -634,11 +638,13 @@
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.11",
+ "@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.138",
+ "cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
diff --git a/package.json b/package.json
index cc2d3f4c2..fc73a94d2 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"catalog": {
"@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11",
+ "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -47,6 +48,7 @@
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.43",
"ai": "6.0.138",
+ "cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 173bec6e8..b64cc1922 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -51,7 +51,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
- "@types/cross-spawn": "6.0.6",
+ "@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
@@ -118,7 +118,7 @@
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
- "cross-spawn": "^7.0.6",
+ "cross-spawn": "catalog:",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "catalog:",
diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts
index 1230ed323..e45ceb471 100644
--- a/packages/opencode/src/util/process.ts
+++ b/packages/opencode/src/util/process.ts
@@ -144,7 +144,11 @@ export namespace Process {
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
+ // Duplicated in `packages/sdk/js/src/process.ts` because the SDK cannot import
+ // `opencode` without creating a cycle. Keep both copies in sync.
export async function stop(proc: ChildProcess) {
+ if (proc.exitCode !== null || proc.signalCode !== null) return
+
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 231e8d9da..ed4e47384 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -23,9 +23,12 @@
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
+ "@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
- "typescript": "catalog:",
- "@typescript/native-preview": "catalog:"
+ "@typescript/native-preview": "catalog:",
+ "typescript": "catalog:"
},
- "dependencies": {}
+ "dependencies": {
+ "cross-spawn": "catalog:"
+ }
}
diff --git a/packages/sdk/js/src/process.ts b/packages/sdk/js/src/process.ts
new file mode 100644
index 000000000..3111b424a
--- /dev/null
+++ b/packages/sdk/js/src/process.ts
@@ -0,0 +1,31 @@
+import { type ChildProcess, spawnSync } from "node:child_process"
+
+// Duplicated from `packages/opencode/src/util/process.ts` because the SDK cannot
+// import `opencode` without creating a cycle (`opencode` depends on `@opencode-ai/sdk`).
+export function stop(proc: ChildProcess) {
+ if (proc.exitCode !== null || proc.signalCode !== null) return
+ if (process.platform === "win32" && proc.pid) {
+ const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true })
+ if (!out.error && out.status === 0) return
+ }
+ proc.kill()
+}
+
+export function bindAbort(proc: ChildProcess, signal?: AbortSignal, onAbort?: () => void) {
+ if (!signal) return () => {}
+ const abort = () => {
+ clear()
+ stop(proc)
+ onAbort?.()
+ }
+ const clear = () => {
+ signal.removeEventListener("abort", abort)
+ proc.off("exit", clear)
+ proc.off("error", clear)
+ }
+ signal.addEventListener("abort", abort, { once: true })
+ proc.on("exit", clear)
+ proc.on("error", clear)
+ if (signal.aborted) abort()
+ return clear
+}
diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts
index 174131ccf..2d1ab29fc 100644
--- a/packages/sdk/js/src/server.ts
+++ b/packages/sdk/js/src/server.ts
@@ -1,5 +1,6 @@
-import { spawn } from "node:child_process"
+import launch from "cross-spawn"
import { type Config } from "./gen/types.gen.js"
+import { stop, bindAbort } from "./process.js"
export type ServerOptions = {
hostname?: string
@@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) {
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
- const proc = spawn(`opencode`, args, {
- signal: options.signal,
+ const proc = launch(`opencode`, args, {
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
})
+ let clear = () => {}
const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
+ clear()
+ stop(proc)
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
}, options.timeout)
let output = ""
+ let resolved = false
proc.stdout?.on("data", (chunk) => {
+ if (resolved) return
output += chunk.toString()
const lines = output.split("\n")
for (const line of lines) {
if (line.startsWith("opencode server listening")) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
if (!match) {
- throw new Error(`Failed to parse server url from output: ${line}`)
+ clear()
+ stop(proc)
+ clearTimeout(id)
+ reject(new Error(`Failed to parse server url from output: ${line}`))
+ return
}
clearTimeout(id)
+ resolved = true
resolve(match[1]!)
return
}
@@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) {
clearTimeout(id)
reject(error)
})
- if (options.signal) {
- options.signal.addEventListener("abort", () => {
- clearTimeout(id)
- reject(new Error("Aborted"))
- })
- }
+ clear = bindAbort(proc, options.signal, () => {
+ clearTimeout(id)
+ reject(options.signal?.reason)
+ })
})
return {
url,
close() {
- proc.kill()
+ clear()
+ stop(proc)
},
}
}
@@ -106,8 +115,7 @@ export function createOpencodeTui(options?: TuiOptions) {
args.push(`--agent=${options.agent}`)
}
- const proc = spawn(`opencode`, args, {
- signal: options?.signal,
+ const proc = launch(`opencode`, args, {
stdio: "inherit",
env: {
...process.env,
@@ -115,9 +123,12 @@ export function createOpencodeTui(options?: TuiOptions) {
},
})
+ const clear = bindAbort(proc, options?.signal)
+
return {
close() {
- proc.kill()
+ clear()
+ stop(proc)
},
}
}
diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts
index 174131ccf..48f1a253d 100644
--- a/packages/sdk/js/src/v2/server.ts
+++ b/packages/sdk/js/src/v2/server.ts
@@ -1,5 +1,6 @@
-import { spawn } from "node:child_process"
+import launch from "cross-spawn"
import { type Config } from "./gen/types.gen.js"
+import { stop, bindAbort } from "../process.js"
export type ServerOptions = {
hostname?: string
@@ -31,29 +32,38 @@ export async function createOpencodeServer(options?: ServerOptions) {
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
- const proc = spawn(`opencode`, args, {
- signal: options.signal,
+ const proc = launch(`opencode`, args, {
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
})
+ let clear = () => {}
const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
+ clear()
+ stop(proc)
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
}, options.timeout)
let output = ""
+ let resolved = false
proc.stdout?.on("data", (chunk) => {
+ if (resolved) return
output += chunk.toString()
const lines = output.split("\n")
for (const line of lines) {
if (line.startsWith("opencode server listening")) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
if (!match) {
- throw new Error(`Failed to parse server url from output: ${line}`)
+ clear()
+ stop(proc)
+ clearTimeout(id)
+ reject(new Error(`Failed to parse server url from output: ${line}`))
+ return
}
clearTimeout(id)
+ resolved = true
resolve(match[1]!)
return
}
@@ -74,18 +84,17 @@ export async function createOpencodeServer(options?: ServerOptions) {
clearTimeout(id)
reject(error)
})
- if (options.signal) {
- options.signal.addEventListener("abort", () => {
- clearTimeout(id)
- reject(new Error("Aborted"))
- })
- }
+ clear = bindAbort(proc, options.signal, () => {
+ clearTimeout(id)
+ reject(options.signal?.reason)
+ })
})
return {
url,
close() {
- proc.kill()
+ clear()
+ stop(proc)
},
}
}
@@ -106,8 +115,7 @@ export function createOpencodeTui(options?: TuiOptions) {
args.push(`--agent=${options.agent}`)
}
- const proc = spawn(`opencode`, args, {
- signal: options?.signal,
+ const proc = launch(`opencode`, args, {
stdio: "inherit",
env: {
...process.env,
@@ -115,9 +123,12 @@ export function createOpencodeTui(options?: TuiOptions) {
},
})
+ const clear = bindAbort(proc, options?.signal)
+
return {
close() {
- proc.kill()
+ clear()
+ stop(proc)
},
}
}