pi-link 0.1.3 → 0.1.5

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/CHANGELOG.md ADDED
@@ -0,0 +1,107 @@
1
+ # Changelog
2
+
3
+ All notable changes to pi-link are documented here.
4
+
5
+ This changelog is based on the git history from `2026-03-21` through `2026-04-02` (current). Versions correspond to npm publishes.
6
+
7
+ ---
8
+
9
+ ## 0.1.5 — 2026-04-02
10
+
11
+ ### Added
12
+
13
+ - **Working directory sharing.** Each terminal reports its `cwd` on connect and on session switch. New `cwd_update` protocol message (10th message type) broadcasts mid-session directory changes. `link_list` and `/link` now show per-terminal working directories — full absolute paths in tool output, `~/…` shortened in the TUI. Agents can use this to choose the right target, use explicit paths when terminals differ, and catch wrong-project mistakes early.
14
+
15
+ - **Header comment cleanup.** Simplified the top-of-file doc comment — removed feature bullet list and install instructions in favor of a concise summary.
16
+
17
+ ---
18
+
19
+ ## 0.1.4 — 2026-03-30
20
+
21
+ ### Added
22
+
23
+ - **Heartbeat-based prompt timeout.** `link_prompt` no longer uses a fixed 2-minute timeout. The target sends keepalives every 30s while working (reusing `status_update`). The sender resets a 90-second inactivity timer on each keepalive. A 30-minute hard ceiling prevents broken-but-chatty targets from hanging forever. Long tasks with regular activity no longer false-timeout. (`fc73a00`, `5603f0d`)
24
+
25
+ - **Self-target rejection.** `link_prompt` immediately rejects prompts where `to` equals your own terminal name, instead of sending a round-trip that would fail. (`0086c04`)
26
+
27
+ - **Immediate failure on disconnect.** Pending `link_prompt` calls fail instantly when the target terminal leaves the network (`terminal_left`), instead of waiting for the inactivity timeout. (`0086c04`)
28
+
29
+ - **`cleanupPending()` helper.** Single authority for resolving pending prompt state — all paths (response, inactivity, ceiling, abort, disconnect, delivery failure) go through one function, preventing double-resolution races. (`fc73a00`)
30
+
31
+ ---
32
+
33
+ ## 0.1.3 — 2026-03-26
34
+
35
+ ### Added
36
+
37
+ - **Persistent link names.** `/link-name` saves your preferred name to the session via `pi.appendEntry()`. Resume a session and your name is restored automatically. Session switches (`/resume`) restore the new session's preferred name. Only explicit `/link-name` calls persist — hub-assigned variants like `"builder-2"` are not saved. (`369cf5d`)
38
+
39
+ ### Fixed
40
+
41
+ - **Self join/leave echoes suppressed.** Hub no longer sends `terminal_joined`/`terminal_left` back to the terminal that triggered the event (e.g., during renames). Previously, renaming on the hub would echo a leave/join pair back to yourself. (`45cb018`)
42
+
43
+ - **Pre-flight target validation for `link_prompt`.** The sender now checks if the target exists in the local terminal list before sending, returning an immediate error with the current terminal list instead of waiting for a timeout. (`45cb018`)
44
+
45
+ ---
46
+
47
+ ## 0.1.2 — 2026-03-24
48
+
49
+ ### Added
50
+
51
+ - **Automatic agent status.** Each terminal's activity status is derived from Pi lifecycle events and broadcast across the link. Three states: `idle`, `thinking`, `tool:<name>` — each with a duration computed at render time. New `status_update` protocol message (push model: terminal → hub → all). New joiners receive a status snapshot in the `welcome` message. (`454415a`)
52
+
53
+ - `/link` and `link_list` now show per-terminal status alongside names.
54
+
55
+ ---
56
+
57
+ ## 0.1.1 — 2026-03-22
58
+
59
+ ### Changed
60
+
61
+ - **Published to npm.** Install command changed from `pi install git:github.com/alvivar/pi-mesh` to `pi install npm:pi-link`. (`87b394f`, `ed1e6cf`)
62
+
63
+ ---
64
+
65
+ ## 0.1.0 — 2026-03-22
66
+
67
+ First npm publish. Renamed from `pi-mesh` to `pi-link`. (`57bda8b`)
68
+
69
+ Everything below shipped together as the initial release.
70
+
71
+ ### Core
72
+
73
+ - **Hub-spoke WebSocket network** on `127.0.0.1:9900`. First terminal becomes the hub; others connect as clients. All messages route through the hub. (`c239a9e`)
74
+
75
+ - **Auto-discovery protocol.** Try client → fallback to hub → retry with 2–5s randomized backoff on race conditions. (`c239a9e`)
76
+
77
+ - **Hub promotion.** When the hub goes down, the first client to reconnect becomes the new hub (race-based, no leader election). (`c239a9e`)
78
+
79
+ ### Tools
80
+
81
+ - **`link_send`** — fire-and-forget message to a specific terminal or `"*"` for broadcast. Optional `triggerTurn` to kick off the remote LLM via `deliverAs: "steer"`. (`c239a9e`)
82
+
83
+ - **`link_prompt`** — synchronous RPC: send a prompt to a remote terminal, wait for the LLM's response. Single-queue per terminal (immediate `"Terminal is busy"` rejection, no queuing). 2-minute fixed timeout at this version. (`c239a9e`)
84
+
85
+ - **`link_list`** — list connected terminals with role info and self-identification. (`c239a9e`)
86
+
87
+ ### Commands
88
+
89
+ - **`/link`** — show link status (name, role, online count). (`c239a9e`)
90
+ - **`/link-name [name]`** — rename this terminal. No-arg form adopts the Pi session name. (`c239a9e`, `2fd67c7`)
91
+ - **`/link-broadcast <msg>`** — broadcast a chat message to all other terminals. (`c0bf65a`)
92
+ - **`/link-connect`** — connect mid-session without `--link` flag. Enables auto-reconnect. (`a2a0eac`)
93
+ - **`/link-disconnect`** — disconnect and suppress auto-reconnect, even if `--link` was passed. (`a2a0eac`)
94
+
95
+ ### Opt-in startup
96
+
97
+ - **`--link` flag.** Link is off by default — completely silent without the flag. No status bar, no connection attempts, no warnings. (`48d7e97`)
98
+
99
+ ### Protocol hardening (pre-release)
100
+
101
+ These fixes shipped before 0.1.0 but are worth noting as they shaped the protocol:
102
+
103
+ - **Early failure on missing targets.** Hub sends `prompt_response` with error for unknown targets, so the sender's promise resolves immediately instead of timing out. (`da38f62`)
104
+ - **Delivery status from routing.** `routeMessage()` returns a boolean — authoritative on the hub, optimistic on clients. (`a29fefc`)
105
+ - **Unique name enforcement.** Hub deduplicates names (`builder` → `builder-2`). Renames check for collisions. No-op renames short-circuit. (`84d2b68`, `1207647`)
106
+ - **Unregistered client guard.** Hub ignores all non-`register` messages from clients that haven't completed registration. (`679f25f`)
107
+ - **Session names as defaults.** Terminals use the Pi session name as their default link identity when available. (`2fd67c7`)
package/README.md CHANGED
@@ -85,7 +85,9 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
85
85
  > /link
