pi-link 0.1.10 → 0.1.12

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,31 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
6
6
 
7
7
  ---
8
8
 
9
+ ## 0.1.12 — 2026-05-03
10
+
11
+ ### Changed
12
+
13
+ - **TypeBox import migrated from `@sinclair/typebox` to `typebox`.** Pi 0.69.0 renamed the package; both names still resolve to the same module via Pi's loader alias, so behavior is unchanged. Aligns with Pi's preferred naming and futureproofs against alias removal. README's "Provided by Pi" table updated to match.
14
+
15
+ ### Fixed
16
+
17
+ - **`pi-link list/resolve/<name>` now respect Pi's session-dir configuration.** The CLI hardcoded `~/.pi/agent/sessions` and ignored Pi's actual lookup chain, so users with a custom session location saw "no sessions" from `list`/`resolve` and — worse — `pi-link <name>` silently started a new session instead of resuming the existing one, fragmenting history into orphans across the real session dir. Resolution now matches Pi's lookup order (minus `--session-dir`, which the CLI rejects): `PI_CODING_AGENT_SESSION_DIR` → `<cwd>/.pi/settings.json` `sessionDir` → `<agentDir>/settings.json` `sessionDir` → default `<agentDir>/sessions/<encoded-cwd>`. `<agentDir>` follows `PI_CODING_AGENT_DIR`. Tilde expansion (`~`, `~/...`) matches Pi's `expandTildePath`. Custom layouts are scanned flat; default keeps the encoded-cwd subdirs. Malformed `settings.json` warns to stderr and falls through. Empty env vars and empty/non-string `sessionDir` values are treated as absent.
18
+ - **`pi-link <name>` now rejects Pi-managed flags even when passed as the first token.** Previously `pi-link --link-name foo` or `pi-link --session path` silently treated the flag as a session name. The validation that already covered later flags now also runs on the first token, with the same error messages.
19
+ - **`pi-link <name>` and `pi-link resolve <name>` now scope name lookup to the current cwd by default; `--global` / `-g` widens to any cwd.** Previously both commands scanned every session everywhere, so `pi-link work` from `~/projects/A` would silently resume `~/projects/B`'s `work` session if no local match existed — mixing one cwd's files into another cwd's session history. By default only current-cwd matches are considered; `--global` restores cross-cwd lookup, with duplicate exact names still failing with candidates. When `pi-link <name>` finds no local match but matches exist elsewhere, it warns and points at `--global` instead of silently jumping. `--global` may be passed before or after the name. `pi-link resolve` now also rejects extra positional arguments and unknown flags. **Breaking change**: `pi-link list --all` is renamed to `pi-link list --global` (`-a` → `-g`) for consistency across the three commands. As a transition aid, `--all` / `-a` are explicitly rejected with a pointer to the new flag name (mirroring the `--link-name was removed` treatment) so users with muscle memory get a clear hint instead of a generic "Unknown argument".
20
+ - **Hub now uses its authoritative socket→name mapping when forwarding chat/prompt messages.** Previously the hub forwarded `chat`, `prompt_request`, and `prompt_response` with whatever `from` the client claimed, while normalizing `status_update` against its socket→name mapping. The asymmetry meant a client with a stale or optimistic local `terminalName` could leak the wrong sender to other terminals — and under a rename-to-taken-name race, prompt responses could route back to the wrong terminal entirely. Hub now spread-normalizes `from` for all routed client messages, matching the existing `status_update` pattern.
21
+ - **`/link-name` no longer updates local `terminalName` before the hub confirms the rename.** Previously the client branch optimistically set `terminalName = newName` before reconnect, so during the close→reconnect→welcome window `/link` and `link_list` would report the requested name even if the hub later deduped it. Local identity now stays at the pre-rename value until `welcome` arrives. Notification wording updated from "Reconnecting as" to "Reconnecting, requesting" to reflect that the hub may assign a different name.
22
+ - **Hub promotion now preserves a pending client rename request.** Same-release follow-up to the previous bullet: with `terminalName` no longer updated optimistically, a client whose previous hub vanished mid-rename and who then wins hub promotion via `startHub` would otherwise have announced under the old local name. A `pendingClientRename` flag, set in `/link-name` and cleared on `welcome`, lets `startHub` adopt the requested name only when a rename was in flight. Hub-assigned deduped names from prior welcomes are otherwise preserved — no general `preferredName` replay.
23
+
24
+ ---
25
+
26
+ ## 0.1.11 — 2026-04-27
27
+
28
+ ### Added
29
+
30
+ - **`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).
31
+
32
+ ---
33
+
9
34
  ## 0.1.10 — 2026-04-26
10
35
 
11
36
  ### Changed
package/README.md CHANGED
@@ -103,7 +103,7 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
103
103
 
104
104
  ```
