2
0
Эх сурвалжийг харах

refactor: lsp server and core improvements

Dax Raad 1 сар өмнө
parent
commit
656fa191c1

+ 4 - 2
.opencode/.gitignore

@@ -1,3 +1,5 @@
-plans/
-bun.lock
+node_modules
 package.json
+bun.lock
+.gitignore
+package-lock.json

+ 209 - 0
.opencode/plans/1771135996918-mighty-wizard.md

@@ -0,0 +1,209 @@
+# Plan: Project References Feature
+
+Add support for referencing external git repositories in `opencode.json` that can be queried via a dedicated subagent.
+
+## Overview
+
+Users can specify references in config:
+
+```json
+{
+  "references": ["[email protected]:Effect-TS/effect.git", "[email protected]:foo/bar.git#v1.0.0", "/local/path/to/repo"]
+}
+```
+
+These get cloned/cached to `~/.cache/opencode/references/` and a `reference` subagent can search across them.
+
+---
+
+## Files to Create
+
+### `src/reference/index.ts`
+
+Core reference management module with:
+
+- `parse(url: string): Reference` - Parse git URL or local path, extract branch if present (`#branch` suffix)
+- `cachePath(ref: Reference): string` - SHA-256 hash of URL for deterministic directory names
+- `isStale(ref: Reference): Promise<boolean>` - Check if `FETCH_HEAD` mtime > 1 hour old
+- `fetch(ref: Reference): Promise<void>` - Clone with `--depth 1` or `git fetch`, handle branch checkout
+- `ensureFresh(ref: Reference): Promise<void>` - Lazy fetch wrapper (skip if not stale)
+- `list(): Promise<Reference[]>` - Get references from config
+- `directories(): Promise<string[]>` - Get all cached reference paths for permissions
+
+Types:
+
+```ts
+interface Reference {
+  url: string // Original URL/path
+  path: string // Cache directory (for git) or resolved path (for local)
+  branch?: string // Optional branch/tag from URL fragment
+  type: "git" | "local"
+}
+```
+
+URL parsing supports:
+
+- `[email protected]:foo/bar.git` → git
+- `[email protected]:foo/bar.git#main` → git with branch
+- `https://github.com/foo/bar.git#v1.0.0` → git with tag
+- `/absolute/path` → local
+- `~/relative/path` → local (expanded)
+
+### `src/agent/prompt/reference.txt`
+
+Prompt for reference subagent:
+
+```
+You are a multi-project code search specialist. You search across referenced projects
+to answer questions about external codebases.
+
+Available references: {references}
+
+Guidelines:
+- Search across ALL referenced projects unless the user specifies one
+- Report which project(s) contained relevant findings
+- Use Glob for file patterns, Grep for content search, Read for specific files
+- Return absolute paths so users can locate findings
+- Do not modify any files or run destructive commands
+```
+
+---
+
+## Files to Modify
+
+### `src/config/config.ts`
+
+Add `references` field to `Config.Info` schema (around line 1174):
+
+```ts
+references: z.array(z.string()).optional().describe(
+  "Git repositories or local paths to reference from subagents"
+),
+```
+
+### `src/global/index.ts`
+
+Add reference cache path and create directory:
+
+```ts
+// Add to Path object:
+reference: path.join(cache, "references"),
+
+// Add to mkdir promise array:
+fs.mkdir(path.join(cache, "references"), { recursive: true }),
+```
+
+### `src/agent/agent.ts`
+
+Add `reference` agent to built-in agents (after `explore` around line 155):
+
+```ts
+reference: {
+  name: "reference",
+  description: `Search across referenced projects configured in opencode.json under "references". Use this to query code in external repositories.`,
+  permission: PermissionNext.merge(
+    defaults,
+    PermissionNext.fromConfig({
+      "*": "deny",
+      grep: "allow",
+      glob: "allow",
+      list: "allow",
+      bash: "allow",
+      webfetch: "allow",
+      websearch: "allow",
+      codesearch: "allow",
+      read: "allow",
+      lsp: "allow",
+      external_directory: {
+        [Truncate.GLOB]: "allow",
+        // Reference paths added dynamically via Reference.directories()
+      },
+    }),
+    user,
+  ),
+  prompt: PROMPT_REFERENCE,
+  options: {},
+  mode: "subagent",
+  native: true,
+},
+```
+
+Need to inject reference paths into `external_directory` permissions. This requires:
+
+1. Loading references via `Reference.list()`
+2. Ensuring they're fresh via `Reference.ensureFresh()`
+3. Adding their paths to `external_directory` permissions
+
+**Challenge**: The agent definition is loaded once at startup, but references may change. Solution: Inject reference paths dynamically in the Task tool when creating the subagent session.
+
+### `src/tool/task.ts`
+
+When creating session for `reference` subagent (around line 72):
+
+```ts
+// Before Session.create:
+let extraPermissions = []
+if (params.subagent_type === "reference") {
+  const refs = await Reference.list()
+  await Promise.all(refs.map((r) => Reference.ensureFresh(r)))
+  const paths = refs.map((r) => path.join(r.path, "*"))
+  extraPermissions = paths.map((p) => ({
+    permission: "external_directory" as const,
+    pattern: p,
+    action: "allow" as const,
+  }))
+}
+
+// Add extraPermissions to the permission array in Session.create
+```
+
+Also need to inject available references into the prompt. Modify `SessionPrompt.resolvePromptParts` or add a way to inject context.
+
+### `src/session/prompt.ts` (or similar)
+
+Inject reference list into reference agent's system prompt:
+
+```ts
+// When agent.name === "reference", prepend reference info to prompt
+const refs = await Reference.list()
+const refList = refs.map((r) => `- ${r.url} at ${r.path}`).join("\n")
+// Inject into prompt or parts
+```
+
+---
+
+## Edge Cases
+
+1. **Clone failures**: Log warning, continue with other references. Don't block subagent.
+2. **Network failures**: Use cached version if exists, even if stale. Only error if never cloned.
+3. **Branch not found**: Log error, skip that reference.
+4. **Local path missing**: Log warning, skip reference.
+5. **Invalid URL format**: Log warning, skip reference.
+6. **Shallow clone**: Use `--depth 1` for faster clones. For branch-specific URLs, use `--branch <ref> --depth 1`.
+
+---
+
+## Verification
+
+1. Add to `opencode.json`: `{"references": ["[email protected]:effect-ts/effect.git"]}`
+2. Invoke `@reference` with query like "show me the Effect schema module"
+3. Verify repo cloned to `~/.cache/opencode/references/<hash>/`
+4. Verify subagent can search/read files in the cloned repo
+5. Wait 1+ hour, invoke again, verify fetch happens
+6. Test with local path: `{"references": ["/path/to/local/repo"]}`
+7. Test with branch: `{"references": ["[email protected]:foo/bar.git#main"]}`
+8. Verify error handling with invalid URL
+
+---
+
+## Files Summary
+
+| File                             | Action                                      |
+| -------------------------------- | ------------------------------------------- |
+| `src/reference/index.ts`         | Create                                      |
+| `src/agent/prompt/reference.txt` | Create                                      |
+| `src/config/config.ts`           | Add `references` to schema                  |
+| `src/global/index.ts`            | Add `reference` path                        |
+| `src/agent/agent.ts`             | Add `reference` agent                       |
+| `src/tool/task.ts`               | Inject ref permissions + paths into session |
+| `src/session/prompt.ts`          | Inject reference list into system prompt    |

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 8 - 9
bun.lock