86
86
  ⚡ Link: builder (hub) · 2 online
87
87
  builder: idle (5s)
88
+ cwd: ~/my-project
88
89
  researcher: idle (12s)
90
+ cwd: ~/my-project
89
91
  ```
90
92
 
91
93
  **Terminal 2** — rename it too:
@@ -142,11 +144,11 @@ The extension registers three tools that the LLM can invoke during agent runs.
142
144
 
143
145
  ### Which tool should I use?
144
146
 
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 |
147
+ | Tool | Behavior | Returns |
148
+ | ------------- | ---------------------------------------------------- | ----------------------------------------- |
149
+ | `link_send` | Send a message; optionally trigger the remote LLM | Send/delivery status only |
150
+ | `link_prompt` | Run a prompt on a remote terminal and wait for reply | The remote terminal's assistant response |
151
+ | `link_list` | List currently connected terminals | Terminal list with roles, status, and cwd |
150
152
 
151
153
  **If you need the other terminal's answer back, use `link_prompt`.** Use `link_send` to notify or steer without waiting.
152
154
 
@@ -164,7 +166,7 @@ When `triggerTurn` is enabled, the message is delivered via `pi.sendMessage` wit
164
166
 
165
167
  > **Broadcast note:** Sending to `"*"` delivers to **all other terminals** — the sender is excluded.
166
168
 
167
- 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.
169
+ Pre-validates the target name against the local terminal list before sending, catching typos early. See [Message Routing](#message-routing--error-handling) for delivery semantics.
168
170
 
169
171
  ### `link_prompt`
170
172
 
@@ -177,14 +179,19 @@ Send a prompt to a remote terminal and **wait** for the LLM's response (synchron
177
179
 
178
180
  - The remote terminal processes the prompt via `pi.sendUserMessage()` — as if a user typed it.
179
181
  - Returns the remote terminal's actual assistant reply text as the tool result.
180
- - **2-minute timeout**; supports abort signals.
182
+ - **Self-target rejection** prompting yourself (`to` equals your own name) returns an immediate error.
183
+ - **Heartbeat-based timeout** — no short fixed deadline. The target sends keepalives every 30s while working. The sender resets a 90-second inactivity timer on each keepalive. A 30-minute hard ceiling acts as a safety net against broken-but-chatty targets. A 10-minute task with regular activity never times out; a genuinely dead target times out in 90 seconds of silence.
184
+ - **Immediate failure on disconnect** — if the target leaves the network (`terminal_left`), pending prompts to that target fail immediately instead of waiting for the inactivity timeout.
181
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
+ - Supports abort signals.
182
187
  - Targets **one terminal at a time** (no broadcast mode).
183
188
  - Only **one remote prompt** can execute at a time per target terminal. Concurrent requests are rejected with `"Terminal is busy"`.
184
189
 
185
190
  ### `link_list`
186
191
 
187
- Lists all connected terminals with role info, live agent status, and self-identification. Takes no parameters.
192
+ Lists all connected terminals with role info, live agent status, working directory, and self-identification. Takes no parameters.
193
+
194
+ Each terminal reports its current working directory on connect and on session switch. `link_list` shows the full absolute path so agents can choose the right target, use explicit paths when terminals differ, and catch wrong-project mistakes early.
188
195
 
189
196
  Each terminal's status is derived automatically from Pi lifecycle events — agents can't set it manually. Three states:
190
197
 
@@ -196,13 +203,18 @@ Each terminal's status is derived automatically from Pi lifecycle events — age
196
203
 
197
204
  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.
198
205
 
206
+ Working directories use full absolute paths in tool output. In the TUI (`/link`), paths are shortened to `~/...` when possible to keep the display compact.
207
+
199
208
  **Example output:**
200
209
 
201
210
  ```
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)
211
+ Connected terminals:
212
+ opus@pi-link (you) idle (12s)
213
+ cwd: C:\Users\andre\.pi
214
+ gpt@pi-link thinking (3s)
215
+ cwd: C:\Users\andre\.pi
216
+ • docs@pi-link idle (1m)
217
+ cwd: C:\Users\andre\.pi
206
218
  ```
207
219
 
208
220
  ---
@@ -211,7 +223,7 @@ link (hub) 3 terminal(s)
211
223
 
212
224
  | Command | Purpose |
213
225
  | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
214
- | `/link` | Show link status (name, role, online count, agent status per terminal) |
226
+ | `/link` | Show link status (name, role, online count, agent status, and cwd per terminal) |
215
227
  | `/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
228
  | `/link-broadcast <msg>` | Broadcast a chat message to all other terminals |
217
229
  | `/link-connect` | Connect to Pi Link (works anytime, with or without `--link`) |
@@ -223,8 +235,11 @@ link (hub) 3 terminal(s)
223
235
  > /link
224
236
  ⚡ Link: builder (hub) · 3 online
225
237
  builder: idle (12s)
238
+ cwd: ~/my-project
226
239
  worker-1: thinking (3s)
