Issue #22872 reports the write tool hanging indefinitely after a file
is written. Two underlying causes, both in the post-write LSP
enrichment path:
1. LSPClient.create wraps the `initialize` request in a 45s
withTimeout. If the spawned LSP process is wedged (happens with
pyright under certain conditions), every write that matches that
LSP blocks the tool for up to 45s even though the file is on disk.
2. Server.spawn for npm-distributed LSPs (pyright, tsserver,
biome, ...) calls Npm.which, which internally uses arborist.reify
with no timeout. In sandboxed containers with no network access
this promise never resolves — the write tool hangs forever.
Fix applied at three layers of defense:
- write.ts / edit.ts / apply_patch.ts: wrap the touchFile +
diagnostics tail in a 5s Effect.timeout with catch-to-empty.
Diagnostics are a best-effort enrichment; they must not block the
tool's return after the file is already written.
- lsp.ts schedule(): bound server.spawn with a 10s Promise.race
timeout. On timeout the server is added to s.broken so subsequent
touches short-circuit instantly instead of re-racing.
- client.ts: lower the `initialize` withTimeout from 45_000 to
10_000. If a server hasn't responded to initialize in 10s it's
wedged; 45s was punishing for no benefit.
Reproducer tests (added in earlier commits on this branch) now pass:
- write-lsp-hang.test.ts (branch A, 45s initialize timeout)
- write-lsp-spawn-hang.test.ts (branch B, forever Npm.which)
Both complete in ~5s.
Full opencode test suite: 1934 pass, 0 fail.
Adds a second reproducer covering the 'forever' branch of issue #22872:
when Pyright.spawn calls Npm.which('pyright') and the npm registry is
unreachable (sandboxed container), arborist.reify blocks indefinitely
with no timeout.
Changes:
- Adds optional Info.spawnEffect alongside the existing async Info.spawn.
spawnEffect returns an Effect that can yield from Npm.Service, making
npm lookups injectable for tests.
- Migrates Pyright to use spawnEffect, pulling the venv probing logic
into a reusable pyrightVenvInitialization helper. The legacy async
spawn stays for backwards compatibility.
- Threads Npm.Service through LSP.layer so getClients captures a stable
reference and uses it for any server that provides spawnEffect.
- Adds test/tool/write-lsp-spawn-hang.test.ts — mocks Npm.Service.which
with Effect.never and asserts the write tool still returns in < 10s.
Fails today (hangs forever); the fix must bound the touchFile tail
so the tool cannot wait on a wedged LSP spawn.
The two reproducers now cover both hang branches:
- write-lsp-hang.test.ts: 45s LSPClient.create initialize timeout
- write-lsp-spawn-hang.test.ts: unbounded Npm.which arborist.reify
Adds a failing regression test that reproduces the write tool hang
reported in #22872. The write tool calls lsp.touchFile + lsp.diagnostics
to enrich its output; if a matching LSP server spawns but never responds
to the initialize request, the tool blocks on LSPClient.create's 45s
withTimeout.
The test configures a fake LSP server (hanging-lsp-server.js) that
swallows every message and never replies, asserts the file is still
written correctly, and checks the tool returns within 10s. On dev today
the assertion fails with ~45s actual, proving the hang. The fix should
make this green by bounding the diagnostic-enrichment tail.
Extract error handling, parsing logic, and variable substitution into dedicated
modules. This reduces duplication between tui.json and opencode.json parsing
and makes the config system easier to extend for future config formats.
Adds explanatory comments to config.ts and plugin.ts clarifying:
- How plugin specs are stored and normalized during config loading
- Why plugin_origins tracks provenance for location-sensitive decisions
- Why path-like specs are resolved early to prevent reinterpretation during merges
- How plugin deduplication works while keeping origin metadata for writes and diagnostics
Fixes potential plugin resolution issues when switching between projects by wrapping
plugin loading in Instance.provide(). This ensures each plugin resolves dependencies
relative to its correct project directory instead of inheriting context from whatever
instance happened to be active.
Also reorganizes config loading code into focused modules (command.ts, managed.ts,
plugin.ts) to make the codebase easier to maintain and test.