Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bdbb1f6
feat(protocol): dual-protocol client for OpenCode V1 and V2 wire APIs
jensenojs Sep 15, 2026
c3b0180
fix(renderer): reach older history through the Observation contract
jensenojs Sep 16, 2026
b87f489
fix: perpetual loading state for v1 server
sudo-tee Sep 16, 2026
c44b6d3
refactor(formatter): pass the tool registry into task formatting
jensenojs Sep 16, 2026
eeaa5ef
refactor(server): move kill_pid into util
jensenojs Sep 16, 2026
6d58028
fix(util): kill the whole process tree recursively
jensenojs Sep 16, 2026
a3e1667
feat(topology): rule-level allowed exceptions
jensenojs Sep 16, 2026
6cd93c9
fix(tests): repair broken port_mapping spec and catch syntax errors
jensenojs Sep 16, 2026
3302a16
fix: 400 error when body is empty in http protocol
sudo-tee Sep 16, 2026
6e5f517
fix: new session tab not receiving location
sudo-tee Sep 16, 2026
ec97933
fix: broken session tabs tests
sudo-tee Sep 16, 2026
a403c9b
feat: support async prompt submission and V1 reply in quick chat
sudo-tee Sep 16, 2026
ede5df8
refactor(server): centralize server connection via server_job.ensure_…
sudo-tee Sep 16, 2026
5ef33e6
perf(server): cache connection health checks
sudo-tee Sep 16, 2026
96d2ba4
perf(renderer): reconcile incremental output updates
sudo-tee Sep 16, 2026
15904e1
perf(renderer): cache reference facts incrementally
sudo-tee Sep 16, 2026
3970d1e
fix(session): restore last used model
sudo-tee Sep 16, 2026
99a3042
feat(ui): add streaming event throttle and collapse for message renders
sudo-tee Sep 16, 2026
a78b2c4
feat(messaging): apply model selection for V2 sessions
sudo-tee Sep 16, 2026
2563b33
refactor(ui): coalesce topbar renders and extend stats handling
sudo-tee Sep 16, 2026
671880a
fix(protocol): use ascending message IDs for proper server completion…
sudo-tee Sep 16, 2026
6b7a1a2
feat: unify quick chat reply handling via protocol-independent reques…
sudo-tee Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions docs/drafts/v2-migration-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# opencode.nvim architecture (DRAFT)

## The proposition

One writable fact store per session — the Observation. Protocol adapters are
the only writers; the presentation layer is the only reader. Everything below
follows from this: the layer shape, the contract at the read/write line,
where protocol differences die, and how far the current code is from it.

Dual-protocol support (V1 1.18.x, V2 2.0.x) is the forcing function, not the
subject: keeping two wire protocols honest is what exposed where the
boundaries are.

## The layer shape

```text
Entry keymap / commands / pickers user intent, nothing else
Dispatch commands registry + parse intent routing only
Domain services.* facts & operations;
writes facts only through
protocol adapters
Presentation ui.* (renderer, windows, tabs) reads the Observation;
never writes session facts
Infrastructure server_job / Connection / the only Observation
transport / protocols/v1|v2 writers; protocol truth
Foundation config / state / util / promise passive; no layer rules
```

Infrastructure is the only layer shown in detail, because it is the only
boundary that has already hardened:

