@pananfly/dsh-opencode-acp
v0.1.1
Published
DSH LLM adapter that routes a simulated opencode-acp provider through the opencode CLI's ACP server (persistent process, streamed thinking, effort levels)
Maintainers
Readme
@pananfly/dsh-opencode-acp
Repository: https://github.com/pananfly/dsh-opencode-acp · npm:
@pananfly/dsh-opencode-acp
中文 | English
DSH LLM adapter that registers a simulated provider opencode-acp and routes every call through a persistent opencode acp daemon (line-delimited JSON-RPC over stdio). Pick it in the DSH model picker like any other provider — full agent turns (opencode's own tools), streamed thinking, thinking-level selection.
What it does
- Simulated provider — registers
["opencode-acp"]viactx.llm.registerAdapter; the picker lists it alongside real providers, nothing else changes - One persistent daemon — a single
opencode acp --port 0child process serves all conversations; lazily respawned if it dies, never spawned per-request - Streamed thinking — ACP
agent_thought_chunk→ harness reasoning blocks,agent_message_chunk→ text blocks, so thinking renders live instead of arriving as a final result - Thinking levels — opencode model
variantsbecome the picker's effort options;reasoningEffortflows intosession/set_config_option {configId:"effort"} - Model catalog — parsed once at startup from
opencode models --verbose(names, context limits, reasoning variants), hot-reloadable without restarting dsh - Full delegation — tool execution happens inside opencode (its own bash/edit/read tools and permission config); DSH-side tools do not run during an
opencode-acpturn
Architecture
DSH harness ── picks "opencode-acp/<model>" from the picker
│ ctx.llm.registerAdapter(["opencode-acp"], adapter)
▼
OpencodeAcpAdapter (src/adapter.ts)
conversation→ACP-session map (LRU ≤ 32) · incremental turn diffing
history flattening · notification→chunk pump · cancel forwarding
│ session/new · session/set_config_option · session/prompt
│ session/cancel · session/close
▼
AcpConnection (src/acp.ts)
line-delimited JSON-RPC 2.0 over stdio · initialize handshake
lazy respawn · per-request timeout+abort · host-exit guard · stop escalation
▼
opencode acp --port 0 (one persistent child process)
runs opencode's own agent loop and tools| module | role |
|---|---|
| src/index.ts | plugin entry: binary discovery, catalog lifecycle, command + dispose registration, warm-up |
| src/models.ts | parser for opencode models --verbose repeating sections (header + brace-balanced JSON) |
| src/acp.ts | AcpConnection: stdio JSON-RPC client, process lifecycle |
| src/adapter.ts | OpencodeAcpAdapter: duck-typed DSH LlmAdapter |
Key implementation points
- Session mapping — one DSH conversation (
options.sessionId) maps to one persistent ACP session. The first turn replays the whole flattened history as text parts ([system]/[assistant]/[called tool x]/[tool result]markers); later turns send only new messages (sentCountwatermark). No per-turn process or session churn. - History-shrink rebuild — regenerate/edit shortens history below the watermark → the stale ACP session is closed and a fresh one replays everything.
- Anonymous calls close themselves — a
stream()withoutsessionIdcan never be hit again, so its daemon-side session is closed immediately in the generator'sfinally. - LRU session cap — named sessions are capped at 32; eviction closes the least-recently-used via
session/close, never touching the active conversation or a session with an in-flight turn. - Concurrency guard — a second
stream()on the same conversation while one is streaming fails fast withTURN_IN_PROGRESSinstead of corrupting the shared ACP session. - Streaming pump —
session/promptis dispatched concurrently (never awaited first);session/updatenotifications are queued into a per-session turn and drained by an async generator with condition-variable waiters;block-startis emitted once per block kind so the UI gets clean reasoning/text blocks. - Cancel & error mapping — abort signal forwards
session/cancel;stopReasonmapsmax_tokens/refusal/cancelledtoMAX_TOKENS/CONTENT_FILTER/ABORTED. - Process hygiene —
process.once("exit")SIGKILL guard prevents orphaned daemons when the host dies;stop()closes stdin (EOF) then SIGTERM, escalating to SIGKILL after 3s with a 4s hard resolve cap;ensure()waits for a pending stop before respawning (no double-daemon window). - Request robustness — every request has a timeout (30s default, 600s for
session/prompt), abort listeners are always removed, write failures reject the waiter, and undelimited stdout output is discarded past a 16MB buffer cap. - Catalog loading —
models --verboseis retried ×4 (opencode shares one SQLite DB across instances; concurrent writers briefly lock it). Before the first load finishes,listModels()returns[]through a Proxy instead of throwing; once loaded,handle.replace()re-commits the route so the picker updates live. - Reload safety —
/opencode-refreshdelegates to a module-levelactiveRuntime, so handlers of a replaced plugin instance operate on the current daemon rather than reviving a stopped one.
Usage
Install
# from npm
dsh plugin --profile web add @pananfly/dsh-opencode-acp
# or local
pnpm build && dsh plugin --profile web add ./path/to/dsh-opencode-acpRequires the opencode binary on the machine running dsh.
Binary discovery order
binplugin config (seecordis.patch.yml, interpolated fromOPENCODE_BIN)OPENCODE_BINenvironment variable~/.opencode/bin/opencode,/opt/homebrew/bin/opencode,/usr/local/bin/opencode- bare
opencodeonPATH
Configuration
| item | default | description |
|---|---|---|
| bin (plugin config) | "" | explicit opencode binary path |
| OPENCODE_BIN (env) | — | opencode binary path fallback |
| OPENCODE_ACP_CWD (env) | dsh cwd | working directory handed to ACP sessions |
Command
/opencode-refresh— stop + respawn the daemon, reload the model catalog, and hot-swap the registration (handle.replace) so the picker updates without restarting dsh.
Console
[info] [dsh-opencode-acp] spawning /Users/you/.opencode/bin/opencode acp --port 0
[info] [dsh-opencode-acp] daemon initialized
[info] [dsh-opencode-acp] catalog refreshed: 12 models (4 with effort levels)
[warn] [dsh-opencode-acp] daemon exited code=1 signal=null
[error] [dsh-opencode-acp] initial catalog load failed: ... (use /opencode-refresh to retry)
# /opencode-refresh → "opencode-acp restarted; 12 models available (4 support thinking levels)"Provider scope — not for TUI
The provider is registered inside the dsh profile that installs this plugin (ctx.llm.registerAdapter). Today that makes it usable only in the web profile (and in headless, if you install it there too). It does not work in a TUI scenario:
- dsh ships exactly two application templates (
PROFILE_TEMPLATES):web→dsh-web-app,headless→dsh-headless. No TUI application exists; thetuiname seen in CLI help examples is just a placeholder - Any other profile name initializes as bare
@deepseek-ai/dsh-base: plugins install fine, but no app ever loads, so there is no model picker to consume the registered provider - Profiles never share plugins — installing here affects only this profile. Conversely, a profile without this plugin cannot resolve a default model pointing at
opencode-acp/*
If a TUI app ships in a future dsh release, installing this plugin into that profile should be all that is needed — the adapter targets the shared llm service API.
Notes
- No ACP permission handler is registered: opencode tools requiring permission are auto-rejected by opencode itself. Allow them in opencode's own config if needed.
- Tool transcripts stay clean:
tool_call/tool_call_updatenotifications are intentionally ignored (only compact[called tool x]markers appear when history is replayed). - Sessions are bounded: anonymous turns release their ACP session immediately; named conversations are capped at 32 with LRU eviction — long-running daemons do not accumulate sessions.
Development
pnpm install
pnpm build
pnpm typecheck
# publish (scoped public)
pnpm publish --access publicLicense
MIT
