Skip to content

opencode 2-0#16918

Open
thdxr wants to merge 35 commits intodevfrom
opencode-2-0
Open

opencode 2-0#16918
thdxr wants to merge 35 commits intodevfrom
opencode-2-0

Conversation

@thdxr
Copy link
Member

@thdxr thdxr commented Mar 10, 2026

  • refactor: lsp server and core improvements
  • sync
  • Update packages/opencode/src/npm/index.ts
  • Update packages/opencode/src/util/which.ts
  • sync
  • core: dynamically resolve formatter executable paths at runtime
  • core: disable npm bin links to fix package installation in sandboxed environments
  • core: fix dependency installation failures behind corporate proxies or in CI by disabling Bun cache when network interception is detected
  • core: enable npm bin links on non-Windows platforms to allow plugin executables to work while keeping them disabled on Windows CI where symlink permissions are restricted
  • core: fix npm dependency installation on Windows CI by disabling bin links when symlink permissions are restricted
  • tui: export sessions using consistent Filesystem API instead of Bun.write
  • sync
  • core: fix CLI tools from npm packages not being accessible after install on Windows
  • sync
  • core: log npm install errors to console for debugging dependency failures
  • sync
  • sync
  • sync
  • core: fix custom tool loading to properly resolve module paths
  • tui: fix Windows plugin loading by using direct paths instead of file URLs
  • fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar (fix: work around Bun/Windows UV_FS_O_FILEMAP incompatibility in tar #16853)
  • core: add Node.js runtime support
  • refactor(server): replace Bun serve with Hono node adapters
  • core: bundle database migrations into node build and auto-start server on port 1338
  • sync
  • core: remove shell execution and server URL from plugin API
  • core: return structured server info with stop method from workspace server
  • core: cleaner error output and more flexible custom tool directories

@greptile-apps
Copy link

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR is a major architectural refactor that adds Node.js runtime support alongside Bun, replacing Bun.serve with @hono/node-server adapters, replacing BunProc.install with @npmcli/arborist for package management, and splitting the SQLite layer into Bun-specific (db.bun.ts) and Node.js-specific (db.node.ts) implementations selected at build-time via package #db imports conditions.

Key changes:

  • New Npm namespace (npm/index.ts) replaces BunProc.install and manages packages via @npmcli/arborist with lazy binary resolution through Npm.which()
  • Node.js server support: Server.listen is now async and uses @hono/node-server; PTY routes are temporarily commented out
  • Dual-target DB: #db conditional imports select bun:sqlite vs node:sqlite at runtime, with migrations bundled into the Node.js build via build-node.ts
  • Dynamic formatter commands: formatter.ts enabled() now returns string[] | false (the resolved command) instead of a separate command property and a boolean
  • Breaking plugin API change: serverUrl and $ (Bun shell) removed from PluginInput

Issues found:

  • Npm.which() has an infinite recursion path: if add(pkg) succeeds but the package has no .bin entries, the function calls itself indefinitely with no escape condition
  • Npm.which() returns files[0] (alphabetically first file in .bin) without filtering for the platform-appropriate executable variant — on Windows this may return the wrong file type
  • db.ts imports migrate from drizzle-orm/bun-sqlite/migrator at the top level, which is incompatible with the node-sqlite db returned by init() in Node.js builds
  • WorkspaceServer.Listen returns opts.port synchronously before the socket has bound, which is incorrect when port 0 is used for OS-assigned port selection
  • Database.close() no longer guards against calling Client() when the client was never initialized, causing a full DB initialization cycle just to close it

Confidence Score: 2/5

  • This PR introduces two confirmed logic bugs (infinite recursion in Npm.which, wrong migrator for Node.js builds) that could cause hard crashes in production; not safe to merge without fixes.
  • Two bugs are likely to surface immediately: Npm.which will infinite-loop for any package without .bin entries (which includes some LSP servers that may place their binary elsewhere), and calling migrate from the bun-sqlite migrator on a node-sqlite db in Node.js builds will likely fail at the type boundary when migrations are applied on first launch. Additionally, WorkspaceServer.Listen returns a wrong port for ephemeral port 0 usage. The rest of the refactor is structurally sound.
  • packages/opencode/src/npm/index.ts (infinite recursion + wrong binary selection), packages/opencode/src/storage/db.ts (incompatible migrator import for Node.js builds)

Important Files Changed

Filename Overview
packages/opencode/src/npm/index.ts New npm package manager using @npmcli/arborist. Has two bugs: infinite recursion in which() if a package installs but has no .bin entries, and files[0] without platform-specific executable filtering (wrong file on Windows).
packages/opencode/src/storage/db.ts Refactored to use conditional #db import for Bun/Node compatibility. Critical issue: migrate is still imported from drizzle-orm/bun-sqlite/migrator even in Node.js builds where init() returns a node-sqlite db. Additionally, close() no longer guards against initializing the lazy client unnecessarily.
packages/opencode/src/control-plane/workspace-server/server.ts Migrated from Bun.serve to @hono/node-server. The Listen function returns opts.port synchronously before the socket binds, which would be incorrect if port 0 is passed for OS-assigned ports.
packages/opencode/src/server/server.ts Main server migrated from Bun.serve to @hono/node-server with proper async listen/stop lifecycle. PTY routes temporarily commented out. Server.Default returns only the app without WebSocket injection.
packages/opencode/src/format/formatter.ts Formatters now dynamically resolve executable paths at runtime (returning `string[]
packages/opencode/src/lsp/server.ts LSP servers now use Npm.which() for on-demand binary installation instead of manual Bun install to Global.Path.bin. ESLint server now uses tsx as the runner instead of BunProc.which(). Clean simplification of LSP server management.
packages/opencode/src/storage/db.bun.ts New Bun-specific DB init module. Simple and correct — wraps bun:sqlite with drizzle for the #db conditional import.
packages/opencode/src/storage/db.node.ts New Node.js-specific DB init module using node:sqlite and drizzle-orm/node-sqlite. Correctly complements db.bun.ts through the #db package imports condition.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Plugin / LSP / Formatter needs binary] --> B{Binary on system PATH?}
    B -- Yes --> C[Use system binary]
    B -- No --> D[Npm.which called]
    D --> E{dot-bin dir exists and non-empty?}
    E -- No --> F[Npm.add via arborist]
    F --> G{Package has bin entries?}
    G -- Yes --> H[Return first file from dot-bin]
    G -- No --> I["⚠️ Recurse back to Npm.which - INFINITE LOOP"]
    E -- Yes --> H
    H --> J{Platform?}
    J -- Linux or Mac --> K[Correct executable]
    J -- Windows --> L["⚠️ files-0 may be wrong file type"]

    subgraph DB["DB Init"]
        M[Server starts] --> N{Runtime?}
        N -- Bun --> O["#db → db.bun.ts - BunSQLiteDatabase"]
        N -- Node.js --> P["#db → db.node.ts - NodeSQLiteDatabase"]
        O --> Q["migrate from bun-sqlite/migrator ✅"]
        P --> R["migrate from bun-sqlite/migrator ⚠️ type mismatch"]
    end

    subgraph SRV["Server Lifecycle"]
        S[Server.listen called] --> T[createAdaptorServer]
        T --> U[Await listening event]
        U --> V[Read actual bound port]
        V --> W[Return Listener with stop method]
    end
Loading

Last reviewed commit: 7925df1

Comment on lines +108 to +116
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])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Infinite recursion if package has no bin entries

If add(pkg) successfully installs a package that has no entries in node_modules/.bin (e.g., it only exposes a library API), readdir(dir) will return [] again after the install and the function recurses forever. The add() function will also return early on the second call (via the loadVirtual() check finding an existing tree), meaning there's no escape condition.

For example, if typescript-language-server installs but places its binary somewhere other than the expected .bin path, or if there's a transient filesystem race, this will loop indefinitely.

A guard against unbounded recursion is needed:

Suggested change
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])
}
export async function which(pkg: string, retry = false): Promise<string> {
const dir = path.join(directory(pkg), "node_modules", ".bin")
const files = await readdir(dir).catch(() => [])
if (!files.length) {
if (retry) throw new InstallFailedError({ pkg })
await add(pkg)
return which(pkg, true)
}
return path.join(dir, files[0])
}

await add(pkg)
return which(pkg)
}
return path.join(dir, files[0])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First file in .bin may not be the correct executable on Windows

files[0] returns the alphabetically first file in the .bin directory. On Windows, npm typically creates three variants for each binary: the bare script (no extension), a .cmd file, and a .ps1 file. Since readdir returns sorted results, files[0] would be the alphabetically first entry — which could be the bare script (not executable on Windows without Node.js on PATH) rather than the .cmd wrapper that Windows shells actually need.

For example, for biome, the sorted list might be ["biome", "biome.cmd", "biome.ps1"]. On Windows, the process spawner needs biome.cmd to launch correctly from a shell, but files[0] returns the bare biome script.

Consider filtering for the platform-appropriate extension:

const ext = process.platform === "win32" ? ".cmd" : ""
const match = files.find((f) => path.extname(f) === ext) ?? files[0]
return path.join(dir, match)

Comment on lines +1 to 2
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bun-sqlite migrator imported at top-level, incompatible with Node.js init()

migrate is imported from drizzle-orm/bun-sqlite/migrator at the top of this file. In the Bun build, #db resolves to db.bun.ts (returning a BunSQLiteDatabase) so migrate(db, entries) at line 104 is type-compatible. However, in the Node.js build (#dbdb.node.ts), init(Path) returns a drizzle-orm/node-sqlite database object.

The bun-sqlite migrator is typed to accept BunSQLiteDatabase exclusively, so calling migrate(db, entries) where db is a NodeSQLiteDatabase is a type mismatch and may fail at runtime if the migrator internally relies on bun-specific methods.

The Node.js build needs to use drizzle-orm/node-sqlite/migrator instead. This could be addressed the same way init is handled — via the #db import condition, or by branching the migrate import similarly.

Comment on lines 56 to 79
}

export function Listen(opts: { hostname: string; port: number }) {
return Bun.serve({
hostname: opts.hostname,
port: opts.port,
const server = createAdaptorServer({
fetch: App().fetch,
})
server.listen(opts.port, opts.hostname)
return {
hostname: opts.hostname,
port: opts.port,
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
},
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Listen returns opts.port before the socket actually binds

server.listen(opts.port, opts.hostname) is asynchronous — it schedules a bind and returns immediately. The returned object then uses opts.port as the reported port. If opts.port === 0 (OS-assigned ephemeral port), the caller receives port: 0 instead of the actual bound port, making the address unusable.

By contrast, the main Server.listen correctly awaits the "listening" event and reads server.address().port. The same pattern should be applied here:

export async function Listen(opts: { hostname: string; port: number }) {
  const server = createAdaptorServer({ fetch: App().fetch })
  await new Promise<void>((resolve, reject) => {
    server.once("error", reject)
    server.once("listening", resolve)
    server.listen(opts.port, opts.hostname)
  })
  const addr = server.address()
  const boundPort = addr && typeof addr !== "string" ? addr.port : opts.port
  return {
    hostname: opts.hostname,
    port: boundPort,
    stop() { ... }
  }
}

Comment on lines 110 to 113
export function close() {
const sqlite = state.sqlite
if (!sqlite) return
sqlite.close()
state.sqlite = undefined
Client().$client.close()
Client.reset()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close() initializes the lazy client just to close it

The old code used a separate state.sqlite reference and guarded with if (!sqlite) return before closing. With the new implementation, calling close() when the database was never opened will trigger Client() (the lazy initializer), which opens a brand-new SQLite connection — just to immediately close it. This is wasteful and may produce unexpected side effects (e.g., WAL and PRAGMA setup, migration runs).

Consider adding a guard to avoid initializing a client that was never needed:

export function close() {
  if (!Client.initialized()) return  // assuming lazy() exposes an initialized flag
  Client().$client.close()
  Client.reset()
}

If lazy() doesn't expose an initialized() check, a separate boolean flag like the old state.sqlite pattern would work.

@thdxr thdxr added the beta label Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
@thdxr thdxr changed the title opencode 2 0 opencode 2-0 Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
thdxr and others added 22 commits March 10, 2026 16:02
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Formatters now determine their executable location when enabled rather than
using hardcoded paths. This ensures formatters work correctly regardless
of how the tool was installed or where executables are located on the system.
…r in CI by disabling Bun cache when network interception is detected
…xecutables to work while keeping them disabled on Windows CI where symlink permissions are restricted
…links when symlink permissions are restricted
Enable running opencode on Node.js by adding platform-specific database adapters and replacing Bun-specific shell execution with cross-platform Process utility.
thdxr added 9 commits March 10, 2026 16:02
Plugins no longer receive shell access or server URL to prevent unauthorized
execution and limit plugin sandbox surface area.
…erver

- Enables graceful server shutdown for workspace management
- Removes unsupported serverUrl getter that threw errors in plugin context
- Removed debug console.log when dependency installation fails so users see clean warning messages instead of raw error dumps
- Fixed database connection cleanup to prevent resource leaks between sessions
- Added support for loading custom tools from both .opencode/tool (singular) and .opencode/tools (plural) directories, matching common naming conventions
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 10, 2026
opencode-agent bot added a commit that referenced this pull request Mar 11, 2026
opencode-agent bot added a commit that referenced this pull request Mar 11, 2026
opencode-agent bot added a commit that referenced this pull request Mar 11, 2026
opencode-agent bot added a commit that referenced this pull request Mar 11, 2026
opencode-agent bot added a commit that referenced this pull request Mar 11, 2026
opencode-agent bot added a commit that referenced this pull request Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants