Skip to content
Apothic-AIPublic

About

Rooms, DMs and reply-all for pi coding agent sessions. File-based, no daemon.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

Repository files navigation

pi-rooms

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.

What this is not

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.

Install

pi install npm:@apothic-ai/pi-rooms
pi install git:github.com/Apothic-AI/pi-rooms   # from source

State lives in ~/.pi/agent/rooms unless PI_ROOMS_DIR says otherwise.

Identity

Each session registers one name. Precedence:

  1. PI_ROOMS_NAME
  2. PI_AGENT_NAME (so existing pi tooling and this extension agree)
  3. a name derived from the working directory plus a short hash, e.g. m2-core-1a2f

Pin PI_ROOMS_NAME if you use rooms

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-core

Treat 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.

Pinning a running fleet: names.json

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:

  1. PI_ROOMS_NAME, then PI_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.
  2. session:<session-id> — the key to prefer. The extension reads the supported ctx.sessionManager.getSessionId() API (PI_SESSION_ID is a fallback only). Resume/reload keeps the id; /new and /fork create a new id, so do not inherit the old session pin. The pin survives a resume in a new process or pane.
  3. 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.
  4. 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, and rooms_pins says 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 a lane: leave-and-join

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.

Correlating a name with a pane

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.

Liveness

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.

Tools

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

Delivery status

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_dm reports this the moment it returns.
  • deliveredAt — the recipient's own session was handed the message through pi.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 alongside deliveredAt. 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

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.

Joining and leaving

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.

Reply all

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.

Configuration

<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.

Storage

<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.

Notes for fleets

  • One PI_ROOMS_DIR shared 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_NAME on 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_who as the live roster on Linux, and as advisory on macOS where the /proc checks are unavailable.

Development

npm test        # store, config and extension integration tests

Tests run against temp directories; no pi process and no model are needed.

About

Rooms, DMs and reply-all for pi coding agent sessions. File-based, no daemon.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages