pi-link 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +482 -0
  3. package/index.ts +986 -0
  4. package/package.json +28 -0
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 alvivar
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,482 @@
1
+ # pi-link
2
+
3
+ A WebSocket-based inter-terminal communication system that creates a local network between multiple Pi coding agent terminals. Enables terminals to discover each other, exchange messages, and orchestrate work across agents — all automatically on `localhost`.
4
+
5
+ > Self-contained TypeScript in a single `index.ts` file. Start Pi with `--link` to enable.
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Why?](#why)
12
+ - [Prerequisites](#prerequisites)
13
+ - [Quick Start](#quick-start)
14
+ - [Walkthrough](#walkthrough)
15
+ - [Configuration](#configuration)
16
+ - [LLM Tools](#llm-tools)
17
+ - [Slash Commands](#slash-commands)
18
+ - [Architecture](#architecture)
19
+ - [Troubleshooting](#troubleshooting)
20
+ - [Limitations & Design Decisions](#limitations--design-decisions)
21
+ - [Dependencies](#dependencies)
22
+ - [Internals](#internals)
23
+
24
+ ---
25
+
26
+ ## Why?
27
+
28
+ A single Pi terminal is powerful. Multiple terminals working together unlock new patterns:
29
+
30
+ - **Research + Build** — one terminal investigates APIs, docs, or logs while another writes code based on the findings.
31
+ - **Fan-out** — split a large task across agents (e.g., "terminal A handles the backend, terminal B handles the frontend") and collect results.
32
+ - **Orchestrator / Worker** — designate one terminal as a coordinator that delegates subtasks to others via `link_prompt` and assembles the final output.
33
+ - **Review pipeline** — one terminal writes code, another reviews it, back and forth until both are satisfied.
34
+
35
+ ---
36
+
37
+ ## Prerequisites
38
+
39
+ - [Pi coding agent](https://github.com/nicholasgasior/pi-coding-agent) installed and working
40
+ - Node.js (LTS recommended)
41
+
42
+ ---
43
+
44
+ ## Quick Start
45
+
46
+ ### Install
47
+
48
+ ```bash
49
+ pi install npm:pi-link
50
+ ```
51
+
52
+ or from source:
53
+
54
+ ```bash
55
+ pi install https://github.com/alvivar/pi-link
56
+ ```
57
+
58
+ ### Uninstall
59
+
60
+ ```bash
61
+ pi uninstall npm:pi-link
62
+ ```
63
+
64
+ ### Usage
65
+
66
+ Link is **off by default**. Start Pi with the `--link` flag to auto-connect on startup:
67
+
68
+ ```
69
+ Terminal 1 Terminal 2
70
+ ---------- ----------
71
+ $ pi --link $ pi --link
72
+ ✓ Link hub on :9900 as "t-a1b2" ✓ Joined link as "t-c3d4" (2 online)
73
+ ```
74
+
75
+ Already in a session without `--link`? You can connect mid-session with `/link-connect`.
76
+
77
+ Use `/link` in any terminal to check status, or let the LLM tools handle cross-terminal coordination.
78
+
79
+ ---
80
+
81
+ ## Walkthrough
82
+
83
+ Here's a concrete example of two terminals collaborating. Open two separate `pi --link` sessions.
84
+
85
+ **Terminal 1** — rename and check status:
86
+
87
+ ```
88
+ > /link-name builder
89
+ ✓ Renamed to "builder"
90
+
91
+ > /link
92
+ ⚡ Link: "builder" (hub) · 2 terminals online: builder, researcher
93
+ ```
94
+
95
+ **Terminal 2** — rename it too:
96
+
97
+ ```
98
+ > /link-name researcher
99
+ ✓ Reconnecting as "researcher" (hub may assign a different name if taken)...
100
+ ```
101
+
102
+ **Now ask Terminal 1's LLM to delegate work:**
103
+
104
+ In Terminal 1, type a normal prompt:
105
+
106
+ ```
107
+ > Use link_prompt to ask "researcher" to summarize the contents of README.md in this directory
108
+ ```
109
+
110
+ The LLM in Terminal 1 calls `link_prompt` → Terminal 2's LLM receives the prompt, reads the file, and sends back a summary → Terminal 1's LLM presents the result to you.
111
+
112
+ **Or broadcast a message to all terminals:**
113
+
114
+ ```
115
+ > /link-broadcast starting the deployment pipeline
116
+ ✓ Broadcast sent
117
+ ```
118
+
119
+ Every other terminal sees:
120
+
121
+ ```
122
+ ⚡ [builder] starting the deployment pipeline
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Configuration
128
+
129
+ Link is **off by default**. Without `--link`, the extension is completely silent — no status bar, no connections, no warnings.
130
+
131
+ | Method | When | Auto-reconnect? |
132
+ | ------------------- | ----------------------------------- | --- |
133
+ | `pi --link` | Auto-connect on startup | Yes |
134
+ | `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
135
+ | `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
136
+
137
+ `/link-connect` enables full participation in Pi Link regardless of whether `--link` was passed. `/link-disconnect` always wins — even over `--link` — until you explicitly `/link-connect` again.
138
+
139
+ Once connected, terminals discover each other on `127.0.0.1:9900`. See [Limitations](#limitations--design-decisions) for the hardcoded port.
140
+
141
+ ---
142
+
143
+ ## LLM Tools
144
+
145
+ The extension registers three tools that the LLM can invoke during agent runs.
146
+
147
+ ### Which tool should I use?
148
+
149
+ | Tool | Behavior | Returns |
150
+ | -------------- | ---------------------------------------------------- | ---------------------------------------- |
151
+ | `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
152
+ | `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
153
+ | `link_list` | List currently connected terminals | Terminal directory with roles |
154
+
155
+ **If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
156
+
157
+ ### `link_send`
158
+
159
+ Send a fire-and-forget chat message to a specific terminal or broadcast to all.
160
+
161
+ | Parameter | Type | Description |
162
+ | ------------- | --------- | ---------------------------------------------------- |
163
+ | `to` | `string` | Target terminal name, or `"*"` for broadcast |
164
+ | `message` | `string` | Message content |
165
+ | `triggerTurn` | `boolean` | If `true`, the receiver's LLM responds automatically |
166
+
167
+ When `triggerTurn` is enabled, the message is delivered via `pi.sendMessage` with `deliverAs: "steer"`, causing the remote agent to kick off an LLM turn. Note: `triggerTurn` does **not** cause the response to come back to the caller — use `link_prompt` for that.
168
+
169
+ > **Broadcast note:** Sending to `"*"` delivers to **all other terminals** — the sender is excluded.
170
+
171
+ Pre-validates the target name against the local terminal list before sending, catching typos early. On the hub, delivery confirmation is authoritative. On clients, delivery is optimistic — the message is sent to the hub for routing.
172
+
173
+ ### `link_prompt`
174
+
175
+ Send a prompt to a remote terminal and **wait** for the LLM's response (synchronous RPC pattern).
176
+
177
+ | Parameter | Type | Description |
178
+ | --------- | -------- | -------------------- |
179
+ | `to` | `string` | Target terminal name |
180
+ | `prompt` | `string` | Prompt text to send |
181
+
182
+ - The remote terminal processes the prompt via `pi.sendUserMessage()` — as if a user typed it.
183
+ - Returns the remote terminal's actual assistant reply text as the tool result.
184
+ - **2-minute timeout**; supports abort signals.
185
+ - **Early failure detection** — if the message can't be delivered (e.g., target not found), the tool resolves immediately with an error instead of waiting for the timeout.
186
+ - Targets **one terminal at a time** (no broadcast mode).
187
+ - Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
188
+
189
+ ### `link_list`
190
+
191
+ Lists all connected terminals with role info and self-identification. Takes no parameters.
192
+
193
+ **Example output:**
194
+
195
+ ```
196
+ Connected terminals:
197
+ • pi-1 (you)
198
+ • pi-2
199
+ • pi-3
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Slash Commands
205
+
206
+ | Command | Purpose |
207
+ | ------------------------ | --------------------------------------------------------------------------------------------------------- |
208
+ | `/link` | Show link status (name, role, online count) |
209
+ | `/link-name [name]` | Rename this terminal. With no argument, adopts the current Pi session name if available. Collision-safe. |
210
+ | `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
211
+ | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
212
+ | `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
213
+
214
+ ### Examples
215
+
216
+ ```
217
+ > /link
218
+ ⚡ Link: "builder" (hub) · 3 online: builder, worker-1, worker-2
219
+
220
+ > /link-name orchestrator
221
+ ✓ Renamed to "orchestrator"
222
+
223
+ > /link-name
224
+ ✓ Renamed to "my-session" (adopts Pi session name)
225
+
226
+ > /link-broadcast starting the build pipeline
227
+ ✓ Broadcast sent
228
+
229
+ > /link-disconnect
230
+ ✓ Disconnected from Pi Link
231
+
232
+ > /link-connect
233
+ ✓ Joined Pi Link as "orchestrator" (3 online) ... or ...
234
+ ✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
235
+ ```
236
+
237
+ See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
238
+
239
+ ---
240
+
241
+ ## Architecture
242
+
243
+ ### Hub-Spoke Topology
244
+
245
+ The network topology is **hub-spoke (star)**:
246
+
247
+ ```
248
+ +-----------+
249
+ | Hub |
250
+ | :9900 |
251
+ +-----+-----+
252
+ |
253
+ +--------------+--------------+
254
+ | | |
255
+ +---+---+ +---+---+ +---+---+
256
+ | pi-2 | | pi-3 | | pi-4 |
257
+ |client | |client | |client |
258
+ +-------+ +-------+ +-------+
259
+ ```
260
+
261
+ - The **first terminal** to start becomes the **hub** — it runs a `WebSocketServer` on `127.0.0.1:9900`.
262
+ - **Subsequent terminals** connect as **clients** via plain WebSocket.
263
+ - All messages route **through the hub**; clients never talk directly to each other.
264
+
265
+ ### Auto-Discovery Protocol
266
+
267
+ The discovery sequence runs on startup (with `--link`) or when `/link-connect` is used. See [Configuration](#configuration) for details.
268
+
269
+ The sequence is a simple fallback:
270
+
271
+ 1. Attempt to connect as a **client** to `127.0.0.1:9900`.
272
+ 2. If connection fails → become the **hub** (start a WebSocket server on that port).
273
+ 3. If both fail (rare race condition) → retry after a randomized 2–5 second backoff.
274
+
275
+ ### Hub Promotion
276
+
277
+ When the hub disconnects, clients detect the WebSocket close event, enter `"disconnected"` state, and call `scheduleReconnect()`. The **first terminal to retry** becomes the new hub via the same initialize-or-fallback flow.
278
+
279
+ There is **no explicit leader election** — promotion is race-based.
280
+
281
+ ---
282
+
283
+ ## Troubleshooting
284
+
285
+ ### Port 9900 is already in use
286
+
287
+ If another process occupies port 9900, the terminal can't become the hub. It will attempt to connect as a client instead (which also fails if there's no real hub), then retry after 2–5 seconds. Free the port or modify `DEFAULT_PORT` in `index.ts` — see [Limitations](#limitations--design-decisions).
288
+
289
+ ### "Terminal is busy" rejections
290
+
291
+ Each terminal can only execute **one remote prompt at a time**. If a `link_prompt` arrives while the agent is already running (either from a local user or another remote prompt), it's immediately rejected with `"Terminal is busy"`. There is no queuing. Solutions:
292
+
293
+ - Wait for the target terminal to finish its current task.
294
+ - Spread prompts across multiple worker terminals.
295
+ - Have the sender retry after a delay.
296
+
297
+ ### Terminals don't see each other
298
+
299
+ - Verify both terminals are on the same machine (the link only works on `127.0.0.1`).
300
+ - Run `/link` in each terminal to check status.
301
+ - Ensure port 9900 isn't blocked or occupied by a non-link process.
302
+
303
+ ### Hub promotion loses state
304
+
305
+ When the hub goes down and a client promotes itself, terminal names and in-flight prompts from the old hub session are lost. All surviving clients reconnect and re-register. This is by design — see [Limitations](#limitations--design-decisions).
306
+
307
+ ---
308
+
309
+ ## Limitations & Design Decisions
310
+
311
+ | # | Decision | Rationale / Impact |
312
+ | --- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
313
+ | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
314
+ | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
315
+ | 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
316
+ | 4 | **Single remote prompt per terminal** | No queuing — immediate rejection if the target is busy. Keeps the model simple and avoids unbounded backlogs. |
317
+ | 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
318
+ | 6 | **Client rename triggers full reconnect** | Changing a client's name requires a new `register` message, so the client disconnects and reconnects. Hub renames are handled in-place with collision checks. |
319
+ | 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
320
+ | 8 | **Opt-in startup** | Link is off by default. Use `pi --link` or `/link-connect` to participate. See [Configuration](#configuration). |
321
+
322
+ ---
323
+
324
+ ## Dependencies
325
+
326
+ ### Runtime (installed by `pi install`)
327
+
328
+ | Package | Version | Purpose |
329
+ | ------- | ------- | ----------------------------------- |
330
+ | `ws` | ^8.20.0 | WebSocket library (server + client) |
331
+
332
+ ### Development
333
+
334
+ | Package | Version | Purpose |
335
+ | ----------- | ------- | --------------------------- |
336
+ | `@types/ws` | ^8.18.1 | TypeScript type definitions |
337
+
338
+ ### Provided by Pi (no install needed)
339
+
340
+ | Package | Purpose |
341
+ | ------------------------------- | ------------------------------------------------ |
342
+ | `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
343
+ | `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
344
+ | `@sinclair/typebox` | JSON Schema type definitions for tool parameters |
345
+
346
+ ### `package.json`
347
+
348
+ ```json
349
+ {
350
+ "name": "pi-link",
351
+ "private": true,
352
+ "dependencies": {
353
+ "ws": "^8.20.0"
354
+ },
355
+ "devDependencies": {
356
+ "@types/ws": "^8.18.1"
357
+ },
358
+ "pi": {
359
+ "extensions": ["./index.ts"]
360
+ }
361
+ }
362
+ ```
363
+
364
+ The `pi.extensions` field tells Pi which files to load as extensions. Here it points to `./index.ts`, which Pi compiles and registers on startup.
365
+
366
+ ---
367
+
368
+ ## Internals
369
+
370
+ > This section covers implementation details for contributors and developers who want to understand or modify the extension's internals.
371
+
372
+ ### Protocol
373
+
374
+ The wire protocol consists of **8 message types**, all serialized as JSON over WebSocket frames:
375
+
376
+ | Type | Direction | Purpose |
377
+ | ----------------- | ------------- | ----------------------------------------------------- |
378
+ | `register` | Client → Hub | First message after connecting; requests a name |
379
+ | `welcome` | Hub → Client | Confirms assigned name (deduplicated) + terminal list |
380
+ | `terminal_joined` | Hub → All | Broadcast when a terminal joins |
381
+ | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
382
+ | `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
383
+ | `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
384
+ | `prompt_response` | Any → Any | Response carrying the remote prompt result |
385
+ | `error` | Hub → Client | Error notification |
386
+
387
+ ### Message Flow Examples
388
+
389
+ **Joining the link:**
390
+
391
+ ```
392
+ Client Hub
393
+ | |
394
+ | register {name:"builder"} |
395
+ |---------------------------->|
396
+ | |
397
+ | welcome {name:"builder", |
398
+ | terminals:["pi-1"]} |
399
+ |<----------------------------|
400
+ | |
401
+ ```
402
+
403
+ Hub then broadcasts `terminal_joined` to the other connected terminals.
404
+
405
+ **Sending a chat message:**
406
+
407
+ ```
408
+ Client A Hub Client B
409
+ | | |
410
+ | chat {to:pi-2} | |
411
+ |----------------->| |
412
+ | | chat {from:A} |
413
+ | |----------------->|
414
+ | | |
415
+ ```
416
+
417
+ **Remote prompt (synchronous RPC):**
418
+
419
+ ```
420
+ Client A Hub Client B
421
+ | | |
422
+ | prompt_request | |
423
+ |----------------->| |
424
+ | | prompt_request |
425
+ | |----------------->|
426
+ | | (LLM runs) |
427
+ | |<-----------------|
428
+ | prompt_response | |
429
+ |<-----------------| |
430
+ ```
431
+
432
+ ### Name Uniqueness
433
+
434
+ The hub enforces unique terminal names via a `uniqueName()` function. If `"builder"` is already taken, the next terminal requesting that name is assigned `"builder-2"`, then `"builder-3"`, and so on.
435
+
436
+ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
437
+
438
+ **Rename guards:**
439
+
440
+ - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
441
+ - On the hub, renaming checks if the name is taken by another connected client before accepting the change.
442
+ - On a client, the rename triggers a reconnect; the hub enforces uniqueness during re-registration and may assign a different name if taken.
443
+
444
+ **Unregistered client guard:** The hub ignores all non-`register` messages from clients that haven't completed registration, preventing protocol violations from malformed or out-of-order messages.
445
+
446
+ ### State Management
447
+
448
+ | State Field | Type | Purpose |
449
+ | ------------------------ | --------------------------------------- | ----------------------------------------------------- |
450
+ | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
451
+ | `isAgentBusy` | `boolean` | Prevents accepting remote prompts during agent runs |
452
+ | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
453
+ | `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
454
+ | `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses |
455
+
456
+ ### Message Routing & Error Handling
457
+
458
+ `routeMessage()` returns a `boolean` indicating delivery status:
459
+
460
+ - **Hub** — delivery is authoritative. If the target terminal isn't connected, the hub sends a protocol-level error back to the sender. For `prompt_request` messages to unknown targets, the hub sends a `prompt_response` with an error field so the sender's pending promise resolves immediately rather than timing out.
461
+ - **Client** — delivery is optimistic (`true` means "sent to hub"). The hub handles routing and errors via the protocol.
462
+
463
+ ### Connection Lifecycle
464
+
465
+ Internally, teardown is split into two functions:
466
+
467
+ - **`disconnect()`** — closes sockets, clears connection state, resolves pending promises. Used by `/link-disconnect` and called internally by `cleanup()`.
468
+ - **`cleanup()`** — calls `disconnect()` then marks the extension as disposed. Used on `session_shutdown`.
469
+
470
+ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link-disconnect`) from connection loss. When set, `scheduleReconnect()` is suppressed — the terminal stays offline until `/link-connect` is explicitly called.
471
+
472
+ ### Agent Lifecycle Integration
473
+
474
+ The extension hooks into Pi's agent lifecycle events:
475
+
476
+ - **`agent_start`** → Sets `isAgentBusy = true`, blocking incoming remote prompts.
477
+ - **`agent_end`** → Checks if a remote prompt was running. If so, extracts the last assistant response from `event.messages` and sends back a `prompt_response`.
478
+ - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
479
+
480
+ ### Rendering
481
+
482
+ Incoming link chat messages render with a styled `⚡ [sender]` prefix using the theme's accent color. The link status text in Pi's footer uses `theme.fg("dim", ...)` to match Pi's standard footer styling.