Explorar el Código

wip: node-pty

Adam hace 1 mes
padre
commit
6f5b2f786e

+ 5 - 1
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": ["[email protected]", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
 
+    "node-pty": ["[email protected]", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
+
     "node-releases": ["[email protected]", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
 
     "nopt": ["[email protected]", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],

+ 2 - 0
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",

+ 7 - 0
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",

+ 1 - 1
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),
   },

+ 28 - 0
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"}`)
+  }
+}

+ 8 - 5
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[] = []

+ 2 - 1
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)

+ 7 - 0
packages/opencode/src/project/instance.ts

@@ -79,6 +79,13 @@ export const Instance = {
       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() {
     return context.use().directory
   },

+ 12 - 12
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

+ 26 - 0
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)
+    },
+  }
+}

+ 26 - 0
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)
+    },
+  }
+}

+ 25 - 0
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<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
+}

+ 2 - 2
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