240
+ cwd: ~/my-project
227
241
  worker-2: tool:bash (5s)
242
+ cwd: ~/other-project
228
243
 
229
244
  > /link-name orchestrator
230
245
  ✓ Renamed to "orchestrator"
@@ -243,7 +258,7 @@ link (hub) 3 terminal(s)
243
258
  ✓ Pi Link hub started on :9900 as "orchestrator" ... if no hub exists
244
259
  ```
245
260
 
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.
261
+ **Name persistence:** `/link-name` saves your preferred name to the session. Resume later and it's restored automatically. If the name is taken, the hub assigns a variant (e.g., `"builder-2"`), but your preferred name stays saved for the next reconnect. See [Name Uniqueness & Persistence](#name-uniqueness--persistence) for details.
247
262
 
248
263
  See [Configuration](#configuration) for details on `--link`, `/link-connect`, and `/link-disconnect` behavior.
249
264
 
@@ -319,16 +334,16 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
319
334
 
320
335
  ## Limitations & Design Decisions
321
336
 
322
- | # | Decision | Rationale / Impact |
323
- | --- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
324
- | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
325
- | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
326
- | 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
327
- | 4 | **Single remote prompt per terminal** | No queuing — immediate rejection if the target is busy. Keeps the model simple and avoids unbounded backlogs. |
328
- | 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
329
- | 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. |
330
- | 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
331
- | 8 | **Opt-in startup** | Link is off by default. Use `pi --link` or `/link-connect` to participate. See [Configuration](#configuration). |
337
+ | # | Decision | Rationale / Impact |
338
+ | --- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
339
+ | 1 | **No authentication** | Any localhost process can connect to port 9900. Acceptable for local dev; don't expose the port externally. |
340
+ | 2 | **Hardcoded port (9900)** | Not configurable without editing `DEFAULT_PORT` in `index.ts`. Could conflict with other services on the same port. |
341
+ | 3 | **Race-based hub promotion** | Non-deterministic. Terminal state (names, in-flight prompts) is lost during promotion. Simple but imperfect. |
342
+ | 4 | **Single remote prompt per terminal** | No queuing — immediate rejection if busy. See [`link_prompt`](#link_prompt) and [Troubleshooting](#terminal-is-busy-rejections). |
343
+ | 5 | **No message persistence** | Purely ephemeral WebSocket frames. Messages are lost if the recipient is offline. |
344
+ | 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. |
345
+ | 7 | **Single-machine / localhost-only** | Link only binds to `127.0.0.1`; terminals on different machines cannot join. |
346
+ | 8 | **Rename during prompt loses keepalives** | If the target renames mid-prompt, keepalive resets stop working (pending requests track by name). The final response can still succeed by request ID, but inactivity may false-fire on long tasks after rename. |
332
347
 
333
348
  ---
334
349
 
@@ -382,19 +397,20 @@ The `pi.extensions` field tells Pi which files to load as extensions. Here it po
382
397
 
383
398
  ### Protocol
384
399
 
385
- The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames:
386
-
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 |
400
+ The wire protocol consists of **10 message types**, all serialized as JSON over WebSocket frames. New cwd-related fields are optional for backward compatibility.
401
+
402
+ | Type | Direction | Purpose |
403
+ | ----------------- | --------------- | ----------------------------------------------------------------------- |
404
+ | `register` | Client → Hub | First message after connecting; requests a name, optionally reports cwd |
405
+ | `welcome` | Hub → Client | Confirms assigned name, terminal list + status/cwd snapshots |
406
+ | `terminal_joined` | Hub → All | Broadcast when a terminal joins; may include cwd |
407
+ | `terminal_left` | Hub → All | Broadcast when a terminal disconnects |
408
+ | `chat` | Any → Any/All | Fire-and-forget message; optionally triggers LLM turn |
409
+ | `prompt_request` | Any → Any | Request a remote terminal to execute a prompt |
410
+ | `prompt_response` | Any → Any | Response carrying the remote prompt result |
411
+ | `status_update` | Any → Hub → All | Terminal broadcasts its agent status change |
412
+ | `cwd_update` | Any → Hub → All | Terminal broadcasts a cwd change |
413
+ | `error` | Hub → Client | Error notification |
398
414
 
399
415
  ### Message Flow Examples
400
416
 
@@ -403,16 +419,17 @@ The wire protocol consists of **9 message types**, all serialized as JSON over W
403
419
  ```
404
420
  Client Hub
405
421
  | |
406
- | register {name:"builder"} |
422
+ | register {name:"builder", |
423
+ | cwd:"C:\\Users\\..."} |
407
424
  |---------------------------->|
408
425
  | |
409
426
  | welcome {name, terminals, |
410
- | statuses} |
427
+ | statuses, cwds} |
411
428
  |<----------------------------|
412
429
  | |
413
430
  ```
414
431
 
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).
432
+ Hub then broadcasts `terminal_joined` to the other connected terminals. The `welcome` message includes status and cwd snapshots for all connected terminals (fields omitted above for brevity). `terminal_joined` also includes the new terminal's optional cwd, and mid-session cwd changes are distributed via `cwd_update`.
416
433
 
417
434
  **Sending a chat message:**
418
435
 
@@ -459,13 +476,16 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
459
476
 
460
477
  ### State Management
461
478
 
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 |
479
+ | State Field | Type | Purpose |
480
+ | ------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------- |
481
+ | `role` | `"hub" \| "client" \| "disconnected"` | Current network role |
482
+ | `agentRunning` | `boolean` | Whether an agent run is active; blocks incoming remote prompts |
483
+ | `activeToolName` | `string \| null` | Name of the currently executing tool (drives `tool:<name>` status) |
484
+ | `stateSince` | `number` | Timestamp of last status change (used for duration display) |
485
+ | `currentCwd` | `string` | Current working directory reported to peers on connect and session switch |
486
+ | `manuallyDisconnected` | `boolean` | Set by `/link-disconnect`; suppresses auto-reconnect |
487
+ | `pendingRemotePrompt` | `object \| null` | Tracks the single in-flight remote prompt execution |
488
+ | `pendingPromptResponses` | `Map` | Outstanding prompt RPCs awaiting responses (includes inactivity + ceiling timers per entry) |
469
489
 
470
490
  ### Message Routing & Error Handling
471
491
 
@@ -487,13 +507,15 @@ The `manuallyDisconnected` flag distinguishes user-initiated disconnects (`/link
487
507
 
