pi-mesh is a standalone Pi extension for live agent-to-agent
communication. Local Pi agents talk to each other in < 50 ms through a tiny
local broker (unix socket, NDJSON frames, protocol mesh.v1). Presence is
observed (live sockets), statuses are honest (delivered ≠ read ≠
answered), and the durable ledger is hash-only. Zero runtime dependencies;
Node ≥ 22.
This repo is the mesh-only slice of the harness it was extracted from: no guild, no worklist, no orchestration — just the plugin you drop into any Pi project and use on any machine.
┌────────────────────────────────────────────┐
│ broker (detached) $TMPDIR/mesh-<uid>/ │
│ peers / rooms / mailbox / rates (memory) │
└───────▲───────────────▲─────────────▲──────┘
│ connexions persistantes NDJSON │
┌───────────────┴───┐ ┌───────┴────────┐ ┌┴───────────────┐
│ client (agent A) │ │ client (agent B)│ │ CLI mesh │
└───────▲───────────┘ └───────▲────────┘ └────────────────┘
┌───────┴───────────┐ ┌────────┴────────┐
│ extension Pi A │ │ extension Pi B │
│ mesh_send/reply/… │ │ injection │
│ ledger hash-only │ │ followUp/steer/ │
│ transcript opt-in │ │ abort+steer │
└───────────────────┘ └─────────────────┘
As a Pi package (recommended — the extension auto-loads):
pi install npm:pi-mesh-extension
# or from git, pinned to a release tag
pi install git:github.com/cgarrot/pi-mesh@v0.1.3From source (clone and run):
git clone git@github.com:cgarrot/pi-mesh.git
cd pi-mesh
npm install
npm run build # tsc → dist/ (build needed for CLI + tests; the Pi
# extension itself loads from src/ directly)Pi auto-loads the project extension from .pi/extensions/mesh/. Open two Pi
sessions in this directory (each session gets its own alias):
# session 1
pi
> /mesh alias # → @agent-a1b2c3 (or set MESH_ALIAS=alice)
# session 2
pi
> /mesh alias # → @agent-d4e5f6Then, in session 1 (tool call by the agent, or ask it):
mesh_send { "to": "agent-d4e5f6", "message": "hello from A" }
# → "delivered m_lxyz_ab12cd34"
Session 2 receives [mesh] @agent-a1b2c3 (room default, normal) hello from A
as a follow-up turn. It can answer with
mesh_reply { "msgId": "m_lxyz_ab12cd34", "message": "hi A" }.
The broker auto-spawns on first use (lockfile in $TMPDIR/mesh-<uid>/). No
daemon management needed. Try npm run smoke for a full headless demo
(2 clients, mailbox, broker-kill recovery).
Drop the extension into another Pi project: copy
index.tsand thesrc/tree into that project, orpi installthis package — themesh_*tools +/meshcommand are available. The extension is a thin adapter over the client — everything else is self-contained Node.
- Versioning follows semver from
package.json(v0.1.0= tag + npm version). - The
ReleaseGitHub Action publishes to npm automatically onv*tags (requires theNPM_TOKENrepository secret):npm version patch|minor|major && git push && git push --tags.prepublishOnlyruns the full build + test suite before every publish. - The package is published as
pi-mesh-extensionon npmjs.org (publishConfig.accessis public; the plainpi-meshname is already taken on npm by another project) and is a Pi package (pimanifest +pi-packagekeyword), sopi install npm:pi-mesh-extensionworks on any machine and the package appears in the pi.dev gallery automatically.
| tool | params | returns (honest one-liner + details) |
|---|---|---|
mesh_send |
to?, message, room?, broadcast?, priority?, reason?, awaitReply?, timeoutMs?, refs? |
delivered / queued_offline / reply: … / expired / blocked: … |
mesh_reply |
msgId, message, replyAll?, to?, refs? |
delivered or blocked: reply_without_target |
mesh_status |
room? |
live broker snapshot (peers, rooms, reservations) |
mesh_history |
limit?, withBodies? |
local memory ring (never the ledger) |
mesh_reserve |
paths, reason? |
reserve files/dirs — peers' edit/write get blocked on them |
mesh_release |
paths? (omit = all) |
release reservations, peers notified immediately |
Reply handling (D25): replies are deduped — only the FIRST answer to a
given msgId reaches the session (via awaitReply or as an injected orphan);
later duplicates (agents re-answering on reminds or after re-sends) are
dropped silently, so the orchestrator never re-processes an answer it already
handled. Replies are delivered with steer (they interrupt the current
reflection) instead of queuing until the turn ends. awaitReply defaults to
30 min (was 10) — long missions no longer "expire" while agents are still
working — and an expired result explicitly says late replies are still
delivered. Re-replying to the same msgId within 10 min returns a warning
(already_replied) instead of silently duplicating.
Broadcast & reply variants (D24): mesh_send with broadcast: true fans
a message out to every member of room (omitting to), and the honest ack
reports deliveredCount/totalCount. mesh_reply keeps strict 1:1 correlation
by default, and adds two variants: replyAll: true answers the WHOLE room of
the original message (counts in the ack), and to: <alias> targets another
member than the original sender (e.g. bounce a mission to a colleague). The
inbound format marks fan-outs: (room ops, normal, broadcast) / reply-all.
Priorities: normal → followUp · urgent → steer · force → controlled abort
(when the recipient is busy) + delivery once the aborted turn settles. force
requires reason (hashed into reasonHash, never persisted) and is governed by
policy.forceAllowedFrom.
File reservations: claim paths before touching them — a trailing /
reserves a whole subtree (web/tools/), anything else is exact. While a peer
holds a reservation, other agents' edit/write tool calls on those paths are
blocked with a message naming the holder + reason, and they are told to
coordinate via mesh_send. Reservations live with the connection (like
presence): a disconnected peer's reservations vanish automatically, and every
change is broadcast to all peers in < 50 ms.
Command: /mesh status [room] · join <room> [as <alias>] [observer] · leave <room> · alias [<new-alias>] · log [on|off] · ping <alias> · broker · help. join … as <alias> renames this session live (re-hello under the new
alias, rooms + reservations re-declared) and then joins the room — so a
session can claim agent-1 at any time, not just at startup.
HUD: a live widget above the editor shows mesh state at a glance —
mesh ● alice @default,ops · peers: bob,carol(+3) · pend:2 · tx:on — with the
connection dot (● online / ◐ connecting / ○ offline), rooms, online peers,
awaitReply pendings, transcript state, failure counters (only when > 0) and the
last activity (< 90 s, memory-only preview). A compact mesh:N / mesh:off
sits in the footer status. Run /reload after changing the extension to
refresh it.
node dist/src/cli/mesh.js broker start|stop|status
node dist/src/cli/mesh.js peers [--room R]
node dist/src/cli/mesh.js send <alias> "text" [--room R] [--await] [--timeout MS]
node dist/src/cli/mesh.js tail # follows the local hash-only ledger
node dist/src/cli/mesh.js doctor # socket? lock stale? pid? protocol?<cwd>/.mesh/config.json (all optional):
Aliases can also be set at runtime: /mesh join ops as agent-1 claims the
alias agent-1 and joins room ops in one step, and
/mesh alias <new-alias> renames at any time. An alias already held by a
live peer is refused (alias_taken) and the session keeps its previous
identity.
Identity survives /reload: alias, rooms and reservations are persisted
in <stateDir>/identity-<sessionId>.json — one file per pi session (stable
across extension reloads, and multiple sessions sharing a stateDir never
overwrite each other). After a reload the agent comes back with the exact same
mesh identity — no more random alias, lost rooms or vanished reservations.
Stale persisted reservations older than 24 h are dropped at load, and if a
crashed session still holds the alias, the client falls back to a random one
(notify + persisted) instead of looping.
.mesh/policy.json (declarative governance, evaluated at send time):
{ "allow": [{ "from": "*", "to": "*", "room": "*" }],
"deny": [{ "from": "observer-*", "to": "*" }],
"forceAllowedFrom": ["lead"],
"rateLimits": { "msgPerMin": 30, "urgentPerMin": 5, "forcePerMin": 1 } }Env overrides: MESH_ALIAS, MESH_ROOMS, MESH_RUNTIME_DIR,
MESH_STATE_DIR, MESH_MAX_FRAME_BYTES, MESH_MAILBOX_CAP,
MESH_MAILBOX_TTL_MS, MESH_TRANSCRIPT=1, MESH_POLICY.
- Windows: AF_UNIX sockets are unavailable on win32 (
listenthrowsEACCES), so the broker endpoint falls back to a named pipe (\\.\pipe\mesh-<hash>-broker). Everything else is unchanged: the lock file is still a regular file,mesh doctorshows the resolved endpoint, and the full test suite + smoke pass on Windows.
- I1 no message body is ever persisted outside the opt-in transcript;
the ledger is hash-only (
bodyStored:false, fail-closed forbidden-key scan). - I2 presence = live sockets. No registry files, no sweeps of files.
- I3 one alias = one connection;
alias_takenrefused in the same tick. - I4
delivered= written on the recipient socket (or its mailbox) — after the broker ack, never before (C5). Never a completion. - I5 replies exist only via explicit
mesh_reply(strictreplyTo === msgId). - I6 the broker is stateless: kill it any time, clients re-
hello(reservations are re-declared in the hello frame). - I7 zero loops: rate caps + anti-duplicate window + self-send block.
- I8 every bound is a named constant (frame 64 KiB, body 32 KiB, mailbox 100/1 h, reminds ≤ 2, 16 rooms/peer, …).
- I9
protocol/,broker/,client/import nothing from Pi; the extension is a thin adapter with localpi-types.ts. - I10 broker down → tools answer
blocked{broker_unavailable}, never crash. - I11 file reservations live with the connection: declared at
hello, broadcast on every change, gone when the peer disconnects (D21). - I12 identity persistence (D23): alias/rooms/reservations are stored per
pi-sessionId in
<stateDir>/identity-<sessionId>.jsonand re-declared on reload; sessions sharing a stateDir stay isolated (one file per session).
- Mailbox is volatile: broker restart loses queued offline messages.
Senders always get the honest
queued_offlinestatus. - Loopback only: unix socket (named pipe on Windows) on one machine, no network, no auth/encryption.
- Rooms are per-connection: a peer in zero rooms cannot send/receive
room messages until it joins one again (
/mesh join <room>). - No room broadcast: messages are strictly unicast (
to=<alias>required). A room carries presence + authorization only (rolesmember/observer,policy) — sending "into a room" only touches the explicit recipient.
npm run build # strict tsc (ESM, NodeNext)
npm test # build + node --test dist/test/*.test.js
npm run smoke # E2E without Pi: broker + 2 headless clientsLayout: src/protocol (frames, envelope) · src/broker (server, rooms,
mailbox, ratelimit, policy) · src/client (MeshClient, pending, reconnect) ·
src/extension (Pi adapter: tools, commands, inbound, guards, ledger,
transcript) · src/cli · test/ · scripts/mesh-smoke.mjs.

{ "alias": "alice", "rooms": ["default"], "transcript": false, "mailboxCap": 100, "mailboxTtlMs": 3600000, "ledgerMaxBytes": 5242880 }