pi-link 0.1.1 → 0.1.3
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.
- package/README.md +73 -49
- package/index.ts +259 -21
- package/package.json +30 -30
package/README.md
CHANGED
|
@@ -83,7 +83,9 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
|
|
|
83
83
|
✓ Renamed to "builder"
|
|
84
84
|
|
|
85
85
|
> /link
|
|
86
|
-
⚡ Link:
|
|
86
|
+
⚡ Link: builder (hub) · 2 online
|
|
87
|
+
builder: idle (5s)
|
|
88
|
+
researcher: idle (12s)
|
|
87
89
|
```
|
|
88
90
|
|
|
89
91
|
**Terminal 2** — rename it too:
|
|
@@ -122,11 +124,11 @@ Every other terminal sees:
|
|
|
122
124
|
|
|
123
125
|
Link is **off by default**. Without `--link`, the extension is completely silent — no status bar, no connections, no warnings.
|
|
124
126
|
|
|
125
|
-
| Method
|
|
126
|
-
|
|
|
127
|
-
| `pi --link`
|
|
128
|
-
| `/link-connect`
|
|
129
|
-
| `/link-disconnect`
|
|
127
|
+
| Method | When | Auto-reconnect? |
|
|
128
|
+
| ------------------ | ----------------------------------- | -------------------------------- |
|
|
129
|
+
| `pi --link` | Auto-connect on startup | Yes |
|
|
130
|
+
| `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
|
|
131
|
+
| `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
|
|
130
132
|
|
|
131
133
|
`/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.
|
|
132
134
|
|
|
@@ -140,11 +142,11 @@ The extension registers three tools that the LLM can invoke during agent runs.
|
|
|
140
142
|
|
|
141
143
|
### Which tool should I use?
|
|
142
144
|
|
|
143
|
-
| Tool
|
|
144
|
-
|
|
|
145
|
-
| `link_send`
|
|
146
|
-
| `link_prompt`
|
|
147
|
-
| `link_list`
|
|
145
|
+
| Tool | Behavior | Returns |
|
|
146
|
+
| ------------- | ---------------------------------------------------- | ---------------------------------------- |
|
|
147
|
+
| `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
|
|
148
|
+
| `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
|
|
149
|
+
| `link_list` | List currently connected terminals | Terminal directory with roles and status |
|
|
148
150
|
|
|
149
151
|
**If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
|
|
150
152
|
|
|
@@ -182,34 +184,47 @@ Send a prompt to a remote terminal and **wait** for the LLM's response (synchron
|
|
|
182
184
|
|
|
183
185
|
### `link_list`
|
|
184
186
|
|
|
185
|
-
Lists all connected terminals with role info and self-identification. Takes no parameters.
|
|
187
|
+
Lists all connected terminals with role info, live agent status, and self-identification. Takes no parameters.
|
|
188
|
+
|
|
189
|
+
Each terminal's status is derived automatically from Pi lifecycle events — agents can't set it manually. Three states:
|
|
190
|
+
|
|
191
|
+
| Status | Meaning |
|
|
192
|
+
| ----------------- | ----------------------- |
|
|
193
|
+
| `idle (2m)` | Waiting for user input |
|
|
194
|
+
| `thinking (3s)` | LLM is generating |
|
|
195
|
+
| `tool:bash (12s)` | Running a specific tool |
|
|
196
|
+
|
|
197
|
+
Durations are computed at render time from a `since` timestamp — no timer traffic over the wire. Terminals that just joined with no status data yet render as blank, not fake idle.
|
|
186
198
|
|
|
187
199
|
**Example output:**
|
|
188
200
|
|
|
189
201
|
```
|
|
190
|
-
|
|
191
|
-
• pi-
|
|
192
|
-
• pi-
|
|
193
|
-
• pi-
|
|
202
|
+
link (hub) 3 terminal(s)
|
|
203
|
+
• builder@pi-link (you) thinking (3s)
|
|
204
|
+
• reviewer@pi-link idle (45s)
|
|
205
|
+
• docs@pi-link tool:read (2s)
|
|
194
206
|
```
|
|
195
207
|
|
|
196
208
|
---
|
|
197
209
|
|
|
198
210
|
## Slash Commands
|
|
199
211
|
|
|
200
|
-
| Command
|
|
201
|
-
|
|
|
202
|
-
| `/link`
|
|
203
|
-
| `/link-name [name]`
|
|
204
|
-
| `/link-broadcast <msg>`
|
|
205
|
-
| `/link-connect`
|
|
206
|
-
| `/link-disconnect`
|
|
212
|
+
| Command | Purpose |
|
|
213
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
214
|
+
| `/link` | Show link status (name, role, online count, agent status per terminal) |
|
|
215
|
+
| `/link-name [name]` | Rename and save as this session's preferred link name. With no argument, adopts the Pi session name. Restored on resume. |
|
|
216
|
+
| `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
|
|
217
|
+
| `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
|
|
218
|
+
| `/link-disconnect` | Disconnect from Pi Link and suppress auto-reconnect (overrides `--link`) |
|
|
207
219
|
|
|
208
220
|
### Examples
|
|
209
221
|
|
|
210
222
|
```
|
|
211
223
|
> /link
|
|
212
|
-
⚡ Link:
|
|
224
|
+
⚡ Link: builder (hub) · 3 online
|
|
225
|
+
builder: idle (12s)
|
|
226
|
+
worker-1: thinking (3s)
|
|
227
|
+
worker-2: tool:bash (5s)
|
|
213
228
|
|
|
214
229
|
> /link-name orchestrator
|
|
215
230
|
✓ Renamed to "orchestrator"
|
|
@@ -228,6 +243,8 @@ Connected terminals:
|
|
|
228
243
|
✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
|
|
229
244
|
```
|
|
230
245
|
|
|
246
|
+
**Name persistence:** `/link-name` saves your preferred name to the session. Resume that session later and your name is restored automatically. If the name is taken, the hub assigns a variant (e.g., `"builder-2"`), but your preferred name stays saved — the next reconnect retries it. Both `/link-name builder` and `/link-name` (no args) count as explicit saves; hub-assigned variants like `"builder-2"` are never persisted.
|
|
247
|
+
|
|
231
248
|
See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
|
|
232
249
|
|
|
233
250
|
---
|
|
@@ -333,7 +350,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
333
350
|
|
|
334
351
|
| Package | Purpose |
|
|
335
352
|
| ------------------------------- | ------------------------------------------------ |
|
|
336
|
-
| `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext)
|
|
353
|
+
| `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
|
|
337
354
|
| `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
|
|
338
355
|
| `@sinclair/typebox` | JSON Schema type definitions for tool parameters |
|
|
339
356
|
|
|
@@ -365,18 +382,19 @@ The `pi.extensions` field tells Pi which files to load as extensions. Here it po
|
|
|
365
382
|
|
|
366
383
|
### Protocol
|
|
367
384
|
|
|
368
|
-
The wire protocol consists of **
|
|
385
|
+
The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames:
|
|
369
386
|
|
|
370
|
-
| Type | Direction
|
|
371
|
-
| ----------------- |
|
|
372
|
-
| `register` | Client → Hub
|
|
373
|
-
| `welcome` | Hub → Client
|
|
374
|
-
| `terminal_joined` | Hub → All
|
|
375
|
-
| `terminal_left` | Hub → All
|
|
376
|
-
| `chat` | Any → Any/All
|
|
377
|
-
| `prompt_request` | Any → Any
|
|
378
|
-
| `prompt_response` | Any → Any
|
|
379
|
-
| `
|
|
387
|
+
| Type | Direction | Purpose |
|
|
388
|
+
| ----------------- | --------------- | ------------------------------------------------------- |
|
|
389
|
+
| `register` | Client → Hub | First message after connecting; requests a name |
|
|
390
|
+
| `welcome` | Hub → Client | Confirms assigned name, terminal list + status snapshot |
|
|
391
|
+
| `terminal_joined` | Hub → All | Broadcast when a terminal joins |
|
|
392
|
+
| `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
|
|
393
|
+
| `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
|
|
394
|
+
| `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
|
|
395
|
+
| `prompt_response` | Any → Any | Response carrying the remote prompt result |
|
|
396
|
+
| `status_update` | Any → Hub → All | Terminal broadcasts its agent status change |
|
|
397
|
+
| `error` | Hub → Client | Error notification |
|
|
380
398
|
|
|
381
399
|
### Message Flow Examples
|
|
382
400
|
|
|
@@ -388,13 +406,13 @@ Client Hub
|
|
|
388
406
|
| register {name:"builder"} |
|
|
389
407
|
|---------------------------->|
|
|
390
408
|
| |
|
|
391
|
-
| welcome {name
|
|
392
|
-
|
|
|
409
|
+
| welcome {name, terminals, |
|
|
410
|
+
| statuses} |
|
|
393
411
|
|<----------------------------|
|
|
394
412
|
| |
|
|
395
413
|
```
|
|
396
414
|
|
|
397
|
-
Hub then broadcasts `terminal_joined` to the other connected terminals.
|
|
415
|
+
Hub then broadcasts `terminal_joined` to the other connected terminals. The `welcome` message includes a status snapshot for all connected terminals (fields omitted above for brevity).
|
|
398
416
|
|
|
399
417
|
**Sending a chat message:**
|
|
400
418
|
|
|
@@ -423,12 +441,14 @@ Client A Hub Client B
|
|
|
423
441
|
|<-----------------| |
|
|
424
442
|
```
|
|
425
443
|
|
|
426
|
-
### Name Uniqueness
|
|
444
|
+
### Name Uniqueness & Persistence
|
|
427
445
|
|
|
428
446
|
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.
|
|
429
447
|
|
|
430
448
|
Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
431
449
|
|
|
450
|
+
**Persistence:** `/link-name` saves the preferred name to the session via `pi.appendEntry("link-name", { name })`. On session resume, the saved name is restored and requested from the hub. Only explicit `/link-name` calls persist — hub-assigned variants like `"builder-2"` are not saved. On reconnect, the terminal always requests the preferred name, not the last runtime name.
|
|
451
|
+
|
|
432
452
|
**Rename guards:**
|
|
433
453
|
|
|
434
454
|
- If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
|
|
@@ -439,13 +459,13 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
439
459
|
|
|
440
460
|
### State Management
|
|
441
461
|
|
|
442
|
-
| State Field | Type
|
|
443
|
-
| ------------------------ |
|
|
444
|
-
| `role` | `"hub" \| "client" \| "disconnected"`
|
|
445
|
-
| `isAgentBusy` | `boolean`
|
|
446
|
-
| `manuallyDisconnected` | `boolean`
|
|
447
|
-
| `pendingRemotePrompt` | `object \| null`
|
|
448
|
-
| `pendingPromptResponses` | `Map`
|
|
462
|
+
| State Field | Type | Purpose |
|
|
463
|
+
| ------------------------ | ------------------------------------- | ---------------------------------------------------- |
|
|
464
|
+
| `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
|
|
465
|
+
| `isAgentBusy` | `boolean` | Prevents accepting remote prompts during agent runs |
|
|
466
|
+
| `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
|
|
467
|
+
| `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
|
|
468
|
+
| `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses |
|
|
449
469
|
|
|
450
470
|
### Message Routing & Error Handling
|
|
451
471
|
|
|
@@ -467,10 +487,14 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
|
|
|
467
487
|
|
|
468
488
|
The extension hooks into Pi's agent lifecycle events:
|
|
469
489
|
|
|
470
|
-
- **`agent_start`** → Sets `isAgentBusy = true`, blocking incoming remote prompts.
|
|
471
|
-
- **`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`.
|
|
490
|
+
- **`agent_start`** → Sets `isAgentBusy = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
|
|
491
|
+
- **`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`. Broadcasts `status_update` (`idle`).
|
|
492
|
+
- **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
|
|
493
|
+
- **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
|
|
472
494
|
- **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
|
|
473
495
|
|
|
496
|
+
Status updates are push-based: each terminal broadcasts changes to the hub, which fans them out. New joiners receive a status snapshot for all terminals in the `welcome` message. Durations are computed at render time from a `since` timestamp — no polling or timer traffic over the wire.
|
|
497
|
+
|
|
474
498
|
### Rendering
|
|
475
499
|
|
|
476
500
|
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.
|
package/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ interface WelcomeMsg {
|
|
|
45
45
|
type: "welcome";
|
|
46
46
|
name: string;
|
|
47
47
|
terminals: string[];
|
|
48
|
+
statuses?: Record<string, LinkStatus>;
|
|
48
49
|
}
|
|
49
50
|
interface TerminalJoinedMsg {
|
|
50
51
|
type: "terminal_joined";
|
|
@@ -78,11 +79,21 @@ interface PromptResponseMsg {
|
|
|
78
79
|
response: string;
|
|
79
80
|
error?: string;
|
|
80
81
|
}
|
|
82
|
+
interface StatusUpdateMsg {
|
|
83
|
+
type: "status_update";
|
|
84
|
+
name: string;
|
|
85
|
+
status: LinkStatus;
|
|
86
|
+
}
|
|
81
87
|
interface ErrorMsg {
|
|
82
88
|
type: "error";
|
|
83
89
|
message: string;
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
type LinkStatus =
|
|
93
|
+
| { kind: "idle"; since: number }
|
|
94
|
+
| { kind: "thinking"; since: number }
|
|
95
|
+
| { kind: "tool"; toolName: string; since: number };
|
|
96
|
+
|
|
86
97
|
type LinkMessage =
|
|
87
98
|
| RegisterMsg
|
|
88
99
|
| WelcomeMsg
|
|
@@ -91,6 +102,7 @@ type LinkMessage =
|
|
|
91
102
|
| ChatMsg
|
|
92
103
|
| PromptRequestMsg
|
|
93
104
|
| PromptResponseMsg
|
|
105
|
+
| StatusUpdateMsg
|
|
94
106
|
| ErrorMsg;
|
|
95
107
|
|
|
96
108
|
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
@@ -106,16 +118,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
106
118
|
|
|
107
119
|
let role: "hub" | "client" | "disconnected" = "disconnected";
|
|
108
120
|
let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
|
|
121
|
+
let preferredName: string | null = null;
|
|
109
122
|
let connectedTerminals: string[] = [];
|
|
110
123
|
let ctx: ExtensionContext | undefined;
|
|
111
|
-
let isAgentBusy = false;
|
|
112
124
|
let disposed = false;
|
|
113
125
|
let manuallyDisconnected = false;
|
|
114
126
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
115
127
|
|
|
128
|
+
// Status tracking (local truth)
|
|
129
|
+
let agentRunning = false;
|
|
130
|
+
let activeToolName: string | null = null;
|
|
131
|
+
let stateSince = Date.now();
|
|
132
|
+
let lastPushedKind: string | null = null;
|
|
133
|
+
let lastPushedTool: string | null = null;
|
|
134
|
+
const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
|
|
135
|
+
|
|
116
136
|
// Hub state
|
|
117
137
|
let wss: WebSocketServer | null = null;
|
|
118
138
|
const hubClients = new Map<WebSocket, string>(); // ws → terminal name
|
|
139
|
+
const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
|
|
119
140
|
|
|
120
141
|
// Client state
|
|
121
142
|
let ws: WebSocket | null = null;
|
|
@@ -148,6 +169,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
169
|
ctx.ui.setStatus("link", theme.fg("dim", info));
|
|
149
170
|
}
|
|
150
171
|
|
|
172
|
+
function deriveStatus(): LinkStatus {
|
|
173
|
+
if (activeToolName)
|
|
174
|
+
return { kind: "tool", toolName: activeToolName, since: stateSince };
|
|
175
|
+
if (agentRunning) return { kind: "thinking", since: stateSince };
|
|
176
|
+
return { kind: "idle", since: stateSince };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function pushStatus(force = false) {
|
|
180
|
+
if (role === "disconnected") return;
|
|
181
|
+
const status = deriveStatus();
|
|
182
|
+
const newKind = status.kind;
|
|
183
|
+
const newTool = status.kind === "tool" ? status.toolName : null;
|
|
184
|
+
if (!force && newKind === lastPushedKind && newTool === lastPushedTool)
|
|
185
|
+
return;
|
|
186
|
+
lastPushedKind = newKind;
|
|
187
|
+
lastPushedTool = newTool;
|
|
188
|
+
const msg: StatusUpdateMsg = {
|
|
189
|
+
type: "status_update",
|
|
190
|
+
name: terminalName,
|
|
191
|
+
status,
|
|
192
|
+
};
|
|
193
|
+
if (role === "hub") {
|
|
194
|
+
hubBroadcast(msg, terminalName);
|
|
195
|
+
} else if (ws?.readyState === WebSocket.OPEN) {
|
|
196
|
+
ws.send(JSON.stringify(msg));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatDuration(since: number): string {
|
|
201
|
+
const sec = Math.floor((Date.now() - since) / 1000);
|
|
202
|
+
if (sec < 60) return `${sec}s`;
|
|
203
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
|
204
|
+
return `${Math.floor(sec / 3600)}h`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatStatus(s: LinkStatus): string {
|
|
208
|
+
const dur = formatDuration(s.since);
|
|
209
|
+
if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
|
|
210
|
+
return `${s.kind} (${dur})`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getStatusFor(name: string): LinkStatus | null {
|
|
214
|
+
if (name === terminalName) return deriveStatus();
|
|
215
|
+
const map = role === "hub" ? hubTerminalStatuses : terminalStatuses;
|
|
216
|
+
return map.get(name) ?? null;
|
|
217
|
+
}
|
|
218
|
+
|
|
151
219
|
function allTerminalNames(): Set<string> {
|
|
152
220
|
const names = new Set<string>();
|
|
153
221
|
names.add(terminalName); // hub's own name
|
|
@@ -257,11 +325,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
257
325
|
case "welcome":
|
|
258
326
|
terminalName = msg.name;
|
|
259
327
|
connectedTerminals = msg.terminals;
|
|
328
|
+
terminalStatuses.clear();
|
|
329
|
+
if (msg.statuses) {
|
|
330
|
+
for (const [name, status] of Object.entries(msg.statuses)) {
|
|
331
|
+
terminalStatuses.set(name, status);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
260
334
|
updateStatus();
|
|
261
335
|
ctx?.ui.notify(
|
|
262
336
|
`Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
|
|
263
337
|
"info",
|
|
264
338
|
);
|
|
339
|
+
pushStatus(true);
|
|
265
340
|
break;
|
|
266
341
|
|
|
267
342
|
// ── Directory updates ──
|
|
@@ -273,10 +348,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
273
348
|
|
|
274
349
|
case "terminal_left":
|
|
275
350
|
connectedTerminals = msg.terminals;
|
|
351
|
+
terminalStatuses.delete(msg.name);
|
|
276
352
|
updateStatus();
|
|
277
353
|
ctx?.ui.notify(`"${msg.name}" left the link`, "info");
|
|
278
354
|
break;
|
|
279
355
|
|
|
356
|
+
// ── Status update from another terminal ──
|
|
357
|
+
case "status_update":
|
|
358
|
+
terminalStatuses.set(msg.name, msg.status);
|
|
359
|
+
break;
|
|
360
|
+
|
|
280
361
|
// ── Chat message ──
|
|
281
362
|
case "chat":
|
|
282
363
|
pi.sendMessage(
|
|
@@ -292,7 +373,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
292
373
|
|
|
293
374
|
// ── Another terminal asks us to run a prompt ──
|
|
294
375
|
case "prompt_request":
|
|
295
|
-
if (
|
|
376
|
+
if (agentRunning || pendingRemotePrompt) {
|
|
296
377
|
routeMessage({
|
|
297
378
|
type: "prompt_response",
|
|
298
379
|
id: msg.id,
|
|
@@ -353,12 +434,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
353
434
|
connectedTerminals = list;
|
|
354
435
|
updateStatus();
|
|
355
436
|
|
|
356
|
-
// Confirm to the new client
|
|
437
|
+
// Confirm to the new client (include status snapshot)
|
|
438
|
+
const statuses: Record<string, LinkStatus> = {};
|
|
439
|
+
statuses[terminalName] = deriveStatus(); // hub's own status
|
|
440
|
+
for (const [name, status] of hubTerminalStatuses) {
|
|
441
|
+
if (name !== clientName) statuses[name] = status;
|
|
442
|
+
}
|
|
357
443
|
clientWs.send(
|
|
358
444
|
JSON.stringify({
|
|
359
445
|
type: "welcome",
|
|
360
446
|
name: clientName,
|
|
361
447
|
terminals: list,
|
|
448
|
+
statuses,
|
|
362
449
|
} satisfies WelcomeMsg),
|
|
363
450
|
);
|
|
364
451
|
|
|
@@ -375,6 +462,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
375
462
|
// Ignore messages from unregistered clients
|
|
376
463
|
if (!clientName) return;
|
|
377
464
|
|
|
465
|
+
// Status update — store and fan out to other clients only (not back to hub)
|
|
466
|
+
if (msg.type === "status_update") {
|
|
467
|
+
hubTerminalStatuses.set(clientName, msg.status);
|
|
468
|
+
const normalized: StatusUpdateMsg = {
|
|
469
|
+
type: "status_update",
|
|
470
|
+
name: clientName,
|
|
471
|
+
status: msg.status,
|
|
472
|
+
};
|
|
473
|
+
const json = JSON.stringify(normalized);
|
|
474
|
+
for (const [otherWs, name] of hubClients) {
|
|
475
|
+
if (name !== clientName) otherWs.send(json);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
378
480
|
// Route chat / prompt messages
|
|
379
481
|
if (
|
|
380
482
|
msg.type === "chat" ||
|
|
@@ -388,6 +490,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
388
490
|
clientWs.on("close", () => {
|
|
389
491
|
if (clientName) {
|
|
390
492
|
hubClients.delete(clientWs);
|
|
493
|
+
hubTerminalStatuses.delete(clientName);
|
|
391
494
|
const list = terminalList();
|
|
392
495
|
connectedTerminals = list;
|
|
393
496
|
updateStatus();
|
|
@@ -438,20 +541,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
438
541
|
|
|
439
542
|
// ── Connect as client ────────────────────────────────────────────────────
|
|
440
543
|
|
|
441
|
-
function connectAsClient(
|
|
544
|
+
function connectAsClient(): Promise<boolean> {
|
|
442
545
|
return new Promise((resolve) => {
|
|
443
|
-
const socket = new WebSocket(`ws://127.0.0.1:${
|
|
546
|
+
const socket = new WebSocket(`ws://127.0.0.1:${DEFAULT_PORT}`);
|
|
444
547
|
let resolved = false;
|
|
445
548
|
|
|
446
549
|
socket.on("open", () => {
|
|
447
550
|
ws = socket;
|
|
448
551
|
role = "client";
|
|
449
552
|
resolved = true;
|
|
450
|
-
// Register with
|
|
553
|
+
// Register with preferred name if available, otherwise current name
|
|
451
554
|
socket.send(
|
|
452
555
|
JSON.stringify({
|
|
453
556
|
type: "register",
|
|
454
|
-
name: terminalName,
|
|
557
|
+
name: preferredName ?? terminalName,
|
|
455
558
|
} satisfies RegisterMsg),
|
|
456
559
|
);
|
|
457
560
|
resolve(true);
|
|
@@ -492,7 +595,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
492
595
|
if (disposed) return;
|
|
493
596
|
|
|
494
597
|
// Try connecting to an existing hub
|
|
495
|
-
if (await connectAsClient(
|
|
598
|
+
if (await connectAsClient()) return;
|
|
496
599
|
|
|
497
600
|
// No hub found — become the hub
|
|
498
601
|
if (await startHub()) return;
|
|
@@ -545,6 +648,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
545
648
|
|
|
546
649
|
role = "disconnected";
|
|
547
650
|
connectedTerminals = [];
|
|
651
|
+
terminalStatuses.clear();
|
|
652
|
+
hubTerminalStatuses.clear();
|
|
653
|
+
lastPushedKind = null;
|
|
654
|
+
lastPushedTool = null;
|
|
548
655
|
updateStatus();
|
|
549
656
|
}
|
|
550
657
|
|
|
@@ -557,6 +664,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
557
664
|
|
|
558
665
|
pi.on("session_start", async (_event, _ctx) => {
|
|
559
666
|
ctx = _ctx;
|
|
667
|
+
|
|
668
|
+
// Restore preferred link name from session
|
|
669
|
+
const saved = _ctx.sessionManager
|
|
670
|
+
.getEntries()
|
|
671
|
+
.filter(
|
|
672
|
+
(e: { type: string; customType?: string }) =>
|
|
673
|
+
e.type === "custom" && e.customType === "link-name",
|
|
674
|
+
)
|
|
675
|
+
.pop() as { data?: { name?: string } } | undefined;
|
|
676
|
+
if (saved?.data?.name) {
|
|
677
|
+
preferredName = saved.data.name;
|
|
678
|
+
terminalName = preferredName;
|
|
679
|
+
}
|
|
680
|
+
|
|
560
681
|
if (pi.getFlag("link") === true) await initialize();
|
|
561
682
|
});
|
|
562
683
|
|
|
@@ -564,12 +685,87 @@ export default function (pi: ExtensionAPI) {
|
|
|
564
685
|
cleanup();
|
|
565
686
|
});
|
|
566
687
|
|
|
688
|
+
pi.on("session_switch", async (_event, _ctx) => {
|
|
689
|
+
ctx = _ctx;
|
|
690
|
+
|
|
691
|
+
// Restore preferred name from the new session
|
|
692
|
+
const saved = _ctx.sessionManager
|
|
693
|
+
.getEntries()
|
|
694
|
+
.filter(
|
|
695
|
+
(e: { type: string; customType?: string }) =>
|
|
696
|
+
e.type === "custom" && e.customType === "link-name",
|
|
697
|
+
)
|
|
698
|
+
.pop() as { data?: { name?: string } } | undefined;
|
|
699
|
+
|
|
700
|
+
preferredName = saved?.data?.name ?? null;
|
|
701
|
+
const desiredName = preferredName ?? `t-${crypto.randomUUID().slice(0, 4)}`;
|
|
702
|
+
|
|
703
|
+
if (desiredName === terminalName) return; // no identity change needed
|
|
704
|
+
|
|
705
|
+
if (role === "hub") {
|
|
706
|
+
// Hub rename in-place — avoid tearing down the server
|
|
707
|
+
const takenByOther = Array.from(hubClients.values()).includes(
|
|
708
|
+
desiredName,
|
|
709
|
+
);
|
|
710
|
+
if (takenByOther) {
|
|
711
|
+
// Can't use preferred name — keep current identity
|
|
712
|
+
ctx?.ui.notify(
|
|
713
|
+
`Session preferred name "${desiredName}" is taken, keeping "${terminalName}"`,
|
|
714
|
+
"warning",
|
|
715
|
+
);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const old = terminalName;
|
|
719
|
+
terminalName = desiredName;
|
|
720
|
+
const list = terminalList();
|
|
721
|
+
connectedTerminals = list;
|
|
722
|
+
updateStatus();
|
|
723
|
+
// Notify clients only — hub already updated local state
|
|
724
|
+
hubBroadcast(
|
|
725
|
+
{ type: "terminal_left", name: old, terminals: list },
|
|
726
|
+
terminalName,
|
|
727
|
+
);
|
|
728
|
+
hubBroadcast(
|
|
729
|
+
{ type: "terminal_joined", name: desiredName, terminals: list },
|
|
730
|
+
terminalName,
|
|
731
|
+
);
|
|
732
|
+
pushStatus(true);
|
|
733
|
+
} else if (role === "client") {
|
|
734
|
+
// Client — disconnect and reconnect with new name
|
|
735
|
+
terminalName = desiredName;
|
|
736
|
+
disconnect();
|
|
737
|
+
manuallyDisconnected = false;
|
|
738
|
+
await initialize();
|
|
739
|
+
} else {
|
|
740
|
+
// Disconnected — just update local name
|
|
741
|
+
terminalName = desiredName;
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
567
745
|
pi.on("agent_start", async () => {
|
|
568
|
-
|
|
746
|
+
agentRunning = true;
|
|
747
|
+
activeToolName = null;
|
|
748
|
+
stateSince = Date.now();
|
|
749
|
+
pushStatus();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
pi.on("tool_execution_start", async (event) => {
|
|
753
|
+
activeToolName = event.toolName;
|
|
754
|
+
stateSince = Date.now();
|
|
755
|
+
pushStatus();
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
pi.on("tool_execution_end", async () => {
|
|
759
|
+
activeToolName = null;
|
|
760
|
+
if (agentRunning) stateSince = Date.now();
|
|
761
|
+
pushStatus();
|
|
569
762
|
});
|
|
570
763
|
|
|
571
764
|
pi.on("agent_end", async (event) => {
|
|
572
|
-
|
|
765
|
+
agentRunning = false;
|
|
766
|
+
activeToolName = null;
|
|
767
|
+
stateSince = Date.now();
|
|
768
|
+
pushStatus();
|
|
573
769
|
|
|
574
770
|
// If we were running a remote prompt, send the response back
|
|
575
771
|
if (pendingRemotePrompt) {
|
|
@@ -712,6 +908,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
712
908
|
async execute(_toolCallId, params, signal) {
|
|
713
909
|
if (role === "disconnected") return notConnectedResult();
|
|
714
910
|
|
|
911
|
+
if (!connectedTerminals.includes(params.to)) {
|
|
912
|
+
return textResult(
|
|
913
|
+
`Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
|
|
914
|
+
{ to: params.to, error: "not_found" },
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
715
918
|
const requestId = crypto.randomUUID();
|
|
716
919
|
|
|
717
920
|
return new Promise((resolve) => {
|
|
@@ -806,15 +1009,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
806
1009
|
async execute() {
|
|
807
1010
|
if (role === "disconnected") return notConnectedResult();
|
|
808
1011
|
|
|
1012
|
+
const statuses: Record<string, string> = {};
|
|
809
1013
|
const list = connectedTerminals
|
|
810
1014
|
.map((name) => {
|
|
1015
|
+
const status = getStatusFor(name);
|
|
1016
|
+
const statusStr = status ? formatStatus(status) : "";
|
|
1017
|
+
if (statusStr) statuses[name] = statusStr;
|
|
811
1018
|
const marker = name === terminalName ? " (you)" : "";
|
|
812
|
-
return ` • ${name}${marker}`;
|
|
1019
|
+
return ` • ${name}${marker}${statusStr ? " " + statusStr : ""}`;
|
|
813
1020
|
})
|
|
814
1021
|
.join("\n");
|
|
815
1022
|
|
|
816
1023
|
return textResult(`Connected terminals:\n${list}`, {
|
|
817
1024
|
terminals: connectedTerminals,
|
|
1025
|
+
statuses,
|
|
818
1026
|
self: terminalName,
|
|
819
1027
|
role,
|
|
820
1028
|
});
|
|
@@ -822,7 +1030,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
822
1030
|
|
|
823
1031
|
renderResult(result, _options, theme) {
|
|
824
1032
|
const details = result.details as
|
|
825
|
-
| {
|
|
1033
|
+
| {
|
|
1034
|
+
terminals?: string[];
|
|
1035
|
+
statuses?: Record<string, string>;
|
|
1036
|
+
self?: string;
|
|
1037
|
+
role?: string;
|
|
1038
|
+
}
|
|
826
1039
|
| undefined;
|
|
827
1040
|
if (!details?.terminals) {
|
|
828
1041
|
const txt = result.content[0];
|
|
@@ -834,11 +1047,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
834
1047
|
text += theme.fg("accent", `${details.terminals.length} terminal(s)`);
|
|
835
1048
|
for (const name of details.terminals) {
|
|
836
1049
|
const isSelf = name === details.self;
|
|
1050
|
+
const status = details.statuses?.[name] ?? "";
|
|
1051
|
+
const nameStr = isSelf ? `• ${name} (you)` : `• ${name}`;
|
|
837
1052
|
text +=
|
|
838
1053
|
"\n " +
|
|
839
|
-
(isSelf
|
|
840
|
-
|
|
841
|
-
: theme.fg("text", `• ${name}`));
|
|
1054
|
+
(isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
|
|
1055
|
+
(status ? " " + theme.fg("dim", status) : "");
|
|
842
1056
|
}
|
|
843
1057
|
return new Text(text, 0, 0);
|
|
844
1058
|
},
|
|
@@ -853,9 +1067,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
853
1067
|
_ctx.ui.notify("Link: not connected", "warning");
|
|
854
1068
|
return;
|
|
855
1069
|
}
|
|
856
|
-
const
|
|
1070
|
+
const lines = connectedTerminals.map((name) => {
|
|
1071
|
+
const status = getStatusFor(name);
|
|
1072
|
+
const statusStr = status ? formatStatus(status) : "";
|
|
1073
|
+
const marker = name === terminalName ? " (you)" : "";
|
|
1074
|
+
return `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
|
|
1075
|
+
});
|
|
857
1076
|
_ctx.ui.notify(
|
|
858
|
-
`Link: ${terminalName} (${role}) · ${connectedTerminals.length} online
|
|
1077
|
+
`Link: ${terminalName} (${role}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
|
|
859
1078
|
"info",
|
|
860
1079
|
);
|
|
861
1080
|
},
|
|
@@ -879,12 +1098,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
879
1098
|
}
|
|
880
1099
|
}
|
|
881
1100
|
|
|
882
|
-
if (newName === terminalName) {
|
|
1101
|
+
if (newName === terminalName && newName === preferredName) {
|
|
883
1102
|
_ctx.ui.notify(`Already using "${newName}"`, "info");
|
|
884
1103
|
return;
|
|
885
1104
|
}
|
|
886
1105
|
|
|
887
|
-
|
|
1106
|
+
function savePreference() {
|
|
1107
|
+
preferredName = newName;
|
|
1108
|
+
pi.appendEntry("link-name", { name: preferredName });
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (newName === terminalName) {
|
|
1112
|
+
savePreference();
|
|
1113
|
+
_ctx.ui.notify(`Saved "${newName}" as preferred link name`, "info");
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// If we're the hub, check uniqueness before persisting
|
|
888
1118
|
if (role === "hub") {
|
|
889
1119
|
// Check if name is taken by another terminal
|
|
890
1120
|
const takenByOther = Array.from(hubClients.values()).includes(newName);
|
|
@@ -900,14 +1130,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
900
1130
|
const list = terminalList();
|
|
901
1131
|
connectedTerminals = list;
|
|
902
1132
|
updateStatus();
|
|
903
|
-
|
|
1133
|
+
// Notify clients only — hub already updated local state
|
|
1134
|
+
hubBroadcast(
|
|
1135
|
+
{ type: "terminal_left", name: old, terminals: list },
|
|
1136
|
+
terminalName,
|
|
1137
|
+
);
|
|
904
1138
|
hubBroadcast(
|
|
905
1139
|
{ type: "terminal_joined", name: newName, terminals: list },
|
|
906
|
-
|
|
1140
|
+
terminalName,
|
|
907
1141
|
);
|
|
1142
|
+
pushStatus(true);
|
|
1143
|
+
savePreference();
|
|
908
1144
|
_ctx.ui.notify(`Renamed to "${newName}"`, "info");
|
|
909
1145
|
} else if (role === "client") {
|
|
910
1146
|
// Reconnect with new name — hub will enforce uniqueness via register
|
|
1147
|
+
savePreference();
|
|
911
1148
|
terminalName = newName;
|
|
912
1149
|
ws?.close();
|
|
913
1150
|
// Reconnect will happen via the onClose handler → scheduleReconnect
|
|
@@ -916,6 +1153,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
916
1153
|
"info",
|
|
917
1154
|
);
|
|
918
1155
|
} else {
|
|
1156
|
+
savePreference();
|
|
919
1157
|
terminalName = newName;
|
|
920
1158
|
_ctx.ui.notify(`Name set to "${newName}" (not connected)`, "info");
|
|
921
1159
|
}
|
package/package.json
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-link",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
|
|
5
|
-
"author": "alvivar",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
8
|
-
"type": "git",
|
|
9
|
-
"url": "https://github.com/alvivar/pi-link"
|
|
10
|
-
},
|
|
11
|
-
"keywords": [
|
|
12
|
-
"pi-package",
|
|
13
|
-
"pi",
|
|
14
|
-
"link",
|
|
15
|
-
"websocket",
|
|
16
|
-
"terminal",
|
|
17
|
-
"multi-agent"
|
|
18
|
-
],
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"ws": "^8.20.0"
|
|
21
|
-
},
|
|
22
|
-
"devDependencies": {
|
|
23
|
-
"@types/ws": "^8.18.1"
|
|
24
|
-
},
|
|
25
|
-
"pi": {
|
|
26
|
-
"extensions": [
|
|
27
|
-
"./index.ts"
|
|
28
|
-
]
|
|
29
|
-
}
|
|
30
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-link",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
|
|
5
|
+
"author": "alvivar",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/alvivar/pi-link"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"link",
|
|
15
|
+
"websocket",
|
|
16
|
+
"terminal",
|
|
17
|
+
"multi-agent"
|
|
18
|
+
],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"ws": "^8.20.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/ws": "^8.18.1"
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|