+ 3 - 1
packages/opencode/package.json

@@ -42,10 +42,11 @@
     "@types/babel__core": "7.20.5",
     "@types/bun": "catalog:",
     "@types/mime-types": "3.0.1",
+    "@types/npmcli__arborist": "6.3.3",
     "@types/semver": "^7.5.8",
     "@types/turndown": "5.0.5",
-    "@types/yargs": "17.0.33",
     "@types/which": "3.0.4",
+    "@types/yargs": "17.0.33",
     "@typescript/native-preview": "catalog:",
     "drizzle-kit": "1.0.0-beta.16-ea816b6",
     "drizzle-orm": "1.0.0-beta.16-ea816b6",
@@ -84,6 +85,7 @@
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
     "@modelcontextprotocol/sdk": "1.25.2",
+    "@npmcli/arborist": "9.4.0",
     "@octokit/graphql": "9.0.2",
     "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",

+ 0 - 91
packages/opencode/src/bun/index.ts

@@ -1,13 +1,5 @@
-import z from "zod"
-import { Global } from "../global"
 import { Log } from "../util/log"
-import path from "path"
-import { Filesystem } from "../util/filesystem"
-import { NamedError } from "@opencode-ai/util/error"
 import { text } from "node:stream/consumers"
-import { Lock } from "../util/lock"
-import { PackageRegistry } from "./registry"
-import { proxied } from "@/util/proxied"
 import { Process } from "../util/process"
 
 export namespace BunProc {
@@ -45,87 +37,4 @@ export namespace BunProc {
   export function which() {
     return process.execPath
   }
-
-  export const InstallFailedError = NamedError.create(
-    "BunInstallFailedError",
-    z.object({
-      pkg: z.string(),
-      version: z.string(),
-    }),
-  )
-
-  export async function install(pkg: string, version = "latest") {
-    // Use lock to ensure only one install at a time
-    using _ = await Lock.write("bun-install")
-
-    const mod = path.join(Global.Path.cache, "node_modules", pkg)
-    const pkgjsonPath = path.join(Global.Path.cache, "package.json")
-    const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
-      const result = { dependencies: {} as Record<string, string> }
-      await Filesystem.writeJson(pkgjsonPath, result)
-      return result
-    })
-    if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
-    const dependencies = parsed.dependencies
-    const modExists = await Filesystem.exists(mod)
-    const cachedVersion = dependencies[pkg]
-
-    if (!modExists || !cachedVersion) {
-      // continue to install
-    } else if (version !== "latest" && cachedVersion === version) {
-      return mod
-    } else if (version === "latest") {
-      const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
-      if (!isOutdated) return mod
-      log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
-    }
-
-    // Build command arguments
-    const args = [
-      "add",
-      "--force",
-      "--exact",
-      // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
-      ...(proxied() || process.env.CI ? ["--no-cache"] : []),
-      "--cwd",
-      Global.Path.cache,
-      pkg + "@" + version,
-    ]
-
-    // Let Bun handle registry resolution:
-    // - If .npmrc files exist, Bun will use them automatically
-    // - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
-    // - No need to pass --registry flag
-    log.info("installing package using Bun's default registry resolution", {
-      pkg,
-      version,
-    })
-
-    await BunProc.run(args, {
-      cwd: Global.Path.cache,
-    }).catch((e) => {
-      throw new InstallFailedError(
-        { pkg, version },
-        {
-          cause: e,
-        },
-      )
-    })
-
-    // Resolve actual version from installed package when using "latest"
-    // This ensures subsequent starts use the cached version until explicitly updated
-    let resolvedVersion = version
-    if (version === "latest") {
-      const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
-        () => null,
-      )
-      if (installedPkg?.version) {
-        resolvedVersion = installedPkg.version
-      }
-    }
-
-    parsed.dependencies[pkg] = resolvedVersion
-    await Filesystem.writeJson(pkgjsonPath, parsed)
-    return mod
-  }
 }

