pi-link 0.1.10 → 0.1.11

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 CHANGED
@@ -6,6 +6,14 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
6
6
 
7
7
  ---
8
8
 
9
+ ## 0.1.11 — 2026-04-27
10
+
11
+ ### Added
12
+
13
+ - **`pi-link list` command.** Lists pi-link sessions in the current cwd. Use `--all` (or `-a`) to list sessions across all directories — adds a CWD column with `~` substituted for `$HOME`. Shows name, last-modified time, message count, and short ID. Sessions are detected by presence of a `link-name` entry. ANSI styling (bold headers, dim secondary columns) in TTY; plain when piped (`NO_COLOR` honored).
14
+
15
+ ---
16
+
9
17
  ## 0.1.10 — 2026-04-26
10
18
 
11
19
  ### Changed
package/README.md CHANGED
@@ -142,7 +142,7 @@ Link is **off by default**. Without `--link` or `pi-link`, the extension is comp
142
142
  | `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
143
143
  | `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
144
144
 
145
- **Name precedence:** `PI_LINK_NAME` env (set by `pi-link`) > saved `/link-name` > Pi session name > random `t-xxxx`.
145
+ **Name precedence:** `pi-link <name>` > saved `/link-name` > Pi session name > random `t-xxxx`.
146
146
 
147
147
  `/link-connect` and `/link-disconnect` save their intent to the session — resume later and the connection state is restored without needing the flag. Explicit user intent takes precedence over `--link`.
148
148
 
@@ -157,13 +157,40 @@ pi-link worker-1 # resume or create session "worker-1"
157
157
  pi-link worker-1 --model sonnet # with extra Pi flags