```text
Connection acquisition (server_job.lua)
-> authenticated health probe decides the protocol, once per connection
-> ready Connection (opencode_server.lua)
+-- transport.lua # raw HTTP/SSE bytes, cancellation
+-- protocols/http.lua # query, JSON, path-mapping mechanics
+-- protocols/v1|v2/operations.lua # native endpoints per protocol
+-- protocols/v1|v2/observation.lua # native events, recovery, admission
`-- Observation per session # single writable fact store per session
ui/renderer.lua -> watches Observation resources, re-reads on change
```

The Domain/Presentation line above is a declaration, not yet a fact — the
measured distance is in the last section. The old middle layer
(`api_client`, `event_manager`, `session`, `ui/renderer/events`,
`ui/event_scope`, `ui/session_scope`) was removed to make room for it.
Session tabs (logical tabs per session, from upstream) keep one renderer
context per tab and re-attach through the Observation path, not a parallel
event scope.

## The contract

The only interface between protocol adapters and everything above:

```text
read() snapshot of the session facts
watch(resources, callback) per-subscription change notices
load_older() pull and merge one older history page
load_complete_history() loop until the history is complete
submit(content) user input -> submission evidence
wait_until_idle() session idle with provable outcome (V2 only)
interrupt() server response to the interrupt request
reply_permission(request, answer)
reply_question(request, answers)
reject_question(request)
```

Naming follows the domain, never the wire protocol: no method exposes event
names, payload shapes, or paging cursors. A concept enters this contract only
when an adapter cannot absorb it. `wait_until_idle` is the worked example:
V1 1.18.x's `session.idle` event carries only a `sessionID` — no outcome, no
error, no binding to a submission — so V1 honestly does not provide it.

## The absorption rules

Protocol differences die inside adapters. What each difference became:

- **Protocol identity** — one authenticated health probe per connection
decides V1/V2 for the connection's lifetime; a protocol change is an
identity change and forces a reconnect. Discovery and credentials come
from the `opencode` CLI on V2; local spawn / explicit URL / port
coordination on V1. Neovim exiting never kills the native shared service.
- **History** — V1 serves the whole history in one response; V2 pages
through a cursor. The Observation holds the newest page and pulls older
pages on demand (`load_older` / `load_complete_history`); long sessions
fetch incrementally on navigation — the only user-visible behavior change.
- **Usage** — V2 emits server-side session totals; V1 does not. Both
surface as the same session fact, with the V1 fallback derived from
entries. Malformed payloads surface as `sync.session` errors and trigger a
resource re-read; foreign-session events are rejected at the boundary.
- **Per-message settings** exist only in V1 — the single explicit runtime
branch (in `services/messaging.lua`).

## Current distance

The same store/reader split names the boundary still missing in the middle:
Domain (services) and Presentation (ui) form one tangled layer today. The
`dependency-topology` scanner measures the distance:

- one 40-module strongly-connected component spanning entry to ui, glued
mainly by services calling ui containers (`session_runtime`,
`agent_model` → `ui.ui`, `input_window`)
- 8 policy violations (windows bind keymaps, pickers call `api` directly,
`ui.ui` wires autocmds and contextual actions)
- 3 two-module cycles, each one edge away from acyclic

Convergence is incremental, not a rewrite: mechanical violation fixes first,
then the Domain/Presentation split as three local decisions (orchestration
ownership, services unidirectionality, `ui.ui` decomposition). Refer to this
section when picking follow-up work; `topology.jsonc` is the machine-checked
form of the goal, this document the narrative one.

## Evidence base

The published OpenAPI spec and the running 2.0.x server disagree at several
endpoints (`/api/project/current`, `rename` via POST, `command` field name,
`fork` body shape). The adapters follow the running server; `tests/data/v2/`
fixtures are live captures from a real server, not hand-written guesses. V1
1.18.30 is pinned to source + fixtures and exercised offline; the V1
`session.idle` payload shape was re-verified against a live 1.18.21 server.

Known future break point: upstream dev already renames `permission.asked` /
`form.*` events (`permission.v2.asked`, `question.v2.asked`). Bumping the
server version means re-verifying the event contract first.

Verification: `./run_tests.sh` green on the CI matrix (nvim 0.10.3 →
nightly; mention ranges cross the UTF-16 boundary in both directions, and
the encoding-argument forms of `vim.str_*` only exist on 0.11+, so the
adapters use the version-independent converters in `util.lua`, verified
case-by-case against the native API). Contract tests per protocol:
`protocol_{v1,v2}_{operations,observation}*_spec.lua`, with counterexample
coverage (cross-session pollution, malformed payloads, duplicate terminal
events, admission races, paging edge cases). Live dual-client acceptance
against a real 2.0.3 service is recorded in the project spec.

## Known gaps (deliberate)

- V1 `list_agents` / `list_commands` exports have no production callers (V1
reads config directly); kept for symmetry with V2's live equivalents.
- Compaction progress events (`session.compaction.*`) and retry/revert
events are not rendered live; state converges on the next snapshot read.
- `form.replied` payload shape and `filesystem.changed` data shape lack
event samples; they are the current blind spots to close.
- `config.server.url` containing an explicit port without `server.port`
re-derives the port from the SSH port-mapping table (or falls back to a
local spawn) instead of using the URL as given.
106 changes: 55 additions & 51 deletions docs/recipes/bidirectional-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Use a single shared HTTP server that both TUI and nvim connect to:

```mermaid
flowchart LR
A[Terminal: oc-sync.sh] -->|starts| B[Shared Server :4096]
A[Terminal: native opencode --server] -->|connects| B[Shared Server]
C[nvim] -->|connects| B
D[TUI] -->|connects| B
B -->|shares session| C
Expand All @@ -35,53 +35,40 @@ flowchart LR

## Quick Start

### 1. Install Wrapper
### V2 native service

V2 2.0.x 的 TUI 默认连接 OpenCode 自己管理的后台 service。Neovim 默认也使用这个 service,无需 wrapper、固定端口、额外 password_file 或用户填写 ownership。下面的 V2 路径已在 2.0.3 实测。

```lua
require("opencode").setup({
server = { timeout = 30 },
})
```

```bash
chmod +x oc-sync.sh
cp oc-sync.sh ~/.local/bin/
# 普通 TUI 使用原生后台 service
opencode /path/to/project
# 继续 Neovim 正在显示的同一 session
opencode --session ses_... /path/to/project
```

