pi-link 0.1.10 → 0.1.12
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 +25 -0
- package/README.md +40 -9
- package/bin/pi-link.mjs +319 -69
- package/index.ts +22 -8
- package/package.json +1 -1
- package/skills/pi-link-coordination/SKILL.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## 0.1.12 — 2026-05-03
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **TypeBox import migrated from `@sinclair/typebox` to `typebox`.** Pi 0.69.0 renamed the package; both names still resolve to the same module via Pi's loader alias, so behavior is unchanged. Aligns with Pi's preferred naming and futureproofs against alias removal. README's "Provided by Pi" table updated to match.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`pi-link list/resolve/<name>` now respect Pi's session-dir configuration.** The CLI hardcoded `~/.pi/agent/sessions` and ignored Pi's actual lookup chain, so users with a custom session location saw "no sessions" from `list`/`resolve` and — worse — `pi-link <name>` silently started a new session instead of resuming the existing one, fragmenting history into orphans across the real session dir. Resolution now matches Pi's lookup order (minus `--session-dir`, which the CLI rejects): `PI_CODING_AGENT_SESSION_DIR` → `<cwd>/.pi/settings.json` `sessionDir` → `<agentDir>/settings.json` `sessionDir` → default `<agentDir>/sessions/<encoded-cwd>`. `<agentDir>` follows `PI_CODING_AGENT_DIR`. Tilde expansion (`~`, `~/...`) matches Pi's `expandTildePath`. Custom layouts are scanned flat; default keeps the encoded-cwd subdirs. Malformed `settings.json` warns to stderr and falls through. Empty env vars and empty/non-string `sessionDir` values are treated as absent.
|
|
18
|
+
- **`pi-link <name>` now rejects Pi-managed flags even when passed as the first token.** Previously `pi-link --link-name foo` or `pi-link --session path` silently treated the flag as a session name. The validation that already covered later flags now also runs on the first token, with the same error messages.
|
|
19
|
+
- **`pi-link <name>` and `pi-link resolve <name>` now scope name lookup to the current cwd by default; `--global` / `-g` widens to any cwd.** Previously both commands scanned every session everywhere, so `pi-link work` from `~/projects/A` would silently resume `~/projects/B`'s `work` session if no local match existed — mixing one cwd's files into another cwd's session history. By default only current-cwd matches are considered; `--global` restores cross-cwd lookup, with duplicate exact names still failing with candidates. When `pi-link <name>` finds no local match but matches exist elsewhere, it warns and points at `--global` instead of silently jumping. `--global` may be passed before or after the name. `pi-link resolve` now also rejects extra positional arguments and unknown flags. **Breaking change**: `pi-link list --all` is renamed to `pi-link list --global` (`-a` → `-g`) for consistency across the three commands. As a transition aid, `--all` / `-a` are explicitly rejected with a pointer to the new flag name (mirroring the `--link-name was removed` treatment) so users with muscle memory get a clear hint instead of a generic "Unknown argument".
|
|
20
|
+
- **Hub now uses its authoritative socket→name mapping when forwarding chat/prompt messages.** Previously the hub forwarded `chat`, `prompt_request`, and `prompt_response` with whatever `from` the client claimed, while normalizing `status_update` against its socket→name mapping. The asymmetry meant a client with a stale or optimistic local `terminalName` could leak the wrong sender to other terminals — and under a rename-to-taken-name race, prompt responses could route back to the wrong terminal entirely. Hub now spread-normalizes `from` for all routed client messages, matching the existing `status_update` pattern.
|
|
21
|
+
- **`/link-name` no longer updates local `terminalName` before the hub confirms the rename.** Previously the client branch optimistically set `terminalName = newName` before reconnect, so during the close→reconnect→welcome window `/link` and `link_list` would report the requested name even if the hub later deduped it. Local identity now stays at the pre-rename value until `welcome` arrives. Notification wording updated from "Reconnecting as" to "Reconnecting, requesting" to reflect that the hub may assign a different name.
|
|
22
|
+
- **Hub promotion now preserves a pending client rename request.** Same-release follow-up to the previous bullet: with `terminalName` no longer updated optimistically, a client whose previous hub vanished mid-rename and who then wins hub promotion via `startHub` would otherwise have announced under the old local name. A `pendingClientRename` flag, set in `/link-name` and cleared on `welcome`, lets `startHub` adopt the requested name only when a rename was in flight. Hub-assigned deduped names from prior welcomes are otherwise preserved — no general `preferredName` replay.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 0.1.11 — 2026-04-27
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`pi-link list` command.** Lists pi-link sessions in the current cwd. Use `--all` (or `-a`) to list sessions across all directories — adds a CWD column with `~` substituted for `$HOME`. Shows name, last-modified time, message count, and short ID. Sessions are detected by presence of a `link-name` entry. ANSI styling (bold headers, dim secondary columns) in TTY; plain when piped (`NO_COLOR` honored).
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
9
34
|
## 0.1.10 — 2026-04-26
|
|
10
35
|
|
|
11
36
|
### Changed
|
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ Here's a concrete example of two terminals collaborating. Open two separate `pi
|
|
|
103
103
|
|
|
104
104
|
```
|
|
105
105
|
> /link-name researcher
|
|
106
|
-
✓ Reconnecting
|
|
106
|
+
✓ Reconnecting, requesting "researcher" (hub may assign a different name if taken)...
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
**Now ask Terminal 1's LLM to delegate work:**
|
|
@@ -142,7 +142,7 @@ Link is **off by default**. Without `--link` or `pi-link`, the extension is comp
|
|
|
142
142
|
| `/link-connect` | Opt-in mid-session (no flag needed) | Yes |
|
|
143
143
|
| `/link-disconnect` | Opt-out mid-session | Suppressed until `/link-connect` |
|
|
144
144
|
|
|
145
|
-
**Name precedence:** `
|
|
145
|
+
**Name precedence:** `pi-link <name>` > saved `/link-name` > Pi session name > random `t-xxxx`.
|
|
146
146
|
|
|
147
147
|
`/link-connect` and `/link-disconnect` save their intent to the session — resume later and the connection state is restored without needing the flag. Explicit user intent takes precedence over `--link`.
|
|
148
148
|
|
|
@@ -157,13 +157,44 @@ 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
|
|
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 mirrors Pi's: `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
|
-
|
|
163
|
-
- **No match** → creates a new session
|
|
164
|
-
- **Multiple matches** → prints candidates to stderr, exits 1
|
|
162
|
+
Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd.
|
|
165
163
|
|
|
166
|
-
|
|
164
|
+
- **One match in scope** → resumes that session
|
|
165
|
+
- **No match in scope** → creates a new session in the current cwd. If matches exist outside the scope, prints a hint pointing at `--global`.
|
|
166
|
+
- **Multiple matches in scope** → prints candidates to stderr, exits 1
|
|
167
|
+
- **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
|
|
168
|
+
|
|
169
|
+
### Discovering sessions
|
|
170
|
+
|
|
171
|
+
`pi-link list` shows pi-link sessions in the current cwd; `pi-link list --global` (or `-g`) lists them across all directories. Sorted by last activity.
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
$ pi-link list
|
|
175
|
+
NAME MODIFIED MESSAGES ID
|
|
176
|
+
opus@pi-link 2m ago 4632 6332faab
|
|
177
|
+
gpt@pi-link 5m ago 1493 20d43841
|
|
178
|
+
|
|
179
|
+
Resume: pi-link <name>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
With `--global`:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
$ pi-link list --global
|
|
186
|
+
NAME CWD MODIFIED MESSAGES ID
|
|
187
|
+
opus@pi-link ~/my-project 2m ago 4632 6332faab
|
|
188
|
+
gpt@pi-link ~/other-project 5m ago 1493 20d43841
|
|
189
|
+
|
|
190
|
+
Resume: pi-link <name>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`--global` adds a `CWD` column with `~` substituted for `$HOME`. Output is plain when piped (`NO_COLOR` honored).
|
|
194
|
+
|
|
195
|
+
`pi-link <name>` and `pi-link resolve <name>` follow the same scoping: local cwd by default, `--global` (or `-g`) widens. When `pi-link <name>` finds no local match but matches exist elsewhere, it warns and points at `--global` instead of silently jumping cwds.
|
|
196
|
+
|
|
197
|
+
For scripting, `pi-link resolve <name>` prints just the session path (machine-readable, no other output).
|
|
167
198
|
|
|
168
199
|
---
|
|
169
200
|
|
|
@@ -399,7 +430,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
399
430
|
| ------------------------------- | ------------------------------------------------ |
|
|
400
431
|
| `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
|
|
401
432
|
| `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
|
|
402
|
-
|
|
|
433
|
+
| `typebox` | JSON Schema type definitions for tool parameters |
|
|
403
434
|
|
|
404
435
|
### `package.json`
|
|
405
436
|
|
|
@@ -432,7 +463,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
432
463
|
|
|
433
464
|
### Protocol
|
|
434
465
|
|
|
435
|
-
The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames. Cwd-related fields are optional
|
|
466
|
+
The wire protocol consists of **9 message types**, all serialized as JSON over WebSocket frames. Cwd-related fields are optional.
|
|
436
467
|
|
|
437
468
|
| Type | Direction | Purpose |
|
|
438
469
|
| ----------------- | --------------- | ----------------------------------------------------------------------- |
|
package/bin/pi-link.mjs
CHANGED
|
@@ -3,88 +3,263 @@
|
|
|
3
3
|
// pi-link CLI — launch Pi with session resume by name
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// pi-link <name> [flags...]
|
|
7
|
-
//
|
|
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).
|
|
8
11
|
|
|
9
12
|
import { readdir, stat } from "fs/promises";
|
|
10
|
-
import { createReadStream } from "fs";
|
|
13
|
+
import { createReadStream, existsSync, readFileSync } from "fs";
|
|
11
14
|
import { createInterface } from "readline";
|
|
12
15
|
import { join } from "path";
|
|
13
16
|
import { homedir } from "os";
|
|
14
17
|
import { spawn } from "child_process";
|
|
15
18
|
|
|
16
|
-
|
|
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>.
|
|
17
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.
|
|
18
72
|
async function getSessionMeta(filePath) {
|
|
19
|
-
let
|
|
73
|
+
let linkName;
|
|
74
|
+
let sessionName;
|
|
20
75
|
let cwd;
|
|
76
|
+
let id;
|
|
77
|
+
let hasLinkName = false;
|
|
78
|
+
let messages = 0;
|
|
21
79
|
const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
|
|
22
80
|
for await (const line of rl) {
|
|
23
81
|
if (!line) continue;
|
|
24
82
|
try {
|
|
25
83
|
const entry = JSON.parse(line);
|
|
26
|
-
if (entry.type === "session"
|
|
27
|
-
|
|
28
|
-
|
|
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++;
|
|
29
97
|
}
|
|
30
98
|
} catch {
|
|
31
|
-
// skip malformed lines
|
|
99
|
+
// skip malformed lines (incl. partial last line of active sessions)
|
|
32
100
|
}
|
|
33
101
|
}
|
|
34
|
-
return { name, cwd };
|
|
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;
|
|
35
109
|
}
|
|
36
110
|
|
|
37
|
-
|
|
38
|
-
|
|
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) {
|
|
39
143
|
try {
|
|
40
|
-
|
|
144
|
+
const meta = await getSessionMeta(filePath);
|
|
145
|
+
const stats = await stat(filePath);
|
|
146
|
+
return { ...meta, modified: stats.mtime, path: filePath };
|
|
41
147
|
} catch {
|
|
42
|
-
return
|
|
148
|
+
return null;
|
|
43
149
|
}
|
|
150
|
+
}
|
|
44
151
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|
|
50
163
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)));
|
|
56
169
|
}
|
|
57
|
-
|
|
58
|
-
for (const
|
|
59
|
-
if (!
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
continue;
|
|
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)));
|
|
69
179
|
}
|
|
70
180
|
}
|
|
71
181
|
}
|
|
72
182
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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");
|
|
82
231
|
}
|
|
83
232
|
|
|
84
233
|
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
85
234
|
|
|
86
235
|
const [command, ...args] = process.argv.slice(2);
|
|
87
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
|
+
|
|
88
263
|
function printCandidates(name, matches) {
|
|
89
264
|
console.error(`Multiple sessions named "${name}":\n`);
|
|
90
265
|
for (const m of matches) {
|
|
@@ -95,40 +270,104 @@ function printCandidates(name, matches) {
|
|
|
95
270
|
process.exit(1);
|
|
96
271
|
}
|
|
97
272
|
|
|
98
|
-
if (command === "
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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]");
|
|
102
324
|
process.exit(1);
|
|
103
325
|
}
|
|
104
|
-
const
|
|
326
|
+
const name = positional[0].trim().replace(/\s+/g, " ");
|
|
327
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
328
|
+
const { local, all } = await findSessionsByName(name, dir, isCustom);
|
|
329
|
+
const matches = global ? all : local;
|
|
105
330
|
if (matches.length === 1) {
|
|
106
331
|
process.stdout.write(matches[0].path);
|
|
107
332
|
} else if (matches.length > 1) {
|
|
108
333
|
printCandidates(name, matches);
|
|
109
334
|
}
|
|
110
335
|
} else if (command && command !== "--help" && command !== "-h") {
|
|
111
|
-
// pi-link <name> [flags...] — resolve and launch Pi
|
|
112
|
-
|
|
336
|
+
// pi-link [--global|-g] <name> [pi flags...] — resolve and launch Pi.
|
|
337
|
+
// Walk every token in one pass: pull out --global wherever it appears, treat
|
|
338
|
+
// the first non-flag token as the name, reject managed flags, forward the rest.
|
|
339
|
+
let global = false;
|
|
340
|
+
let name = null;
|
|
341
|
+
const piPassthrough = [];
|
|
342
|
+
for (const token of [command, ...args]) {
|
|
343
|
+
rejectRenamedFlag(token);
|
|
344
|
+
if (token === "--global" || token === "-g") { global = true; continue; }
|
|
345
|
+
rejectManagedFlag(token);
|
|
346
|
+
if (name === null) {
|
|
347
|
+
// Before the name is set, an unknown leading flag is almost certainly a
|
|
348
|
+
// user mistake (`pi-link --model gpt-4 foo`) — don't silently treat it
|
|
349
|
+
// as a session name. After the name is set, anything goes (forwarded to Pi).
|
|
350
|
+
if (token.startsWith("-")) {
|
|
351
|
+
console.error(`Unknown argument before name: ${token}`);
|
|
352
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
name = token;
|
|
356
|
+
} else piPassthrough.push(token);
|
|
357
|
+
}
|
|
113
358
|
if (!name) {
|
|
114
|
-
console.error("Usage: pi-link <name> [pi flags...]");
|
|
359
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
115
360
|
process.exit(1);
|
|
116
361
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (["--session", "--continue", "-c", "--resume", "-r", "--fork", "--no-session", "--session-dir"].includes(key)) {
|
|
122
|
-
console.error(`Error: ${key} is managed by pi-link. Remove it.`);
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
if (key === "--link-name") {
|
|
126
|
-
console.error("Error: --link-name was removed. Use: pi-link <name>");
|
|
127
|
-
process.exit(1);
|
|
128
|
-
}
|
|
362
|
+
name = name.trim().replace(/\s+/g, " ");
|
|
363
|
+
if (!name) {
|
|
364
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
365
|
+
process.exit(1);
|
|
129
366
|
}
|
|
130
367
|
|
|
131
|
-
const
|
|
368
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
369
|
+
const { local, all } = await findSessionsByName(name, dir, isCustom);
|
|
370
|
+
const matches = global ? all : local;
|
|
132
371
|
if (matches.length > 1) {
|
|
133
372
|
printCandidates(name, matches);
|
|
134
373
|
}
|
|
@@ -138,14 +377,20 @@ if (command === "resolve") {
|
|
|
138
377
|
console.error(`Resuming session: ${matches[0].path}`);
|
|
139
378
|
piArgs.push("--session", matches[0].path);
|
|
140
379
|
} else {
|
|
141
|
-
|
|
380
|
+
if (!global && all.length > local.length) {
|
|
381
|
+
const elsewhere = all.length - local.length;
|
|
382
|
+
console.error(`No "${name}" in this cwd. (${elsewhere} match${elsewhere === 1 ? "" : "es"} in other cwds — use --global to consider ${elsewhere === 1 ? "it" : "them"}.)`);
|
|
383
|
+
}
|
|
384
|
+
console.error("Starting new session.");
|
|
142
385
|
}
|
|
143
|
-
piArgs.push("--link", ...
|
|
386
|
+
piArgs.push("--link", ...piPassthrough);
|
|
144
387
|
|
|
145
388
|
const isWin = process.platform === "win32";
|
|
146
389
|
const cmd = isWin ? "cmd.exe" : "pi";
|
|
147
390
|
const cmdArgs = isWin ? ["/d", "/c", "pi", ...piArgs] : piArgs;
|
|
148
391
|
|
|
392
|
+
// PI_LINK_NAME is the internal handoff to the pi-link extension on the Pi side.
|
|
393
|
+
// The extension consumes and deletes it on startup; never expose this as a public API.
|
|
149
394
|
const child = spawn(cmd, cmdArgs, {
|
|
150
395
|
stdio: "inherit",
|
|
151
396
|
env: { ...process.env, PI_LINK_NAME: name },
|
|
@@ -159,6 +404,11 @@ if (command === "resolve") {
|
|
|
159
404
|
process.exit(1);
|
|
160
405
|
});
|
|
161
406
|
} else {
|
|
162
|
-
console.error("Usage: pi-link <name> [pi flags...]
|
|
407
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
408
|
+
console.error(" pi-link list [--global|-g]");
|
|
409
|
+
console.error(" pi-link resolve <name> [--global|-g]");
|
|
410
|
+
console.error("");
|
|
411
|
+
console.error("By default, name lookup is scoped to the current cwd.");
|
|
412
|
+
console.error("--global / -g widens the search to sessions in any cwd.");
|
|
163
413
|
process.exit(0);
|
|
164
414
|
}
|
package/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
ExtensionContext,
|
|
16
16
|
} from "@mariozechner/pi-coding-agent";
|
|
17
17
|
import { Text } from "@mariozechner/pi-tui";
|
|
18
|
-
import { Type } from "
|
|
18
|
+
import { Type } from "typebox";
|
|
19
19
|
import * as crypto from "node:crypto";
|
|
20
20
|
import * as os from "node:os";
|
|
21
21
|
|
|
@@ -120,6 +120,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
120
|
let role: "hub" | "client" | "disconnected" = "disconnected";
|
|
121
121
|
let terminalName = `t-${crypto.randomUUID().slice(0, 4)}`;
|
|
122
122
|
let preferredName: string | null = null;
|
|
123
|
+
// True between a client `/link-name` close and the next welcome/promotion.
|
|
124
|
+
// Lets `startHub` adopt the requested name if it wins promotion before welcome.
|
|
125
|
+
let pendingClientRename = false;
|
|
123
126
|
let connectedTerminals: string[] = [];
|
|
124
127
|
let ctx: ExtensionContext | undefined;
|
|
125
128
|
let disposed = false;
|
|
@@ -481,6 +484,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
481
484
|
// ── Client receives after registering ──
|
|
482
485
|
case "welcome":
|
|
483
486
|
terminalName = msg.name;
|
|
487
|
+
pendingClientRename = false;
|
|
484
488
|
connectedTerminals = msg.terminals;
|
|
485
489
|
terminalStatuses.clear();
|
|
486
490
|
terminalCwds.clear();
|
|
@@ -676,13 +680,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
676
680
|
return;
|
|
677
681
|
}
|
|
678
682
|
|
|
679
|
-
// Route chat / prompt messages
|
|
683
|
+
// Route chat / prompt messages.
|
|
684
|
+
// Normalize `from` to the hub's authoritative socket→name mapping,
|
|
685
|
+
// mirroring the status_update path above. Don't trust the client.
|
|
680
686
|
if (
|
|
681
687
|
msg.type === "chat" ||
|
|
682
688
|
msg.type === "prompt_request" ||
|
|
683
689
|
msg.type === "prompt_response"
|
|
684
690
|
) {
|
|
685
|
-
routeMessage(msg);
|
|
691
|
+
routeMessage({ ...msg, from: clientName });
|
|
686
692
|
}
|
|
687
693
|
});
|
|
688
694
|
|
|
@@ -725,6 +731,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
725
731
|
return;
|
|
726
732
|
}
|
|
727
733
|
wss = server;
|
|
734
|
+
// If a client `/link-name` was in flight when the previous hub vanished,
|
|
735
|
+
// this terminal is now establishing hub identity, so honor that pending
|
|
736
|
+
// request. Otherwise keep the last hub-assigned identity — don't replay
|
|
737
|
+
// a stale `preferredName` that may already have been deduped.
|
|
738
|
+
if (pendingClientRename && preferredName) terminalName = preferredName;
|
|
739
|
+
pendingClientRename = false;
|
|
728
740
|
role = "hub";
|
|
729
741
|
connectedTerminals = [terminalName];
|
|
730
742
|
updateStatus();
|
|
@@ -915,7 +927,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
915
927
|
ctx = _ctx;
|
|
916
928
|
currentCwd = _ctx.cwd;
|
|
917
929
|
|
|
918
|
-
// Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random
|
|
930
|
+
// Resolve terminal name: PI_LINK_NAME env > saved link-name > session name > random.
|
|
931
|
+
// PI_LINK_NAME is an internal handoff from the `pi-link` CLI launcher.
|
|
932
|
+
// Consumed once here and removed from process.env so spawned children don't inherit it.
|
|
919
933
|
const rawLinkName = process.env.PI_LINK_NAME;
|
|
920
934
|
delete process.env.PI_LINK_NAME;
|
|
921
935
|
const flagName = rawLinkName?.trim().replace(/\s+/g, " ") || undefined;
|
|
@@ -1393,13 +1407,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1393
1407
|
savePreference();
|
|
1394
1408
|
_ctx.ui.notify(`Renamed to "${newName}"`, "info");
|
|
1395
1409
|
} else if (role === "client") {
|
|
1396
|
-
//
|
|
1410
|
+
// Don't update terminalName here — welcome will assign authoritatively
|
|
1411
|
+
// after reconnect. Hub may dedupe newName to newName-2 if taken.
|
|
1397
1412
|
savePreference();
|
|
1398
|
-
|
|
1413
|
+
pendingClientRename = true;
|
|
1399
1414
|
ws?.close();
|
|
1400
|
-
// Reconnect will happen via the onClose handler → scheduleReconnect
|
|
1401
1415
|
_ctx.ui.notify(
|
|
1402
|
-
`Reconnecting
|
|
1416
|
+
`Reconnecting, requesting "${newName}" (hub may assign a different name if taken)...`,
|
|
1403
1417
|
"info",
|
|
1404
1418
|
);
|
|
1405
1419
|
} else {
|
package/package.json
CHANGED
|
@@ -31,6 +31,8 @@ Pick one mode per terminal per task. Mixing sync and async on the same terminal
|
|
|
31
31
|
|
|
32
32
|
Returns connected terminals with names, live status (`idle`, `thinking`, `tool:<name>`), and working directory (cwd). Use before delegating when availability or path context is uncertain. Your own entry is marked `(you)` — use this to discover your link name when replying to broadcast tasks.
|
|
33
33
|
|
|
34
|
+
Only currently connected terminals are visible. If a target is missing, it is offline; messages to offline terminals are not queued.
|
|
35
|
+
|
|
34
36
|
### `link_prompt`
|
|
35
37
|
|
|
36
38
|
Synchronous RPC. Send a prompt, wait for the response.
|