Rooms, direct messages and reply-all for pi coding-agent sessions. File-based: no daemon, no server, no database.
A room is a named set of agents with an append-only history. A leader creates the room, everyone in it can post, and any agent can read the history forward from a sequence number — so a lane that joins late can catch up without replaying everything.
Deliberately not included: task orchestration, planning, worker spawning, file reservations, activity feeds. This is a messaging layer only. If you want a PRD-to-parallel-workers orchestrator, this is the wrong package.
pi install npm:@apothic-ai/pi-rooms
pi install git:github.com/Apothic-AI/pi-rooms # from sourceState lives in ~/.pi/agent/rooms unless PI_ROOMS_DIR says otherwise.
Each session registers one name. Precedence:
PI_ROOMS_NAMEPI_AGENT_NAME(so existing pi tooling and this extension agree)- a name derived from the working directory plus a short hash, e.g.
m2-core-1a2f
This is the supported way to get stable identity, and rooms are not durable
without it. The directory-derived fallback is stable across restarts only while
the name is unchallenged. If two sessions in the same directory claim it at once,
the second gets a pid suffix (overlord-18c7-46525), and after a restart neither
name comes back: the hash is the same but the pid is new, so the lane returns as
overlord-18c7-72208.
The consequence is silent: a room's membership empties across a restart. Room
members are stored by name, so every member that comes back under a different name
is simply no longer that room's member — rooms_post pushes to nobody, and
rooms_who shows a lane the room has never heard of. Nothing errors, because from
the extension's point of view the old member is gone and a new agent has arrived.
Pin the name per pane and none of that happens:
# in each lane's environment
PI_ROOMS_NAME=dxdb-coreTreat the name as a durable lane identity, like a hostname, not as a label. If you rename a lane, it leaves its rooms and must be re-added; there is no rename alias.
PI_ROOMS_NAME only takes effect at process start, so pinning a fleet that is
already running would mean restarting every lane. <base>/names.json assigns names
without that:
{
"pins": {
"pane:wE:p1B": { "name": "dxdb-core" },
"session:01a1b2c3": { "name": "sysops-leader" },
"cwd:/home/me/Code/dxdb": { "name": "dxdb-lead" }
}
}Pins are read on every tool call, so a pin written now applies to a running
lane on its next call — no restart, no signal. rooms_pins lists every pin and
whether it is claimed; the table below is what it prints.
Precedence, highest first:
PI_ROOMS_NAME, thenPI_AGENT_NAME— a pin never overrides an explicit environment name. This is deliberate: the environment is a per-process instruction, and a file cannot speak for a process that already said what it is.session:<session-id>— the key to prefer. The extension reads the supportedctx.sessionManager.getSessionId()API (PI_SESSION_IDis a fallback only). Resume/reload keeps the id;/newand/forkcreate a new id, so do not inherit the old session pin. The pin survives a resume in a new process or pane.pane:<HERDR_PANE_ID>— what you can see in the UI, but herdr recycles pane ids, so it is the convenient key and not the durable one.cwd:<path>— only honoured when exactly one pi lane occupies that directory. A cwd pin carries no way to say which lane in a directory it means, so where two lanes share one it is ignored rather than guessed at, androoms_pinssays so. The check counts live pi processes in the directory, not just lanes that have joined, because a directory with two panes is ambiguous even before the second one registers.
Session before pane is not arbitrary: a session id survives its pane being moved, closed or recreated, and a pane id survives nothing.
Pane/cwd pins are bound to the claiming process. On adoption they are stamped
with that process's start time; a different process cannot inherit them. A
session: pin is instead restart-safe and re-stamped when that session resumes.
All name requests (explicit, pinned and derived) use an atomic presence claim. A live foreign holder wins: the contender gets a pid suffix without overwriting the holder or stamping a pin it did not take. The suffix stays for the session even if the holder exits; restart re-resolves it. A changed explicit name or pin is a new intent and re-registers immediately. Shutdown only removes our own entry.
For cooperating writers on a shared filesystem with atomic mkdir and rename,
presence claims, updates, stale pruning and guarded release serialize under a
per-name atomic directory lock. Mixed/legacy or direct writers can bypass it;
this is not a distributed-filesystem guarantee. An abandoned lock fails closed
after a bounded wait; it is never reclaimed merely because it is old. After verifying no writer
is alive, an operator can remove the abandoned <base>/registry/<name>.lock.
Claims reserve presence before returning. With autoJoin:false, startup and
inspection only resolve name intent: no registry entry, pin stamp, watcher or
inbox drain is created until an explicit joining tool runs (for example
rooms_create or rooms_dm). That join atomically claims an available name,
publishes complete metadata and starts owned-inbox delivery; it never drains the
unowned intent's inbox as a rename. Before joining, every resolution uses current
inputs: a foreign-stamped, ambiguous or removed pin falls back to the derived
name if no other eligible pin remains. First join resolves before claiming;
already-joined lanes retain suffix stickiness. Collision suffixes, including retry
counters, fit within the same 48-character name limit as unsuffixed names.
Renaming is not an alias. A lane's rooms are keyed on its name, so a rename takes it out of every room it was in. Nothing announces this: the lane still runs, its rooms still exist, and posts simply stop reaching it.
Because that failure is silent, rooms_pins refuses to rename a lane that is
currently a member of a room:
Refusing to rename lead -> renamed: it is a member of dxdb. Rooms store members by
name and there is no rename alias, so renaming leaves those rooms. Pin the lane
before creating rooms over it, or pass force:true to proceed.
When it does proceed, it names the rooms left behind. So the order that avoids the problem entirely is: pin first, then create rooms over the lane. A rename is only safe before a lane has joined anything.
A rename is therefore an implicit parting, and a messy one: the lane stops
answering to the old name, but the old name is not removed from room.json.
The room keeps listing it, keeps queueing posts into an inbox nobody drains, and
appends no event saying it happened. rooms_leave is the explicit exit: it takes
the name out of the room, records the event, and clears that name's queue. So the
order that leaves nothing behind is rooms_leave, then rename. See
Joining and leaving.
The lane itself does not strand direct mail across a rename: it drains the inbox
it is leaving once, and then keeps draining it for two minutes, so a peer that has
not noticed the rename still reaches it and the message is receipted against the
name it was addressed to. That window is bounded, and it stops early if another
live agent takes the old name — from that moment the inbox is theirs. It does not
extend to rooms: a room still listing the old name keeps queueing to it, and only
rooms_leave or a re-join under the new name fixes that.
When pi runs under herdr, its process environment carries HERDR_PANE_ID,
HERDR_TAB_ID and HERDR_WORKSPACE_ID. These are recorded at registration and
shown by rooms_who, so a name can be matched to the pane you are looking at
without comparing directories:
* dxdb-core wE:p1B /home/me/Code/dxdb wE:t19
The pane id is a correlation attribute, not an identity. It is positional and recycled, so on its own it says nothing about when; it means "this name is this pane, right now" only because it is paired with the process identity recorded beside it, and the liveness checks invalidate the pairing when that process goes away. Off herdr the fields are simply absent and nothing changes — there is no herdr dependency, no socket, no polling.
The roster reports running processes, not registered ones. A registration is considered gone when its pid is missing, when it was written during an earlier boot, when the pid's start time does not match the one recorded, or when the process is a zombie. Reused pids and post-reboot ghosts are therefore reaped rather than listed.
Platform caveat, stated plainly. The start-time and zombie checks read
/proc/<pid>/stat and are Linux only. Elsewhere — macOS included — there is no
/proc, so the extension degrades to process.kill(pid, 0) and the boot-id file
is absent too. On those platforms a reused pid is not detectable, and a zombie
answers kill(pid, 0) successfully, so presence can outlive the process. The
fallback is deliberate: reporting everything dead on a platform without /proc
would be worse than reporting a stale entry. If you run a long-lived fleet on
macOS, treat rooms_who as advisory and confirm a lane with the multiplexer.
| Tool | Purpose |
|---|---|
rooms_who |
Live agents, their directories and models |
rooms_dm |
Message one agent, or several by passing an array |
rooms_reply_all |
Answer every recipient of a message you received |
rooms_create |
Create a room with a fixed member list |
rooms_join |
Join an existing room, or re-enter one you left |
rooms_leave |
Leave a room: stop its traffic and discard your queue for it |
rooms_post |
Post into a room |
rooms_read |
Read room history, since a sequence number |
rooms_list |
Rooms, members and post counts |
rooms_pins |
List, set and remove name pins; refuses a rename that leaves rooms |
rooms_receipts |
Receipt maintenance: consolidate folds per-recipient receipts back for a rollback |
Every message gets one receipt per recipient at
<base>/receipts/<message-id>/<recipient>.json, recording what the extension could
actually observe:
queuedAt— the recipient's inbox accepted the message.rooms_dmreports this the moment it returns.deliveredAt— the recipient's own session was handed the message throughpi.sendMessage, and the message is archived as delivered only after that handoff. This is the last moment the extension can see anything.injectedAt— the handoff the flush made, recorded alongsidedeliveredAt. It is the same observation, kept separate for diagnosis; neither field is a state of its own.
A receipt is written per recipient, under a short lock, because several sessions
archiving the same message at once used to overwrite one shared file and lose
deliveredAt — a delivered message that read as queued forever. Builds before
this layout kept one file per message, <base>/receipts/<message-id>.json; those
are still read and merged, and rooms_receipts({action:"consolidate"}) writes the
old layout back if you need to roll back to such a build.
There is deliberately no read or acknowledged state. The extension cannot see what a model does with a message once its session holds it, and a receipt that claimed more than it observed would be worse than no receipt at all. "Queued but not delivered" is a real and useful signal: it means the recipient's session is not running, or its inbox watcher never fired.
When a send observes no live holder for a queued target, the sender's tool reply says so. The queue is preserved without expiry, bounce, or deletion; this is only a presence observation, not a delivery acknowledgement. The notice is once per target per sender session until a later send observes a live holder again. It adds no receipt state, background wake, or schema migration.
rooms_create({ room: "dxdb", members: ["core", "testkit", "browser"] })
rooms_post({ room: "dxdb", message: "m2-core green, taking testkit next" })
rooms_read({ room: "dxdb" }) // #1..#20
rooms_read({ room: "dxdb", since: 20 }) // only what is new
History is the file's line order — posts carry no stored sequence number, so two
agents appending at the same moment cannot disagree about what came next. The
number you see is the line's position, which is why since is stable.
rooms_post appends to the history and pushes a copy to every other member.
Members who are not running receive nothing until they start; the history is
still there for them.
Membership is a name test against the room's members list. There is no join
table and no session bookkeeping: a room is joinable by name whether or not any
process currently holds that name.
rooms_join({ room: "dxdb" })
rooms_leave({ room: "dxdb" }) // discards your queued posts for dxdb
rooms_leave({ room: "dxdb", discardQueued: false })
A join or a leave is history, not speech. Both append a line to the room log
with kind: "event", which has no recipients: nobody is notified, no inbox file
is written, and rooms_read shows it differently from a message.
#11 09:41 dxdb-core: m2-core green
#12 09:44 · dxdb-core left the room
Events occupy the same sequence numbers as messages, so a since cursor keeps
working across them. Only a real membership change is recorded — a second
rooms_join for a room you are already in is a no-op, and creating a room does
not invent a join event for its creator.
rooms_leave discards your backlog by default, because the point of parting
is to stop spending context on that room. Queued-but-undelivered messages of
yours whose room is the room you left are deleted, and the count is reported.
Only your own inbox is touched, and only the queue — the read/ archive kept for
reply_all, messages for other rooms, and direct messages all survive. Message
receipts are left alone too, so a discarded message honestly stays queued and
never claims a delivery that did not happen. Pass discardQueued: false to keep
the queue; those messages will still be delivered after you have left.
Re-entry does not replay. rooms_join re-admits you, and posts made while you
were out stay in the history for rooms_read — they are never re-queued into your
inbox. Nothing is delivered to a non-member, so a post after your parting writes
no inbox file for you at all.
Direct messages are unaffected by room membership: a lane that has left a room is
still reachable with rooms_dm. Leaving the last member is allowed — the room
persists, empty and joinable, and its history stays readable.
rooms_dm records its recipient list on the message. A recipient can then answer
everyone without re-deriving the list:
rooms_dm({ to: ["core", "testkit"], message: "who owns the migration?" })
// in core's session, from the delivered message id:
rooms_reply_all({ messageId: "0b773ed9-…", message: "mine, until Friday" })
The reply goes to the original sender plus everyone else who was addressed,
minus yourself. For a message that came from a room, rooms_reply_all posts into
the room instead, so the answer lands in the history.
<base>/config.json, all fields optional:
{
"autoJoin": true,
"maxSendsPerSession": null,
"dmDelivery": "steer",
"roomDelivery": "followUp",
"pollMs": 2000,
"maxDeliverPerFlush": 5,
"staleAfterMs": 900000
}| Field | Default | Meaning |
|---|---|---|
autoJoin |
true |
Register presence on session start |
maxSendsPerSession |
null |
Unbounded. Set a positive number to cap outbound messages per session |
dmDelivery |
"steer" |
A DM interrupts the receiver now |
roomDelivery |
"followUp" |
A room post is delivered when the receiver is idle |
pollMs |
2000 |
Inbox fallback poll, for filesystems where watch is unreliable |
maxDeliverPerFlush |
5 |
Most messages handed to a session per tick. The rest stay queued, in arrival order, and follow on the next tick — a lane coming back from a long turn is not injected with the whole backlog at once. Only the first message of each tick asks for a turn |
staleAfterMs |
900000 |
Messages queued longer than this (15 min) are rendered with an explicit age prefix, so a catch-up cannot read as fresh conversation |
There is no summary mode: every queued message is handed over in full. A digest
would have to archive a body nothing can fetch back — rooms_read covers rooms
only, and there is no tool that reads a direct message — so bounding is the only
mechanism here, and the age prefix is the only compression.
The send cap is off by default and is structural when on: it is enforced on the
only path that can send, so an agent cannot route around it. A cap is the
pragmatic brake on two agents volleying indefinitely; a fleet that would rather
not have one should leave it null and rely on delivery modes instead.
<base>/registry/<name>.json presence: name, pid, proc identity, pane, cwd, model
<base>/inbox/<name>/*.json undelivered messages
<base>/inbox/<name>/read/*.json delivered messages, kept for reply_all lookup
<base>/receipts/<id>/<recipient>.json one receipt per recipient: queued/delivered/injected stamps
<base>/receipts/<id>.json pre-migration single-file receipt, still read
<base>/receipts/<id>/<recipient>.json.lock held for the duration of one receipt write
<base>/names.json name pins (pane, session, cwd)
<base>/rooms/<room>/room.json members, creator
<base>/rooms/<room>/log.jsonl append-only history, messages and membership events
Presence is pruned by process liveness, so a killed lane disappears from the
roster rather than lingering as a ghost. Nothing here is a database: tail -f
on a room log is a complete view of that room.
- One
PI_ROOMS_DIRshared by every lane is one namespace. A room is the unit of grouping; you do not need to partition directories to keep conversations apart. - Pin
PI_ROOMS_NAMEon every lane. See Identity. Without it a room's membership silently empties across a restart, which is the kind of thing a user discovers at the worst possible moment — mid-recovery, when a reliable roster matters most. roomDelivery: "followUp"is the safer default for a fleet: room chatter waits for an idle boundary instead of interrupting work.- Messages are delivered into the receiver's context. Anyone in a room can therefore put text in front of every other member — treat room membership as a trust boundary, the same as giving someone a prompt.
- Treat
rooms_whoas the live roster on Linux, and as advisory on macOS where the/procchecks are unavailable.
npm test # store, config and extension integration testsTests run against temp directories; no pi process and no model are needed.