From 6f5b2f786e98dd3245168caf208da46cce821fd3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:46:09 -0500 Subject: [PATCH 1/2] wip: node-pty --- bun.lock | 6 ++++- package.json | 2 ++ packages/opencode/package.json | 7 ++++++ packages/opencode/script/build-node.ts | 2 +- packages/opencode/script/fix-node-pty.ts | 28 +++++++++++++++++++++++ packages/opencode/src/file/watcher.ts | 13 +++++++---- packages/opencode/src/lsp/client.ts | 3 ++- packages/opencode/src/project/instance.ts | 7 ++++++ packages/opencode/src/pty/index.ts | 24 +++++++++---------- packages/opencode/src/pty/pty.bun.ts | 26 +++++++++++++++++++++ packages/opencode/src/pty/pty.node.ts | 26 +++++++++++++++++++++ packages/opencode/src/pty/pty.ts | 25 ++++++++++++++++++++ packages/opencode/src/server/server.ts | 4 ++-- 13 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 packages/opencode/script/fix-node-pty.ts create mode 100644 packages/opencode/src/pty/pty.bun.ts create mode 100644 packages/opencode/src/pty/pty.node.ts create mode 100644 packages/opencode/src/pty/pty.ts 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/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 3797c162702..3bb478e1c95 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -48,6 +48,7 @@ export namespace FileWatcher { const state = Instance.state( async () => { log.info("init") + const dir = Instance.directory const cfg = await Config.get() const backend = (() => { if (process.platform === "win32") return "windows" @@ -65,11 +66,13 @@ export namespace FileWatcher { const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } + void Instance.run(dir, () => { + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) } const subs: ParcelWatcher.AsyncSubscription[] = [] diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 084ccf831ee..21364627b39 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -41,6 +41,7 @@ export namespace LSPClient { export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { const l = log.clone().tag("serverID", input.serverID) + const dir = Instance.directory l.info("starting client") const connection = createMessageConnection( @@ -58,7 +59,7 @@ export namespace LSPClient { const exists = diagnostics.has(filePath) diagnostics.set(filePath, params.diagnostics) if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + void Instance.run(dir, () => Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })) }) connection.onRequest("window/workDoneProgress/create", (params) => { l.info("window/workDoneProgress/create", params) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index df44a3a229c..56dc4a44a53 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -79,6 +79,13 @@ export const Instance = { return input.fn() }) }, + async run(directory: string, fn: () => R): Promise { + const existing = cache.get(Filesystem.resolve(directory)) + if (!existing) return + const ctx = await existing.catch(() => undefined) + if (!ctx) return + return context.provide(ctx, fn) + }, get directory() { return context.use().directory }, diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 791062aaff2..4096ee5c4a7 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 @@ -121,6 +118,7 @@ export namespace Pty { export async function create(input: CreateInput) { const id = Identifier.create("pty", false) + const dir = Instance.directory const command = input.command || Shell.preferred() const args = input.args || [] if (command.endsWith("sh")) { @@ -144,7 +142,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, @@ -197,11 +195,13 @@ export namespace Pty { session.bufferCursor += excess }) ptyProcess.onExit(({ exitCode }) => { - if (session.info.status === "exited") return - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - Bus.publish(Event.Exited, { id, exitCode }) - remove(id) + void Instance.run(dir, () => { + if (session.info.status === "exited") return + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + Bus.publish(Event.Exited, { id, exitCode }) + remove(id) + }) }) Bus.publish(Event.Created, { info }) return info 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 From 0a53f8e084d0cdf576b21c2c44c3c0c000c7bf85 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:56:16 -0500 Subject: [PATCH 2/2] chore: cleanup --- packages/opencode/src/file/watcher.ts | 13 +++++-------- packages/opencode/src/lsp/client.ts | 3 +-- packages/opencode/src/project/instance.ts | 7 ------- packages/opencode/src/pty/index.ts | 13 +++++-------- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 3bb478e1c95..3797c162702 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -48,7 +48,6 @@ export namespace FileWatcher { const state = Instance.state( async () => { log.info("init") - const dir = Instance.directory const cfg = await Config.get() const backend = (() => { if (process.platform === "win32") return "windows" @@ -66,13 +65,11 @@ export namespace FileWatcher { const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { if (err) return - void Instance.run(dir, () => { - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } - }) + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } } const subs: ParcelWatcher.AsyncSubscription[] = [] diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 21364627b39..084ccf831ee 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -41,7 +41,6 @@ export namespace LSPClient { export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { const l = log.clone().tag("serverID", input.serverID) - const dir = Instance.directory l.info("starting client") const connection = createMessageConnection( @@ -59,7 +58,7 @@ export namespace LSPClient { const exists = diagnostics.has(filePath) diagnostics.set(filePath, params.diagnostics) if (!exists && input.serverID === "typescript") return - void Instance.run(dir, () => Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })) + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) }) connection.onRequest("window/workDoneProgress/create", (params) => { l.info("window/workDoneProgress/create", params) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 56dc4a44a53..df44a3a229c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -79,13 +79,6 @@ export const Instance = { return input.fn() }) }, - async run(directory: string, fn: () => R): Promise { - const existing = cache.get(Filesystem.resolve(directory)) - if (!existing) return - const ctx = await existing.catch(() => undefined) - if (!ctx) return - return context.provide(ctx, fn) - }, get directory() { return context.use().directory }, diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 4096ee5c4a7..7014cb6f0c3 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -118,7 +118,6 @@ export namespace Pty { export async function create(input: CreateInput) { const id = Identifier.create("pty", false) - const dir = Instance.directory const command = input.command || Shell.preferred() const args = input.args || [] if (command.endsWith("sh")) { @@ -195,13 +194,11 @@ export namespace Pty { session.bufferCursor += excess }) ptyProcess.onExit(({ exitCode }) => { - void Instance.run(dir, () => { - if (session.info.status === "exited") return - log.info("session exited", { id, exitCode }) - session.info.status = "exited" - Bus.publish(Event.Exited, { id, exitCode }) - remove(id) - }) + if (session.info.status === "exited") return + log.info("session exited", { id, exitCode }) + session.info.status = "exited" + Bus.publish(Event.Exited, { id, exitCode }) + remove(id) }) Bus.publish(Event.Created, { info }) return info