pi-link 0.1.11 → 0.1.13

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,32 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
6
6
 
7
7
  ---
8
8
 
9
+ ## 0.1.13 — 2026-05-03
10
+
11
+ ### Fixed
12
+
13
+ - **`pi-link resolve <name>` now rejects whitespace-only names.** Previously a name that normalized to empty (e.g. `pi-link resolve " "`) fell through to session lookup and silently reported no match. The empty-name check that already covered `pi-link <name>` now also runs in `resolve`, printing usage and exiting non-zero.
14
+ - **README wording: session-dir lookup phrasing tightened** to say "matches Pi's lookup order" instead of "mirrors Pi's".
15
+
16
+ ---
17
+
18
+ ## 0.1.12 — 2026-05-03
19
+
20
+ ### Changed
21
+
22
+ - **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.
23
+
24
+ ### Fixed
25
+
26
+ - **`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.
27
+ - **`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.
28
+ - **`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".
29
+ - **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.
30
+ - **`/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.
31
+ - **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.
32
+
33
+ ---
34
+
9
35
  ## 0.1.11 — 2026-04-27
10
36
 
11
37
  ### Added
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:**
@@ -157,16 +157,18 @@ 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 spawns `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 matches Pi's lookup order: `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.
163
+
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
165
167
  - **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
166
168
 
167
169
  ### Discovering sessions
168
170
 
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.
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.
170
172
 
171
173
  ```
172
174
  $ pi-link list
@@ -177,10 +179,10 @@ gpt@pi-link 5m ago 1493 20d43841
177
179
  Resume: pi-link <name>
178
180
  ```
179
181
 
180
- With `--all`:
182
+ With `--global`:
181
183
 
182
184
  ```
183
- $ pi-link list --all
185
+ $ pi-link list --global
184
186
  NAME CWD MODIFIED MESSAGES ID
185
187
  opus@pi-link ~/my-project 2m ago 4632 6332faab
186
188
  gpt@pi-link ~/other-project 5m ago 1493 20d43841
@@ -188,7 +190,9 @@ gpt@pi-link ~/other-project 5m ago 1493 20d43841
188
190
  Resume: pi-link <name>
189
191
  ```
190
192
 
191
- `--all` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
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.
192
196
 
193
197
  For scripting, `pi-link resolve <name>` prints just the session path (machine-readable, no other output).
194
198
 
@@ -426,7 +430,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
426
430
  | ------------------------------- | ------------------------------------------------ |
427
431
  | `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
428
432
  | `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
429
- | `@sinclair/typebox` | JSON Schema type definitions for tool parameters |
433
+ | `typebox` | JSON Schema type definitions for tool parameters |
430
434
 
431
435
  ### `package.json`
432
436
 
@@ -527,8 +531,6 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
527
531
 
528
532
  **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.
529
533
 
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
-
532
534
  **Rename guards:**
533
535
 
534
536
  - If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