158
158
  ```
159
159
 
160
- How it works: `pi-link worker-1` scans `~/.pi/agent/sessions/`, finds the session named "worker-1", and launches `pi --session <path> --link`.
160
+ How it works: `pi-link worker-1` scans `~/.pi/agent/sessions/`, finds the session named "worker-1", and spawns `pi --session <path> --link`.
161
161
 
162
162
  - **One match** → resumes that session
163
163
  - **No match** → creates a new session
164
164
  - **Multiple matches** → prints candidates to stderr, exits 1
165
+ - **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
165
166
 
166
- `pi-link resolve <name>` is also available for machine-readable output (prints just the session path).
167
+ ### Discovering sessions
168
+
169
+ `pi-link list` shows pi-link sessions in the current cwd; `pi-link list --all` (or `-a`) lists them across all directories. Sorted by last activity.
170
+
171
+ ```
172
+ $ pi-link list
173
+ NAME MODIFIED MESSAGES ID
174
+ opus@pi-link 2m ago 4632 6332faab
175
+ gpt@pi-link 5m ago 1493 20d43841
176
+
177
+ Resume: pi-link <name>
178
+ ```
179
+
180
+ With `--all`:
181
+
182
+ ```
183
+ $ pi-link list --all
184
+ NAME CWD MODIFIED MESSAGES ID
185
+ opus@pi-link ~/my-project 2m ago 4632 6332faab
186
+ gpt@pi-link ~/other-project 5m ago 1493 20d43841
187
+
188
+ Resume: pi-link <name>
189
+ ```
190
+
191
+ `--all` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
192
+
193
+ For scripting, `pi-link resolve <name>` prints just the session path (machine-readable, no other output).
167
194
 
168
195
  ---
169
196
 
@@ -432,7 +459,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
432
459
 
433
460
  ### Protocol
434
461
 
435
- The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames. Cwd-related fields are optional for backward compatibility.
462
+ The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames. Cwd-related fields are optional.
436
463
 
437
464
  | Type | Direction | Purpose |
438
465
  | ----------------- | --------------- | ----------------------------------------------------------------------- |
@@ -500,6 +527,8 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
500
527
 
501
528
  **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.
502
529
 
530
+ **Internal handoff (`PI_LINK_NAME`):** the `pi-link` launcher passes the chosen name to Pi via the `PI_LINK_NAME` environment variable. The extension reads it once on `session_start` and immediately removes it from `process.env` so child processes don't inherit it. This is an internal mechanism — don't set `PI_LINK_NAME` manually; use `pi-link <name>` to start a session or `/link-name` to rename mid-session.
531
+
503
532
  **Rename guards:**
504
533
 
505
534
  - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
package/bin/pi-link.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // Usage:
6
6
  // pi-link <name> [flags...] Resume or create a named session, connected to link.
7
+ // pi-link list [--all|-a] List pi-link sessions in current cwd (or everywhere).
7
8
  // pi-link resolve <name> Print just the session path (machine-readable).
8
9
 
9
10
  import { readdir, stat } from "fs/promises";
@@ -15,26 +16,86 @@ import { spawn } from "child_process";
15
16
 
16
17
  const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
17
18
 
19
+ // Reads a session JSONL file and returns its display name, cwd, id, link
20
+ // status, and message count.
21
+ //
22
+ // Name precedence: latest valid `link-name` custom entry wins as the
23
+ // authoritative pi-link name. `session_info.name` is only a fallback for
24
+ // sessions that never set a link-name. Historical link-names are not aliases.
18
25
  async function getSessionMeta(filePath) {
19
- let name;
26
+ let linkName;
27
+ let sessionName;
20
28
  let cwd;
29
+ let id;
30
+ let hasLinkName = false;
31
+ let messages = 0;
21
32
  const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
22
33
  for await (const line of rl) {
23
34
  if (!line) continue;
24
35
  try {
25
36
  const entry = JSON.parse(line);
26
- if (entry.type === "session" && typeof entry.cwd === "string") cwd = entry.cwd;
27
- if (entry.type === "session_info" && typeof entry.name === "string") {
28
- name = entry.name.trim().replace(/\s+/g, " ") || undefined;
37
+ if (entry.type === "session") {
38
+ if (typeof entry.cwd === "string") cwd = entry.cwd;
39
+ if (typeof entry.id === "string") id = entry.id;
40
+ } else if (entry.type === "session_info" && typeof entry.name === "string") {
41
+ sessionName = entry.name.trim().replace(/\s+/g, " ") || undefined;
42
+ } else if (entry.type === "custom" && entry.customType === "link-name") {
43
+ hasLinkName = true;
44
+ if (entry.data && typeof entry.data.name === "string") {
45
+ const n = entry.data.name.trim().replace(/\s+/g, " ");
46
+ if (n) linkName = n;
47
+ }
48
+ } else if (entry.type === "message" || entry.type === "user" || entry.type === "assistant") {
49
+ messages++;
29
50
  }
30
51
  } catch {
31
- // skip malformed lines
52
+ // skip malformed lines (incl. partial last line of active sessions)
32
53
  }
33
54
  }
34
- return { name, cwd };
55
+ return { name: linkName ?? sessionName, cwd, id, hasLinkName, messages };
35
56
  }
36
57
 
37
- async function findSessionsByName(targetName) {
58
+ function normalizePath(p) {
59
+ let s = p.replace(/[/\\]+/g, "/").replace(/\/+$/, "");
60
+ if (process.platform === "win32") s = s.toLowerCase();
61
+ return s;
62
+ }
63
+
64
+ // Replace $HOME with ~ in display paths. Comparison is normalized
65
+ // (case-insensitive on Windows) but display preserves original casing.
66
+ function displayPath(p) {
67
+ if (!p) return p;
68
+ const home = homedir();
69
+ const normP = normalizePath(p);
70
+ const normHome = normalizePath(home);
71
+ if (normP === normHome) return "~";
72
+ if (normP.startsWith(normHome + "/")) return "~" + p.slice(home.length).replace(/\\/g, "/");
73
+ return p;
74
+ }
75
+
76
+ const useAnsi =
77
+ !!process.stdout.isTTY &&
78
+ process.env.NO_COLOR === undefined &&
79
+ process.env.TERM !== "dumb";
80
+ const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
81
+ const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
82
+
83
+ function relTime(d) {
84
+ const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
85
+ if (sec < 60) return `${sec}s ago`;
86
+ const min = Math.floor(sec / 60);
87
+ if (min < 60) return `${min}m ago`;
88
+ const hr = Math.floor(min / 60);
89
+ if (hr < 24) return `${hr}h ago`;
90
+ const day = Math.floor(hr / 24);
91
+ if (day < 30) return `${day}d ago`;
92
+ return d.toISOString().slice(0, 10);
93
+ }
94
+
95
+ // Walks SESSIONS_DIR in parallel, returning meta + mtime + path for every
96
+ // readable session. Callers filter and sort. Errors on individual files/dirs
97
+ // are silently skipped — active or partially-written sessions are tolerated.
98
+ async function scanSessions() {
38
99
  let cwdDirs;
39
100
  try {
40
101
  cwdDirs = await readdir(SESSIONS_DIR, { withFileTypes: true });
@@ -42,43 +103,77 @@ async function findSessionsByName(targetName) {
42
103
  return [];
43
104
  }
44
105
 
45
- const matches = [];
46
-
106
+ const tasks = [];
47
107
  for (const dir of cwdDirs) {
48
108
  if (!dir.isDirectory()) continue;
49
109
  const dirPath = join(SESSIONS_DIR, dir.name);
50
-
51
110
  let files;
52
- try {
53
- files = await readdir(dirPath);
54
- } catch {
55
- continue;
56
- }
57
-
111
+ try { files = await readdir(dirPath); } catch { continue; }
58
112
  for (const file of files) {
59
113
  if (!file.endsWith(".jsonl")) continue;
60
114
  const filePath = join(dirPath, file);
61
- try {
62
- const { name, cwd } = await getSessionMeta(filePath);
63
- if (name === targetName) {
115
+ tasks.push((async () => {
116
+ try {
117
+ const meta = await getSessionMeta(filePath);
64
118
  const stats = await stat(filePath);
65
- matches.push({ path: filePath, cwd: cwd || "?", modified: stats.mtime });
119
+ return { ...meta, modified: stats.mtime, path: filePath };
120
+ } catch {
121
+ return null;
66
122
  }
67
- } catch {
68
- continue;
69
- }
123
+ })());
70
124
  }
71
125
  }
72
126
 
73
- // Local-first: current cwd matches before others, then by modified time
74
- const localCwd = process.cwd();
75
- matches.sort((a, b) => {
76
- const aLocal = a.cwd === localCwd ? 1 : 0;
77
- const bLocal = b.cwd === localCwd ? 1 : 0;
78
- if (aLocal !== bLocal) return bLocal - aLocal;
79
- return b.modified.getTime() - a.modified.getTime();
80
- });
81
- return matches;
127
+ return (await Promise.all(tasks)).filter((s) => s !== null);
128
+ }
129
+
130
+ // Find sessions whose current display name matches `targetName`. Local cwd
131
+ // matches sort first, then by recency. Falls back to `session_info.name` for
132
+ // sessions without a link-name (so `pi-link <name>` can attach link to a
133
+ // previously-unlinked named session).
134
+ async function findSessionsByName(targetName) {
135
+ const localCwd = normalizePath(process.cwd());
136
+ return (await scanSessions())
137
+ .filter((s) => s.name === targetName)
138
+ .map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
139
+ .sort((a, b) => {
140
+ const aLocal = normalizePath(a.cwd) === localCwd ? 1 : 0;
141
+ const bLocal = normalizePath(b.cwd) === localCwd ? 1 : 0;
142
+ if (aLocal !== bLocal) return bLocal - aLocal;
143
+ return b.modified.getTime() - a.modified.getTime();
144
+ });
145
+ }
146
+
147
+ // List pi-link sessions (those with at least one link-name entry). Default
148
+ // scope is current cwd; `all` widens to every directory.
149
+ async function listSessions({ all }) {
150
+ const localCwd = normalizePath(process.cwd());
151
+ return (await scanSessions())
152
+ .filter((s) => s.hasLinkName)
153
+ .filter((s) => all || (s.cwd && normalizePath(s.cwd) === localCwd))
154
+ .map((s) => ({
155
+ name: s.name || "(unnamed)",
156
+ cwd: s.cwd || "?",
157
+ id: s.id ? s.id.slice(0, 8) : "?",
158
+ messages: s.messages,
159
+ modified: s.modified,
160
+ path: s.path,
161
+ }))
162
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime());
163
+ }
164
+
165
+ // Renders a plain-text table. Widths are computed from unstyled cells; ANSI
166
+ // styles are applied after padding so column alignment is preserved when piped
167
+ // or styled. Mark a column with `dim: true` to render its cells dim.
168
+ function renderTable(rows, columns) {
169
+ const widths = columns.map((c) => Math.max(c.header.length, ...rows.map((r) => String(c.get(r)).length)));
170
+ const padCell = (text, i) => (i === columns.length - 1 ? text : text.padEnd(widths[i]));
171
+ const styleBody = (text, i) => (columns[i].dim ? dim(text) : text);
172
+ const headerLine = columns.map((c, i) => bold(padCell(c.header, i))).join(" ");
173
+ const bodyLines = rows.map((r) =>
174
+ columns.map((c, i) => styleBody(padCell(String(c.get(r)), i), i)).join(" "),
175
+ );
176
+ return [headerLine, ...bodyLines].join("\n");
82
177
  }
83
178
 
84
179
  // ── CLI ────────────────────────────────────────────────────────────────────
@@ -95,7 +190,42 @@ function printCandidates(name, matches) {
95
190
  process.exit(1);
96
191
  }
97
192
 
98
- if (command === "resolve") {
193
+ if (command === "list") {
194
+ let all = false;
195
+ for (const a of args) {
196
+ if (a === "--all" || a === "-a") all = true;
197
+ else {
198
+ console.error(`Unknown argument: ${a}`);
199
+ console.error("Usage: pi-link list [--all|-a]");
200
+ process.exit(1);
201
+ }
202
+ }
203
+ const sessions = await listSessions({ all });
204
+ if (sessions.length === 0) {
205
+ console.log(all ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
206
+ console.log("Start one: pi-link <name>");
207
+ process.exit(0);
208
+ }
209
+ const columns = all
210
+ ? [
211
+ { header: "NAME", get: (s) => s.name },
212
+ { header: "CWD", get: (s) => displayPath(s.cwd) },
213
+ { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
214
+ { header: "MESSAGES", get: (s) => s.messages, dim: true },
215
+ { header: "ID", get: (s) => s.id, dim: true },
216
+ ]
217
+ : [
218
+ { header: "NAME", get: (s) => s.name },
219
+ { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
220
+ { header: "MESSAGES", get: (s) => s.messages, dim: true },
221
+ { header: "ID", get: (s) => s.id, dim: true },
222
+ ];
223
+ console.log(renderTable(sessions, columns));
224
+ if (process.stdout.isTTY) {
225
+ console.log("");
226
+ console.log(dim("Resume: pi-link <name>"));
227
+ }
228
+ } else if (command === "resolve") {
99
229
  const name = args[0]?.trim().replace(/\s+/g, " ");
100
230
  if (!name) {
101
231
  console.error("Usage: pi-link resolve <name>");
@@ -122,6 +252,7 @@ if (command === "resolve") {
122
252
  console.error(`Error: ${key} is managed by pi-link. Remove it.`);
123
253
  process.exit(1);
124
254
  }
255
+ // Catch the removed extension flag before forwarding args to Pi.
125
256
  if (key === "--link-name") {
126
257
  console.error("Error: --link-name was removed. Use: pi-link <name>");
127
258
  process.exit(1);
@@ -146,6 +277,8 @@ if (command === "resolve") {
146
277
  const cmd = isWin ? "cmd.exe" : "pi";
147
278
  const cmdArgs = isWin ? ["/d", "/c", "pi", ...piArgs] : piArgs;
148
279
 
280
+ // PI_LINK_NAME is the internal handoff to the pi-link extension on the Pi side.
281
+ // The extension consumes and deletes it on startup; never expose this as a public API.
149
282
  const child = spawn(cmd, cmdArgs, {
150
283
  stdio: "inherit",
151
284
  env: { ...process.env, PI_LINK_NAME: name },
@@ -159,6 +292,8 @@ if (command === "resolve") {
159
292
  process.exit(1);
160
293
  });
161
294
  } else {
162
- console.error("Usage: pi-link <name> [pi flags...]\n pi-link resolve <name>");
295
+ console.error("Usage: pi-link <name> [pi flags...]");
296
+ console.error(" pi-link list [--all|-a]");
297
+ console.error(" pi-link resolve <name>");
163
298
  process.exit(0);
164
299
  }
package/index.ts CHANGED
@@ -915,7 +915,9 @@ export default function (pi: ExtensionAPI) {
915
915
  ctx = _ctx;
916
916
  currentCwd = _ctx.cwd;
917
917
 
918
- // Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random
918
+ // Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random.
919
+ // PI_LINK_NAME is an internal handoff from the `pi-link` CLI launcher.
920
+ // Consumed once here and removed from process.env so spawned children don't inherit it.
919
921
  const rawLinkName = process.env.PI_LINK_NAME;
920
922
  delete process.env.PI_LINK_NAME;
921
923
  const flagName = rawLinkName?.trim().replace(/\s+/g, " ") || undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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",
@@ -31,6 +31,8 @@ Pick one mode per terminal per task. Mixing sync and async on the same terminal
31
31
 
32
32
  Returns connected terminals with names, live status (`idle`, `thinking`, `tool:<name>`), and working directory (cwd). Use before delegating when availability or path context is uncertain. Your own entry is marked `(you)` — use this to discover your link name when replying to broadcast tasks.
33
33
 
34
+ Only currently connected terminals are visible. If a target is missing, it is offline; messages to offline terminals are not queued.
35
+
34
36
  ### `link_prompt`
35
37
 
36
38
  Synchronous RPC. Send a prompt, wait for the response.