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 +8 -0
- package/README.md +33 -4
- package/bin/pi-link.mjs +169 -34
- package/index.ts +3 -1
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +2 -0
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:** `
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
115
|
+
tasks.push((async () => {
|
|
116
|
+
try {
|
|
117
|
+
const meta = await getSessionMeta(filePath);
|
|
64
118
|
const stats = await stat(filePath);
|
|
65
|
-
|
|
119
|
+
return { ...meta, modified: stats.mtime, path: filePath };
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
66
122
|
}
|
|
67
|
-
}
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
123
|
+
})());
|
|
70
124
|
}
|
|
71
125
|
}
|
|
72
126
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 === "
|
|
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...]
|
|
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
|
@@ -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.
|