package/bin/pi-link.mjs CHANGED
@@ -1,299 +1,418 @@
1
- #!/usr/bin/env node
2
-
3
- // pi-link CLI — launch Pi with session resume by name
4
- //
5
- // Usage:
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).
8
- // pi-link resolve <name> Print just the session path (machine-readable).
9
-
10
- import { readdir, stat } from "fs/promises";
11
- import { createReadStream } from "fs";
12
- import { createInterface } from "readline";
13
- import { join } from "path";
14
- import { homedir } from "os";
15
- import { spawn } from "child_process";
16
-
17
- const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
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.
25
- async function getSessionMeta(filePath) {
26
- let linkName;
27
- let sessionName;
28
- let cwd;
29
- let id;
30
- let hasLinkName = false;
31
- let messages = 0;
32
- const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
33
- for await (const line of rl) {
34
- if (!line) continue;
35
- try {
36
- const entry = JSON.parse(line);
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++;
50
- }
51
- } catch {
52
- // skip malformed lines (incl. partial last line of active sessions)
53
- }
54
- }
55
- return { name: linkName ?? sessionName, cwd, id, hasLinkName, messages };
56
- }
57
-
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() {
99
- let cwdDirs;
100
- try {
101
- cwdDirs = await readdir(SESSIONS_DIR, { withFileTypes: true });
102
- } catch {
103
- return [];
104
- }
105
-
106
- const tasks = [];
107
- for (const dir of cwdDirs) {
108
- if (!dir.isDirectory()) continue;
109
- const dirPath = join(SESSIONS_DIR, dir.name);
110
- let files;
111
- try { files = await readdir(dirPath); } catch { continue; }
112
- for (const file of files) {
113
- if (!file.endsWith(".jsonl")) continue;
114
- const filePath = join(dirPath, file);
115
- tasks.push((async () => {
116
- try {
117
- const meta = await getSessionMeta(filePath);
118
- const stats = await stat(filePath);
119
- return { ...meta, modified: stats.mtime, path: filePath };
120
- } catch {
121
- return null;
122
- }
123
- })());
124
- }
125
- }
126
-
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");
177
- }
178
-
179
- // ── CLI ────────────────────────────────────────────────────────────────────
180
-
181
- const [command, ...args] = process.argv.slice(2);
182
-
183
- function printCandidates(name, matches) {
184
- console.error(`Multiple sessions named "${name}":\n`);
185
- for (const m of matches) {
186
- console.error(` ${m.modified.toISOString().slice(0, 19)} cwd: ${m.cwd}`);
187
- console.error(` ${m.path}\n`);
188
- }
189
- console.error(`Use: pi --session <path> --link`);
190
- process.exit(1);
191
- }
192
-
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") {
229
- const name = args[0]?.trim().replace(/\s+/g, " ");
230
- if (!name) {
231
- console.error("Usage: pi-link resolve <name>");
232
- process.exit(1);
233
- }
234
- const matches = await findSessionsByName(name);
235
- if (matches.length === 1) {
236
- process.stdout.write(matches[0].path);
237
- } else if (matches.length > 1) {
238
- printCandidates(name, matches);
239
- }
240
- } else if (command && command !== "--help" && command !== "-h") {
241
- // pi-link <name> [flags...] resolve and launch Pi
242
- const name = command.trim().replace(/\s+/g, " ");
243
- if (!name) {
244
- console.error("Usage: pi-link <name> [pi flags...]");
245
- process.exit(1);
246
- }
247
-
248
- // Reject conflicting flags
249
- for (const flag of args) {
250
- const key = flag.split("=")[0];
251
- if (["--session", "--continue", "-c", "--resume", "-r", "--fork", "--no-session", "--session-dir"].includes(key)) {
252
- console.error(`Error: ${key} is managed by pi-link. Remove it.`);
253
- process.exit(1);
254
- }
255
- // Catch the removed extension flag before forwarding args to Pi.
256
- if (key === "--link-name") {
257
- console.error("Error: --link-name was removed. Use: pi-link <name>");
258
- process.exit(1);
259
- }
260
- }
261
-
262
- const matches = await findSessionsByName(name);
263
- if (matches.length > 1) {
264
- printCandidates(name, matches);
265
- }
266
-
267
- const piArgs = [];
268
- if (matches.length === 1) {
269
- console.error(`Resuming session: ${matches[0].path}`);
270
- piArgs.push("--session", matches[0].path);
271
- } else {
272
- console.error("No existing session found. Starting new session.");
273
- }
274
- piArgs.push("--link", ...args);
275
-
276
- const isWin = process.platform === "win32";
277
- const cmd = isWin ? "cmd.exe" : "pi";
278
- const cmdArgs = isWin ? ["/d", "/c", "pi", ...piArgs] : piArgs;
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.
282
- const child = spawn(cmd, cmdArgs, {
283
- stdio: "inherit",
284
- env: { ...process.env, PI_LINK_NAME: name },
285
- });
286
- child.once("exit", (code, signal) => {
287
- if (code !== null) process.exit(code);
288
- process.exit(signal === "SIGINT" ? 130 : 1);
289
- });
290
- child.once("error", (err) => {
291
- console.error(`Failed to start pi: ${err.message}`);
292
- process.exit(1);
293
- });
294
- } else {
295
- console.error("Usage: pi-link <name> [pi flags...]");
296
- console.error(" pi-link list [--all|-a]");
297
- console.error(" pi-link resolve <name>");
298
- process.exit(0);
299
- }
1
+ #!/usr/bin/env node
2
+
3
+ // pi-link CLI — launch Pi with session resume by name
4
+ //
5
+ // Usage:
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).
11
+
12
+ import { readdir, stat } from "fs/promises";
13
+ import { createReadStream, existsSync, readFileSync } from "fs";
14
+ import { createInterface } from "readline";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+ import { spawn } from "child_process";
18
+
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>.
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.
72
+ async function getSessionMeta(filePath) {
73
+ let linkName;
74
+ let sessionName;
75
+ let cwd;
76
+ let id;
77
+ let hasLinkName = false;
78
+ let messages = 0;
79
+ const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
80
+ for await (const line of rl) {
81
+ if (!line) continue;
82
+ try {
83
+ const entry = JSON.parse(line);
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++;
97
+ }
98
+ } catch {
99
+ // skip malformed lines (incl. partial last line of active sessions)
100
+ }
101
+ }
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;
109
+ }
110
+
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) {
143
+ try {
144
+ const meta = await getSessionMeta(filePath);
145
+ const stats = await stat(filePath);
146
+ return { ...meta, modified: stats.mtime, path: filePath };
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
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
+ }
163
+
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)));
169
+ }
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)));
179
+ }
180
+ }
181
+ }
182
+
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");
231
+ }
232
+
233
+ // ── CLI ────────────────────────────────────────────────────────────────────
234
+
235
+ const [command, ...args] = process.argv.slice(2);
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
+
263
+ function printCandidates(name, matches) {
264
+ console.error(`Multiple sessions named "${name}":\n`);
265
+ for (const m of matches) {
266
+ console.error(` ${m.modified.toISOString().slice(0, 19)} cwd: ${m.cwd}`);
267
+ console.error(` ${m.path}\n`);
268
+ }
269
+ console.error(`Use: pi --session <path> --link`);
270
+ process.exit(1);
271
+ }
272
+
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]");
324
+ process.exit(1);
325
+ }
326
+ const name = positional[0].trim().replace(/\s+/g, " ");
327
+ if (!name) {
328
+ console.error("Usage: pi-link resolve <name> [--global|-g]");
329
+ process.exit(1);
330
+ }
331
+ const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
332
+ const { local, all } = await findSessionsByName(name, dir, isCustom);
333
+ const matches = global ? all : local;
334
+ if (matches.length === 1) {
335
+ process.stdout.write(matches[0].path);
336
+ } else if (matches.length > 1) {
337
+ printCandidates(name, matches);
338
+ }
339
+ } else if (command && command !== "--help" && command !== "-h") {
340
+ // pi-link [--global|-g] <name> [pi flags...] — resolve and launch Pi.
341
+ // Walk every token in one pass: pull out --global wherever it appears, treat
342
+ // the first non-flag token as the name, reject managed flags, forward the rest.
343
+ let global = false;
344
+ let name = null;
345
+ const piPassthrough = [];
346
+ for (const token of [command, ...args]) {
347
+ rejectRenamedFlag(token);
348
+ if (token === "--global" || token === "-g") { global = true; continue; }
349
+ rejectManagedFlag(token);
350
+ if (name === null) {
351
+ // Before the name is set, an unknown leading flag is almost certainly a
352
+ // user mistake (`pi-link --model gpt-4 foo`) — don't silently treat it
353
+ // as a session name. After the name is set, anything goes (forwarded to Pi).
354
+ if (token.startsWith("-")) {
355
+ console.error(`Unknown argument before name: ${token}`);
356
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
357
+ process.exit(1);
358
+ }
359
+ name = token;
360
+ } else piPassthrough.push(token);
361
+ }
362
+ if (!name) {
363
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
364
+ process.exit(1);
365
+ }
366
+ name = name.trim().replace(/\s+/g, " ");
367
+ if (!name) {
368
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
369
+ process.exit(1);
370
+ }
371
+
372
+ const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
373
+ const { local, all } = await findSessionsByName(name, dir, isCustom);
374
+ const matches = global ? all : local;
375
+ if (matches.length > 1) {
376
+ printCandidates(name, matches);
377
+ }
378
+
379
+ const piArgs = [];
380
+ if (matches.length === 1) {
381
+ console.error(`Resuming session: ${matches[0].path}`);
382
+ piArgs.push("--session", matches[0].path);
383
+ } else {
384
+ if (!global && all.length > local.length) {
385
+ const elsewhere = all.length - local.length;
386
+ console.error(`No "${name}" in this cwd. (${elsewhere} match${elsewhere === 1 ? "" : "es"} in other cwds — use --global to consider ${elsewhere === 1 ? "it" : "them"}.)`);
387
+ }
388
+ console.error("Starting new session.");
389
+ }
390
+ piArgs.push("--link", ...piPassthrough);
391
+
392
+ const isWin = process.platform === "win32";
393
+ const cmd = isWin ? "cmd.exe" : "pi";
394
+ const cmdArgs = isWin ? ["/d", "/c", "pi", ...piArgs] : piArgs;
395
+
396
+ // PI_LINK_NAME is the internal handoff to the pi-link extension on the Pi side.
397
+ // The extension consumes and deletes it on startup; never expose this as a public API.
398
+ const child = spawn(cmd, cmdArgs, {
399
+ stdio: "inherit",
400
+ env: { ...process.env, PI_LINK_NAME: name },
401
+ });
402
+ child.once("exit", (code, signal) => {
403
+ if (code !== null) process.exit(code);
404
+ process.exit(signal === "SIGINT" ? 130 : 1);
405
+ });
406
+ child.once("error", (err) => {
407
+ console.error(`Failed to start pi: ${err.message}`);
408
+ process.exit(1);
409
+ });
410
+ } else {
411
+ console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
412
+ console.error(" pi-link list [--global|-g]");
413
+ console.error(" pi-link resolve <name> [--global|-g]");
414
+ console.error("");
415
+ console.error("By default, name lookup is scoped to the current cwd.");
416
+ console.error("--global / -g widens the search to sessions in any cwd.");
417
+ process.exit(0);
418
+ }
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();
@@ -1395,13 +1407,13 @@ export default function (pi: ExtensionAPI) {
1395
1407
  savePreference();
1396
1408
  _ctx.ui.notify(`Renamed to "${newName}"`, "info");
1397
1409
  } else if (role === "client") {
1398
- // 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.
1399
1412
  savePreference();
1400
- terminalName = newName;
1413
+ pendingClientRename = true;
1401
1414
  ws?.close();
1402
- // Reconnect will happen via the onClose handler → scheduleReconnect
1403
1415
  _ctx.ui.notify(
1404
- `Reconnecting as "${newName}" (hub may assign a different name if taken)...`,
1416
+ `Reconnecting, requesting "${newName}" (hub may assign a different name if taken)...`,
1405
1417
  "info",
1406
1418
  );
1407
1419
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-link",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",