105
105
  > /link-name researcher
106
- ✓ Reconnecting as "researcher" (hub may assign a different name if taken)...
106
+ ✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
107
107
  ```
108
108
 
109
109
  **Now ask Terminal 1's LLM to delegate work:**
@@ -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,44 @@ 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's session directory, finds the session named "worker-1", and spawns `pi --session <path> --link`. Session-dir resolution mirrors Pi's: `PI_CODING_AGENT_SESSION_DIR` env > `<cwd>/.pi/settings.json` `sessionDir` > `<agentDir>/settings.json` `sessionDir` > default `<agentDir>/sessions/`. `<agentDir>` follows `PI_CODING_AGENT_DIR` and defaults to `~/.pi/agent/`.
161
161
 
162
- - **One match** resumes that session
163
- - **No match** → creates a new session
164
- - **Multiple matches** → prints candidates to stderr, exits 1
162
+ Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd.
165
163
 
166
- `pi-link resolve <name>` is also available for machine-readable output (prints just the session path).
164
+ - **One match in scope** resumes that session
165
+ - **No match in scope** → creates a new session in the current cwd. If matches exist outside the scope, prints a hint pointing at `--global`.
166
+ - **Multiple matches in scope** → prints candidates to stderr, exits 1
167
+ - **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
168
+
169
+ ### Discovering sessions
170
+
171
+ `pi-link list` shows pi-link sessions in the current cwd; `pi-link list --global` (or `-g`) lists them across all directories. Sorted by last activity.
172
+
173
+ ```
174
+ $ pi-link list
175
+ NAME MODIFIED MESSAGES ID
176
+ opus@pi-link 2m ago 4632 6332faab
177
+ gpt@pi-link 5m ago 1493 20d43841
178
+
179
+ Resume: pi-link <name>
180
+ ```
181
+
182
+ With `--global`:
183
+
184
+ ```
185
+ $ pi-link list --global
186
+ NAME CWD MODIFIED MESSAGES ID
187
+ opus@pi-link ~/my-project 2m ago 4632 6332faab
188
+ gpt@pi-link ~/other-project 5m ago 1493 20d43841
189
+
190
+ Resume: pi-link <name>
191
+ ```
192
+
193
+ `--global` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
194
+
195
+ `pi-link <name>` and `pi-link resolve <name>` follow the same scoping: local cwd by default, `--global` (or `-g`) widens. When `pi-link <name>` finds no local match but matches exist elsewhere, it warns and points at `--global` instead of silently jumping cwds.
196
+
197
+ For scripting, `pi-link resolve <name>` prints just the session path (machine-readable, no other output).
167
198
 
168
199
  ---
169
200
 
@@ -399,7 +430,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
399
430
  | ------------------------------- | ------------------------------------------------ |
400
431
  | `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
