pi-link 0.1.11 → 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 +17 -0
- package/README.md +14 -12
- package/bin/pi-link.mjs +189 -74
- package/index.ts +19 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ 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
|
+
|
|
9
26
|
## 0.1.11 — 2026-04-27
|
|
10
27
|
|
|
11
28
|
### Added
|
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:**
|
|
@@ -157,16 +157,18 @@ 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
|
-
|
|
164
|
-
- **
|
|
162
|
+
Lookup is **scoped to the current cwd by default**; pass `--global` (`-g`) to consider sessions in any cwd.
|
|
163
|
+
|
|
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
|
|
165
167
|
- **Conflicting flags** (`--session`, `--continue`, `--resume`, `--fork`, etc.) → rejected with an error
|
|
166
168
|
|
|
167
169
|
### Discovering sessions
|
|
168
170
|
|
|
169
|
-
`pi-link list` shows pi-link sessions in the current cwd; `pi-link list --
|
|
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.
|
|
170
172
|
|
|
171
173
|
```
|
|
172
174
|
$ pi-link list
|
|
@@ -177,10 +179,10 @@ gpt@pi-link 5m ago 1493 20d43841
|
|
|
177
179
|
Resume: pi-link <name>
|
|
178
180
|
```
|
|
179
181
|
|
|
180
|
-
With `--
|
|
182
|
+
With `--global`:
|
|
181
183
|
|
|
182
184
|
```
|
|
183
|
-
$ pi-link list --
|
|
185
|
+
$ pi-link list --global
|
|
184
186
|
NAME CWD MODIFIED MESSAGES ID
|
|
185
187
|
opus@pi-link ~/my-project 2m ago 4632 6332faab
|
|
186
188
|
gpt@pi-link ~/other-project 5m ago 1493 20d43841
|
|
@@ -188,7 +190,9 @@ gpt@pi-link ~/other-project 5m ago 1493 20d43841
|
|
|
188
190
|
Resume: pi-link <name>
|
|
189
191
|
```
|
|
190
192
|
|
|
191
|
-
`--
|
|
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.
|
|
192
196
|
|
|
193
197
|
For scripting, `pi-link resolve <name>` prints just the session path (machine-readable, no other output).
|
|
194
198
|
|
|
@@ -426,7 +430,7 @@ When the hub goes down and a client promotes itself, terminal names and in-fligh
|
|
|
426
430
|
| ------------------------------- | ------------------------------------------------ |
|
|
427
431
|
| `@mariozechner/pi-coding-agent` | Pi SDK types (ExtensionAPI, ExtensionContext) |
|
|
428
432
|
| `@mariozechner/pi-tui` | TUI Text widget for custom message rendering |
|
|
429
|
-
|
|
|
433
|
+
| `typebox` | JSON Schema type definitions for tool parameters |
|
|
430
434
|
|
|
431
435
|
### `package.json`
|
|
432
436
|
|
|
@@ -527,8 +531,6 @@ Default names are random 4-character hex IDs: `t-a1b2`, `t-c3d4`, etc.
|
|
|
527
531
|
|
|
528
532
|
**Persistence:** `/link-name` saves the preferred name to the session via `pi.appendEntry("link-name", { name })`. On session resume, the saved name is restored and requested from the hub. Only explicit `/link-name` calls persist - hub-assigned variants like `"builder-2"` are not saved. On reconnect, the terminal always requests the preferred name, not the last runtime name.
|
|
529
533
|
|
|
530
|
-
**Internal handoff (`PI_LINK_NAME`):** the `pi-link` launcher passes the chosen name to Pi via the `PI_LINK_NAME` environment variable. The extension reads it once on `session_start` and immediately removes it from `process.env` so child processes don't inherit it. This is an internal mechanism — don't set `PI_LINK_NAME` manually; use `pi-link <name>` to start a session or `/link-name` to rename mid-session.
|
|
531
|
-
|
|
532
534
|
**Rename guards:**
|
|
533
535
|
|
|
534
536
|
- If you're already using the requested name, `/link-name` returns early (`"Already using..."`).
|
package/bin/pi-link.mjs
CHANGED
|
@@ -3,18 +3,65 @@
|
|
|
3
3
|
// pi-link CLI — launch Pi with session resume by name
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// pi-link <name> [flags...]
|
|
7
|
-
//
|
|
8
|
-
// pi-link
|
|
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).
|
|
9
11
|
|
|
10
12
|
import { readdir, stat } from "fs/promises";
|
|
11
|
-
import { createReadStream } from "fs";
|
|
13
|
+
import { createReadStream, existsSync, readFileSync } from "fs";
|
|
12
14
|
import { createInterface } from "readline";
|
|
13
15
|
import { join } from "path";
|
|
14
16
|
import { homedir } from "os";
|
|
15
17
|
import { spawn } from "child_process";
|
|
16
18
|
|
|
17
|
-
|
|
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
|
+
}
|
|
18
65
|
|
|
19
66
|
// Reads a session JSONL file and returns its display name, cwd, id, link
|
|
20
67
|
// status, and message count.
|
|
@@ -92,63 +139,70 @@ function relTime(d) {
|
|
|
92
139
|
return d.toISOString().slice(0, 10);
|
|
93
140
|
}
|
|
94
141
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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;
|
|
100
158
|
try {
|
|
101
|
-
|
|
159
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
102
160
|
} catch {
|
|
103
161
|
return [];
|
|
104
162
|
}
|
|
105
163
|
|
|
106
164
|
const tasks = [];
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
})());
|
|
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
|
+
}
|
|
124
180
|
}
|
|
125
181
|
}
|
|
126
182
|
|
|
127
183
|
return (await Promise.all(tasks)).filter((s) => s !== null);
|
|
128
184
|
}
|
|
129
185
|
|
|
130
|
-
// Find sessions whose current display name matches `targetName`.
|
|
131
|
-
// matches
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
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) {
|
|
135
192
|
const localCwd = normalizePath(process.cwd());
|
|
136
|
-
|
|
193
|
+
const all = (await scanSessions(dir, isCustom))
|
|
137
194
|
.filter((s) => s.name === targetName)
|
|
138
195
|
.map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
|
|
139
|
-
.sort((a, b) =>
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (aLocal !== bLocal) return bLocal - aLocal;
|
|
143
|
-
return b.modified.getTime() - a.modified.getTime();
|
|
144
|
-
});
|
|
196
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
197
|
+
const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
|
|
198
|
+
return { local, all };
|
|
145
199
|
}
|
|
146
200
|
|
|
147
201
|
// List pi-link sessions (those with at least one link-name entry). Default
|
|
148
202
|
// scope is current cwd; `all` widens to every directory.
|
|
149
|
-
async function listSessions({ all }) {
|
|
203
|
+
async function listSessions({ all, dir, isCustom }) {
|
|
150
204
|
const localCwd = normalizePath(process.cwd());
|
|
151
|
-
return (await scanSessions())
|
|
205
|
+
return (await scanSessions(dir, isCustom))
|
|
152
206
|
.filter((s) => s.hasLinkName)
|
|
153
207
|
.filter((s) => all || (s.cwd && normalizePath(s.cwd) === localCwd))
|
|
154
208
|
.map((s) => ({
|
|
@@ -180,6 +234,32 @@ function renderTable(rows, columns) {
|
|
|
180
234
|
|
|
181
235
|
const [command, ...args] = process.argv.slice(2);
|
|
182
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
|
+
|
|
183
263
|
function printCandidates(name, matches) {
|
|
184
264
|
console.error(`Multiple sessions named "${name}":\n`);
|
|
185
265
|
for (const m of matches) {
|
|
@@ -191,22 +271,24 @@ function printCandidates(name, matches) {
|
|
|
191
271
|
}
|
|
192
272
|
|
|
193
273
|
if (command === "list") {
|
|
194
|
-
let
|
|
274
|
+
let global = false;
|
|
195
275
|
for (const a of args) {
|
|
196
|
-
|
|
276
|
+
rejectRenamedFlag(a);
|
|
277
|
+
if (a === "--global" || a === "-g") global = true;
|
|
197
278
|
else {
|
|
198
279
|
console.error(`Unknown argument: ${a}`);
|
|
199
|
-
console.error("Usage: pi-link list [--
|
|
280
|
+
console.error("Usage: pi-link list [--global|-g]");
|
|
200
281
|
process.exit(1);
|
|
201
282
|
}
|
|
202
283
|
}
|
|
203
|
-
const
|
|
284
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
285
|
+
const sessions = await listSessions({ all: global, dir, isCustom });
|
|
204
286
|
if (sessions.length === 0) {
|
|
205
|
-
console.log(
|
|
287
|
+
console.log(global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
|
|
206
288
|
console.log("Start one: pi-link <name>");
|
|
207
289
|
process.exit(0);
|
|
208
290
|
}
|
|
209
|
-
const columns =
|
|
291
|
+
const columns = global
|
|
210
292
|
? [
|
|
211
293
|
{ header: "NAME", get: (s) => s.name },
|
|
212
294
|
{ header: "CWD", get: (s) => displayPath(s.cwd) },
|
|
@@ -226,40 +308,66 @@ if (command === "list") {
|
|
|
226
308
|
console.log(dim("Resume: pi-link <name>"));
|
|
227
309
|
}
|
|
228
310
|
} else if (command === "resolve") {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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]");
|
|
232
324
|
process.exit(1);
|
|
233
325
|
}
|
|
234
|
-
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;
|
|
235
330
|
if (matches.length === 1) {
|
|
236
331
|
process.stdout.write(matches[0].path);
|
|
237
332
|
} else if (matches.length > 1) {
|
|
238
333
|
printCandidates(name, matches);
|
|
239
334
|
}
|
|
240
335
|
} else if (command && command !== "--help" && command !== "-h") {
|
|
241
|
-
// pi-link <name> [flags...] — resolve and launch Pi
|
|
242
|
-
|
|
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
|
+
}
|
|
243
358
|
if (!name) {
|
|
244
|
-
console.error("Usage: pi-link <name> [pi flags...]");
|
|
359
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
245
360
|
process.exit(1);
|
|
246
361
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (["--session", "--continue", "-c", "--resume", "-r", "--fork", "--no-session", "--session-dir"].includes(key)) {
|
|
252
|
-
console.error(`Error: ${key} is managed by pi-link. Remove it.`);
|
|
253
|
-
process.exit(1);
|
|
254
|
-
}
|
|
255
|
-
// Catch the removed extension flag before forwarding args to Pi.
|
|
256
|
-
if (key === "--link-name") {
|
|
257
|
-
console.error("Error: --link-name was removed. Use: pi-link <name>");
|
|
258
|
-
process.exit(1);
|
|
259
|
-
}
|
|
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);
|
|
260
366
|
}
|
|
261
367
|
|
|
262
|
-
const
|
|
368
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
369
|
+
const { local, all } = await findSessionsByName(name, dir, isCustom);
|
|
370
|
+
const matches = global ? all : local;
|
|
263
371
|
if (matches.length > 1) {
|
|
264
372
|
printCandidates(name, matches);
|
|
265
373
|
}
|
|
@@ -269,9 +377,13 @@ if (command === "list") {
|
|
|
269
377
|
console.error(`Resuming session: ${matches[0].path}`);
|
|
270
378
|
piArgs.push("--session", matches[0].path);
|
|
271
379
|
} else {
|
|
272
|
-
|
|
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.");
|
|
273
385
|
}
|
|
274
|
-
piArgs.push("--link", ...
|
|
386
|
+
piArgs.push("--link", ...piPassthrough);
|
|
275
387
|
|
|
276
388
|
const isWin = process.platform === "win32";
|
|
277
389
|
const cmd = isWin ? "cmd.exe" : "pi";
|
|
@@ -292,8 +404,11 @@ if (command === "list") {
|
|
|
292
404
|
process.exit(1);
|
|
293
405
|
});
|
|
294
406
|
} else {
|
|
295
|
-
console.error("Usage: pi-link <name> [pi flags...]");
|
|
296
|
-
console.error(" pi-link list [--
|
|
297
|
-
console.error(" pi-link resolve <name>");
|
|
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.");
|
|
298
413
|
process.exit(0);
|
|
299
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();
|
|
@@ -1395,13 +1407,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1395
1407
|
savePreference();
|
|
1396
1408
|
_ctx.ui.notify(`Renamed to "${newName}"`, "info");
|
|
1397
1409
|
} else if (role === "client") {
|
|
1398
|
-
//
|
|
1410
|
+
// Don't update terminalName here — welcome will assign authoritatively
|
|
1411
|
+
// after reconnect. Hub may dedupe newName to newName-2 if taken.
|
|
1399
1412
|
savePreference();
|
|
1400
|
-
|
|
1413
|
+
pendingClientRename = true;
|
|
1401
1414
|
ws?.close();
|
|
1402
|
-
// Reconnect will happen via the onClose handler → scheduleReconnect
|
|
1403
1415
|
_ctx.ui.notify(
|
|
1404
|
-
`Reconnecting
|
|
1416
|
+
`Reconnecting, requesting "${newName}" (hub may assign a different name if taken)...`,
|
|
1405
1417
|
"info",
|
|
1406
1418
|
);
|
|
1407
1419
|
} else {
|
package/package.json
CHANGED