pi-link 0.1.9 → 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 +179 -159
- package/README.md +618 -580
- package/bin/pi-link.mjs +234 -89
- package/index.ts +1482 -1424
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +18 -9
package/bin/pi-link.mjs
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// pi-link CLI —
|
|
3
|
+
// pi-link CLI — launch Pi with session resume by name
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// pi-link
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// If not, creates a new session.
|
|
10
|
-
// Always connects to the link as <name>.
|
|
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).
|
|
11
9
|
|
|
12
10
|
import { readdir, stat } from "fs/promises";
|
|
13
11
|
import { createReadStream } from "fs";
|
|
@@ -18,28 +16,86 @@ import { spawn } from "child_process";
|
|
|
18
16
|
|
|
19
17
|
const SESSIONS_DIR = join(homedir(), ".pi", "agent", "sessions");
|
|
20
18
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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;
|
|
25
28
|
let cwd;
|
|
29
|
+
let id;
|
|
30
|
+
let hasLinkName = false;
|
|
31
|
+
let messages = 0;
|
|
26
32
|
const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
|
|
27
33
|
for await (const line of rl) {
|
|
28
34
|
if (!line) continue;
|
|
29
35
|
try {
|
|
30
36
|
const entry = JSON.parse(line);
|
|
31
|
-
if (entry.type === "session"
|
|
32
|
-
|
|
33
|
-
|
|
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++;
|
|
34
50
|
}
|
|
35
51
|
} catch {
|
|
36
|
-
// skip malformed lines
|
|
52
|
+
// skip malformed lines (incl. partial last line of active sessions)
|
|
37
53
|
}
|
|
38
54
|
}
|
|
39
|
-
return { name, cwd };
|
|
55
|
+
return { name: linkName ?? sessionName, cwd, id, hasLinkName, messages };
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
|
|
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() {
|
|
43
99
|
let cwdDirs;
|
|
44
100
|
try {
|
|
45
101
|
cwdDirs = await readdir(SESSIONS_DIR, { withFileTypes: true });
|
|
@@ -47,108 +103,197 @@ async function findSessionsByName(targetName) {
|
|
|
47
103
|
return [];
|
|
48
104
|
}
|
|
49
105
|
|
|
50
|
-
const
|
|
51
|
-
|
|
106
|
+
const tasks = [];
|
|
52
107
|
for (const dir of cwdDirs) {
|
|
53
108
|
if (!dir.isDirectory()) continue;
|
|
54
109
|
const dirPath = join(SESSIONS_DIR, dir.name);
|
|
55
|
-
|
|
56
110
|
let files;
|
|
57
|
-
try {
|
|
58
|
-
files = await readdir(dirPath);
|
|
59
|
-
} catch {
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
111
|
+
try { files = await readdir(dirPath); } catch { continue; }
|
|
63
112
|
for (const file of files) {
|
|
64
113
|
if (!file.endsWith(".jsonl")) continue;
|
|
65
114
|
const filePath = join(dirPath, file);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
115
|
+
tasks.push((async () => {
|
|
116
|
+
try {
|
|
117
|
+
const meta = await getSessionMeta(filePath);
|
|
69
118
|
const stats = await stat(filePath);
|
|
70
|
-
|
|
119
|
+
return { ...meta, modified: stats.mtime, path: filePath };
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
71
122
|
}
|
|
72
|
-
}
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
123
|
+
})());
|
|
75
124
|
}
|
|
76
125
|
}
|
|
77
126
|
|
|
78
|
-
|
|
79
|
-
const localCwd = process.cwd();
|
|
80
|
-
matches.sort((a, b) => {
|
|
81
|
-
const aLocal = a.cwd === localCwd ? 1 : 0;
|
|
82
|
-
const bLocal = b.cwd === localCwd ? 1 : 0;
|
|
83
|
-
if (aLocal !== bLocal) return bLocal - aLocal;
|
|
84
|
-
return b.modified.getTime() - a.modified.getTime();
|
|
85
|
-
});
|
|
86
|
-
return matches;
|
|
127
|
+
return (await Promise.all(tasks)).filter((s) => s !== null);
|
|
87
128
|
}
|
|
88
129
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
process.exit(command === "start" ? 1 : 0);
|
|
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
|
+
});
|
|
105
145
|
}
|
|
106
146
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
process.
|
|
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());
|
|
111
163
|
}
|
|
112
164
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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");
|
|
119
177
|
}
|
|
120
178
|
|
|
121
|
-
|
|
122
|
-
const matches = await findSessionsByName(name);
|
|
179
|
+
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
123
180
|
|
|
124
|
-
const
|
|
181
|
+
const [command, ...args] = process.argv.slice(2);
|
|
125
182
|
|
|
126
|
-
|
|
127
|
-
console.
|
|
128
|
-
piArgs.push("--session", matches[0].path);
|
|
129
|
-
} else if (matches.length > 1) {
|
|
130
|
-
console.error(`\nMultiple sessions named "${name}":\n`);
|
|
183
|
+
function printCandidates(name, matches) {
|
|
184
|
+
console.error(`Multiple sessions named "${name}":\n`);
|
|
131
185
|
for (const m of matches) {
|
|
132
186
|
console.error(` ${m.modified.toISOString().slice(0, 19)} cwd: ${m.cwd}`);
|
|
133
187
|
console.error(` ${m.path}\n`);
|
|
134
188
|
}
|
|
135
|
-
console.error(`Use pi --session <path> --link
|
|
189
|
+
console.error(`Use: pi --session <path> --link`);
|
|
136
190
|
process.exit(1);
|
|
137
|
-
} else {
|
|
138
|
-
console.log("No existing session found. Starting new session.");
|
|
139
191
|
}
|
|
140
192
|
|
|
141
|
-
|
|
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
|
+
}
|
|
142
247
|
|
|
143
|
-
//
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
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
|
+
}
|
|
147
261
|
|
|
148
|
-
const
|
|
262
|
+
const matches = await findSessionsByName(name);
|
|
263
|
+
if (matches.length > 1) {
|
|
264
|
+
printCandidates(name, matches);
|
|
265
|
+
}
|
|
149
266
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
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
|
+
}
|