Several Pi terminals,
talking to each other.
A Pi extension that lets terminals on the same machine find each other, send each other work, see who is busy, and ask each other to compact. This page follows the real code, one piece at a time.
The pieces
Three files do the work, plus Pi itself. The extension is the core: every terminal loads the
same index.ts and, once linked (--link, the launcher or /link-connect),
either hosts the network or joins it.
Networking, routing, inbox, status, tools and slash commands, all in one file that Pi loads into each terminal.
Starts Pi with a named, resumable session, lists sessions, and asks the running hub who is connected.
Teaches the model how the three tools really behave: delivery, snapshots, callbacks, groups.
Plain WebSockets. Everything else is Node and the Pi extension API.
A star on localhost
The network is hub-and-spoke. One terminal hosts a WebSocket server on
127.0.0.1:9900; every other terminal connects to it as a client. Clients never
talk to each other directly: every message goes through the hub.
Being on one machine is a design decision, not an accident: no authentication, no network exposure, and working directories that mean something to every participant.
Finding a hub: try, then become
There is no leader election. On startup (--link, or the pi-link
launcher) and on /link-connect, initialize() runs one simple
fallback. Whoever binds the port is the hub.
Only one attempt is ever in flight. A startup connect, a retry and a manual
/link-connect all join the same attempt instead of opening a second socket, and the
attempt record itself is the ownership token: callbacks from a cancelled or superseded
transport are inert.
Joining, and who you are
A client introduces itself with register; the hub answers and tells everyone else.
Names
The requested name is resolved at session start, in this order (/link-name changes it later):
The hub keeps names unique with uniqueName(): a second builder becomes
builder-2. A name you set explicitly (flag, launcher or /link-name) is saved and
requested again on reconnect; a hub suffix never is.
Groups
A name like archon@pi-link belongs to group pi-link, the text after the
first @. The hub routes every group over the same connections, but
routeMessage() treats a target in another group as not found, and
link_list only shows your own. Suffixes touch the local part only:
archon-2@pi-link. It isolates attention, not security.
The whole protocol, in nine frames
JSON over WebSocket frames. There is no versioning and no compatibility layer: every terminal is expected to run the same release.
| Frame | Direction | Purpose |
|---|---|---|
register | client → hub | Ask for a name; report cwd and context |
welcome | hub → client | Assigned name and the peers' snapshots |
terminal_joined | hub → all | Someone arrived |
terminal_left | hub → all | Someone left |
chat | any → any | A message for the receiver's model |
compact_request | any → any | Ask a terminal to compact |
compact_response | any → any | Its result: ok, or a reason |
status_update | any → hub → all | Status plus fresh context |
error | hub → client | For example: target not found |
A message's journey
When a model calls link_send, the text travels as a chat frame through
the hub to the target, where it does not go straight into the model. It lands in an
inbox first.
Two properties follow from the code:
The window is fixed, not sliding. scheduleFlush() only arms a timer when none
is pending, so later arrivals join the window instead of pushing it back. A flood cannot starve
delivery.
Success means "accepted", not "read". On a client, routeMessage() returns
true as soon as the frame is on its way to the hub. There are no receipts; a reply is
just another link_send from the other side.
Status and context: pushed, not polled
Each terminal tells the others what it is doing. Pi's lifecycle events mutate a few flags, one function turns them into a single status, and it is broadcast only when what a peer would see actually changed.
Why compacting comes first. Reporting compacting and holding delivery are the
same condition, compactionGated(), so a peer's view and the inbox cannot drift apart.
Why context rides along. Every push reads the context again and carries it, or null when there is none; peers compute the percentage when they show it. The forced pushes cover changes that alter usage without changing status: after a compaction, a model switch (the window can change, and with it the percentage) and a move to another branch of the session tree.
Parallel tools don't churn. The displayed tool is the first still-running call; a second starting behind it is silent.
link_list computes your own
entry live; everyone else's is what they last pushed.Remote compaction
link_compact asks another terminal to run the same operation as /compact
and waits for the answer, at most five minutes. The target decides.
Why hold the inbox at all. A message delivered while the context is being rebuilt would be reasoned about against context that is about to disappear. Automatic compaction is left alone: it runs inside an agent run, where Pi already queues steered messages.
Timeouts bound waiting, nothing else. A requester that gives up does not abort the target;
the compaction runs to its own end. An error before Pi's hook drains the inbox at once; one after it
leaves localCompacting up until the next run, a later successful compaction, or a
300 s backstop.
When the hub dies
Clients see their socket close, go disconnected, and schedule a reconnect after a
random 2–5 seconds. Then they run the same initialize() as at startup. Whoever first
binds the port becomes the hub; the rest join it, or retry if they lose the race.
Nothing is handed over. Every terminal drops the snapshots it held about others (names,
statuses, cwds, context) and keeps its own state, inbox included. The new roster is rebuilt from
fresh register frames.
The CLI, outside the network
bin/pi-link.mjs never joins the link. It does three jobs around it:
Resumes the session named builder in this cwd (or creates it) and starts
pi --link, handing the name over through PI_LINK_NAME, which the
extension reads once and deletes.
--list shows saved pi-link sessions for this cwd (every cwd with
--global); --resolve <name> prints one's path.
Plain HTTP GET /status on the hub's port. The hub owns the HTTP server its
WebSocket server rides on, which is what makes this possible.
Code map
Where each idea lives in index.ts, top to bottom.
| Concern | Functions |
|---|---|
| Constants | DEFAULT_PORT FLUSH_DELAY_MS BATCH_MAX_* COMPACT_TIMEOUT_MS |
| Status | deriveStatus displayedTool statusIdentity captureContext pushStatus |
| Names & groups | normalizeName groupOf uniqueName |
| Inbox & gate | scheduleFlush flushInbox compactionGated releaseInbox |
| Routing | routeMessage handleIncoming hubBroadcast hubHandleClient |
| Connection | initialize runAttempt connectAsClient startHub scheduleReconnect disconnect cleanup |
| Pi integration | pi.on(…) handlers · registerTool × 3 · registerCommand × 4 |
Floor: Pi 0.84.2, for agent_settled and ctx.isIdle(). Tools are
registered with loadMode: "essential" so harnesses such as Oh My Pi show them top-level.