opencode-mesh: teaching my AI agents to knock before editing
Run two OpenCode
sessions on the same repository and you will quickly discover their favorite shared resource: the same file, at the same time, with total confidence in their own version. Agents are brilliant coworkers with the territorial discipline of seagulls. opencode-mesh
(v0.2.0, TypeScript, npm @log0u7/opencode-mesh) is the plugin I wrote so that my sessions coordinate instead of colliding, locally and across the LAN.
The layer cake
The plugin registers itself with one line in opencode.json ("plugins": ["@log0u7/opencode-mesh"]); on a v1 host the key stays plugin and v2 accepts that shape, normalizing it at load. Since v0.2.0 the package exposes both entry points at once. At startup it opens an HTTP server on port 4399 and advertises itself over mDNS. From there, three levels of coordination:
- Same machine, same repo: sessions discover each other through the local registry and exchange messages.
mesh_statusshows who is alive,mesh_send/mesh_inboxdeliver handoff notes (“I refactored the parser, yours is stale”). - Same machine, different worktrees:
mesh_lock(path="src/api.ts", ttl=600)claims a file before editing; the claim has a TTL so a crashed session does not lock the file into eternity. The lock is local to the machine: it applies between sessions sharing this host’s data directory, it is not part of the wire protocol. - Across the LAN or tailnet: nodes pair explicitly.
mesh_pairlists discovered peers, the operator approves by hostname and fingerprint and from then on every HTTP call carries a per-peer bearer token.
Deny by default, approve by human
The security model is the part I am least willing to relax. Every endpoint requires a per-peer bearer token; nothing listens openly and that became a structural guarantee: the HTTP server binds to 127.0.0.1 by default, "bind": "lan" is the explicit opt-in to all interfaces and mesh_status as well as the mesh_pair offer report the address actually bound. mDNS TXT records carry only the node id and the fingerprint, never secrets. One side emits a one-time pairing token, the other completes the pairing with it and a human clicks the approval; offers expire after 5 minutes and a pending token alone no longer installs a permanent peer: the node claiming it must already be known to this host (mDNS discovery or an explicit mesh_pair), auto-pairing an unknown id is dead. The Ed25519 ceremony is gone, because nothing signed with it: the short fingerprint is now derived from the node_id, existing ids survive, the fingerprint value changes, which may require re-pairing peers. An agent cannot silently enroll a stranger’s laptop into my swarm; the operator is the trust root, not the network.
That matters more than it sounds: these plugins run in the same trust domain as my shell. “Deny by default, approve by human” is the same discipline I want from my SSH config and agents inherit it only if the tooling enforces it.
The worker orchestrator
The second act is mesh_spawn(task="implement the parser", name="parser"): it creates a native opencode worktree, spawns a headless opencode run --dir <worktree> --format json session with --auto permissions (opt back into approvals with auto=false) and hands back a worker id. mesh_workers lists lifecycle states, mesh_worker_logs tails captured output, mesh_worker_stop SIGTERMs a worker, mesh_worker_remove cleans the worktree and the registry row. When the parent session stops, dispose SIGTERMs every live worker: no orphan processes quietly burning tokens in a forgotten worktree.
Lifecycle hardening came with usage: a spawn failing before the child runs cleans the worktree and the dead registry row; a worker that never starts is finalized as failed instead of staying running forever (a -1 pid is never persisted); mesh_worker_stop now takes down the whole process group, SIGTERM then SIGKILL after 3 s (workers are detached process-group leaders, no orphaned children); stderr is drained (the pipe’s 64 KB limit), logs are capped at 512 KB with rotation and deleted on remove; a JSON line split across two stdout chunks is reassembled before parsing, so the session_id gets captured; and a host restart reconciles dead pids. Worktree create and remove calls on the 1.x REST client are bounded by a 15 s timeout.
This is the boring shape of “multi-agent”: not a swarm consciousness, but a supervisor spawning workers in isolated worktrees, tracking them and cleaning up. Every time I was tempted to add a scheduler or a graph of agents, I reminded myself that mesh_spawn plus file locks already covers the use cases I actually have. Workers also run with OPENCODE_MESH_CHILD=1 and skip the server, mDNS and every mesh tool: a pool of N workers costs N worktrees, not N mesh nodes. And under an OpenCode 2.x host, mesh_spawn and mesh_worker_remove no longer hand the raw ctx.worktree domain (flat project-scoped inputs, payloads resolved directly, without the v1 {data, error} envelope) to the REST client: the domain is wrapped back into the expected shape, both generations share one code path and an injected options.worktreeClient takes precedence. The host-local lock from the start, the security hardening and the whole lifecycle work are merged on main, not yet tagged: v0.2.0 only carries the dual entry point.
State: SQLite, one file
Everything lives in ~/.local/share/opencode-mesh/mesh.db (honors XDG_DATA_HOME, created with restrictive permissions): session registry, peers, tokens, locks, messages. One SQLite file means mesh_status is a query, not an audit of JSON files and the crash story is the database’s crash story, which is a story with decades of practice behind it.
Knock before you edit
In the end three convictions carry this plugin. The operator is the trust root, mDNS discovers, humans approve, tokens authenticate, no auto-pairing ever, the loopback default makes it true by construction. Multi-agent is mostly janitorial work, a failed spawn picks up its worktree, a stopped worker takes its children along. And one SQLite file beats five JSON files, especially when the alternative is inventing atomic writes for the fifth time.