488
508
  The extension hooks into Pi's agent lifecycle events:
489
509
 
490
- - **`agent_start`** → Sets `isAgentBusy = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
510
+ - **`agent_start`** → Sets `agentRunning = true`, blocking incoming remote prompts. Broadcasts `status_update` (`thinking`).
491
511
  - **`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
512
  - **`tool_execution_start`** → Broadcasts `status_update` (`tool:<name>`).
493
513
  - **`tool_execution_end`** → Clears tool status; broadcasts `status_update` (`thinking`) while the agent run continues.
494
514
  - **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
495
515
 
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.
516
+ 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.
517
+
518
+ While executing a remote prompt, the target sends a forced `status_update` every 30 seconds as a keepalive — reusing the existing status push mechanism. On the sender side, each incoming `status_update` from the target resets the 90-second inactivity timer. All resolution paths (response, inactivity, ceiling, abort, disconnect, delivery failure) go through a single `cleanupPending()` helper to prevent double-resolution races.
497
519
 
498
520
  ### Rendering
499
521
 
package/index.ts CHANGED
@@ -2,21 +2,12 @@
2
2
  * Pi Link — WebSocket-based inter-terminal communication
3
3
  *
4
4
  * Connects multiple Pi terminals over a local WebSocket link.
5
- * The first terminal becomes the hub (server); others join as clients.
6
- * If the hub exits, a surviving terminal promotes itself automatically.
5
+ * Opt-in via --link flag or /link-connect command.
6
+ * First terminal to connect becomes the hub; others join as clients.
7
+ * Hub loss triggers automatic promotion of a surviving client.
7
8
  *
8
- * Features:
9
- * - Auto-discovery: try to connect → fall back to becoming the hub
10
- * - Named terminals with uniqueness enforcement
11
- * - LLM tools: link_send (chat), link_prompt (remote prompt + response), link_list
12
- * - Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
13
- * - Custom message renderer for incoming link messages
14
- * - Auto-reconnect with hub promotion on disconnect
15
- *
16
- * Install:
17
- * cd ~/.pi/agent/extensions/pi-link && npm install
18
- *
19
- * Then just start two or more `pi` terminals — they discover each other.
9
+ * Tools: link_send, link_prompt, link_list
10
+ * Commands: /link, /link-name, /link-broadcast, /link-connect, /link-disconnect
20
11
  */
21
12
 