### 2. Configure Nvim
插件用 CLI 的 `service status` 获取地址、`service get password` 获取凭据;仅当状态明确为 `stopped` 时调用 `service start`。HTTP health 决定 V1/V2 协议。Neovim 退出不关闭原生 service。CLI 能力检查只选择启动入口,不代替 server health 的协议判定。

Add to your opencode.nvim setup:
同一目录不代表两端自动选中同一 session;在另一端显式 resume 同一 session。两端共享消息、工具、question 与 permission 状态,各自保留窗口、光标和未提交输入。

```lua
server = {
url = "localhost",
port = 4096,
timeout = 30, -- First boot can be slow (MCP initialization)
auto_kill = false, -- Keep server alive when TUI is active
spawn_command = function(port, url)
local script = vim.fn.expand("~/.local/bin/oc-sync.sh")
vim.fn.system(script .. " --sync-ensure")
return nil -- Server lifecycle managed externally
end,
}
```
### V1 and explicit servers

### 3. Use It
旧 V1 CLI 没有 service 命令,插件保留原有本地 `serve` 路径。已有 `server.url`、`port`、`spawn_command` 的配置继续按显式连接处理。

Terminal 1 - Start TUI:
```bash
oc-sync.sh /path/to/project
```
V1 的共享服务需要两端使用同一 endpoint 和凭据,TUI 原生命令是:

Terminal 2 - Open nvim in same directory:
```bash
cd /path/to/project && nvim
opencode attach http://127.0.0.1:4096 --dir /path/to/project --session ses_...
```

Both will share the same session state.
V2 的显式远端连接可使用 `opencode --server <endpoint> --session ses_... <directory>`,并按服务要求提供 `OPENCODE_PASSWORD`。只有该显式场景需要双方约定地址。以下 legacy helper 配置仅适用于 V1;V2 无需安装或调用 `oc-sync.sh`。

## Implementation Notes

- `oc-sync.sh --sync-ensure` starts shared HTTP server (port 4096)
- TUI runs `opencode attach <endpoint>` to connect
- Nvim plugin connects to same endpoint
- Server stays alive until manually killed

## Customization
## V1 legacy helper configuration

Environment variables:

Expand All @@ -90,29 +77,46 @@ Environment variables:
| `OPENCODE_SYNC_PORT` | 4096 | HTTP server port |
| `OPENCODE_SYNC_HOST` | 127.0.0.1 | Server bind address |
| `OPENCODE_SYNC_WAIT_TIMEOUT_SEC` | 20 | Startup timeout |
| `OPENCODE_SYNC_PASSWORD_FILE` | `$XDG_STATE_HOME/nvim/opencode/server-password` or `~/.local/state/nvim/opencode/server-password` | Shared credential file |

## Troubleshooting

**Port already in use?**
```bash
# Check what's using it
lsof -i :4096
V2 先用原生命令检查服务状态和真实 health:

# Kill the process
kill $(lsof -t -i :4096)
```

**MCP plugins taking too long?**
```bash
# Increase timeout
export OPENCODE_SYNC_WAIT_TIMEOUT_SEC=60
opencode service status
opencode api GET /api/health
```

**Server not responding?**
```bash
# Check health
curl http://localhost:4096/global/health
```
插件错误与 CLI 错误应分别检查。401/403 不会触发私有 server 启动或 V1 回退。无需查找并杀掉某个约定端口的进程。

The nvim client and TUI share the HTTP server and session data. Selecting a
session in one frontend does not select it in the other frontend. Pass
`--session ses_...` when both clients must display the same conversation. Each
frontend still owns its windows, cursor, input draft, and current selection.
Native V2 service lifecycle belongs to OpenCode. For an explicitly managed shared server, do not use
`--shutdown-after-last-client` when starting it.

Server ownership controls shutdown and port cleanup only. Prompt completion uses
the admission ID returned to nvim and the matching inbox events from the shared
server. The server runs one serial execution horizon per session, so messages
delivered by another client during that horizon are included in the same next
terminal event. The plugin keeps one local prompt in flight per session. A lost
event stream, or evidence that the server started overlapping execution horizons,
resolves that local completion as `unknown`; messages already stored by the server
remain visible to both frontends after a snapshot refresh.

For a V1 explicit launcher, set `server.password_file` to a state-directory path. On a launcher path, the
plugin persists the selected password there with owner-only permissions before
starting its local server, so a later nvim process and the TUI read the same value.
Plugin credential selection is deterministic: `server.password`, then the
configured password file, then `OPENCODE_PASSWORD`, then
`OPENCODE_SERVER_PASSWORD`. This recipe leaves `server.password` unset and uses
the password file as the shared source. When the file is absent, the V1 helper
persists the environment password or generates one; an existing invalid file
fails immediately instead of being replaced.

The legacy helper rejects a CLI with the native service command before creating credentials or starting a process. Its health endpoint is `/global/health`, with a V1 1.18.x JSON response required; HTML 200 and authentication errors are failures. V2 never enters this script's launcher path.

## Integration Ideas

Expand Down
Loading
Loading