401
432
  | `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
402
- | `@sinclair/typebox` | JSON Schema type definitions for tool parameters |
433
+ | `typebox` | JSON Schema type definitions for tool parameters |
403
434
 
404
435
  ### `package.json`
405
436
 
@@ -432,7 +463,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
432
463
 
433
464
  ### Protocol
434
465
 
435
- The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames. Cwd-related fields are optional for backward compatibility.
466
+ The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames. Cwd-related fields are optional.
436
467
 
437
468
  | Type | Direction | Purpose |
438
469
  | ----------------- | --------------- | ----------------------------------------------------------------------- |
package/bin/pi-link.mjs CHANGED
@@ -3,88 +3,263 @@
3
3
  // pi-link CLI — launch Pi with session resume by name
4
4
  //
5
5
  // Usage:
6
- // pi-link <name> [flags...] Resume or create a named session, connected to link.
7
- // pi-link resolve <name> Print just the session path (machine-readable).
6
+ // pi-link <name> [--global|-g] [flags...]
7
+ // Resume or create a named session, connected to link.
8
+ // pi-link list [--global|-g] List pi-link sessions in current cwd (or everywhere).
9
+ // pi-link resolve <name> [--global|-g]
10
+ // Print just the session path (machine-readable).
8
11
 
9
12
  import { readdir, stat } from "fs/promises";
10
- import { createReadStream } from "fs";
13
+ import { createReadStream, existsSync, readFileSync } from "fs";
11
14
  import { createInterface } from "readline";
12
15
  import { join } from "path";
13
16
  import { homedir } from "os";
14
17
  import { spawn } from "child_process";
15
18
 
16
- const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
19
+ // ── Pi config resolution ───────────────────────────────────────────────────
20
+ // Match Pi's session-dir lookup order so list/resolve/<name> see what Pi sees.
21
+ // Custom sessionDir → flat layout; default → <agentDir>/sessions/<encoded-cwd>.
17
22
 
23
+ // Match Pi's expandTildePath: only `~` and `~/...`.
24
+ function expandTilde(p) {
25
+ if (p === "~") return homedir();
26
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
27
+ return p;
28
+ }
29
+
30
+ function readSessionDirFromSettings(settingsPath) {
31
+ if (!existsSync(settingsPath)) return undefined;
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
35
+ } catch (err) {
36
+ console.error(`pi-link: ignored ${settingsPath}: ${err.message}`);
37
+ return undefined;
38
+ }
39
+ const value = parsed?.sessionDir;
40
+ if (typeof value !== "string" || value.trim() === "") return undefined;
41
+ return value;
42
+ }
43
+
44
+ // PI_CODING_AGENT_DIR also relocates global settings.json to <agentDir>/settings.json.
45
+ function resolveAgentDir() {
46
+ const env = process.env.PI_CODING_AGENT_DIR;
47
+ if (env) return expandTilde(env);
48
+ return join(homedir(), ".pi", "agent");
49
+ }
50
+
51
+ // Returns { dir, isCustom }. isCustom drives layout in scanSessions:
52
+ // true → flat <dir>/*.jsonl, false → <dir>/<encoded-cwd>/*.jsonl.
53
+ function resolveSessionDir(cwd, agentDir) {
54
+ const env = process.env.PI_CODING_AGENT_SESSION_DIR;
55
+ if (env) return { dir: expandTilde(env), isCustom: true };
56
+
57
+ const projectDir = readSessionDirFromSettings(join(cwd, ".pi", "settings.json"));
58
+ if (projectDir) return { dir: expandTilde(projectDir), isCustom: true };
59
+
60
+ const globalDir = readSessionDirFromSettings(join(agentDir, "settings.json"));
61
+ if (globalDir) return { dir: expandTilde(globalDir), isCustom: true };
62
+
63
+ return { dir: join(agentDir, "sessions"), isCustom: false };
64
+ }
65
+
66
+ // Reads a session JSONL file and returns its display name, cwd, id, link
67
+ // status, and message count.
68
+ //
69
+ // Name precedence: latest valid `link-name` custom entry wins as the
70
+ // authoritative pi-link name. `session_info.name` is only a fallback for
71
+ // sessions that never set a link-name. Historical link-names are not aliases.
18
72
  async function getSessionMeta(filePath) {
19
- let name;
73
+ let linkName;
74
+ let sessionName;
20
75
  let cwd;
76
+ let id;
77
+ let hasLinkName = false;
78
+ let messages = 0;
21
79
  const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
22
80
  for await (const line of rl) {
23
81
  if (!line) continue;
24
82
  try {
25
83
  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;
84
+ if (entry.type === "session") {
85
+ if (typeof entry.cwd === "string") cwd = entry.cwd;
86
+ if (typeof entry.id === "string") id = entry.id;
87
+ } else if (entry.type === "session_info" && typeof entry.name === "string") {
88
+ sessionName = entry.name.trim().replace(/\s+/g, " ") || undefined;
89
+ } else if (entry.type === "custom" && entry.customType === "link-name") {
90
+ hasLinkName = true;
91
+ if (entry.data && typeof entry.data.name === "string") {
92
+ const n = entry.data.name.trim().replace(/\s+/g, " ");
93
+ if (n) linkName = n;
94
+ }
95
+ } else if (entry.type === "message" || entry.type === "user" || entry.type === "assistant") {
96
+ messages++;
29
97
  }
30
98
  } catch {
31
- // skip malformed lines
99
+ // skip malformed lines (incl. partial last line of active sessions)
32
100
  }
33
101
  }
34
- return { name, cwd };
102
+ return { name: linkName ?? sessionName, cwd, id, hasLinkName, messages };
103
+ }
104
+
105
+ function normalizePath(p) {
106
+ let s = p.replace(/[/\\]+/g, "/").replace(/\/+$/, "");
107
+ if (process.platform === "win32") s = s.toLowerCase();
108
+ return s;
35
109
  }
36
110
 
37
- async function findSessionsByName(targetName) {
38
- let cwdDirs;
111
+ // Replace $HOME with ~ in display paths. Comparison is normalized
112
+ // (case-insensitive on Windows) but display preserves original casing.
113
+ function displayPath(p) {
114
+ if (!p) return p;
115
+ const home = homedir();
116
+ const normP = normalizePath(p);
117
+ const normHome = normalizePath(home);
118
+ if (normP === normHome) return "~";
119
+ if (normP.startsWith(normHome + "/")) return "~" + p.slice(home.length).replace(/\\/g, "/");
120
+ return p;
121
+ }
122
+
123
+ const useAnsi =
124
+ !!process.stdout.isTTY &&
125
+ process.env.NO_COLOR === undefined &&
126
+ process.env.TERM !== "dumb";
127
+ const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
128
+ const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
129
+
130
+ function relTime(d) {
131
+ const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
132
+ if (sec < 60) return `${sec}s ago`;
133
+ const min = Math.floor(sec / 60);
134
+ if (min < 60) return `${min}m ago`;
135
+ const hr = Math.floor(min / 60);
136
+ if (hr < 24) return `${hr}h ago`;
137
+ const day = Math.floor(hr / 24);
138
+ if (day < 30) return `${day}d ago`;
139
+ return d.toISOString().slice(0, 10);
140
+ }
141
+
142
+ async function loadSessionRecord(filePath) {
39
143
  try {
40
- cwdDirs = await readdir(SESSIONS_DIR, { withFileTypes: true });
144
+ const meta = await getSessionMeta(filePath);
145
+ const stats = await stat(filePath);
146
+ return { ...meta, modified: stats.mtime, path: filePath };
41
147
  } catch {
42
- return [];
148
+ return null;
43
149
  }
150
+ }
44
151
 
45
- const matches = [];
46
-
47
- for (const dir of cwdDirs) {
48
- if (!dir.isDirectory()) continue;
49
- const dirPath = join(SESSIONS_DIR, dir.name);
152
+ // Returns meta + mtime + path for every readable session in `dir`. Custom
153
+ // layout is flat (<dir>/*.jsonl); default layout has one subdir level per
154
+ // encoded cwd (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are
155
+ // silently skipped — active or partially-written sessions are tolerated.
156
+ async function scanSessions(dir, isCustom) {
157
+ let entries;
158
+ try {
159
+ entries = await readdir(dir, { withFileTypes: true });
160
+ } catch {
161
+ return [];
162
+ }
50
163
 
51
- let files;
52
- try {
53
- files = await readdir(dirPath);
54
- } catch {
55
- continue;
164
+ const tasks = [];
165
+ if (isCustom) {
166
+ for (const entry of entries) {
167
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
168
+ tasks.push(loadSessionRecord(join(dir, entry.name)));
56
169
  }
57
-
58
- for (const file of files) {
59
- if (!file.endsWith(".jsonl")) continue;
60
- const filePath = join(dirPath, file);
61
- try {
62
- const { name, cwd } = await getSessionMeta(filePath);
63
- if (name === targetName) {
64
- const stats = await stat(filePath);
65
- matches.push({ path: filePath, cwd: cwd || "?", modified: stats.mtime });
66
- }
67
- } catch {
68
- continue;
170
+ } else {
171
+ for (const sub of entries) {
172
+ if (!sub.isDirectory()) continue;
173
+ const subPath = join(dir, sub.name);
174
+ let files;
175
+ try { files = await readdir(subPath); } catch { continue; }
176
+ for (const file of files) {
177
+ if (!file.endsWith(".jsonl")) continue;
178
+ tasks.push(loadSessionRecord(join(subPath, file)));
69
179
  }
70
180
  }
71
181
  }
72
182
 
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;
183
+ return (await Promise.all(tasks)).filter((s) => s !== null);
184
+ }
185
+
186
+ // Find sessions whose current display name matches `targetName`. Returns both
187
+ // local-cwd matches and all matches (cross-cwd) so the caller can default to
188
+ // local while still surfacing a hint when non-local matches exist. Falls back
189
+ // to `session_info.name` for sessions without a link-name (so `pi-link <name>`
190
+ // can attach link to a previously-unlinked named session).
191
+ async function findSessionsByName(targetName, dir, isCustom) {
192
+ const localCwd = normalizePath(process.cwd());
193
+ const all = (await scanSessions(dir, isCustom))
194
+ .filter((s) => s.name === targetName)
195
+ .map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
196
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime());
197
+ const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
198
+ return { local, all };
199
+ }
200
+
201
+ // List pi-link sessions (those with at least one link-name entry). Default
202
+ // scope is current cwd; `all` widens to every directory.
203
+ async function listSessions({ all, dir, isCustom }) {
204
+ const localCwd = normalizePath(process.cwd());
205
+ return (await scanSessions(dir, isCustom))
206
+ .filter((s) => s.hasLinkName)
207
+ .filter((s) => all || (s.cwd && normalizePath(s.cwd) === localCwd))
208
+ .map((s) => ({
209
+ name: s.name || "(unnamed)",
210
+ cwd: s.cwd || "?",
211
+ id: s.id ? s.id.slice(0, 8) : "?",
212
+ messages: s.messages,
213
+ modified: s.modified,
214
+ path: s.path,
215
+ }))
216
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime());
217
+ }
218
+
219
+ // Renders a plain-text table. Widths are computed from unstyled cells; ANSI
220
+ // styles are applied after padding so column alignment is preserved when piped
221
+ // or styled. Mark a column with `dim: true` to render its cells dim.
222
+ function renderTable(rows, columns) {
223
+ const widths = columns.map((c) => Math.max(c.header.length, ...rows.map((r) => String(c.get(r)).length)));
224
+ const padCell = (text, i) => (i === columns.length - 1 ? text : text.padEnd(widths[i]));
225
+ const styleBody = (text, i) => (columns[i].dim ? dim(text) : text);
226
+ const headerLine = columns.map((c, i) => bold(padCell(c.header, i))).join(" ");
227
+ const bodyLines = rows.map((r) =>
228
+ columns.map((c, i) => styleBody(padCell(String(c.get(r)), i), i)).join(" "),
229
+ );
230
+ return [headerLine, ...bodyLines].join("\n");
82
231
  }
83
232
 
84
233
  // ── CLI ────────────────────────────────────────────────────────────────────
85
234
 
86
235
  const [command, ...args] = process.argv.slice(2);
87
236
 
237
+ // Reject pi-link flags renamed in 0.1.12 with a clear pointer to the new name.
238
+ // Same intent as `rejectManagedFlag` (specific message > generic "Unknown argument")
239
+ // but for our own renames, not Pi-managed flags.
240
+ function rejectRenamedFlag(token) {
241
+ if (token === "--all" || token === "-a") {
242
+ const replacement = token === "-a" ? "-g" : "--global";
243
+ console.error(`Error: ${token} was renamed to ${replacement}.`);
244
+ process.exit(1);
245
+ }
246
+ }
247
+
248
+ // Reject Pi flags that pi-link manages, plus the removed --link-name extension flag.
249
+ // Runs on both the first token (so `pi-link --session foo` errors clearly) and on each
250
+ // flag in args (so `pi-link foo --session bar` does too).
251
+ function rejectManagedFlag(token) {
252
+ const key = token.split("=")[0];
253
+ if (key === "--link-name") {
254
+ console.error("Error: --link-name was removed. Use: pi-link <name>");
255
+ process.exit(1);
256
+ }
257
+ if (["--session", "--continue", "-c", "--resume", "-r", "--fork", "--no-session", "--session-dir"].includes(key)) {
258
+ console.error(`Error: ${key} is managed by pi-link. Remove it.`);
259
+ process.exit(1);
260
+ }
261
+ }
262
+
88
263
  function printCandidates(name, matches) {
89
264
  console.error(`Multiple sessions named "${name}":\n`);
90
265
  for (const m of matches) {
@@ -95,40 +270,104 @@ function printCandidates(name, matches) {
95
270
  process.exit(1);
96
271
  }
97
272
 
98
- if (command === "resolve") {
99
- const name = args[0]?.trim().replace(/\s+/g, " ");
100
- if (!name) {
101
- console.error("Usage: pi-link resolve <name>");
273
+ if (command === "list") {
274
+ let global = false;
275
+ for (const a of args) {
276
+ rejectRenamedFlag(a);
277
+ if (a === "--global" || a === "-g") global = true;
278
+ else {
279
+ console.error(`Unknown argument: ${a}`);
280
+ console.error("Usage: pi-link list [--global|-g]");
281
+ process.exit(1);
282
+ }
283
+ }
284
+ const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
285
+ const sessions = await listSessions({ all: global, dir, isCustom });
286
+ if (sessions.length === 0) {
287
+ console.log(global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
288
+ console.log("Start one: pi-link <name>");
289
+ process.exit(0);
290
+ }
291
+ const columns = global
292
+ ? [
293
+ { header: "NAME", get: (s) => s.name },
294
+ { header: "CWD", get: (s) => displayPath(s.cwd) },
295
+ { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
296
+ { header: "MESSAGES", get: (s) => s.messages, dim: true },
297
+ { header: "ID", get: (s) => s.id, dim: true },
298
+ ]
299
+ : [
300
+ { header: "NAME", get: (s) => s.name },
301
+ { header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
302
+ { header: "MESSAGES", get: (s) => s.messages, dim: true },
303
+ { header: "ID", get: (s) => s.id, dim: true },
304
+ ];
305
+ console.log(renderTable(sessions, columns));
306
+ if (process.stdout.isTTY) {
307
+ console.log("");
308
+ console.log(dim("Resume: pi-link <name>"));
309
+ }
310
+ } else if (command === "resolve") {
311
+ let global = false;
312
+ const positional = [];
313
+ for (const a of args) {
314
+ rejectRenamedFlag(a);
315
+ if (a === "--global" || a === "-g") global = true;
316
+ else if (a.startsWith("-")) {
317
+ console.error(`Unknown argument: ${a}`);
318
+ console.error("Usage: pi-link resolve <name> [--global|-g]");
319
+ process.exit(1);
320
+ } else positional.push(a);
321
+ }
322
+ if (positional.length !== 1) {
323
+ console.error("Usage: pi-link resolve <name> [--global|-g]");
102
324
  process.exit(1);
103
325
  }
104
- const matches = await findSessionsByName(name);
326
+ const name = positional[0].trim().replace(/\s+/g, " ");
327
+ const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
328
+ const { local, all } = await findSessionsByName(name, dir, isCustom);
329
+ const matches = global ? all : local;
105
330
  if (matches.length === 1) {
106
331
  process.stdout.write(matches[0].path);
107
332
  } else if (matches.length > 1) {
108
333
  printCandidates(name, matches);
109
334
  }
110
335
  } else if (command && command !== "--help" && command !== "-h") {
111
- // pi-link <name> [flags...] — resolve and launch Pi
112
- const name = command.trim().replace(/\s+/g, " ");
336
+ // pi-link [--global|-g] <name> [pi flags...] — resolve and launch Pi.
337
+ // Walk every token in one pass: pull out --global wherever it appears, treat
338
+ // the first non-flag token as the name, reject managed flags, forward the rest.
339
+ let global = false;
340
+ let name = null;
341
+ const piPassthrough = [];
342
+ for (const token of [command, ...args]) {
343
+ rejectRenamedFlag(token);
344
+ if (token === "--global" || token === "-g") { global = true; continue; }
345
+ rejectManagedFlag(token);
346
+ if (name === null) {
347
+ // Before the name is set, an unknown leading flag is almost certainly a
348
+ // user mistake (`pi-link --model gpt-4 foo`) — don't silently treat it
349
+ // as a session name. After the name is set, anything goes (forwarded to Pi).
350
+ if (token.startsWith("-")) {
351
+ console.error(`Unknown argument before name: ${token}`);
352
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
353
+ process.exit(1);
354
+ }
355
+ name = token;
356
+ } else piPassthrough.push(token);
357
+ }
113
358
  if (!name) {
114
- console.error("Usage: pi-link <name> [pi flags...]");
359
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
115
360
  process.exit(1);
116
361
  }
117
-
118
- // Reject conflicting flags
119
- for (const flag of args) {
120
- const key = flag.split("=")[0];
121
- if (["--session", "--continue", "-c", "--resume", "-r", "--fork", "--no-session", "--session-dir"].includes(key)) {
122
- console.error(`Error: ${key} is managed by pi-link. Remove it.`);
123
- process.exit(1);
124
- }
125
- if (key === "--link-name") {
126
- console.error("Error: --link-name was removed. Use: pi-link <name>");
127
- process.exit(1);
128
- }
362
+ name = name.trim().replace(/\s+/g, " ");
363
+ if (!name) {
364
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
365
+ process.exit(1);
129
366
  }
130
367
 
131
- const matches = await findSessionsByName(name);
368
+ const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
369
+ const { local, all } = await findSessionsByName(name, dir, isCustom);
370
+ const matches = global ? all : local;
132
371
  if (matches.length > 1) {
133
372
  printCandidates(name, matches);
134
373
  }
@@ -138,14 +377,20 @@ if (command === "resolve") {
138
377
  console.error(`Resuming session: ${matches[0].path}`);
139
378
  piArgs.push("--session", matches[0].path);
140
379
  } else {
141
- console.error("No existing session found. Starting new session.");
380
+ if (!global && all.length > local.length) {
381
+ const elsewhere = all.length - local.length;
382
+ console.error(`No "${name}" in this cwd. (${elsewhere} match${elsewhere === 1 ? "" : "es"} in other cwds — use --global to consider ${elsewhere === 1 ? "it" : "them"}.)`);
383
+ }
384
+ console.error("Starting new session.");
142
385
  }
143
- piArgs.push("--link", ...args);
386
+ piArgs.push("--link", ...piPassthrough);
144
387
 
145
388
  const isWin = process.platform === "win32";
146
389
  const cmd = isWin ? "cmd.exe" : "pi";
147
390
  const cmdArgs = isWin ? ["/d", "/c", "pi", ...piArgs] : piArgs;
148
391
 
392
+ // PI_LINK_NAME is the internal handoff to the pi-link extension on the Pi side.
393
+ // The extension consumes and deletes it on startup; never expose this as a public API.
149
394
  const child = spawn(cmd, cmdArgs, {
150
395
  stdio: "inherit",
151
396
  env: { ...process.env, PI_LINK_NAME: name },
@@ -159,6 +404,11 @@ if (command === "resolve") {
159
404
  process.exit(1);
160
405
  });
161
406
  } else {
162
- console.error("Usage: pi-link <name> [pi flags...]\n pi-link resolve <name>");
407
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
408
+ console.error(" pi-link list [--global|-g]");
409
+ console.error(" pi-link resolve <name> [--global|-g]");
410
+ console.error("");
411
+ console.error("By default, name lookup is scoped to the current cwd.");
412
+ console.error("--global / -g widens the search to sessions in any cwd.");
163
413
  process.exit(0);
164
414
  }
package/index.ts CHANGED
@@ -15,7 +15,7 @@ import type {
15
15
  ExtensionContext,
16
16
  } from "@mariozechner/pi-coding-agent";
17
17
  import { Text } from "@mariozechner/pi-tui";
18
- import { Type } from "@sinclair/typebox";
18
+ import { Type } from "typebox";
19
19
  import * as crypto from "node:crypto";
20
20
  import * as os from "node:os";
21
21
 
@@ -120,6 +120,9 @@ export default function (pi: ExtensionAPI) {
120
120
  let role: "hub" | "client" | "disconnected" = "disconnected";
121
121
  let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
122
122
  let preferredName: string | null = null;
123
+ // True between a client `/link-name` close and the next welcome/promotion.
124
+ // Lets `startHub` adopt the requested name if it wins promotion before welcome.
125
+ let pendingClientRename = false;
123
126
  let connectedTerminals: string[] = [];
124
127
  let ctx: ExtensionContext | undefined;
125
128
  let disposed = false;
@@ -481,6 +484,7 @@ export default function (pi: ExtensionAPI) {
481
484
  // ── Client receives after registering ──
482
485
  case "welcome":
483
486
  terminalName = msg.name;
487
+ pendingClientRename = false;
484
488
  connectedTerminals = msg.terminals;
485
489
  terminalStatuses.clear();
486
490
  terminalCwds.clear();
@@ -676,13 +680,15 @@ export default function (pi: ExtensionAPI) {
676
680
  return;
677
681
  }
678
682
 
679
- // Route chat / prompt messages
683
+ // Route chat / prompt messages.
684
+ // Normalize `from` to the hub's authoritative socket→name mapping,
685
+ // mirroring the status_update path above. Don't trust the client.
680
686
  if (
681
687
  msg.type === "chat" ||
682
688
  msg.type === "prompt_request" ||
683
689
  msg.type === "prompt_response"
684
690
  ) {
685
- routeMessage(msg);
691
+ routeMessage({ ...msg, from: clientName });
686
692
  }
687
693
  });
688
694
 
@@ -725,6 +731,12 @@ export default function (pi: ExtensionAPI) {
725
731
  return;
726
732
  }
727
733
  wss = server;
734
+ // If a client `/link-name` was in flight when the previous hub vanished,
735
+ // this terminal is now establishing hub identity, so honor that pending
736
+ // request. Otherwise keep the last hub-assigned identity — don't replay
737
+ // a stale `preferredName` that may already have been deduped.
738
+ if (pendingClientRename && preferredName) terminalName = preferredName;
739
+ pendingClientRename = false;
728
740
  role = "hub";
729
741
  connectedTerminals = [terminalName];
730
742
  updateStatus();
@@ -915,7 +927,9 @@ export default function (pi: ExtensionAPI) {
915
927
  ctx = _ctx;
916
928
  currentCwd = _ctx.cwd;
917
929
 
918
- // Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random
930
+ // Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random.
931
+ // PI_LINK_NAME is an internal handoff from the `pi-link` CLI launcher.
932
+ // Consumed once here and removed from process.env so spawned children don't inherit it.
919
933
  const rawLinkName = process.env.PI_LINK_NAME;
920
934
  delete process.env.PI_LINK_NAME;
921
935
  const flagName = rawLinkName?.trim().replace(/\s+/g, " ") || undefined;
@@ -1393,13 +1407,13 @@ export default function (pi: ExtensionAPI) {
1393
1407
  savePreference();
1394
1408
  _ctx.ui.notify(`Renamed to "${newName}"`, "info");
1395
1409
  } else if (role === "client") {
1396
- // Reconnect with new namehub will enforce uniqueness via register
1410
+ // Don't update terminalName herewelcome will assign authoritatively
1411
+ // after reconnect. Hub may dedupe newName to newName-2 if taken.
1397
1412
  savePreference();
1398
- terminalName = newName;
1413
+ pendingClientRename = true;
1399
1414
  ws?.close();
1400
- // Reconnect will happen via the onClose handler → scheduleReconnect
1401
1415
  _ctx.ui.notify(
1402
- `Reconnecting as "${newName}" (hub may assign a different name if taken)...`,
1416
+ `Reconnecting, requesting "${newName}" (hub may assign a different name if taken)...`,
1403
1417
  "info",
1404
1418
  );
1405
1419
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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.