pi-link 0.1.12 → 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 +9 -0
- package/README.md +1 -1
- package/bin/pi-link.mjs +418 -414
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ 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
|
+
|
|
9
18
|
## 0.1.12 — 2026-05-03
|
|
10
19
|
|
|
11
20
|
### Changed
|
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ 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's session directory, finds the session named "worker-1", and spawns `pi --session <path> --link`. Session-dir resolution
|
|
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
162
|
Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd.
|
|
163
163
|
|
package/bin/pi-link.mjs
CHANGED
|
@@ -1,414 +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> [--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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
child
|
|
399
|
-
|
|
400
|
-
process.
|
|
401
|
-
});
|
|
402
|
-
child.once("
|
|
403
|
-
|
|
404
|
-
process.exit(1);
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
console.error("
|
|
412
|
-
console.error("--global
|
|
413
|
-
|
|
414
|
-
|
|
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/package.json
CHANGED