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/bin/pi-link.mjs CHANGED
@@ -1,13 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // pi-link CLI — resolve session by name and launch Pi with --link-name
3
+ // pi-link CLI — launch Pi with session resume by name
4
4
  //
5
5
  // Usage:
6
- // pi-link start <name> [pi-flags...]
7
- //
8
- // If a session named <name> exists, resumes it.
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
- // ── Session scanning ───────────────────────────────────────────────────────
22
-
23
- async function getSessionName(filePath) {
24
- let name;
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" && entry.cwd) cwd = entry.cwd;
32
- if (entry.type === "session_info" && entry.name !== undefined) {
33
- name = entry.name?.trim() || undefined;
37
+ if (entry.type === "session") {
38
+ if (typeof entry.cwd === "string") cwd = entry.cwd;
39
+ if (typeof entry.id === "string") id = entry.id;
40
+ } else if (entry.type === "session_info" && typeof entry.name === "string") {
41
+ sessionName = entry.name.trim().replace(/\s+/g, " ") || undefined;
42
+ } else if (entry.type === "custom" && entry.customType === "link-name") {
43
+ hasLinkName = true;
44
+ if (entry.data && typeof entry.data.name === "string") {
45
+ const n = entry.data.name.trim().replace(/\s+/g, " ");
46
+ if (n) linkName = n;
47
+ }
48
+ } else if (entry.type === "message" || entry.type === "user" || entry.type === "assistant") {
49
+ messages++;
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
- async function findSessionsByName(targetName) {
58
+ function normalizePath(p) {
59
+ let s = p.replace(/[/\\]+/g, "/").replace(/\/+$/, "");
60
+ if (process.platform === "win32") s = s.toLowerCase();
61
+ return s;
62
+ }
63
+
64
+ // Replace $HOME with ~ in display paths. Comparison is normalized
65
+ // (case-insensitive on Windows) but display preserves original casing.
66
+ function displayPath(p) {
67
+ if (!p) return p;
68
+ const home = homedir();
69
+ const normP = normalizePath(p);
70
+ const normHome = normalizePath(home);
71
+ if (normP === normHome) return "~";
72
+ if (normP.startsWith(normHome + "/")) return "~" + p.slice(home.length).replace(/\\/g, "/");
73
+ return p;
74
+ }
75
+
76
+ const useAnsi =
77
+ !!process.stdout.isTTY &&
78
+ process.env.NO_COLOR === undefined &&
79
+ process.env.TERM !== "dumb";
80
+ const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
81
+ const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
82
+
83
+ function relTime(d) {
84
+ const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
85
+ if (sec < 60) return `${sec}s ago`;
86
+ const min = Math.floor(sec / 60);
87
+ if (min < 60) return `${min}m ago`;
88
+ const hr = Math.floor(min / 60);
89
+ if (hr < 24) return `${hr}h ago`;
90
+ const day = Math.floor(hr / 24);
91
+ if (day < 30) return `${day}d ago`;
92
+ return d.toISOString().slice(0, 10);
93
+ }
94
+
95
+ // Walks SESSIONS_DIR in parallel, returning meta + mtime + path for every
96
+ // readable session. Callers filter and sort. Errors on individual files/dirs
97
+ // are silently skipped — active or partially-written sessions are tolerated.
98
+ async function scanSessions() {
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 matches = [];
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
- try {
67
- const { name, cwd } = await getSessionName(filePath);
68
- if (name === targetName) {
115
+ tasks.push((async () => {
116
+ try {
117
+ const meta = await getSessionMeta(filePath);
69
118
  const stats = await stat(filePath);
70
- matches.push({ path: filePath, cwd: cwd || "?", modified: stats.mtime });
119
+ return { ...meta, modified: stats.mtime, path: filePath };
120
+ } catch {
121
+ return null;
71
122
  }
72
- } catch {
73
- continue;
74
- }
123
+ })());
75
124
  }
76
125
  }
77
126
 
78
- // Local-first: current cwd matches before others, then by modified time
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
- // ── CLI ────────────────────────────────────────────────────────────────────
90
-
91
- const args = process.argv.slice(2);
92
- const command = args[0];
93
-
94
- if (command !== "start" || args.length < 2) {
95
- console.log(`Usage: pi-link start <name> [pi-flags...]
96
-
97
- Start Pi connected to the link as <name>.
98
- Resumes a session named <name> if one exists, otherwise creates a new session.
99
-
100
- Examples:
101
- pi-link start worker-1
102
- pi-link start worker-1 --model sonnet
103
- pi-link start worker-1 --model sonnet --thinking high`);
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
- const name = args[1].trim().replace(/\s+/g, " ");
108
- if (!name) {
109
- console.error("Error: name cannot be empty.");
110
- process.exit(1);
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
- const extraFlags = args.slice(2);
114
- for (const flag of ["--session", "--link-name"]) {
115
- if (extraFlags.includes(flag)) {
116
- console.error(`Error: ${flag} is managed by pi-link start. Remove it.`);
117
- process.exit(1);
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
- console.log(`Searching for session "${name}"...`);
122
- const matches = await findSessionsByName(name);
179
+ // ── CLI ────────────────────────────────────────────────────────────────────
123
180
 
124
- const piArgs = [];
181
+ const [command, ...args] = process.argv.slice(2);
125
182
 
126
- if (matches.length === 1) {
127
- console.log(`Resuming session: ${matches[0].path}`);
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-name ${name} to pick one.`);
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
- piArgs.push("--link-name", name, ...extraFlags);
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
- // On Windows, resolve 'pi' through the shell so .cmd/.ps1 shims work
144
- const isWin = process.platform === "win32";
145
- const cmd = isWin ? "cmd" : "pi";
146
- const cmdArgs = isWin ? ["/c", "pi", ...piArgs] : piArgs;
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 child = spawn(cmd, cmdArgs, { stdio: "inherit" });
262
+ const matches = await findSessionsByName(name);
263
+ if (matches.length > 1) {
264
+ printCandidates(name, matches);
265
+ }
149
266
 
150
- child.on("exit", (code) => process.exit(code ?? 0));
151
- child.on("error", (err) => {
152
- console.error(`Failed to start pi: ${err.message}`);
153
- process.exit(1);
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
+ }