22
13
  import type {
@@ -26,31 +17,42 @@ import type {
26
17
  import { Text } from "@mariozechner/pi-tui";
27
18
  import { Type } from "@sinclair/typebox";
28
19
  import * as crypto from "node:crypto";
20
+ import * as os from "node:os";
29
21
 
30
22
  import { WebSocket, WebSocketServer } from "ws";
31
23
 
32
24
  // ─── Constants ───────────────────────────────────────────────────────────────
33
25
 
34
26
  const DEFAULT_PORT = 9900;
35
- const PROMPT_TIMEOUT_MS = 120_000;
27
+ const PROMPT_INACTIVITY_MS = 90_000;
28
+ const PROMPT_HARD_CEILING_MS = 1_800_000;
36
29
  const RECONNECT_DELAY_MS = 2000;
30
+ const KEEPALIVE_INTERVAL_MS = 30_000;
37
31
 
38
32
  // ─── Protocol ────────────────────────────────────────────────────────────────
39
33
 
40
34
  interface RegisterMsg {
41
35
  type: "register";
42
36
  name: string;
37
+ cwd?: string;
43
38
  }
44
39
  interface WelcomeMsg {
45
40
  type: "welcome";
46
41
  name: string;
47
42
  terminals: string[];
48
43
  statuses?: Record<string, LinkStatus>;
44
+ cwds?: Record<string, string>;
49
45
  }
50
46
  interface TerminalJoinedMsg {
51
47
  type: "terminal_joined";
52
48
  name: string;
53
49
  terminals: string[];
50
+ cwd?: string;
51
+ }
52
+ interface CwdUpdateMsg {
53
+ type: "cwd_update";
54
+ name: string;
55
+ cwd: string;
54
56
  }
55
57
  interface TerminalLeftMsg {
56
58
  type: "terminal_left";
@@ -103,6 +105,7 @@ type LinkMessage =
103
105
  | PromptRequestMsg
104
106
  | PromptResponseMsg
105
107
  | StatusUpdateMsg
108
+ | CwdUpdateMsg
106
109
  | ErrorMsg;
107
110
 
108
111
  // ─── Extension ───────────────────────────────────────────────────────────────
@@ -132,11 +135,14 @@ export default function (pi: ExtensionAPI) {
132
135
  let lastPushedKind: string | null = null;
133
136
  let lastPushedTool: string | null = null;
134
137
  const terminalStatuses = new Map<string, LinkStatus>(); // other terminals
138
+ let currentCwd = "";
139
+ const terminalCwds = new Map<string, string>(); // other terminals' cwds
135
140
 
136
141
  // Hub state
137
142
  let wss: WebSocketServer | null = null;
138
143
  const hubClients = new Map<WebSocket, string>(); // ws → terminal name
139
144
  const hubTerminalStatuses = new Map<string, LinkStatus>(); // hub-authoritative
145
+ const hubTerminalCwds = new Map<string, string>(); // hub-authoritative (excludes self)
140
146
 
141
147
  // Client state
142
148
  let ws: WebSocket | null = null;
@@ -149,12 +155,15 @@ export default function (pi: ExtensionAPI) {
149
155
  content: { type: "text"; text: string }[];
150
156
  details: Record<string, unknown>;
151
157
  }) => void;
152
- timeout: ReturnType<typeof setTimeout>;
158
+ targetName: string;
159
+ inactivityTimeout: ReturnType<typeof setTimeout>;
160
+ ceilingTimeout: ReturnType<typeof setTimeout>;
153
161
  }
154
162
  >();
155
163
 
156
164
  // Pending remote prompt (this terminal is executing a prompt for someone else)
157
165
  let pendingRemotePrompt: { id: string; from: string } | null = null;
166
+ let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
158
167
 
159
168
  // ── Helpers ──────────────────────────────────────────────────────────────
160
169
 
@@ -216,6 +225,68 @@ export default function (pi: ExtensionAPI) {
216
225
  return map.get(name) ?? null;
217
226
  }
218
227
 
228
+ function getCwdFor(name: string): string | null {
229
+ if (name === terminalName) return currentCwd || null;
230
+ if (role === "hub") return hubTerminalCwds.get(name) ?? null;
231
+ return terminalCwds.get(name) ?? null;
232
+ }
233
+
234
+ function shortenPath(cwd: string): string {
235
+ const home = os.homedir().replace(/\\/g, "/");
236
+ const normalized = cwd.replace(/\\/g, "/");
237
+ if (normalized === home) return "~";
238
+ if (normalized.startsWith(home + "/"))
239
+ return "~" + normalized.slice(home.length);
240
+ return normalized;
241
+ }
242
+
243
+ function pushCwdUpdate() {
244
+ const msg: CwdUpdateMsg = {
245
+ type: "cwd_update",
246
+ name: terminalName,
247
+ cwd: currentCwd,
248
+ };
249
+ if (role === "hub") {
250
+ hubBroadcast(msg, terminalName);
251
+ } else if (role === "client" && ws?.readyState === WebSocket.OPEN) {
252
+ ws.send(JSON.stringify(msg));
253
+ }
254
+ }
255
+
256
+ // ── Pending prompt helpers ───────────────────────────────────────────────
257
+
258
+ function cleanupPending(requestId: string) {
259
+ const pending = pendingPromptResponses.get(requestId);
260
+ if (!pending) return null;
261
+ clearTimeout(pending.inactivityTimeout);
262
+ clearTimeout(pending.ceilingTimeout);
263
+ pendingPromptResponses.delete(requestId);
264
+ return pending;
265
+ }
266
+
267
+ function makeInactivityTimeout(requestId: string, targetName: string) {
268
+ return setTimeout(() => {
269
+ const pending = cleanupPending(requestId);
270
+ if (pending) {
271
+ pending.resolve(
272
+ textResult(
273
+ `Prompt to "${targetName}" timed out (no activity for ${PROMPT_INACTIVITY_MS / 1000}s)`,
274
+ { to: targetName, error: "timeout" },
275
+ ),
276
+ );
277
+ }
278
+ }, PROMPT_INACTIVITY_MS);
279
+ }
280
+
281
+ function resetInactivityFor(targetName: string) {
282
+ for (const [id, pending] of pendingPromptResponses) {
283
+ if (pending.targetName === targetName) {
284
+ clearTimeout(pending.inactivityTimeout);
285
+ pending.inactivityTimeout = makeInactivityTimeout(id, targetName);
286
+ }
287
+ }
288
+ }
289
+
219
290
  function allTerminalNames(): Set<string> {
220
291
  const names = new Set<string>();
221
292
  names.add(terminalName); // hub's own name
@@ -326,11 +397,17 @@ export default function (pi: ExtensionAPI) {
326
397
  terminalName = msg.name;
327
398
  connectedTerminals = msg.terminals;
328
399
  terminalStatuses.clear();
400
+ terminalCwds.clear();
329
401
  if (msg.statuses) {
330
402
  for (const [name, status] of Object.entries(msg.statuses)) {
331
403
  terminalStatuses.set(name, status);
332
404
  }
333
405
  }
406
+ if (msg.cwds) {
407
+ for (const [name, cwd] of Object.entries(msg.cwds)) {
408
+ terminalCwds.set(name, cwd);
409
+ }
410
+ }
334
411
  updateStatus();
335
412
  ctx?.ui.notify(
336
413
  `Joined link as "${terminalName}" (${connectedTerminals.length} online)`,
@@ -339,9 +416,10 @@ export default function (pi: ExtensionAPI) {
339
416
  pushStatus(true);
340
417
  break;
341
418
 
342
- // ── Directory updates ──
419
+ // ── Membership updates ──
343
420
  case "terminal_joined":
344
421
  connectedTerminals = msg.terminals;
422
+ if (role !== "hub" && msg.cwd) terminalCwds.set(msg.name, msg.cwd);
345
423
  updateStatus();
346
424
  ctx?.ui.notify(`"${msg.name}" joined the link`, "info");
347
425
  break;
@@ -349,6 +427,21 @@ export default function (pi: ExtensionAPI) {
349
427
  case "terminal_left":
350
428
  connectedTerminals = msg.terminals;
351
429
  terminalStatuses.delete(msg.name);
430
+ if (role !== "hub") terminalCwds.delete(msg.name);
431
+ // Fail any pending prompts to the departed terminal immediately
432
+ for (const [id, pending] of pendingPromptResponses) {
433
+ if (pending.targetName === msg.name) {
434
+ const p = cleanupPending(id);
435
+ if (p) {
436
+ p.resolve(
437
+ textResult(`Terminal "${msg.name}" disconnected`, {
438
+ to: msg.name,
439
+ error: "disconnected",
440
+ }),
441
+ );
442
+ }
443
+ }
444
+ }
352
445
  updateStatus();
353
446
  ctx?.ui.notify(`"${msg.name}" left the link`, "info");
354
447
  break;
@@ -356,6 +449,11 @@ export default function (pi: ExtensionAPI) {
356
449
  // ── Status update from another terminal ──
357
450
  case "status_update":
358
451
  terminalStatuses.set(msg.name, msg.status);
452
+ resetInactivityFor(msg.name);
453
+ break;
454
+
455
+ case "cwd_update":
456
+ terminalCwds.set(msg.name, msg.cwd);
359
457
  break;
360
458
 
361
459
  // ── Chat message ──
@@ -384,6 +482,12 @@ export default function (pi: ExtensionAPI) {
384
482
  });
385
483
  } else {
386
484
  pendingRemotePrompt = { id: msg.id, from: msg.from };
485
+ // Keepalive: periodic status push so sender knows we're alive
486
+ if (keepaliveTimer) clearInterval(keepaliveTimer);
487
+ keepaliveTimer = setInterval(
488
+ () => pushStatus(true),
489
+ KEEPALIVE_INTERVAL_MS,
490
+ );
387
491
  ctx?.ui.notify(`Running remote prompt from "${msg.from}"`, "info");
388
492
  pi.sendUserMessage(
389
493
  `[Remote prompt from "${msg.from}"]\n\n${msg.prompt}`,
@@ -393,10 +497,8 @@ export default function (pi: ExtensionAPI) {
393
497
 
394
498
  // ── Response to a prompt we sent ──
395
499
  case "prompt_response": {
396
- const pending = pendingPromptResponses.get(msg.id);
500
+ const pending = cleanupPending(msg.id);
397
501
  if (pending) {
398
- clearTimeout(pending.timeout);
399
- pendingPromptResponses.delete(msg.id);
400
502
  if (msg.error) {
401
503
  pending.resolve(
402
504
  textResult(`Error from "${msg.from}": ${msg.error}`, {
@@ -430,30 +532,38 @@ export default function (pi: ExtensionAPI) {
430
532
  if (msg.type === "register") {
431
533
  clientName = uniqueName(msg.name);
432
534
  hubClients.set(clientWs, clientName);
535
+ if (msg.cwd) hubTerminalCwds.set(clientName, msg.cwd);
433
536
  const list = terminalList();
434
537
  connectedTerminals = list;
435
538
  updateStatus();
436
539
 
437
- // Confirm to the new client (include status snapshot)
540
+ // Confirm to the new client (include status + cwd snapshots)
438
541
  const statuses: Record<string, LinkStatus> = {};
439
542
  statuses[terminalName] = deriveStatus(); // hub's own status
440
543
  for (const [name, status] of hubTerminalStatuses) {
441
544
  if (name !== clientName) statuses[name] = status;
442
545
  }
546
+ const cwds: Record<string, string> = {};
547
+ if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
548
+ for (const [name, cwd] of hubTerminalCwds) {
549
+ if (name !== clientName) cwds[name] = cwd;
550
+ }
443
551
  clientWs.send(
444
552
  JSON.stringify({
445
553
  type: "welcome",
446
554
  name: clientName,
447
555
  terminals: list,
448
556
  statuses,
557
+ cwds,
449
558
  } satisfies WelcomeMsg),
450
559
  );
451
560
 
452
- // Notify everyone else
561
+ // Notify everyone else (include joiner's cwd)
453
562
  const joined: TerminalJoinedMsg = {
454
563
  type: "terminal_joined",
455
564
  name: clientName,
456
565
  terminals: list,
566
+ cwd: msg.cwd,
457
567
  };
458
568
  hubBroadcast(joined, clientName);
459
569
  return;
@@ -465,6 +575,7 @@ export default function (pi: ExtensionAPI) {
465
575
  // Status update — store and fan out to other clients only (not back to hub)
466
576
  if (msg.type === "status_update") {
467
577
  hubTerminalStatuses.set(clientName, msg.status);
578
+ resetInactivityFor(clientName);
468
579
  const normalized: StatusUpdateMsg = {
469
580
  type: "status_update",
470
581
  name: clientName,
@@ -477,6 +588,21 @@ export default function (pi: ExtensionAPI) {
477
588
  return;
478
589
  }
479
590
 
591
+ // Cwd update — store and relay to other clients only
592
+ if (msg.type === "cwd_update") {
593
+ hubTerminalCwds.set(clientName, msg.cwd);
594
+ const normalized: CwdUpdateMsg = {
595
+ type: "cwd_update",
596
+ name: clientName,
597
+ cwd: msg.cwd,
598
+ };
599
+ const json = JSON.stringify(normalized);
600
+ for (const [otherWs, name] of hubClients) {
601
+ if (name !== clientName) otherWs.send(json);
602
+ }
603
+ return;
604
+ }
605
+
480
606
  // Route chat / prompt messages
481
607
  if (
482
608
  msg.type === "chat" ||
@@ -491,6 +617,7 @@ export default function (pi: ExtensionAPI) {
491
617
  if (clientName) {
492
618
  hubClients.delete(clientWs);
493
619
  hubTerminalStatuses.delete(clientName);
620
+ hubTerminalCwds.delete(clientName);
494
621
  const list = terminalList();
495
622
  connectedTerminals = list;
496
623
  updateStatus();
@@ -555,6 +682,7 @@ export default function (pi: ExtensionAPI) {
555
682
  JSON.stringify({
556
683
  type: "register",
557
684
  name: preferredName ?? terminalName,
685
+ cwd: currentCwd || undefined,
558
686
  } satisfies RegisterMsg),
559
687
  );
560
688
  resolve(true);
@@ -623,14 +751,22 @@ export default function (pi: ExtensionAPI) {
623
751
  reconnectTimer = null;
624
752
  }
625
753
 
754
+ // Clean up target-side remote prompt state
755
+ if (keepaliveTimer) {
756
+ clearInterval(keepaliveTimer);
757
+ keepaliveTimer = null;
758
+ }
759
+ pendingRemotePrompt = null;
760
+
626
761
  // Clean up pending prompts
627
- for (const [id, pending] of pendingPromptResponses) {
628
- clearTimeout(pending.timeout);
629
- pending.resolve(
630
- textResult("Link disconnected", { error: "disconnected" }),
631
- );
762
+ for (const id of [...pendingPromptResponses.keys()]) {
763
+ const pending = cleanupPending(id);
764
+ if (pending) {
765
+ pending.resolve(
766
+ textResult("Link disconnected", { error: "disconnected" }),
767
+ );
768
+ }
632
769
  }
633
- pendingPromptResponses.clear();
634
770
 
635
771
  // Close client connection
636
772
  if (ws) {
@@ -650,6 +786,8 @@ export default function (pi: ExtensionAPI) {
650
786
  connectedTerminals = [];
651
787
  terminalStatuses.clear();
652
788
  hubTerminalStatuses.clear();
789
+ terminalCwds.clear();
790
+ hubTerminalCwds.clear();
653
791
  lastPushedKind = null;
654
792
  lastPushedTool = null;
655
793
  updateStatus();
@@ -664,6 +802,7 @@ export default function (pi: ExtensionAPI) {
664
802
 
665
803
  pi.on("session_start", async (_event, _ctx) => {
666
804
  ctx = _ctx;
805
+ currentCwd = _ctx.cwd;
667
806
 
668
807
  // Restore preferred link name from session
669
808
  const saved = _ctx.sessionManager
@@ -688,7 +827,12 @@ export default function (pi: ExtensionAPI) {
688
827
  pi.on("session_switch", async (_event, _ctx) => {
689
828
  ctx = _ctx;
690
829
 
691
- // Restore preferred name from the new session
830
+ // 1. Cwd change detection (always, before any name logic)
831
+ const newCwd = _ctx.cwd;
832
+ const cwdChanged = newCwd !== currentCwd;
833
+ if (cwdChanged) currentCwd = newCwd;
834
+
835
+ // 2. Restore preferred name from the new session
692
836
  const saved = _ctx.sessionManager
693
837
  .getEntries()
694
838
  .filter(
@@ -699,9 +843,17 @@ export default function (pi: ExtensionAPI) {
699
843
 
700
844
  preferredName = saved?.data?.name ?? null;
701
845
  const desiredName = preferredName ?? `t-${crypto.randomUUID().slice(0, 4)}`;
846
+ const nameChanged = desiredName !== terminalName;
702
847
 
703
- if (desiredName === terminalName) return; // no identity change needed
848
+ if (!nameChanged && !cwdChanged) return; // nothing to do
704
849
 
850
+ if (!nameChanged) {
851
+ // Name stayed the same, but cwd changed — push cwd update
852
+ pushCwdUpdate();
853
+ return;
854
+ }
855
+
856
+ // Name changed (cwd may or may not have changed too)
705
857
  if (role === "hub") {
706
858
  // Hub rename in-place — avoid tearing down the server
707
859
  const takenByOther = Array.from(hubClients.values()).includes(
@@ -713,6 +865,8 @@ export default function (pi: ExtensionAPI) {
713
865
  `Session preferred name "${desiredName}" is taken, keeping "${terminalName}"`,
714
866
  "warning",
715
867
  );
868
+ // Still push cwd update under current name if cwd changed
869
+ if (cwdChanged) pushCwdUpdate();
716
870
  return;
717
871
  }
718
872
  const old = terminalName;
@@ -726,12 +880,17 @@ export default function (pi: ExtensionAPI) {
726
880
  terminalName,
727
881
  );
728
882
  hubBroadcast(
729
- { type: "terminal_joined", name: desiredName, terminals: list },
883
+ {
884
+ type: "terminal_joined",
885
+ name: desiredName,
886
+ terminals: list,
887
+ cwd: currentCwd,
888
+ },
730
889
  terminalName,
731
890
  );
732
891
  pushStatus(true);
733
892
  } else if (role === "client") {
734
- // Client — disconnect and reconnect with new name
893
+ // Client — disconnect and reconnect with new name (register includes cwd)
735
894
  terminalName = desiredName;
736
895
  disconnect();
737
896
  manuallyDisconnected = false;
@@ -770,6 +929,10 @@ export default function (pi: ExtensionAPI) {
770
929
  // If we were running a remote prompt, send the response back
771
930
  if (pendingRemotePrompt) {
772
931
  const { id, from } = pendingRemotePrompt;
932
+ if (keepaliveTimer) {
933
+ clearInterval(keepaliveTimer);
934
+ keepaliveTimer = null;
935
+ }
773
936
  pendingRemotePrompt = null;
774
937
 
775
938
  // Find the last assistant text in this run
@@ -896,7 +1059,7 @@ export default function (pi: ExtensionAPI) {
896
1059
  description: [
897
1060
  "Send a prompt to another Pi terminal and wait for its LLM to respond.",
898
1061
  "The remote terminal processes the prompt as if a user typed it,",
899
- "then returns the assistant's response. Times out after 2 minutes.",
1062
+ "then returns the assistant's response. Times out after 90s of inactivity.",
900
1063
  ].join(" "),
901
1064
  promptSnippet:
902
1065
  "Send a prompt to another Pi terminal and receive its LLM response",
@@ -908,6 +1071,13 @@ export default function (pi: ExtensionAPI) {
908
1071
  async execute(_toolCallId, params, signal) {
909
1072
  if (role === "disconnected") return notConnectedResult();
910
1073
 
1074
+ if (params.to === terminalName) {
1075
+ return textResult("Cannot prompt yourself", {
1076
+ to: params.to,
1077
+ error: "self_target",
1078
+ });
1079
+ }
1080
+
911
1081
  if (!connectedTerminals.includes(params.to)) {
912
1082
  return textResult(
913
1083
  `Terminal "${params.to}" not found. Connected: ${connectedTerminals.join(", ")}`,
@@ -918,30 +1088,40 @@ export default function (pi: ExtensionAPI) {
918
1088
  const requestId = crypto.randomUUID();
919
1089
 
920
1090
  return new Promise((resolve) => {
921
- const timeout = setTimeout(() => {
922
- pendingPromptResponses.delete(requestId);
923
- resolve(
924
- textResult(
925
- `Prompt to "${params.to}" timed out after ${PROMPT_TIMEOUT_MS / 1000}s`,
926
- { to: params.to, error: "timeout" },
927
- ),
928
- );
929
- }, PROMPT_TIMEOUT_MS);
1091
+ const inactivityTimeout = makeInactivityTimeout(requestId, params.to);
1092
+
1093
+ const ceilingTimeout = setTimeout(() => {
1094
+ const pending = cleanupPending(requestId);
1095
+ if (pending) {
1096
+ pending.resolve(
1097
+ textResult(
1098
+ `Prompt to "${params.to}" hit hard ceiling (${PROMPT_HARD_CEILING_MS / 60_000}min)`,
1099
+ { to: params.to, error: "timeout" },
1100
+ ),
1101
+ );
1102
+ }
1103
+ }, PROMPT_HARD_CEILING_MS);
930
1104
 
931
- pendingPromptResponses.set(requestId, { resolve, timeout });
1105
+ pendingPromptResponses.set(requestId, {
1106
+ resolve,
1107
+ targetName: params.to,
1108
+ inactivityTimeout,
1109
+ ceilingTimeout,
1110
+ });
932
1111
 
933
1112
  // Abort handling
934
1113
  signal?.addEventListener(
935
1114
  "abort",
936
1115
  () => {
937
- clearTimeout(timeout);
938
- pendingPromptResponses.delete(requestId);
939
- resolve(
940
- textResult("Prompt request aborted", {
941
- to: params.to,
942
- error: "aborted",
943
- }),
944
- );
1116
+ const pending = cleanupPending(requestId);
1117
+ if (pending) {
1118
+ pending.resolve(
1119
+ textResult("Prompt request aborted", {
1120
+ to: params.to,
1121
+ error: "aborted",
1122
+ }),
1123
+ );
1124
+ }
945
1125
  },
946
1126
  { once: true },
947
1127
  );
@@ -955,14 +1135,15 @@ export default function (pi: ExtensionAPI) {
955
1135
  });
956
1136
 
957
1137
  if (!delivered && pendingPromptResponses.has(requestId)) {
958
- clearTimeout(timeout);
959
- pendingPromptResponses.delete(requestId);
960
- resolve(
961
- textResult(`Failed to send prompt to "${params.to}"`, {
962
- to: params.to,
963
- error: "not_delivered",
964
- }),
965
- );
1138
+ const pending = cleanupPending(requestId);
1139
+ if (pending) {
1140
+ pending.resolve(
1141
+ textResult(`Failed to send prompt to "${params.to}"`, {
1142
+ to: params.to,
1143
+ error: "not_delivered",
1144
+ }),
1145
+ );
1146
+ }
966
1147
  }
967
1148
  });
968
1149
  },
@@ -1010,19 +1191,25 @@ export default function (pi: ExtensionAPI) {
1010
1191
  if (role === "disconnected") return notConnectedResult();
1011
1192
 
1012
1193
  const statuses: Record<string, string> = {};
1194
+ const cwds: Record<string, string> = {};
1013
1195
  const list = connectedTerminals
1014
1196
  .map((name) => {
1015
1197
  const status = getStatusFor(name);
1016
1198
  const statusStr = status ? formatStatus(status) : "";
1017
1199
  if (statusStr) statuses[name] = statusStr;
1200
+ const cwd = getCwdFor(name);
1201
+ if (cwd) cwds[name] = cwd;
1018
1202
  const marker = name === terminalName ? " (you)" : "";
1019
- return ` ${name}${marker}${statusStr ? " " + statusStr : ""}`;
1203
+ let line = ` \u2022 ${name}${marker}${statusStr ? " " + statusStr : ""}`;
1204
+ if (cwd) line += `\n cwd: ${cwd}`;
1205
+ return line;
1020
1206
  })
1021
1207
  .join("\n");
1022
1208
 
1023
1209
  return textResult(`Connected terminals:\n${list}`, {
1024
1210
  terminals: connectedTerminals,
1025
1211
  statuses,
1212
+ cwds,
1026
1213
  self: terminalName,
1027
1214
  role,
1028
1215
  });
@@ -1033,6 +1220,7 @@ export default function (pi: ExtensionAPI) {
1033
1220
  | {
1034
1221
  terminals?: string[];
1035
1222
  statuses?: Record<string, string>;
1223
+ cwds?: Record<string, string>;
1036
1224
  self?: string;
1037
1225
  role?: string;
1038
1226
  }
@@ -1048,11 +1236,13 @@ export default function (pi: ExtensionAPI) {
1048
1236
  for (const name of details.terminals) {
1049
1237
  const isSelf = name === details.self;
1050
1238
  const status = details.statuses?.[name] ?? "";
1051
- const nameStr = isSelf ? `• ${name} (you)` : `• ${name}`;
1239
+ const cwd = details.cwds?.[name];
1240
+ const nameStr = isSelf ? `\u2022 ${name} (you)` : `\u2022 ${name}`;
1052
1241
  text +=
1053
1242
  "\n " +
1054
1243
  (isSelf ? theme.fg("accent", nameStr) : theme.fg("text", nameStr)) +
1055
1244
  (status ? " " + theme.fg("dim", status) : "");
1245
+ if (cwd) text += "\n " + theme.fg("dim", `cwd: ${shortenPath(cwd)}`);
1056
1246
  }
1057
1247
  return new Text(text, 0, 0);
1058
1248
  },
@@ -1070,8 +1260,11 @@ export default function (pi: ExtensionAPI) {
1070
1260
  const lines = connectedTerminals.map((name) => {
1071
1261
  const status = getStatusFor(name);
1072
1262
  const statusStr = status ? formatStatus(status) : "";
1263
+ const cwd = getCwdFor(name);
1073
1264
  const marker = name === terminalName ? " (you)" : "";
1074
- return `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1265
+ let line = `${name}${marker}${statusStr ? ": " + statusStr : ""}`;
1266
+ if (cwd) line += `\n cwd: ${shortenPath(cwd)}`;
1267
+ return line;
1075
1268
  });
1076
1269
  _ctx.ui.notify(
1077
1270
  `Link: ${terminalName} (${role}) · ${connectedTerminals.length} online\n${lines.join("\n")}`,
@@ -1136,7 +1329,12 @@ export default function (pi: ExtensionAPI) {
1136
1329
  terminalName,
1137
1330
  );
1138
1331
  hubBroadcast(
1139
- { type: "terminal_joined", name: newName, terminals: list },
1332
+ {
1333
+ type: "terminal_joined",
1334
+ name: newName,
1335
+ terminals: list,
1336
+ cwd: currentCwd,
1337
+ },
1140
1338
  terminalName,
1141
1339
  );
1142
1340
  pushStatus(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "WebSocket-based inter-terminal communication for Pi. Connect multiple Pi terminals over a local link network.",
5
5
  "author": "alvivar",
6
6
  "license": "MIT",
package/sync.ffs_db ADDED
Binary file