pi-link 0.2.0 → 0.3.0
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 +42 -0
- package/README.md +156 -215
- package/bin/pi-link.mjs +60 -39
- package/index.ts +467 -547
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +89 -79
package/bin/pi-link.mjs
CHANGED
|
@@ -71,25 +71,42 @@ function resolveSessionDir(cwd, agentDir) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Reads a session JSONL file and returns its display name, cwd, id, link
|
|
74
|
-
// status, and message count.
|
|
74
|
+
// status, and message count. Returns null when `scopeCwd` is given and the
|
|
75
|
+
// session's header names a different cwd.
|
|
75
76
|
//
|
|
76
77
|
// Name precedence: latest valid `link-name` custom entry wins as the
|
|
77
78
|
// authoritative pi-link name. `session_info.name` is only a fallback for
|
|
78
79
|
// sessions that never set a link-name. Historical link-names are not aliases.
|
|
79
|
-
|
|
80
|
+
//
|
|
81
|
+
// A scoped scan only ever keeps sessions from `scopeCwd`, so a session whose
|
|
82
|
+
// header names another cwd is abandoned there instead of being read to EOF for
|
|
83
|
+
// a name that would be filtered out anyway. Pi writes that header as the first
|
|
84
|
+
// complete line of the file, before any history.
|
|
85
|
+
//
|
|
86
|
+
// Only `undefined` means unscoped: a normalized scope is the empty string at
|
|
87
|
+
// POSIX root, which is a real scope and must not read as "no scope".
|
|
88
|
+
async function getSessionMeta(filePath, scopeCwd) {
|
|
80
89
|
let linkName;
|
|
81
90
|
let sessionName;
|
|
82
91
|
let cwd;
|
|
83
92
|
let id;
|
|
84
93
|
let hasLinkName = false;
|
|
85
94
|
let messages = 0;
|
|
86
|
-
const
|
|
95
|
+
const input = createReadStream(filePath, "utf-8");
|
|
96
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
87
97
|
for await (const line of rl) {
|
|
88
98
|
if (!line) continue;
|
|
89
99
|
try {
|
|
90
100
|
const entry = JSON.parse(line);
|
|
91
101
|
if (entry.type === "session") {
|
|
92
|
-
if (typeof entry.cwd === "string")
|
|
102
|
+
if (typeof entry.cwd === "string") {
|
|
103
|
+
if (scopeCwd !== undefined && normalizePath(entry.cwd) !== scopeCwd) {
|
|
104
|
+
rl.close();
|
|
105
|
+
input.destroy();
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
cwd = entry.cwd;
|
|
109
|
+
}
|
|
93
110
|
if (typeof entry.id === "string") id = entry.id;
|
|
94
111
|
} else if (entry.type === "session_info" && typeof entry.name === "string") {
|
|
95
112
|
sessionName = normalizeName(entry.name) || undefined;
|
|
@@ -146,9 +163,10 @@ function relTime(d) {
|
|
|
146
163
|
return d.toISOString().slice(0, 10);
|
|
147
164
|
}
|
|
148
165
|
|
|
149
|
-
async function loadSessionRecord(filePath) {
|
|
166
|
+
async function loadSessionRecord(filePath, scopeCwd) {
|
|
150
167
|
try {
|
|
151
|
-
const meta = await getSessionMeta(filePath);
|
|
168
|
+
const meta = await getSessionMeta(filePath, scopeCwd);
|
|
169
|
+
if (!meta) return null; // known-foreign: not even worth a stat
|
|
152
170
|
const stats = await stat(filePath);
|
|
153
171
|
return { ...meta, modified: stats.mtime, path: filePath };
|
|
154
172
|
} catch {
|
|
@@ -156,11 +174,12 @@ async function loadSessionRecord(filePath) {
|
|
|
156
174
|
}
|
|
157
175
|
}
|
|
158
176
|
|
|
159
|
-
// Returns meta + mtime + path for every readable session in `dir
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
177
|
+
// Returns meta + mtime + path for every readable session in `dir`, or only
|
|
178
|
+
// those from `scopeCwd` when it is given. Custom layout is flat
|
|
179
|
+
// (<dir>/*.jsonl); default layout has one subdir level per encoded cwd
|
|
180
|
+
// (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are silently skipped
|
|
181
|
+
// — active or partially-written sessions are tolerated.
|
|
182
|
+
async function scanSessions(dir, isCustom, scopeCwd) {
|
|
164
183
|
let entries;
|
|
165
184
|
try {
|
|
166
185
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -172,7 +191,7 @@ async function scanSessions(dir, isCustom) {
|
|
|
172
191
|
if (isCustom) {
|
|
173
192
|
for (const entry of entries) {
|
|
174
193
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
175
|
-
tasks.push(loadSessionRecord(join(dir, entry.name)));
|
|
194
|
+
tasks.push(loadSessionRecord(join(dir, entry.name), scopeCwd));
|
|
176
195
|
}
|
|
177
196
|
} else {
|
|
178
197
|
for (const sub of entries) {
|
|
@@ -182,7 +201,7 @@ async function scanSessions(dir, isCustom) {
|
|
|
182
201
|
try { files = await readdir(subPath); } catch { continue; }
|
|
183
202
|
for (const file of files) {
|
|
184
203
|
if (!file.endsWith(".jsonl")) continue;
|
|
185
|
-
tasks.push(loadSessionRecord(join(subPath, file)));
|
|
204
|
+
tasks.push(loadSessionRecord(join(subPath, file), scopeCwd));
|
|
186
205
|
}
|
|
187
206
|
}
|
|
188
207
|
}
|
|
@@ -190,28 +209,28 @@ async function scanSessions(dir, isCustom) {
|
|
|
190
209
|
return (await Promise.all(tasks)).filter((s) => s !== null);
|
|
191
210
|
}
|
|
192
211
|
|
|
193
|
-
// Find sessions whose current display name matches `targetName
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// to `session_info.name` for sessions without a link-name (so `pi-link <name>`
|
|
212
|
+
// Find sessions whose current display name matches `targetName`, restricted to
|
|
213
|
+
// `scopeCwd` when given and searched across every cwd when not. Falls back to
|
|
214
|
+
// `session_info.name` for sessions without a link-name (so `pi-link <name>`
|
|
197
215
|
// can attach link to a previously-unlinked named session).
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
216
|
+
//
|
|
217
|
+
// The cwd predicate is applied here as well as in the scan: a session whose
|
|
218
|
+
// header carries no cwd is not rejected early, and must still be left out of a
|
|
219
|
+
// scoped result.
|
|
220
|
+
async function findSessionsByName(targetName, dir, isCustom, scopeCwd) {
|
|
221
|
+
return (await scanSessions(dir, isCustom, scopeCwd))
|
|
201
222
|
.filter((s) => s.name === targetName)
|
|
223
|
+
.filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
|
|
202
224
|
.map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
|
|
203
225
|
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
204
|
-
const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
|
|
205
|
-
return { local, all };
|
|
206
226
|
}
|
|
207
227
|
|
|
208
|
-
// List pi-link sessions (those with at least one link-name entry)
|
|
209
|
-
//
|
|
210
|
-
async function listSessions({
|
|
211
|
-
|
|
212
|
-
return (await scanSessions(dir, isCustom))
|
|
228
|
+
// List pi-link sessions (those with at least one link-name entry), restricted
|
|
229
|
+
// to `scopeCwd` when given and covering every cwd when not.
|
|
230
|
+
async function listSessions({ dir, isCustom, scopeCwd }) {
|
|
231
|
+
return (await scanSessions(dir, isCustom, scopeCwd))
|
|
213
232
|
.filter((s) => s.hasLinkName)
|
|
214
|
-
.filter((s) =>
|
|
233
|
+
.filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
|
|
215
234
|
.map((s) => ({
|
|
216
235
|
name: s.name || "(unnamed)",
|
|
217
236
|
cwd: s.cwd || "?",
|
|
@@ -481,9 +500,14 @@ switch (state.mode) {
|
|
|
481
500
|
|
|
482
501
|
// ── Mode handlers ──────────────────────────────────────────────────────────
|
|
483
502
|
|
|
503
|
+
// A local operation scans only its own cwd; `--global` scans every cwd.
|
|
504
|
+
function localScope(state) {
|
|
505
|
+
return state.global ? undefined : normalizePath(process.cwd());
|
|
506
|
+
}
|
|
507
|
+
|
|
484
508
|
async function runList(state) {
|
|
485
509
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
486
|
-
const sessions = await listSessions({
|
|
510
|
+
const sessions = await listSessions({ dir, isCustom, scopeCwd: localScope(state) });
|
|
487
511
|
if (sessions.length === 0) {
|
|
488
512
|
console.log(state.global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
|
|
489
513
|
console.log("Start one: pi-link <name>");
|
|
@@ -513,8 +537,7 @@ async function runList(state) {
|
|
|
513
537
|
async function runResolve(state) {
|
|
514
538
|
const name = state.resolveName; // already normalized
|
|
515
539
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
516
|
-
const
|
|
517
|
-
const matches = state.global ? all : local;
|
|
540
|
+
const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
|
|
518
541
|
if (matches.length === 1) {
|
|
519
542
|
process.stdout.write(matches[0].path);
|
|
520
543
|
return; // exit 0
|
|
@@ -523,18 +546,17 @@ async function runResolve(state) {
|
|
|
523
546
|
printCandidates(name, matches); // exits 1
|
|
524
547
|
}
|
|
525
548
|
// matches.length === 0 → not found; exit 2 to distinguish from ambiguous.
|
|
549
|
+
// A local scan never reads names from other cwds, so the advice is offered
|
|
550
|
+
// without claiming anything is there.
|
|
526
551
|
console.error(`No session named "${name}" found${state.global ? "" : " in this cwd"}.`);
|
|
527
|
-
if (!state.global
|
|
528
|
-
console.error(`(${all.length} match${all.length === 1 ? "" : "es"} in other cwds — try --global to consider ${all.length === 1 ? "it" : "them"}.)`);
|
|
529
|
-
}
|
|
552
|
+
if (!state.global) console.error("Use --global to search other cwds.");
|
|
530
553
|
process.exit(2);
|
|
531
554
|
}
|
|
532
555
|
|
|
533
556
|
async function runLauncher(state) {
|
|
534
557
|
const name = state.launcherName; // already normalized
|
|
535
558
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
536
|
-
const
|
|
537
|
-
const matches = state.global ? all : local;
|
|
559
|
+
const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
|
|
538
560
|
if (matches.length > 1) {
|
|
539
561
|
printCandidates(name, matches);
|
|
540
562
|
}
|
|
@@ -544,9 +566,8 @@ async function runLauncher(state) {
|
|
|
544
566
|
console.error(`Resuming session: ${matches[0].path}`);
|
|
545
567
|
piArgs.push("--session", matches[0].path);
|
|
546
568
|
} else {
|
|
547
|
-
if (!state.global
|
|
548
|
-
|
|
549
|
-
console.error(`No "${name}" in this cwd. (${elsewhere} match${elsewhere === 1 ? "" : "es"} in other cwds — use --global to consider ${elsewhere === 1 ? "it" : "them"}.)`);
|
|
569
|
+
if (!state.global) {
|
|
570
|
+
console.error(`No "${name}" found in this cwd. Use --global to search other cwds.`);
|
|
550
571
|
}
|
|
551
572
|
console.error("Starting new session.");
|
|
552
573
|
}
|