From 9a0606b59e1a9bca70e4cb4f55fb9765bba936df Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 31 Aug 2026 10:19:29 -0700 Subject: [PATCH 1/2] feat(opencode-plugin): give each OpenCode session its own wire identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode forwards no session-id env var to the MCP servers it spawns — the child process sees only OPENCODE and OPENCODE_PID, measured on 1.18.25 by dumping the MCP child's environment. So the documented guidance ('set WIRE_SESSION_ID before launch') forced one persona for every OpenCode session on the box, and the unpinned alternative minted a throwaway identity per MCP boot. The key must exist before wire mcp execs, so wire alone cannot close this. OpenCode does fire session.created ~0.5s before it boots local MCP servers (measured 16:03:39.600Z created -> 16:03:40.101Z exec), and its plugin system runs in-process, so a plugin can still mutate the MCP config in that window. The plugin stamps WIRE_SESSION_ID=opencode- from the first top-level session event, which makes birth identity and resume identity the same key: -c and -s resolve the same by-key home, so a resumed conversation keeps its persona. An exported WIRE_SESSION_ID still wins (pinned long-lived agent); -s / -c (newest top-level session for the cwd, read-only sqlite against opencode.db) cover resumes in hosts where the event path loses the boot race. The last resort is a UUID — a new persona, never a foreign one. --fork gets a fresh key on purpose: a fork is a new conversation. Live-verified through the real MCP path (opencode run + wire_wire_whoami): fresh run lunar-chinook, -s resume lunar-chinook; fresh placid-twilight with -c twice keeping it; next fresh run ferny-kestrel; exported pin kindly-kelp. --- CHANGELOG.md | 4 ++ docs/integrations/OPENCODE.md | 32 +++++++++-- opencode-plugin/wire-session.js | 99 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 opencode-plugin/wire-session.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ce272ab8..217fcf36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ the PR description linked in each section. ## [Unreleased] +### Added + +- **OpenCode gets per-session wire identities via a shipped plugin** (#92 follow-on): OpenCode forwards no session-id env var to spawned MCP servers — measured on 1.18.25, the MCP child sees only `OPENCODE`/`OPENCODE_PID` — so every `wire mcp` boot either reused one static key across all sessions or minted a throwaway identity per launch (one box measured 8,861 by-key homes). The gap cannot be closed from wire's side, because the key must exist before `wire mcp` execs. But OpenCode fires `session.created` ~0.5s *before* booting local MCP servers (measured 16:03:39.600Z → 16:03:40.101Z), and its plugin system runs in-process with a `config` hook that can still mutate the MCP env in that window. Ships **`opencode-plugin/wire-session.js`**: drop it in `~/.config/opencode/plugin/` and each OpenCode session resolves `WIRE_SESSION_ID=opencode-` — so birth identity and resume identity are the same key (`-c`/`-s` included), and no two sessions share one inbox. Resolution priority: exported `WIRE_SESSION_ID` (pinned persona wins) → first top-level `session.created`/`session.updated` event → `-s ` argv or `-c` resolved read-only against `opencode.db` (newest top-level session for the cwd; `--fork` gets a fresh key) → fresh UUID, which yields a new persona rather than ever a foreign one. Live-verified with `opencode run` + `wire_wire_whoami`: fresh→`-s` resume kept one persona; fresh→`-c`×2 kept one persona; consecutive fresh runs differed; exported `WIRE_SESSION_ID` overrode all of it. A lost event race falls back to the UUID path — a new persona, never a wrong one. `docs/integrations/OPENCODE.md` rewritten against what was measured. + ## [v0.17.0] — 2026-07-10 ### Fixed diff --git a/docs/integrations/OPENCODE.md b/docs/integrations/OPENCODE.md index 0688ca55..9195775e 100644 --- a/docs/integrations/OPENCODE.md +++ b/docs/integrations/OPENCODE.md @@ -71,17 +71,39 @@ Re-edit the file at that path, restart OpenCode, and re-run `opencode mcp list`. ## Session identity -Wire resolves session identity per-process. Today OpenCode does not forward a stable session-id environment variable to the spawned MCP server, so each `wire mcp` launch gets a per-process key under `sessions/by-key/`. +OpenCode forwards **no session-id env var** to spawned MCP servers — the spawned process only gets `OPENCODE=1` and `OPENCODE_PID` (measured on 1.18.25 by dumping the MCP child's environment). Without help, `wire mcp` either reuses one static key across every session or mints a throwaway identity on every MCP boot. -To pin a stable wire identity across OpenCode runs (recommended for any session you want peers to find again), set `WIRE_SESSION_ID` explicitly before launching OpenCode: +The shipped plugin closes the gap without waiting on OpenCode: ```bash -WIRE_SESSION_ID=opencode-paul-laptop opencode +cp opencode-plugin/wire-session.js ~/.config/opencode/plugin/ +# opencode auto-loads every *.js in that directory; restart your session after copying. ``` -Wire reads `WIRE_SESSION_ID` at MCP-server boot; the resulting `op_did` is stable as long as you re-launch OpenCode with the same value. Pick a value unique per OpenCode session you want to keep reachable. +**How it works:** OpenCode fires `session.created` ~0.5s *before* it boots local MCP servers (measured: created 16:03:39.600Z → `wire mcp` exec 16:03:40.101Z). The plugin's event hook catches that event and stamps `WIRE_SESSION_ID=opencode-` into the wire MCP env before `wire mcp` ever starts — so every OpenCode session gets its own `sessions/by-key/` home, and resuming the same session (`opencode -c` or `-s `) resolves the same key, hence the same persona, from birth through every resume. + +**Key resolution priority:** + +1. `WIRE_SESSION_ID` exported in the launching terminal (pinned persona — the plugin stands down) +2. First top-level `session.created` / `session.updated` event → `opencode-` +3. `-s ` argv, or `-c` resolved against `~/.local/share/opencode/opencode.db` (read-only, newest top-level session for the cwd; `--fork` skips this and gets a fresh key) +4. Fresh UUID per process — a *new* persona, never a foreign session's identity + +**Verified on 1.18.25** (`opencode run` probing `wire_wire_whoami`): fresh run → `lunar-chinook`, `-s ` resume → `lunar-chinook`; fresh → `placid-twilight`, `-c` twice → `placid-twilight` ×3; next fresh run → `ferny-kestrel`. Exported `WIRE_SESSION_ID=opencode-lauro-mac` → `kindly-kelp` on every launch. + +Caveats: + +- If the `session.created` event ever loses the boot race, the fallback key applies (new persona, not a wrong one). +- A shared server (`opencode run --attach`) hosts many sessions in one process; the wire identity binds to its first top-level session. +- `--fork` intentionally mints a fresh identity: a fork is a new conversation. + +To pin one stable persona across OpenCode runs instead (a long-lived addressable agent peers should keep finding), skip the priority chain by exporting the key: + +```bash +WIRE_SESSION_ID=opencode-paul-laptop opencode +``` -When OpenCode adds a documented per-session env var (à la `CLAUDE_CODE_SESSION_ID` / `COPILOT_AGENT_SESSION_ID`), wire's [adapter trait](https://github.com/SlanchaAi/wire/pull/92) will pick it up automatically; track the discussion at [issue #92](https://github.com/SlanchaAi/wire/issues/92). +If OpenCode ever ships a documented per-session env var (à la `CLAUDE_CODE_SESSION_ID` / `COPILOT_AGENT_SESSION_ID`), wire's [adapter trait](https://github.com/SlanchaAi/wire/pull/92) will pick it up automatically and this plugin becomes belt-and-braces; track [issue #92](https://github.com/SlanchaAi/wire/issues/92). ## Usage examples diff --git a/opencode-plugin/wire-session.js b/opencode-plugin/wire-session.js new file mode 100644 index 00000000..791eb48c --- /dev/null +++ b/opencode-plugin/wire-session.js @@ -0,0 +1,99 @@ +// opencode plugin: per-session wire identities. +// Install: copy (or symlink) this file into ~/.config/opencode/plugin/ — +// opencode auto-loads every *.js there; no opencode.json entry needed. +// opencode forwards no session-id env var to spawned MCP servers +// (SlanchaAi/wire#92). opencode fires session.created ~0.5s BEFORE booting +// local MCP servers, so the event hook learns the session id in time to +// stamp WIRE_SESSION_ID=opencode- — birth identity == resume +// identity for every session. +// Priority: exported WIRE_SESSION_ID > first top-level session.created > +// `-s ` / `-c` argv resolution > fresh uuid. The lookup only ever +// yields the correct or a fresh key, never a foreign session's identity. +import { randomUUID } from "node:crypto" +import { execFileSync } from "node:child_process" +import { existsSync, realpathSync } from "node:fs" +import os from "node:os" +import path from "node:path" + +function explicitSessionId(argv) { + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if ((a === "-s" || a === "--session") && argv[i + 1] && !argv[i + 1].startsWith("-")) { + return argv[i + 1] + } + if (a.startsWith("--session=")) return a.slice("--session=".length) + } + return null +} + +function tryRealpath(p) { + try { + return realpathSync(p) + } catch { + return p + } +} + +function lastSessionId(cwd) { + try { + const db = path.join( + process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), + "opencode", + "opencode.db", + ) + if (!existsSync(db)) return null + const esc = (s) => s.replace(/'/g, "''") + const dirs = [...new Set([cwd, tryRealpath(cwd)])].map(esc).join("','") + const out = execFileSync( + "sqlite3", + [ + "-readonly", + db, + `select id from session where directory in ('${dirs}') and parent_id is null order by time_updated desc limit 1;`, + ], + { encoding: "utf8", timeout: 2000 }, + ) + const id = out.trim() + return /^ses_/.test(id) ? id : null + } catch { + return null + } +} + +export default async () => { + let wireCfg = null + let stampedByEvent = false + + return { + config: (cfg) => { + const wire = cfg.mcp?.wire + if (!wire || wire.type !== "local") return + wireCfg = wire + if (process.env.WIRE_SESSION_ID) return + const argv = process.argv + let key = explicitSessionId(argv) + if (!key && argv.some((a) => a === "-c" || a === "--continue")) { + if (!argv.includes("--fork")) key = lastSessionId(process.cwd()) + } + wire.environment = { + ...wire.environment, + WIRE_SESSION_ID: key ? `opencode-${key}` : `opencode-${randomUUID()}`, + } + }, + event: ({ event }) => { + if (stampedByEvent || !wireCfg || process.env.WIRE_SESSION_ID) return + if (event?.type !== "session.created" && event?.type !== "session.updated") return + const info = event?.properties?.info + if (!info?.id || !/^ses_/.test(info.id)) return + if (info.parentID) return + // For resume flows trust only the id argv already named, if any. + const argvKey = explicitSessionId(process.argv) + if (argvKey && info.id !== argvKey) return + wireCfg.environment = { + ...wireCfg.environment, + WIRE_SESSION_ID: `opencode-${info.id}`, + } + stampedByEvent = true + }, + } +} From 7790695d43b8ff016c52dd1c4cfb813c5c296c81 Mon Sep 17 00:00:00 2001 From: Paul Logan Date: Mon, 31 Aug 2026 10:28:26 -0700 Subject: [PATCH 2/2] style: pass clippy 1.98 on the relay handler and two test literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's clippy job floats on @stable with no pinned toolchain, so the 1.98 rollout turned three pre-existing lints into hard failures under -D warnings for every PR, docs-only ones included (first seen on #375). - src/relay_server.rs check_token: result_large_err fires because axum::response::Response is inherently large. Boxed the alternative — boxing every error return in a handler costs more than it protects — so the fn carries an allow with the reason. - src/pull.rs and tests/heartbeat_body_roundtrip.rs: format! with no args becomes the literal itself. --- src/pull.rs | 2 +- src/relay_server.rs | 4 ++++ tests/heartbeat_body_roundtrip.rs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pull.rs b/src/pull.rs index de560080..5e621f00 100644 --- a/src/pull.rs +++ b/src/pull.rs @@ -695,7 +695,7 @@ mod tests { let existing_line = json!({ "event_id": "different", "from": "did:wire:peer", - "body": format!("the user mentioned event_id deadbeefcafebabe in passing"), + "body": "the user mentioned event_id deadbeefcafebabe in passing", }); let path = tmp.join("peer.jsonl"); std::fs::write(&path, format!("{existing_line}\n")).unwrap(); diff --git a/src/relay_server.rs b/src/relay_server.rs index 10bf56d2..bfedbd9e 100644 --- a/src/relay_server.rs +++ b/src/relay_server.rs @@ -2316,6 +2316,10 @@ async fn responder_health_set( (StatusCode::OK, Json(record)).into_response() } +// `axum::response::Response` is a large type by design; boxing every error +// return in the handler to satisfy `result_large_err` would cost more than it +// protects. +#[allow(clippy::result_large_err)] async fn check_token( relay: &Relay, headers: &HeaderMap, diff --git a/tests/heartbeat_body_roundtrip.rs b/tests/heartbeat_body_roundtrip.rs index b6c1e8f1..8c6399f7 100644 --- a/tests/heartbeat_body_roundtrip.rs +++ b/tests/heartbeat_body_roundtrip.rs @@ -60,7 +60,7 @@ fn build_event(kind: u32, body: Value, handle: &str, fingerprint_hex: &str) -> V json!({ "timestamp": "2026-05-31T00:00:00Z", "from": format!("did:wire:{handle}-{fingerprint_hex}"), - "to": format!("did:wire:peer-deadbeef"), + "to": "did:wire:peer-deadbeef", "type": "heartbeat", "kind": kind, "body": body,