wip: node-pty

This commit is contained in:
Adam 2026-03-11 13:46:09 -05:00
commit 6f5b2f786e
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
13 changed files with 151 additions and 22 deletions

View file

@ -365,6 +365,7 @@
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"node-pty": "1.1.0",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
@ -575,8 +576,9 @@
}, },
}, },
"trustedDependencies": [ "trustedDependencies": [
"electron",
"esbuild", "esbuild",
"node-pty",
"electron",
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
], ],
@ -3813,6 +3815,8 @@
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="], "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=="], "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=="], "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],

View file

@ -11,6 +11,7 @@
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"hello": "echo 'Hello World!'", "hello": "echo 'Hello World!'",
@ -98,6 +99,7 @@
}, },
"trustedDependencies": [ "trustedDependencies": [
"esbuild", "esbuild",
"node-pty",
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",

View file

@ -9,6 +9,7 @@
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000 registry", "test": "bun test --timeout 30000 registry",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.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'", "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", "clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
@ -30,6 +31,11 @@
"bun": "./src/storage/db.bun.ts", "bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts", "node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.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": { "devDependencies": {
@ -129,6 +135,7 @@
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"node-pty": "1.1.0",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",

View file

@ -45,7 +45,7 @@ await Bun.build({
entrypoints: ["./src/node.ts"], entrypoints: ["./src/node.ts"],
outdir: "./dist", outdir: "./dist",
format: "esm", format: "esm",
external: ["jsonc-parser"], external: ["jsonc-parser", "node-pty"],
define: { define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MIGRATIONS: JSON.stringify(migrations),
}, },

View file

@ -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"}`)
}
}

View file

@ -48,6 +48,7 @@ export namespace FileWatcher {
const state = Instance.state( const state = Instance.state(
async () => { async () => {
log.info("init") log.info("init")
const dir = Instance.directory
const cfg = await Config.get() const cfg = await Config.get()
const backend = (() => { const backend = (() => {
if (process.platform === "win32") return "windows" if (process.platform === "win32") return "windows"
@ -65,11 +66,13 @@ export namespace FileWatcher {
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => { const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
if (err) return if (err) return
for (const evt of evts) { void Instance.run(dir, () => {
if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) for (const evt of evts) {
if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) 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[] = [] const subs: ParcelWatcher.AsyncSubscription[] = []

View file

@ -41,6 +41,7 @@ export namespace LSPClient {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const l = log.clone().tag("serverID", input.serverID) const l = log.clone().tag("serverID", input.serverID)
const dir = Instance.directory
l.info("starting client") l.info("starting client")
const connection = createMessageConnection( const connection = createMessageConnection(
@ -58,7 +59,7 @@ export namespace LSPClient {
const exists = diagnostics.has(filePath) const exists = diagnostics.has(filePath)
diagnostics.set(filePath, params.diagnostics) diagnostics.set(filePath, params.diagnostics)
if (!exists && input.serverID === "typescript") return 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) => { connection.onRequest("window/workDoneProgress/create", (params) => {
l.info("window/workDoneProgress/create", params) l.info("window/workDoneProgress/create", params)

View file

@ -79,6 +79,13 @@ export const Instance = {
return input.fn() return input.fn()
}) })
}, },
async run<R>(directory: string, fn: () => R): Promise<R | undefined> {
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() { get directory() {
return context.use().directory return context.use().directory
}, },

View file

@ -1,6 +1,6 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { type IPty } from "bun-pty" import type { Proc } from "#pty"
import z from "zod" import z from "zod"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
import { Log } from "../util/log" import { Log } from "../util/log"
@ -35,10 +35,7 @@ export namespace Pty {
return out return out
} }
const pty = lazy(async () => { const pty = lazy(() => import("#pty"))
const { spawn } = await import("bun-pty")
return spawn
})
export const Info = z export const Info = z
.object({ .object({
@ -85,7 +82,7 @@ export namespace Pty {
interface ActiveSession { interface ActiveSession {
info: Info info: Info
process: IPty process: Proc
buffer: string buffer: string
bufferCursor: number bufferCursor: number
cursor: number cursor: number
@ -121,6 +118,7 @@ export namespace Pty {
export async function create(input: CreateInput) { export async function create(input: CreateInput) {
const id = Identifier.create("pty", false) const id = Identifier.create("pty", false)
const dir = Instance.directory
const command = input.command || Shell.preferred() const command = input.command || Shell.preferred()
const args = input.args || [] const args = input.args || []
if (command.endsWith("sh")) { if (command.endsWith("sh")) {
@ -144,7 +142,7 @@ export namespace Pty {
} }
log.info("creating session", { id, cmd: command, args, cwd }) log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty() const { spawn } = await pty()
const ptyProcess = spawn(command, args, { const ptyProcess = spawn(command, args, {
name: "xterm-256color", name: "xterm-256color",
cwd, cwd,
@ -197,11 +195,13 @@ export namespace Pty {
session.bufferCursor += excess session.bufferCursor += excess
}) })
ptyProcess.onExit(({ exitCode }) => { ptyProcess.onExit(({ exitCode }) => {
if (session.info.status === "exited") return void Instance.run(dir, () => {
log.info("session exited", { id, exitCode }) if (session.info.status === "exited") return
session.info.status = "exited" log.info("session exited", { id, exitCode })
Bus.publish(Event.Exited, { id, exitCode }) session.info.status = "exited"
remove(id) Bus.publish(Event.Exited, { id, exitCode })
remove(id)
})
}) })
Bus.publish(Event.Created, { info }) Bus.publish(Event.Created, { info })
return info return info

View file

@ -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)
},
}
}

View file

@ -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)
},
}
}

View file

@ -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<string, string>
}
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
}

View file

@ -25,7 +25,7 @@ import { WorkspaceContext } from "../control-plane/workspace-context"
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
import { ProjectRoutes } from "./routes/project" import { ProjectRoutes } from "./routes/project"
import { SessionRoutes } from "./routes/session" import { SessionRoutes } from "./routes/session"
// import { PtyRoutes } from "./routes/pty" import { PtyRoutes } from "./routes/pty"
import { McpRoutes } from "./routes/mcp" import { McpRoutes } from "./routes/mcp"
import { FileRoutes } from "./routes/file" import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config" 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) => { .all("/*", async (c) => {
const path = c.req.path const path = c.req.path