pi-link 0.2.0 → 0.4.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 +64 -0
- package/README.md +719 -680
- package/bin/pi-link.mjs +238 -40
- package/index.ts +547 -550
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +124 -114
package/bin/pi-link.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// pi-link <name> [--global|-g] [flags...]
|
|
7
7
|
// Resume or create a named session, connected to link.
|
|
8
8
|
// pi-link --list [--global|-g] List pi-link sessions in current cwd (or everywhere).
|
|
9
|
+
// pi-link --status [--json] Show terminals connected to the running hub right now.
|
|
9
10
|
// pi-link --resolve <name> [--global|-g]
|
|
10
11
|
// Print just the session path (machine-readable).
|
|
11
12
|
// pi-link --version Print the installed pi-link version.
|
|
@@ -71,25 +72,42 @@ function resolveSessionDir(cwd, agentDir) {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
// Reads a session JSONL file and returns its display name, cwd, id, link
|
|
74
|
-
// status, and message count.
|
|
75
|
+
// status, and message count. Returns null when `scopeCwd` is given and the
|
|
76
|
+
// session's header names a different cwd.
|
|
75
77
|
//
|
|
76
78
|
// Name precedence: latest valid `link-name` custom entry wins as the
|
|
77
79
|
// authoritative pi-link name. `session_info.name` is only a fallback for
|
|
78
80
|
// sessions that never set a link-name. Historical link-names are not aliases.
|
|
79
|
-
|
|
81
|
+
//
|
|
82
|
+
// A scoped scan only ever keeps sessions from `scopeCwd`, so a session whose
|
|
83
|
+
// header names another cwd is abandoned there instead of being read to EOF for
|
|
84
|
+
// a name that would be filtered out anyway. Pi writes that header as the first
|
|
85
|
+
// complete line of the file, before any history.
|
|
86
|
+
//
|
|
87
|
+
// Only `undefined` means unscoped: a normalized scope is the empty string at
|
|
88
|
+
// POSIX root, which is a real scope and must not read as "no scope".
|
|
89
|
+
async function getSessionMeta(filePath, scopeCwd) {
|
|
80
90
|
let linkName;
|
|
81
91
|
let sessionName;
|
|
82
92
|
let cwd;
|
|
83
93
|
let id;
|
|
84
94
|
let hasLinkName = false;
|
|
85
95
|
let messages = 0;
|
|
86
|
-
const
|
|
96
|
+
const input = createReadStream(filePath, "utf-8");
|
|
97
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
87
98
|
for await (const line of rl) {
|
|
88
99
|
if (!line) continue;
|
|
89
100
|
try {
|
|
90
101
|
const entry = JSON.parse(line);
|
|
91
102
|
if (entry.type === "session") {
|
|
92
|
-
if (typeof entry.cwd === "string")
|
|
103
|
+
if (typeof entry.cwd === "string") {
|
|
104
|
+
if (scopeCwd !== undefined && normalizePath(entry.cwd) !== scopeCwd) {
|
|
105
|
+
rl.close();
|
|
106
|
+
input.destroy();
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
cwd = entry.cwd;
|
|
110
|
+
}
|
|
93
111
|
if (typeof entry.id === "string") id = entry.id;
|
|
94
112
|
} else if (entry.type === "session_info" && typeof entry.name === "string") {
|
|
95
113
|
sessionName = normalizeName(entry.name) || undefined;
|
|
@@ -134,6 +152,11 @@ const useAnsi =
|
|
|
134
152
|
const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
|
|
135
153
|
const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
|
|
136
154
|
|
|
155
|
+
// What `--status` prints for a field the hub could not report. Declared here,
|
|
156
|
+
// above the dispatcher: the mode handlers are hoisted functions, but a `const`
|
|
157
|
+
// read from one would still be in its temporal dead zone when dispatch runs.
|
|
158
|
+
const UNKNOWN = "?";
|
|
159
|
+
|
|
137
160
|
function relTime(d) {
|
|
138
161
|
const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
|
|
139
162
|
if (sec < 60) return `${sec}s ago`;
|
|
@@ -146,9 +169,10 @@ function relTime(d) {
|
|
|
146
169
|
return d.toISOString().slice(0, 10);
|
|
147
170
|
}
|
|
148
171
|
|
|
149
|
-
async function loadSessionRecord(filePath) {
|
|
172
|
+
async function loadSessionRecord(filePath, scopeCwd) {
|
|
150
173
|
try {
|
|
151
|
-
const meta = await getSessionMeta(filePath);
|
|
174
|
+
const meta = await getSessionMeta(filePath, scopeCwd);
|
|
175
|
+
if (!meta) return null; // known-foreign: not even worth a stat
|
|
152
176
|
const stats = await stat(filePath);
|
|
153
177
|
return { ...meta, modified: stats.mtime, path: filePath };
|
|
154
178
|
} catch {
|
|
@@ -156,11 +180,12 @@ async function loadSessionRecord(filePath) {
|
|
|
156
180
|
}
|
|
157
181
|
}
|
|
158
182
|
|
|
159
|
-
// Returns meta + mtime + path for every readable session in `dir
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
183
|
+
// Returns meta + mtime + path for every readable session in `dir`, or only
|
|
184
|
+
// those from `scopeCwd` when it is given. Custom layout is flat
|
|
185
|
+
// (<dir>/*.jsonl); default layout has one subdir level per encoded cwd
|
|
186
|
+
// (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are silently skipped
|
|
187
|
+
// — active or partially-written sessions are tolerated.
|
|
188
|
+
async function scanSessions(dir, isCustom, scopeCwd) {
|
|
164
189
|
let entries;
|
|
165
190
|
try {
|
|
166
191
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -172,7 +197,7 @@ async function scanSessions(dir, isCustom) {
|
|
|
172
197
|
if (isCustom) {
|
|
173
198
|
for (const entry of entries) {
|
|
174
199
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
175
|
-
tasks.push(loadSessionRecord(join(dir, entry.name)));
|
|
200
|
+
tasks.push(loadSessionRecord(join(dir, entry.name), scopeCwd));
|
|
176
201
|
}
|
|
177
202
|
} else {
|
|
178
203
|
for (const sub of entries) {
|
|
@@ -182,7 +207,7 @@ async function scanSessions(dir, isCustom) {
|
|
|
182
207
|
try { files = await readdir(subPath); } catch { continue; }
|
|
183
208
|
for (const file of files) {
|
|
184
209
|
if (!file.endsWith(".jsonl")) continue;
|
|
185
|
-
tasks.push(loadSessionRecord(join(subPath, file)));
|
|
210
|
+
tasks.push(loadSessionRecord(join(subPath, file), scopeCwd));
|
|
186
211
|
}
|
|
187
212
|
}
|
|
188
213
|
}
|
|
@@ -190,28 +215,28 @@ async function scanSessions(dir, isCustom) {
|
|
|
190
215
|
return (await Promise.all(tasks)).filter((s) => s !== null);
|
|
191
216
|
}
|
|
192
217
|
|
|
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>`
|
|
218
|
+
// Find sessions whose current display name matches `targetName`, restricted to
|
|
219
|
+
// `scopeCwd` when given and searched across every cwd when not. Falls back to
|
|
220
|
+
// `session_info.name` for sessions without a link-name (so `pi-link <name>`
|
|
197
221
|
// can attach link to a previously-unlinked named session).
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
222
|
+
//
|
|
223
|
+
// The cwd predicate is applied here as well as in the scan: a session whose
|
|
224
|
+
// header carries no cwd is not rejected early, and must still be left out of a
|
|
225
|
+
// scoped result.
|
|
226
|
+
async function findSessionsByName(targetName, dir, isCustom, scopeCwd) {
|
|
227
|
+
return (await scanSessions(dir, isCustom, scopeCwd))
|
|
201
228
|
.filter((s) => s.name === targetName)
|
|
229
|
+
.filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
|
|
202
230
|
.map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
|
|
203
231
|
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
204
|
-
const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
|
|
205
|
-
return { local, all };
|
|
206
232
|
}
|
|
207
233
|
|
|
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))
|
|
234
|
+
// List pi-link sessions (those with at least one link-name entry), restricted
|
|
235
|
+
// to `scopeCwd` when given and covering every cwd when not.
|
|
236
|
+
async function listSessions({ dir, isCustom, scopeCwd }) {
|
|
237
|
+
return (await scanSessions(dir, isCustom, scopeCwd))
|
|
213
238
|
.filter((s) => s.hasLinkName)
|
|
214
|
-
.filter((s) =>
|
|
239
|
+
.filter((s) => scopeCwd === undefined || (s.cwd && normalizePath(s.cwd) === scopeCwd))
|
|
215
240
|
.map((s) => ({
|
|
216
241
|
name: s.name || "(unnamed)",
|
|
217
242
|
cwd: s.cwd || "?",
|
|
@@ -280,11 +305,13 @@ function fail(msg) {
|
|
|
280
305
|
function printHelp() {
|
|
281
306
|
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
282
307
|
console.error(" pi-link --list [--global|-g]");
|
|
308
|
+
console.error(" pi-link --status [--json]");
|
|
283
309
|
console.error(" pi-link --resolve <name> [--global|-g]");
|
|
284
310
|
console.error(" pi-link --version");
|
|
285
311
|
console.error("");
|
|
286
312
|
console.error("By default, name lookup is scoped to the current cwd.");
|
|
287
313
|
console.error("--global / -g widens the search to sessions in any cwd.");
|
|
314
|
+
console.error("--list reads saved sessions; --status asks the running hub who is connected.");
|
|
288
315
|
}
|
|
289
316
|
|
|
290
317
|
function printVersion() {
|
|
@@ -303,6 +330,7 @@ function describeMode(mode) {
|
|
|
303
330
|
case "help": return "--help";
|
|
304
331
|
case "version": return "--version";
|
|
305
332
|
case "list": return "--list";
|
|
333
|
+
case "status": return "--status";
|
|
306
334
|
case "resolve": return "--resolve";
|
|
307
335
|
case "launcher": return "session name";
|
|
308
336
|
default: return mode;
|
|
@@ -319,10 +347,11 @@ function describeMode(mode) {
|
|
|
319
347
|
// 5. Launcher passthrough (mode launcher) with orphan-positional rejection
|
|
320
348
|
|
|
321
349
|
const state = {
|
|
322
|
-
mode: null, // null | "help" | "version" | "list" | "resolve" | "launcher"
|
|
350
|
+
mode: null, // null | "help" | "version" | "list" | "status" | "resolve" | "launcher"
|
|
323
351
|
resolveName: null,
|
|
324
352
|
launcherName: null,
|
|
325
353
|
global: false,
|
|
354
|
+
json: false,
|
|
326
355
|
piPassthrough: [],
|
|
327
356
|
};
|
|
328
357
|
|
|
@@ -364,11 +393,25 @@ for (let i = 0; i < rawArgs.length; i++) {
|
|
|
364
393
|
break;
|
|
365
394
|
}
|
|
366
395
|
|
|
396
|
+
// `--json` modifies --status only. Claimed before a mode exists so order does
|
|
397
|
+
// not matter, but never in launcher mode, where it belongs to pi.
|
|
398
|
+
if (a === "--json" && (state.mode === null || state.mode === "status")) {
|
|
399
|
+
state.json = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
367
403
|
// Phase 2: mode-selecting flags.
|
|
368
404
|
if (a === "--list") {
|
|
369
405
|
setMode("list");
|
|
370
406
|
continue;
|
|
371
407
|
}
|
|
408
|
+
// Selects the wrapper's own mode only before a session name has been seen.
|
|
409
|
+
// After that it is pi's flag, exactly like `--json` above — intercepting it
|
|
410
|
+
// unconditionally would break `pi-link foo --status`.
|
|
411
|
+
if (a === "--status" && state.mode !== "launcher") {
|
|
412
|
+
setMode("status");
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
372
415
|
if (a.startsWith("--resolve=")) {
|
|
373
416
|
setMode("resolve");
|
|
374
417
|
if (state.resolveName !== null) fail(`--resolve specified more than once`);
|
|
@@ -397,6 +440,9 @@ for (let i = 0; i < rawArgs.length; i++) {
|
|
|
397
440
|
if (state.mode === "list") {
|
|
398
441
|
fail(`--list does not accept argument: ${a}\n Usage: pi-link --list [--global|-g]`);
|
|
399
442
|
}
|
|
443
|
+
if (state.mode === "status") {
|
|
444
|
+
fail(`--status does not accept arguments: ${a}\n Usage: pi-link --status [--json]`);
|
|
445
|
+
}
|
|
400
446
|
if (state.mode === "resolve") {
|
|
401
447
|
fail(`--resolve accepts exactly one name; got extra: ${a}`);
|
|
402
448
|
}
|
|
@@ -446,6 +492,14 @@ if (state.mode === "resolve") {
|
|
|
446
492
|
}
|
|
447
493
|
state.resolveName = normalized;
|
|
448
494
|
}
|
|
495
|
+
// `--status` reads one running hub, so a cwd scope is meaningless rather than
|
|
496
|
+
// merely unused: silently ignoring `-g` would imply a filter that cannot exist.
|
|
497
|
+
if (state.mode === "status" && state.global) {
|
|
498
|
+
fail(`cannot combine --status and --global`);
|
|
499
|
+
}
|
|
500
|
+
if (state.json && state.mode !== "status") {
|
|
501
|
+
fail(`--json is only valid with --status`);
|
|
502
|
+
}
|
|
449
503
|
if (state.mode === "launcher") {
|
|
450
504
|
const normalized = normalizeName(state.launcherName);
|
|
451
505
|
if (!normalized) {
|
|
@@ -469,6 +523,9 @@ switch (state.mode) {
|
|
|
469
523
|
case "list":
|
|
470
524
|
await runList(state);
|
|
471
525
|
break;
|
|
526
|
+
case "status":
|
|
527
|
+
await runStatus(state);
|
|
528
|
+
break;
|
|
472
529
|
case "resolve":
|
|
473
530
|
await runResolve(state);
|
|
474
531
|
break;
|
|
@@ -481,9 +538,14 @@ switch (state.mode) {
|
|
|
481
538
|
|
|
482
539
|
// ── Mode handlers ──────────────────────────────────────────────────────────
|
|
483
540
|
|
|
541
|
+
// A local operation scans only its own cwd; `--global` scans every cwd.
|
|
542
|
+
function localScope(state) {
|
|
543
|
+
return state.global ? undefined : normalizePath(process.cwd());
|
|
544
|
+
}
|
|
545
|
+
|
|
484
546
|
async function runList(state) {
|
|
485
547
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
486
|
-
const sessions = await listSessions({
|
|
548
|
+
const sessions = await listSessions({ dir, isCustom, scopeCwd: localScope(state) });
|
|
487
549
|
if (sessions.length === 0) {
|
|
488
550
|
console.log(state.global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
|
|
489
551
|
console.log("Start one: pi-link <name>");
|
|
@@ -510,11 +572,149 @@ async function runList(state) {
|
|
|
510
572
|
}
|
|
511
573
|
}
|
|
512
574
|
|
|
575
|
+
// ── Status (live hub query) ────────────────────────────────────────────────
|
|
576
|
+
//
|
|
577
|
+
// `--list` reads saved history; `--status` asks the hub who is connected now.
|
|
578
|
+
// The two answer different questions, so they share no code path.
|
|
579
|
+
|
|
580
|
+
// Mirrors the extension's formatTokens so both renderings of one number agree.
|
|
581
|
+
function formatTokens(n) {
|
|
582
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
583
|
+
if (n >= 1_000) return `${Math.round(n / 1000)}K`;
|
|
584
|
+
return `${n}`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// `92K/272K (34%)`, or `?/272K` when the hub has a window but no token count.
|
|
588
|
+
// Only reached for a validated payload, so `c` is null or a well-typed snapshot;
|
|
589
|
+
// a non-positive window is still possible and still means nothing to report.
|
|
590
|
+
function formatContext(c) {
|
|
591
|
+
if (!c || c.window <= 0) return UNKNOWN;
|
|
592
|
+
const window = formatTokens(c.window);
|
|
593
|
+
if (typeof c.tokens !== "number") return `${UNKNOWN}/${window}`;
|
|
594
|
+
return `${formatTokens(c.tokens)}/${window} (${Math.round((c.tokens / c.window) * 100)}%)`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function formatAge(seconds) {
|
|
598
|
+
if (seconds < 60) return `${seconds}s`;
|
|
599
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
|
600
|
+
return `${Math.floor(seconds / 3600)}h`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// `status` and `sinceSeconds` are an optional pair: the hub omits them for a
|
|
604
|
+
// terminal it has registered but not yet heard from. Absence means unknown, so
|
|
605
|
+
// it must render as unknown — printing `idle` there would be an invention, and
|
|
606
|
+
// acting on it is the misreporting this command exists to end.
|
|
607
|
+
function formatTerminalStatus(entry) {
|
|
608
|
+
if (typeof entry.status !== "string") return UNKNOWN;
|
|
609
|
+
return `${entry.status} (${formatAge(entry.sinceSeconds)})`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function failUnsupported() {
|
|
613
|
+
console.error("Link hub does not support /status \u2014 update pi-link and restart terminals.");
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function failNoHub(port) {
|
|
618
|
+
console.error(`No link hub running on :${port}.`);
|
|
619
|
+
process.exit(2);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// The frozen contract, checked before any field is read. Anything else on the
|
|
623
|
+
// port — a different service, a newer hub, a truncated proxy — must produce the
|
|
624
|
+
// unsupported message, never a stack trace. Unknown extra fields stay allowed.
|
|
625
|
+
function isContextField(c) {
|
|
626
|
+
if (c === null) return true;
|
|
627
|
+
if (!c || typeof c !== "object") return false;
|
|
628
|
+
return (c.tokens === null || typeof c.tokens === "number") && typeof c.window === "number";
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isTerminalEntry(entry, index) {
|
|
632
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
633
|
+
if (typeof entry.name !== "string") return false;
|
|
634
|
+
// Hub first, then clients — the ordering the renderer relies on.
|
|
635
|
+
if (entry.role !== (index === 0 ? "hub" : "client")) return false;
|
|
636
|
+
// `status` and `sinceSeconds` are one optional pair: both or neither.
|
|
637
|
+
//
|
|
638
|
+
// The value is checked for shape, not vocabulary. `idle`/`thinking`/
|
|
639
|
+
// `compacting`/`tool:<name>` are today's kinds, but that set has already grown
|
|
640
|
+
// once (`compacting` arrived after 0.3.0) and the CLI never branches on it — it
|
|
641
|
+
// only prints it. Freezing the list here would make a newer hub's fifth kind
|
|
642
|
+
// reject the whole payload, and only while some terminal happened to be in that
|
|
643
|
+
// state: an intermittent failure telling the user to update. An empty string is
|
|
644
|
+
// still rejected, because it renders as a blank cell with a bare duration.
|
|
645
|
+
const hasStatus = "status" in entry;
|
|
646
|
+
if (hasStatus !== ("sinceSeconds" in entry)) return false;
|
|
647
|
+
if (hasStatus && (typeof entry.status !== "string" || entry.status === "" || typeof entry.sinceSeconds !== "number")) {
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
if ("cwd" in entry && typeof entry.cwd !== "string") return false;
|
|
651
|
+
return "context" in entry && isContextField(entry.context);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function isStatusPayload(payload) {
|
|
655
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
656
|
+
if (typeof payload.hub !== "string" || typeof payload.port !== "number") return false;
|
|
657
|
+
// A hub always reports itself, so an empty list is not this contract.
|
|
658
|
+
if (!Array.isArray(payload.terminals) || payload.terminals.length === 0) return false;
|
|
659
|
+
if (!payload.terminals.every(isTerminalEntry)) return false;
|
|
660
|
+
return payload.terminals[0].name === payload.hub;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function runStatus(state) {
|
|
664
|
+
// Deliberately unvalidated: a bad value fails the fetch and is reported with
|
|
665
|
+
// the port in the message, which is the diagnosis.
|
|
666
|
+
const port = process.env.PI_LINK_PORT ?? 9900;
|
|
667
|
+
// One deadline covers the whole exchange, headers and body alike, so it has to
|
|
668
|
+
// be readable afterwards to tell a timeout from a malformed answer.
|
|
669
|
+
const deadline = AbortSignal.timeout(2000);
|
|
670
|
+
let response;
|
|
671
|
+
try {
|
|
672
|
+
response = await fetch(`http://127.0.0.1:${port}/status`, { signal: deadline });
|
|
673
|
+
} catch {
|
|
674
|
+
// Refused, unreachable or timed out: no hub is answering at this instant.
|
|
675
|
+
failNoHub(port);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (!response.ok) failUnsupported();
|
|
679
|
+
|
|
680
|
+
let body;
|
|
681
|
+
try {
|
|
682
|
+
body = await response.text();
|
|
683
|
+
} catch {
|
|
684
|
+
// A listener that sends headers and then stalls is still an unanswered
|
|
685
|
+
// query, not an old hub: every timeout reports as "no hub".
|
|
686
|
+
if (deadline.aborted) failNoHub(port);
|
|
687
|
+
failUnsupported();
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
let payload;
|
|
691
|
+
try {
|
|
692
|
+
payload = JSON.parse(body);
|
|
693
|
+
} catch {
|
|
694
|
+
failUnsupported();
|
|
695
|
+
}
|
|
696
|
+
if (!isStatusPayload(payload)) failUnsupported();
|
|
697
|
+
|
|
698
|
+
if (state.json) {
|
|
699
|
+
process.stdout.write(body);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Payload order is meaningful: the hub is first, then clients sorted by name.
|
|
704
|
+
console.log(
|
|
705
|
+
renderTable(payload.terminals, [
|
|
706
|
+
{ header: "NAME", get: (e) => e.name },
|
|
707
|
+
{ header: "STATUS", get: (e) => formatTerminalStatus(e) },
|
|
708
|
+
{ header: "CONTEXT", get: (e) => formatContext(e.context) },
|
|
709
|
+
{ header: "CWD", get: (e) => (e.cwd ? displayPath(e.cwd) : UNKNOWN), dim: true },
|
|
710
|
+
]),
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
513
714
|
async function runResolve(state) {
|
|
514
715
|
const name = state.resolveName; // already normalized
|
|
515
716
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
516
|
-
const
|
|
517
|
-
const matches = state.global ? all : local;
|
|
717
|
+
const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
|
|
518
718
|
if (matches.length === 1) {
|
|
519
719
|
process.stdout.write(matches[0].path);
|
|
520
720
|
return; // exit 0
|
|
@@ -523,18 +723,17 @@ async function runResolve(state) {
|
|
|
523
723
|
printCandidates(name, matches); // exits 1
|
|
524
724
|
}
|
|
525
725
|
// matches.length === 0 → not found; exit 2 to distinguish from ambiguous.
|
|
726
|
+
// A local scan never reads names from other cwds, so the advice is offered
|
|
727
|
+
// without claiming anything is there.
|
|
526
728
|
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
|
-
}
|
|
729
|
+
if (!state.global) console.error("Use --global to search other cwds.");
|
|
530
730
|
process.exit(2);
|
|
531
731
|
}
|
|
532
732
|
|
|
533
733
|
async function runLauncher(state) {
|
|
534
734
|
const name = state.launcherName; // already normalized
|
|
535
735
|
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
536
|
-
const
|
|
537
|
-
const matches = state.global ? all : local;
|
|
736
|
+
const matches = await findSessionsByName(name, dir, isCustom, localScope(state));
|
|
538
737
|
if (matches.length > 1) {
|
|
539
738
|
printCandidates(name, matches);
|
|
540
739
|
}
|
|
@@ -544,9 +743,8 @@ async function runLauncher(state) {
|
|
|
544
743
|
console.error(`Resuming session: ${matches[0].path}`);
|
|
545
744
|
piArgs.push("--session", matches[0].path);
|
|
546
745
|
} 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"}.)`);
|
|
746
|
+
if (!state.global) {
|
|
747
|
+
console.error(`No "${name}" found in this cwd. Use --global to search other cwds.`);
|
|
550
748
|
}
|
|
551
749
|
console.error("Starting new session.");
|
|
552
750
|
}
|