+ 7 - 14
packages/opencode/src/config/config.ts

@@ -1,6 +1,6 @@
 import { Log } from "../util/log"
 import path from "path"
-import { pathToFileURL, fileURLToPath } from "url"
+import { pathToFileURL } from "url"
 import { createRequire } from "module"
 import os from "os"
 import z from "zod"
@@ -22,7 +22,6 @@ import {
 } from "jsonc-parser"
 import { Instance } from "../project/instance"
 import { LSPServer } from "../lsp/server"
-import { BunProc } from "@/bun"
 import { Installation } from "@/installation"
 import { ConfigMarkdown } from "./markdown"
 import { constants, existsSync } from "fs"
@@ -31,11 +30,11 @@ import { GlobalBus } from "@/bus/global"
 import { Event } from "../server/event"
 import { Glob } from "../util/glob"
 import { PackageRegistry } from "@/bun/registry"
-import { proxied } from "@/util/proxied"
 import { iife } from "@/util/iife"
 import { Account } from "@/account"
 import { ConfigPaths } from "./paths"
 import { Filesystem } from "@/util/filesystem"
+import { Npm } from "@/npm"
 
 export namespace Config {
   const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -282,20 +281,14 @@ export namespace Config {
     await Filesystem.writeJson(pkg, json)
 
     const gitignore = path.join(dir, ".gitignore")
-    const hasGitIgnore = await Filesystem.exists(gitignore)
-    if (!hasGitIgnore)
-      await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
+    await Filesystem.write(
+      gitignore,
+      ["node_modules", "plans", "package.json", "bun.lock", ".gitignore", "package-lock.json"].join("\n"),
+    )
 
     // Install any additional dependencies defined in the package.json
     // This allows local plugins and custom tools to use external packages
-    await BunProc.run(
-      [
-        "install",
-        // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
-        ...(proxied() || process.env.CI ? ["--no-cache"] : []),
-      ],
-      { cwd: dir },
-    ).catch((err) => {
+    await Npm.install(dir).catch((err) => {
       log.warn("failed to install dependencies", { dir, error: err })
     })
   }

+ 1 - 1
packages/opencode/src/global/index.ts

@@ -18,7 +18,7 @@ export namespace Global {
       return process.env.OPENCODE_TEST_HOME || os.homedir()
     },
     data,
-    bin: path.join(data, "bin"),
+    bin: path.join(cache, "bin"),
     log: path.join(data, "log"),
     cache,
     config,

+ 30 - 173
packages/opencode/src/lsp/server.ts

@@ -3,7 +3,6 @@ import path from "path"
 import os from "os"
 import { Global } from "../global"
 import { Log } from "../util/log"
-import { BunProc } from "../bun"
 import { text } from "node:stream/consumers"
 import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
@@ -13,6 +12,7 @@ import { Archive } from "../util/archive"
 import { Process } from "../util/process"
 import { which } from "../util/which"
 import { Module } from "@opencode-ai/util/module"
+import { Npm } from "@/npm"
 
 export namespace LSPServer {
   const log = Log.create({ service: "lsp.server" })
@@ -102,7 +102,7 @@ export namespace LSPServer {
       const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
       log.info("typescript server", { tsserver })
       if (!tsserver) return
-      const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
+      const proc = spawn(await Npm.which("typescript-language-server"), ["--stdio"], {
         cwd: root,
         env: {
           ...process.env,
@@ -128,29 +128,8 @@ export namespace LSPServer {
       let binary = which("vue-language-server")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(
-          Global.Path.bin,
-          "node_modules",
-          "@vue",
-          "language-server",
-          "bin",
-          "vue-language-server.js",
-        )
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("@vue/language-server")
       }
       args.push("--stdio")
       const proc = spawn(binary, args, {
@@ -213,7 +192,7 @@ export namespace LSPServer {
         log.info("installed VS Code ESLint server", { serverPath })
       }
 
-      const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
+      const proc = spawn(await Npm.which("tsx"), [serverPath, "--stdio"], {
         cwd: root,
         env: {
           ...process.env,
@@ -344,8 +323,8 @@ export namespace LSPServer {
       if (!bin) {
         const resolved = Module.resolve("biome", root)
         if (!resolved) return
-        bin = BunProc.which()
-        args = ["x", "biome", "lsp-proxy", "--stdio"]
+        bin = await Npm.which("biome")
+        args = ["lsp-proxy", "--stdio"]
       }
 
       const proc = spawn(bin, args, {
@@ -371,9 +350,7 @@ export namespace LSPServer {
     },
     extensions: [".go"],
     async spawn(root) {
-      let bin = which("gopls", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("gopls")
       if (!bin) {
         if (!which("go")) return
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -408,9 +385,7 @@ export namespace LSPServer {
     root: NearestRoot(["Gemfile"]),
     extensions: [".rb", ".rake", ".gemspec", ".ru"],
     async spawn(root) {
-      let bin = which("rubocop", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("rubocop")
       if (!bin) {
         const ruby = which("ruby")
         const gem = which("gem")
@@ -515,19 +490,8 @@ export namespace LSPServer {
       let binary = which("pyright-langserver")
       const args = []
       if (!binary) {
-        const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "pyright"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push(...["run", js])
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("pyright")
       }
       args.push("--stdio")
 
@@ -629,9 +593,7 @@ export namespace LSPServer {
     extensions: [".zig", ".zon"],
     root: NearestRoot(["build.zig"]),
     async spawn(root) {
-      let bin = which("zls", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("zls")
 
       if (!bin) {
         const zig = which("zig")
@@ -741,9 +703,7 @@ export namespace LSPServer {
     root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
     extensions: [".cs"],
     async spawn(root) {
-      let bin = which("csharp-ls", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("csharp-ls")
       if (!bin) {
         if (!which("dotnet")) {
           log.error(".NET SDK is required to install csharp-ls")
@@ -780,9 +740,7 @@ export namespace LSPServer {
     root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
     extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
     async spawn(root) {
-      let bin = which("fsautocomplete", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("fsautocomplete")
       if (!bin) {
         if (!which("dotnet")) {
           log.error(".NET SDK is required to install fsautocomplete")
@@ -1048,22 +1006,8 @@ export namespace LSPServer {
       let binary = which("svelteserver")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("svelte-language-server")
       }
       args.push("--stdio")
       const proc = spawn(binary, args, {
@@ -1095,22 +1039,8 @@ export namespace LSPServer {
       let binary = which("astro-ls")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("@astrojs/language-server")
       }
       args.push("--stdio")
       const proc = spawn(binary, args, {
@@ -1359,31 +1289,8 @@ export namespace LSPServer {
       let binary = which("yaml-language-server")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(
-          Global.Path.bin,
-          "node_modules",
-          "yaml-language-server",
-          "out",
-          "server",
-          "src",
-          "server.js",
-        )
-        const exists = await Filesystem.exists(js)
-        if (!exists) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("yaml-language-server")
       }
       args.push("--stdio")
       const proc = spawn(binary, args, {
@@ -1412,9 +1319,7 @@ export namespace LSPServer {
     ]),
     extensions: [".lua"],
     async spawn(root) {
-      let bin = which("lua-language-server", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("lua-language-server")
 
       if (!bin) {
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1550,22 +1455,8 @@ export namespace LSPServer {
       let binary = which("intelephense")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "intelephense"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("intelephense")
       }
       args.push("--stdio")
       const proc = spawn(binary, args, {
@@ -1647,22 +1538,8 @@ export namespace LSPServer {
       let binary = which("bash-language-server")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("bash-language-server")
       }
       args.push("start")
       const proc = spawn(binary, args, {
@@ -1683,9 +1560,7 @@ export namespace LSPServer {
     extensions: [".tf", ".tfvars"],
     root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
     async spawn(root) {
-      let bin = which("terraform-ls", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("terraform-ls")
 
       if (!bin) {
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1766,9 +1641,7 @@ export namespace LSPServer {
     extensions: [".tex", ".bib"],
     root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
     async spawn(root) {
-      let bin = which("texlab", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("texlab")
 
       if (!bin) {
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1859,22 +1732,8 @@ export namespace LSPServer {
       let binary = which("docker-langserver")
       const args: string[] = []
       if (!binary) {
-        const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
-        if (!(await Filesystem.exists(js))) {
-          if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
-          await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
-            cwd: Global.Path.bin,
-            env: {
-              ...process.env,
-              BUN_BE_BUN: "1",
-            },
-            stdout: "pipe",
-            stderr: "pipe",
-            stdin: "pipe",
-          }).exited
-        }
-        binary = BunProc.which()
-        args.push("run", js)
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        binary = await Npm.which("dockerfile-language-server-nodejs")
       }
       args.push("--stdio")
       const proc = spawn(binary, args, {
@@ -1965,9 +1824,7 @@ export namespace LSPServer {
     extensions: [".typ", ".typc"],
     root: NearestRoot(["typst.toml"]),
     async spawn(root) {
-      let bin = which("tinymist", {
-        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
-      })
+      let bin = which("tinymist")
 
       if (!bin) {
         if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return

+ 84 - 0
packages/opencode/src/npm/index.ts

@@ -0,0 +1,84 @@
+import z from "zod"
+import { NamedError } from "@opencode-ai/util/error"
+import { Global } from "../global"
+import { Lock } from "../util/lock"
+import { Log } from "../util/log"
+import path from "path"
+import { readdir } from "fs/promises"
+import { Arborist } from "@npmcli/arborist"
+
+export namespace Npm {
+  const log = Log.create({ service: "npm" })
+
+  export const InstallFailedError = NamedError.create(
+    "NpmInstallFailedError",
+    z.object({
+      pkg: z.string(),
+    }),
+  )
+
+  function directory(pkg: string) {
+    return path.join(Global.Path.cache, "packages", pkg)
+  }
+
+  export async function add(pkg: string) {
+    using _ = await Lock.write("npm-install")
+    log.info("installing package using npm arborist", {
+      pkg,
+    })
+    const hash = pkg
+    const dir = directory(hash)
+
+    const arborist = new Arborist({
+      path: dir,
+      binLinks: true,
+      progress: false,
+      savePrefix: "",
+    })
+    const tree = await arborist.loadVirtual().catch(() => {})
+    if (tree) {
+      const first = tree.edgesOut.values().next().value?.to
+      if (first) return first.path
+    }
+
+    const result = await arborist
+      .reify({
+        add: [pkg],
+        save: true,
+        saveType: "prod",
+      })
+      .catch((cause) => {
+        throw new InstallFailedError(
+          { pkg },
+          {
+            cause,
+          },
+        )
+      })
+
+    const first = result.edgesOut.values().next().value?.to
+    if (!first) throw new InstallFailedError({ pkg })
+    return first.path
+  }
+
+  export async function install(dir: string) {
+    console.log(dir)
+    const arb = new Arborist({
+      path: dir,
+      binLinks: true,
+      progress: false,
+      savePrefix: "",
+    })
+    await arb.reify()
+  }
+
+  export async function which(pkg: string) {
+    const dir = path.join(directory(pkg), "node_modules", ".bin")
+    const files = await readdir(dir).catch(() => [])
+    if (!files.length) {
+      await add(pkg)
+      return which(pkg)
+    }
+    return path.join(dir, files[0])
+  }
+}

+ 4 - 7
packages/opencode/src/plugin/index.ts

@@ -4,7 +4,7 @@ import { Bus } from "../bus"
 import { Log } from "../util/log"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { Server } from "../server/server"
-import { BunProc } from "../bun"
+import { Npm } from "../npm"
 import { Instance } from "../project/instance"
 import { Flag } from "../flag/flag"
 import { CodexAuthPlugin } from "./codex"
@@ -59,16 +59,13 @@ export namespace Plugin {
       if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue
       log.info("loading plugin", { path: plugin })
       if (!plugin.startsWith("file://")) {
-        const lastAtIndex = plugin.lastIndexOf("@")
-        const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
-        const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
-        plugin = await BunProc.install(pkg, version).catch((err) => {
+        plugin = await Npm.add(plugin).catch((err) => {
           const cause = err instanceof Error ? err.cause : err
           const detail = cause instanceof Error ? cause.message : String(cause ?? err)
-          log.error("failed to install plugin", { pkg, version, error: detail })
+          log.error("failed to install plugin", { plugin, error: detail })
           Bus.publish(Session.Event.Error, {
             error: new NamedError.Unknown({
-              message: `Failed to install plugin ${pkg}@${version}: ${detail}`,
+              message: `Failed to install plugin ${plugin}: ${detail}`,
             }).toObject(),
           })
           return ""

+ 2 - 2
packages/opencode/src/provider/provider.ts

@@ -5,7 +5,7 @@ import { Config } from "../config/config"
 import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
 import { NoSuchModelError, type Provider as SDK } from "ai"
 import { Log } from "../util/log"
-import { BunProc } from "../bun"
+import { Npm } from "../npm"
 import { Hash } from "../util/hash"
 import { Plugin } from "../plugin"
 import { NamedError } from "@opencode-ai/util/error"
@@ -1201,7 +1201,7 @@ export namespace Provider {
 
       let installedPath: string
       if (!model.api.npm.startsWith("file://")) {
-        installedPath = await BunProc.install(model.api.npm, "latest")
+        installedPath = await Npm.add(model.api.npm)
       } else {
         log.info("loading local provider", { pkg: model.api.npm })
         installedPath = model.api.npm

+ 5 - 1
packages/opencode/src/util/which.ts

@@ -1,9 +1,13 @@
 import whichPkg from "which"
+import path from "path"
+import { Global } from "../global"
 
 export function which(cmd: string, env?: NodeJS.ProcessEnv) {
+  const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path
+  const full = base + path.delimiter + Global.Path.bin
   const result = whichPkg.sync(cmd, {
     nothrow: true,
-    path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
+    path: full,
     pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
   })
   return typeof result === "string" ? result : null

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно