diff --git a/bun.lock b/bun.lock index 443626164e6..cc0cae83d77 100644 --- a/bun.lock +++ b/bun.lock @@ -365,6 +365,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "node-pty": "1.1.0", "open": "10.1.2", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", @@ -575,8 +576,9 @@ }, }, "trustedDependencies": [ - "electron", "esbuild", + "node-pty", + "electron", "web-tree-sitter", "tree-sitter-bash", ], @@ -3813,6 +3815,8 @@ "node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], diff --git a/package.json b/package.json index 015f3a6c3d6..7e75a708e5d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:web": "bun --cwd packages/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "typecheck": "bun turbo typecheck", + "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", @@ -98,6 +99,7 @@ }, "trustedDependencies": [ "esbuild", + "node-pty", "protobufjs", "tree-sitter", "tree-sitter-bash", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e3818189b27..1a274a2d7df 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -9,6 +9,7 @@ "typecheck": "tsgo --noEmit", "test": "bun test --timeout 30000 registry", "build": "bun run script/build.ts", + "fix-node-pty": "bun run script/fix-node-pty.ts", "dev": "bun run --conditions=browser ./src/index.ts", "random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'", "clean": "echo 'Cleaning up...' && rm -rf node_modules dist", @@ -30,6 +31,11 @@ "bun": "./src/storage/db.bun.ts", "node": "./src/storage/db.node.ts", "default": "./src/storage/db.bun.ts" + }, + "#pty": { + "bun": "./src/pty/pty.bun.ts", + "node": "./src/pty/pty.node.ts", + "default": "./src/pty/pty.bun.ts" } }, "devDependencies": { @@ -129,6 +135,7 @@ "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.0.3", + "node-pty": "1.1.0", "open": "10.1.2", "opentui-spinner": "0.0.6", "partial-json": "0.1.7", diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index 17bc86307af..1d3290b478e 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -45,7 +45,7 @@ await Bun.build({ entrypoints: ["./src/node.ts"], outdir: "./dist", format: "esm", - external: ["jsonc-parser"], + external: ["jsonc-parser", "node-pty"], define: { OPENCODE_MIGRATIONS: JSON.stringify(migrations), }, diff --git a/packages/opencode/script/fix-node-pty.ts b/packages/opencode/script/fix-node-pty.ts new file mode 100644 index 00000000000..93641adbdf3 --- /dev/null +++ b/packages/opencode/script/fix-node-pty.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env bun + +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const dir = path.resolve(__dirname, "..") + +if (process.platform !== "win32") { + const root = path.join(dir, "node_modules", "node-pty", "prebuilds") + const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => []) + const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper")) + const result = await Promise.all( + files.map(async (file) => { + const stat = await fs.stat(file).catch(() => undefined) + if (!stat) return + if ((stat.mode & 0o111) === 0o111) return + await fs.chmod(file, stat.mode | 0o755) + return file + }), + ) + const fixed = result.filter(Boolean) + if (fixed.length) { + console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`) + } +} diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 791062aaff2..7014cb6f0c3 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { type IPty } from "bun-pty" +import type { Proc } from "#pty" import z from "zod" import { Identifier } from "../id/id" import { Log } from "../util/log" @@ -35,10 +35,7 @@ export namespace Pty { return out } - const pty = lazy(async () => { - const { spawn } = await import("bun-pty") - return spawn - }) + const pty = lazy(() => import("#pty")) export const Info = z .object({ @@ -85,7 +82,7 @@ export namespace Pty { interface ActiveSession { info: Info - process: IPty + process: Proc buffer: string bufferCursor: number cursor: number @@ -144,7 +141,7 @@ export namespace Pty { } log.info("creating session", { id, cmd: command, args, cwd }) - const spawn = await pty() + const { spawn } = await pty() const ptyProcess = spawn(command, args, { name: "xterm-256color", cwd, diff --git a/packages/opencode/src/pty/pty.bun.ts b/packages/opencode/src/pty/pty.bun.ts new file mode 100644 index 00000000000..1f8ce8e4546 --- /dev/null +++ b/packages/opencode/src/pty/pty.bun.ts @@ -0,0 +1,26 @@ +import { spawn as create } from "bun-pty" +import type { Opts, Proc } from "./pty" + +export type { Disp, Exit, Opts, Proc } from "./pty" + +export function spawn(file: string, args: string[], opts: Opts): Proc { + const pty = create(file, args, opts) + return { + pid: pty.pid, + onData(listener) { + return pty.onData(listener) + }, + onExit(listener) { + return pty.onExit(listener) + }, + write(data) { + pty.write(data) + }, + resize(cols, rows) { + pty.resize(cols, rows) + }, + kill(signal) { + pty.kill(signal) + }, + } +} diff --git a/packages/opencode/src/pty/pty.node.ts b/packages/opencode/src/pty/pty.node.ts new file mode 100644 index 00000000000..5256fa061fb --- /dev/null +++ b/packages/opencode/src/pty/pty.node.ts @@ -0,0 +1,26 @@ +import * as pty from "node-pty" +import type { Opts, Proc } from "./pty" + +export type { Disp, Exit, Opts, Proc } from "./pty" + +export function spawn(file: string, args: string[], opts: Opts): Proc { + const proc = pty.spawn(file, args, opts) + return { + pid: proc.pid, + onData(listener) { + return proc.onData(listener) + }, + onExit(listener) { + return proc.onExit(listener) + }, + write(data) { + proc.write(data) + }, + resize(cols, rows) { + proc.resize(cols, rows) + }, + kill(signal) { + proc.kill(signal) + }, + } +} diff --git a/packages/opencode/src/pty/pty.ts b/packages/opencode/src/pty/pty.ts new file mode 100644 index 00000000000..fbd1710e52a --- /dev/null +++ b/packages/opencode/src/pty/pty.ts @@ -0,0 +1,25 @@ +export type Disp = { + dispose(): void +} + +export type Exit = { + exitCode: number + signal?: number | string +} + +export type Opts = { + name: string + cols?: number + rows?: number + cwd?: string + env?: Record +} + +export type Proc = { + pid: number + onData(listener: (data: string) => void): Disp + onExit(listener: (event: Exit) => void): Disp + write(data: string): void + resize(cols: number, rows: number): void + kill(signal?: string): void +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ab7394daa1a..601dc85a8fb 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -25,7 +25,7 @@ import { WorkspaceContext } from "../control-plane/workspace-context" import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" -// import { PtyRoutes } from "./routes/pty" +import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -559,7 +559,7 @@ export namespace Server { }) }, ) - // .route("/pty", PtyRoutes(ws.upgradeWebSocket)) + .route("/pty", PtyRoutes(ws.upgradeWebSocket)) .all("/*", async (c) => { const path = c.req.path