pi-link 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +14 -12
- package/bin/pi-link.mjs +418 -299
- package/index.ts +19 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ This changelog is based on the git history from `2026-03-21` (initial commit) th
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## 0.1.13 — 2026-05-03
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`pi-link resolve <name>` now rejects whitespace-only names.** Previously a name that normalized to empty (e.g. `pi-link resolve " "`) fell through to session lookup and silently reported no match. The empty-name check that already covered `pi-link <name>` now also runs in `resolve`, printing usage and exiting non-zero.
|
|
14
|
+
- **README wording: session-dir lookup phrasing tightened** to say "matches Pi's lookup order" instead of "mirrors Pi's".
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 0.1.12 — 2026-05-03
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **`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.
|
|
27
|
+
- **`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.
|
|
28
|
+
- **`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".
|
|
29
|
+
- **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.
|
|
30
|
+
- **`/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.
|
|
31
|
+
- **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.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
9
35
|
## 0.1.11 — 2026-04-27
|
|
10
36
|
|
|
11
37
|
### 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 matches Pi's lookup order: `PI_CODING_AGENT_SESSION_DIR` env > `<cwd>/.pi/settings.json` `sessionDir` > `<agentDir>/settings.json` `sessionDir` > default `<agentDir>/sessions/`. `<agentDir>` follows `PI_CODING_AGENT_DIR` and defaults to `~/.pi/agent/`.
|
|
161
161
|
|
|
162
|
-
|
|
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
|
@@ -1,299 +1,418 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// pi-link CLI — launch Pi with session resume by name
|
|
4
|
-
//
|
|
5
|
-
// Usage:
|
|
6
|
-
// pi-link <name> [flags...]
|
|
7
|
-
//
|
|
8
|
-
// pi-link
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// pi-link CLI — launch Pi with session resume by name
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// pi-link <name> [--global|-g] [flags...]
|
|
7
|
+
// Resume or create a named session, connected to link.
|
|
8
|
+
// pi-link list [--global|-g] List pi-link sessions in current cwd (or everywhere).
|
|
9
|
+
// pi-link resolve <name> [--global|-g]
|
|
10
|
+
// Print just the session path (machine-readable).
|
|
11
|
+
|
|
12
|
+
import { readdir, stat } from "fs/promises";
|
|
13
|
+
import { createReadStream, existsSync, readFileSync } from "fs";
|
|
14
|
+
import { createInterface } from "readline";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { spawn } from "child_process";
|
|
18
|
+
|
|
19
|
+
// ── Pi config resolution ───────────────────────────────────────────────────
|
|
20
|
+
// Match Pi's session-dir lookup order so list/resolve/<name> see what Pi sees.
|
|
21
|
+
// Custom sessionDir → flat layout; default → <agentDir>/sessions/<encoded-cwd>.
|
|
22
|
+
|
|
23
|
+
// Match Pi's expandTildePath: only `~` and `~/...`.
|
|
24
|
+
function expandTilde(p) {
|
|
25
|
+
if (p === "~") return homedir();
|
|
26
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readSessionDirFromSettings(settingsPath) {
|
|
31
|
+
if (!existsSync(settingsPath)) return undefined;
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`pi-link: ignored ${settingsPath}: ${err.message}`);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const value = parsed?.sessionDir;
|
|
40
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// PI_CODING_AGENT_DIR also relocates global settings.json to <agentDir>/settings.json.
|
|
45
|
+
function resolveAgentDir() {
|
|
46
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
47
|
+
if (env) return expandTilde(env);
|
|
48
|
+
return join(homedir(), ".pi", "agent");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Returns { dir, isCustom }. isCustom drives layout in scanSessions:
|
|
52
|
+
// true → flat <dir>/*.jsonl, false → <dir>/<encoded-cwd>/*.jsonl.
|
|
53
|
+
function resolveSessionDir(cwd, agentDir) {
|
|
54
|
+
const env = process.env.PI_CODING_AGENT_SESSION_DIR;
|
|
55
|
+
if (env) return { dir: expandTilde(env), isCustom: true };
|
|
56
|
+
|
|
57
|
+
const projectDir = readSessionDirFromSettings(join(cwd, ".pi", "settings.json"));
|
|
58
|
+
if (projectDir) return { dir: expandTilde(projectDir), isCustom: true };
|
|
59
|
+
|
|
60
|
+
const globalDir = readSessionDirFromSettings(join(agentDir, "settings.json"));
|
|
61
|
+
if (globalDir) return { dir: expandTilde(globalDir), isCustom: true };
|
|
62
|
+
|
|
63
|
+
return { dir: join(agentDir, "sessions"), isCustom: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Reads a session JSONL file and returns its display name, cwd, id, link
|
|
67
|
+
// status, and message count.
|
|
68
|
+
//
|
|
69
|
+
// Name precedence: latest valid `link-name` custom entry wins as the
|
|
70
|
+
// authoritative pi-link name. `session_info.name` is only a fallback for
|
|
71
|
+
// sessions that never set a link-name. Historical link-names are not aliases.
|
|
72
|
+
async function getSessionMeta(filePath) {
|
|
73
|
+
let linkName;
|
|
74
|
+
let sessionName;
|
|
75
|
+
let cwd;
|
|
76
|
+
let id;
|
|
77
|
+
let hasLinkName = false;
|
|
78
|
+
let messages = 0;
|
|
79
|
+
const rl = createInterface({ input: createReadStream(filePath, "utf-8"), crlfDelay: Infinity });
|
|
80
|
+
for await (const line of rl) {
|
|
81
|
+
if (!line) continue;
|
|
82
|
+
try {
|
|
83
|
+
const entry = JSON.parse(line);
|
|
84
|
+
if (entry.type === "session") {
|
|
85
|
+
if (typeof entry.cwd === "string") cwd = entry.cwd;
|
|
86
|
+
if (typeof entry.id === "string") id = entry.id;
|
|
87
|
+
} else if (entry.type === "session_info" && typeof entry.name === "string") {
|
|
88
|
+
sessionName = entry.name.trim().replace(/\s+/g, " ") || undefined;
|
|
89
|
+
} else if (entry.type === "custom" && entry.customType === "link-name") {
|
|
90
|
+
hasLinkName = true;
|
|
91
|
+
if (entry.data && typeof entry.data.name === "string") {
|
|
92
|
+
const n = entry.data.name.trim().replace(/\s+/g, " ");
|
|
93
|
+
if (n) linkName = n;
|
|
94
|
+
}
|
|
95
|
+
} else if (entry.type === "message" || entry.type === "user" || entry.type === "assistant") {
|
|
96
|
+
messages++;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// skip malformed lines (incl. partial last line of active sessions)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { name: linkName ?? sessionName, cwd, id, hasLinkName, messages };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizePath(p) {
|
|
106
|
+
let s = p.replace(/[/\\]+/g, "/").replace(/\/+$/, "");
|
|
107
|
+
if (process.platform === "win32") s = s.toLowerCase();
|
|
108
|
+
return s;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Replace $HOME with ~ in display paths. Comparison is normalized
|
|
112
|
+
// (case-insensitive on Windows) but display preserves original casing.
|
|
113
|
+
function displayPath(p) {
|
|
114
|
+
if (!p) return p;
|
|
115
|
+
const home = homedir();
|
|
116
|
+
const normP = normalizePath(p);
|
|
117
|
+
const normHome = normalizePath(home);
|
|
118
|
+
if (normP === normHome) return "~";
|
|
119
|
+
if (normP.startsWith(normHome + "/")) return "~" + p.slice(home.length).replace(/\\/g, "/");
|
|
120
|
+
return p;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const useAnsi =
|
|
124
|
+
!!process.stdout.isTTY &&
|
|
125
|
+
process.env.NO_COLOR === undefined &&
|
|
126
|
+
process.env.TERM !== "dumb";
|
|
127
|
+
const bold = (s) => (useAnsi ? `\x1b[1m${s}\x1b[22m` : s);
|
|
128
|
+
const dim = (s) => (useAnsi ? `\x1b[2m${s}\x1b[22m` : s);
|
|
129
|
+
|
|
130
|
+
function relTime(d) {
|
|
131
|
+
const sec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000));
|
|
132
|
+
if (sec < 60) return `${sec}s ago`;
|
|
133
|
+
const min = Math.floor(sec / 60);
|
|
134
|
+
if (min < 60) return `${min}m ago`;
|
|
135
|
+
const hr = Math.floor(min / 60);
|
|
136
|
+
if (hr < 24) return `${hr}h ago`;
|
|
137
|
+
const day = Math.floor(hr / 24);
|
|
138
|
+
if (day < 30) return `${day}d ago`;
|
|
139
|
+
return d.toISOString().slice(0, 10);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function loadSessionRecord(filePath) {
|
|
143
|
+
try {
|
|
144
|
+
const meta = await getSessionMeta(filePath);
|
|
145
|
+
const stats = await stat(filePath);
|
|
146
|
+
return { ...meta, modified: stats.mtime, path: filePath };
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Returns meta + mtime + path for every readable session in `dir`. Custom
|
|
153
|
+
// layout is flat (<dir>/*.jsonl); default layout has one subdir level per
|
|
154
|
+
// encoded cwd (<dir>/<sub>/*.jsonl). Errors on individual files/dirs are
|
|
155
|
+
// silently skipped — active or partially-written sessions are tolerated.
|
|
156
|
+
async function scanSessions(dir, isCustom) {
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const tasks = [];
|
|
165
|
+
if (isCustom) {
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
168
|
+
tasks.push(loadSessionRecord(join(dir, entry.name)));
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
for (const sub of entries) {
|
|
172
|
+
if (!sub.isDirectory()) continue;
|
|
173
|
+
const subPath = join(dir, sub.name);
|
|
174
|
+
let files;
|
|
175
|
+
try { files = await readdir(subPath); } catch { continue; }
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
178
|
+
tasks.push(loadSessionRecord(join(subPath, file)));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (await Promise.all(tasks)).filter((s) => s !== null);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Find sessions whose current display name matches `targetName`. Returns both
|
|
187
|
+
// local-cwd matches and all matches (cross-cwd) so the caller can default to
|
|
188
|
+
// local while still surfacing a hint when non-local matches exist. Falls back
|
|
189
|
+
// to `session_info.name` for sessions without a link-name (so `pi-link <name>`
|
|
190
|
+
// can attach link to a previously-unlinked named session).
|
|
191
|
+
async function findSessionsByName(targetName, dir, isCustom) {
|
|
192
|
+
const localCwd = normalizePath(process.cwd());
|
|
193
|
+
const all = (await scanSessions(dir, isCustom))
|
|
194
|
+
.filter((s) => s.name === targetName)
|
|
195
|
+
.map((s) => ({ path: s.path, cwd: s.cwd || "?", modified: s.modified }))
|
|
196
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
197
|
+
const local = all.filter((s) => normalizePath(s.cwd) === localCwd);
|
|
198
|
+
return { local, all };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// List pi-link sessions (those with at least one link-name entry). Default
|
|
202
|
+
// scope is current cwd; `all` widens to every directory.
|
|
203
|
+
async function listSessions({ all, dir, isCustom }) {
|
|
204
|
+
const localCwd = normalizePath(process.cwd());
|
|
205
|
+
return (await scanSessions(dir, isCustom))
|
|
206
|
+
.filter((s) => s.hasLinkName)
|
|
207
|
+
.filter((s) => all || (s.cwd && normalizePath(s.cwd) === localCwd))
|
|
208
|
+
.map((s) => ({
|
|
209
|
+
name: s.name || "(unnamed)",
|
|
210
|
+
cwd: s.cwd || "?",
|
|
211
|
+
id: s.id ? s.id.slice(0, 8) : "?",
|
|
212
|
+
messages: s.messages,
|
|
213
|
+
modified: s.modified,
|
|
214
|
+
path: s.path,
|
|
215
|
+
}))
|
|
216
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Renders a plain-text table. Widths are computed from unstyled cells; ANSI
|
|
220
|
+
// styles are applied after padding so column alignment is preserved when piped
|
|
221
|
+
// or styled. Mark a column with `dim: true` to render its cells dim.
|
|
222
|
+
function renderTable(rows, columns) {
|
|
223
|
+
const widths = columns.map((c) => Math.max(c.header.length, ...rows.map((r) => String(c.get(r)).length)));
|
|
224
|
+
const padCell = (text, i) => (i === columns.length - 1 ? text : text.padEnd(widths[i]));
|
|
225
|
+
const styleBody = (text, i) => (columns[i].dim ? dim(text) : text);
|
|
226
|
+
const headerLine = columns.map((c, i) => bold(padCell(c.header, i))).join(" ");
|
|
227
|
+
const bodyLines = rows.map((r) =>
|
|
228
|
+
columns.map((c, i) => styleBody(padCell(String(c.get(r)), i), i)).join(" "),
|
|
229
|
+
);
|
|
230
|
+
return [headerLine, ...bodyLines].join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
const [command, ...args] = process.argv.slice(2);
|
|
236
|
+
|
|
237
|
+
// Reject pi-link flags renamed in 0.1.12 with a clear pointer to the new name.
|
|
238
|
+
// Same intent as `rejectManagedFlag` (specific message > generic "Unknown argument")
|
|
239
|
+
// but for our own renames, not Pi-managed flags.
|
|
240
|
+
function rejectRenamedFlag(token) {
|
|
241
|
+
if (token === "--all" || token === "-a") {
|
|
242
|
+
const replacement = token === "-a" ? "-g" : "--global";
|
|
243
|
+
console.error(`Error: ${token} was renamed to ${replacement}.`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Reject Pi flags that pi-link manages, plus the removed --link-name extension flag.
|
|
249
|
+
// Runs on both the first token (so `pi-link --session foo` errors clearly) and on each
|
|
250
|
+
// flag in args (so `pi-link foo --session bar` does too).
|
|
251
|
+
function rejectManagedFlag(token) {
|
|
252
|
+
const key = token.split("=")[0];
|
|
253
|
+
if (key === "--link-name") {
|
|
254
|
+
console.error("Error: --link-name was removed. Use: pi-link <name>");
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
if (["--session", "--continue", "-c", "--resume", "-r", "--fork", "--no-session", "--session-dir"].includes(key)) {
|
|
258
|
+
console.error(`Error: ${key} is managed by pi-link. Remove it.`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function printCandidates(name, matches) {
|
|
264
|
+
console.error(`Multiple sessions named "${name}":\n`);
|
|
265
|
+
for (const m of matches) {
|
|
266
|
+
console.error(` ${m.modified.toISOString().slice(0, 19)} cwd: ${m.cwd}`);
|
|
267
|
+
console.error(` ${m.path}\n`);
|
|
268
|
+
}
|
|
269
|
+
console.error(`Use: pi --session <path> --link`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (command === "list") {
|
|
274
|
+
let global = false;
|
|
275
|
+
for (const a of args) {
|
|
276
|
+
rejectRenamedFlag(a);
|
|
277
|
+
if (a === "--global" || a === "-g") global = true;
|
|
278
|
+
else {
|
|
279
|
+
console.error(`Unknown argument: ${a}`);
|
|
280
|
+
console.error("Usage: pi-link list [--global|-g]");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
285
|
+
const sessions = await listSessions({ all: global, dir, isCustom });
|
|
286
|
+
if (sessions.length === 0) {
|
|
287
|
+
console.log(global ? "No pi-link sessions found." : "No pi-link sessions found in this cwd.");
|
|
288
|
+
console.log("Start one: pi-link <name>");
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
const columns = global
|
|
292
|
+
? [
|
|
293
|
+
{ header: "NAME", get: (s) => s.name },
|
|
294
|
+
{ header: "CWD", get: (s) => displayPath(s.cwd) },
|
|
295
|
+
{ header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
|
|
296
|
+
{ header: "MESSAGES", get: (s) => s.messages, dim: true },
|
|
297
|
+
{ header: "ID", get: (s) => s.id, dim: true },
|
|
298
|
+
]
|
|
299
|
+
: [
|
|
300
|
+
{ header: "NAME", get: (s) => s.name },
|
|
301
|
+
{ header: "MODIFIED", get: (s) => relTime(s.modified), dim: true },
|
|
302
|
+
{ header: "MESSAGES", get: (s) => s.messages, dim: true },
|
|
303
|
+
{ header: "ID", get: (s) => s.id, dim: true },
|
|
304
|
+
];
|
|
305
|
+
console.log(renderTable(sessions, columns));
|
|
306
|
+
if (process.stdout.isTTY) {
|
|
307
|
+
console.log("");
|
|
308
|
+
console.log(dim("Resume: pi-link <name>"));
|
|
309
|
+
}
|
|
310
|
+
} else if (command === "resolve") {
|
|
311
|
+
let global = false;
|
|
312
|
+
const positional = [];
|
|
313
|
+
for (const a of args) {
|
|
314
|
+
rejectRenamedFlag(a);
|
|
315
|
+
if (a === "--global" || a === "-g") global = true;
|
|
316
|
+
else if (a.startsWith("-")) {
|
|
317
|
+
console.error(`Unknown argument: ${a}`);
|
|
318
|
+
console.error("Usage: pi-link resolve <name> [--global|-g]");
|
|
319
|
+
process.exit(1);
|
|
320
|
+
} else positional.push(a);
|
|
321
|
+
}
|
|
322
|
+
if (positional.length !== 1) {
|
|
323
|
+
console.error("Usage: pi-link resolve <name> [--global|-g]");
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
const name = positional[0].trim().replace(/\s+/g, " ");
|
|
327
|
+
if (!name) {
|
|
328
|
+
console.error("Usage: pi-link resolve <name> [--global|-g]");
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
332
|
+
const { local, all } = await findSessionsByName(name, dir, isCustom);
|
|
333
|
+
const matches = global ? all : local;
|
|
334
|
+
if (matches.length === 1) {
|
|
335
|
+
process.stdout.write(matches[0].path);
|
|
336
|
+
} else if (matches.length > 1) {
|
|
337
|
+
printCandidates(name, matches);
|
|
338
|
+
}
|
|
339
|
+
} else if (command && command !== "--help" && command !== "-h") {
|
|
340
|
+
// pi-link [--global|-g] <name> [pi flags...] — resolve and launch Pi.
|
|
341
|
+
// Walk every token in one pass: pull out --global wherever it appears, treat
|
|
342
|
+
// the first non-flag token as the name, reject managed flags, forward the rest.
|
|
343
|
+
let global = false;
|
|
344
|
+
let name = null;
|
|
345
|
+
const piPassthrough = [];
|
|
346
|
+
for (const token of [command, ...args]) {
|
|
347
|
+
rejectRenamedFlag(token);
|
|
348
|
+
if (token === "--global" || token === "-g") { global = true; continue; }
|
|
349
|
+
rejectManagedFlag(token);
|
|
350
|
+
if (name === null) {
|
|
351
|
+
// Before the name is set, an unknown leading flag is almost certainly a
|
|
352
|
+
// user mistake (`pi-link --model gpt-4 foo`) — don't silently treat it
|
|
353
|
+
// as a session name. After the name is set, anything goes (forwarded to Pi).
|
|
354
|
+
if (token.startsWith("-")) {
|
|
355
|
+
console.error(`Unknown argument before name: ${token}`);
|
|
356
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
name = token;
|
|
360
|
+
} else piPassthrough.push(token);
|
|
361
|
+
}
|
|
362
|
+
if (!name) {
|
|
363
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
name = name.trim().replace(/\s+/g, " ");
|
|
367
|
+
if (!name) {
|
|
368
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const { dir, isCustom } = resolveSessionDir(process.cwd(), resolveAgentDir());
|
|
373
|
+
const { local, all } = await findSessionsByName(name, dir, isCustom);
|
|
374
|
+
const matches = global ? all : local;
|
|
375
|
+
if (matches.length > 1) {
|
|
376
|
+
printCandidates(name, matches);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const piArgs = [];
|
|
380
|
+
if (matches.length === 1) {
|
|
381
|
+
console.error(`Resuming session: ${matches[0].path}`);
|
|
382
|
+
piArgs.push("--session", matches[0].path);
|
|
383
|
+
} else {
|
|
384
|
+
if (!global && all.length > local.length) {
|
|
385
|
+
const elsewhere = all.length - local.length;
|
|
386
|
+
console.error(`No "${name}" in this cwd. (${elsewhere} match${elsewhere === 1 ? "" : "es"} in other cwds — use --global to consider ${elsewhere === 1 ? "it" : "them"}.)`);
|
|
387
|
+
}
|
|
388
|
+
console.error("Starting new session.");
|
|
389
|
+
}
|
|
390
|
+
piArgs.push("--link", ...piPassthrough);
|
|
391
|
+
|
|
392
|
+
const isWin = process.platform === "win32";
|
|
393
|
+
const cmd = isWin ? "cmd.exe" : "pi";
|
|
394
|
+
const cmdArgs = isWin ? ["/d", "/c", "pi", ...piArgs] : piArgs;
|
|
395
|
+
|
|
396
|
+
// PI_LINK_NAME is the internal handoff to the pi-link extension on the Pi side.
|
|
397
|
+
// The extension consumes and deletes it on startup; never expose this as a public API.
|
|
398
|
+
const child = spawn(cmd, cmdArgs, {
|
|
399
|
+
stdio: "inherit",
|
|
400
|
+
env: { ...process.env, PI_LINK_NAME: name },
|
|
401
|
+
});
|
|
402
|
+
child.once("exit", (code, signal) => {
|
|
403
|
+
if (code !== null) process.exit(code);
|
|
404
|
+
process.exit(signal === "SIGINT" ? 130 : 1);
|
|
405
|
+
});
|
|
406
|
+
child.once("error", (err) => {
|
|
407
|
+
console.error(`Failed to start pi: ${err.message}`);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
console.error("Usage: pi-link <name> [--global|-g] [pi flags...]");
|
|
412
|
+
console.error(" pi-link list [--global|-g]");
|
|
413
|
+
console.error(" pi-link resolve <name> [--global|-g]");
|
|
414
|
+
console.error("");
|
|
415
|
+
console.error("By default, name lookup is scoped to the current cwd.");
|
|
416
|
+
console.error("--global / -g widens the search to sessions in any cwd.");
|
|
417
|
+
process.exit(0);
|
|
418
|
+
}
|
package/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