pi-link · how it works

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.

Scroll ↓
01

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.

index.tsThe extension

Networking, routing, inbox, status, tools and slash commands, all in one file that Pi loads into each terminal.

bin/pi-link.mjsThe CLI

Starts Pi with a named, resumable session, lists sessions, and asks the running hub who is connected.

skills/pi-link-tools/SKILL.mdThe skill

Teaches the model how the three tools really behave: delivery, snapshots, callbacks, groups.

wsOne dependency

Plain WebSockets. Everything else is Node and the Pi extension API.

Pi terminal model · session · tools · lifecycle events pi-link extension index.ts Tools link_send link_list link_compact Commands /link /link-name /link-connect … Lifecycle hooks agent_* tool_execution_* session_* model_* Inbox & status batched delivery · compaction gate flushInbox() pushStatus() Connection role: hub | client | disconnected initialize() routeMessage() Skill read by the model pi-link CLI launches Pi, reads GET /status Other terminals 127.0.0.1:9900
One terminal, from the inside. Every terminal runs the same extension; only its role differs.
02

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.

hub :9900 WS + GET /status builder client reviewer client archon@pi-link client t-a1b2 client · random name
The hub is just a terminal that got there first. It is also a participant: it has its own model, tools and inbox.

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.

03

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.

initialize() one attempt at a time connectAsClient() 5 s handshake limit startHub() bind :9900 retry after 2–5 s client ✓ hub ✓ fail port busy
runAttempt(): a client first, a hub if nobody answers, a randomized retry if both lose a race.

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.

04

Joining, and who you are

A client introduces itself with register; the hub answers and tells everyone else.

new client hub other clients register { name, cwd, context } welcome { name, peers' snapshots } terminal_joined status_update (forced)
Until register arrives, the hub ignores anything else from that socket.

Names

The requested name is resolved at session start, in this order (/link-name changes it later):

--link-name› PI_LINK_NAME › saved via /link-name› session name › random t-a1b2

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.

05

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.

FrameDirectionPurpose
registerclient → hubAsk for a name; report cwd and context
welcomehub → clientAssigned name and the peers' snapshots
terminal_joinedhub → allSomeone arrived
terminal_lefthub → allSomeone left
chatany → anyA message for the receiver's model
compact_requestany → anyAsk a terminal to compact
compact_responseany → anyIts result: ok, or a reason
status_updateany → hub → allStatus plus fresh context
errorhub → clientFor example: target not found
06

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.

link_send sender's model routeMessage() group check hub handleIncoming() receiver inbox first arrival arms a 200 ms window flushInbox() ≤ 20 messages · ~16 000 chars held while compaction gate is up pi.sendMessage( triggerTurn) idle → starts a turn busy → steered in One block: [Link: N message(s) received], then From "name": per message.
Pi decides between a new turn and steering at delivery time, not at send time.

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.

07

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.

Pi events agent_* tool_execution_* session_*compact state agentRunning activeTools compact gates deriveStatus() 1 compacting 2 tool:<name> 3 thinking 4 idle first wins pushStatus() same identity? → skip else broadcast status_update { status, context } context = { tokens, contextWindow } or null, read now Forced pushes publish even when the status is unchanged, because only the context moved: welcome session_compact model_select session_tree
statusIdentity() is the single definition of "the same status": the dedupe and the "since" clock both use it, so they can never disagree.

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.

Peers only ever see the last published snapshot. link_list computes your own entry live; everyone else's is what they last pushed.
08

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.

requester target target's inbox compact_request { instructions } idle and not gated? no → answer "busy", nothing queued compactRunning = true held ctx.compact() session_before_compact if reached → localCompacting = true onComplete → finish() drains onError → finish() held* compact_response { ok, reason? }
Two gates, one condition: a remote request raises compactRunning before Pi's own hook raises localCompacting. *Held only if the error came after the hook; see below.

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.

09

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.

hub exits sockets close random 2–5 s backoff in-flight messages can be lost first bind → hub keeps its name others rejoin request preferred name

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.

10

The CLI, outside the network

bin/pi-link.mjs never joins the link. It does three jobs around it:

pi-link builderLaunch by name

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.

pi-link --list · --resolveFind sessions

--list shows saved pi-link sessions for this cwd (every cwd with --global); --resolve <name> prints one's path.

pi-link --status [--json]Ask the hub

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.

11

Code map

Where each idea lives in index.ts, top to bottom.

ConcernFunctions
ConstantsDEFAULT_PORT FLUSH_DELAY_MS BATCH_MAX_* COMPACT_TIMEOUT_MS
StatusderiveStatus displayedTool statusIdentity captureContext pushStatus
Names & groupsnormalizeName groupOf uniqueName
Inbox & gatescheduleFlush flushInbox compactionGated releaseInbox
RoutingrouteMessage handleIncoming hubBroadcast hubHandleClient
Connectioninitialize runAttempt connectAsClient startHub scheduleReconnect disconnect cleanup
Pi integrationpi.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.