pi-link 0.5.0 → 0.5.2
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 +4 -2
- package/bin/pi-link.mjs +13 -12
- package/index.ts +28 -19
- 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.5.2 — 2026-09-29
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **Session-directory errors no longer look like an empty session history.** If the CLI cannot enumerate the session root for a reason other than a missing directory, it reports the error and exits with code `1`. The launcher no longer starts a new session on that false absence, `--list` no longer reports no sessions, and `--resolve` no longer reports the name as not found. A missing root still means no sessions yet; handling of individual session files and subdirectories is unchanged.
|
|
14
|
+
|
|
15
|
+
- **A terminal named `__proto__` keeps its status, cwd and context snapshots.** The name-keyed records in the initial welcome and `link_list` details now use dictionaries without a prototype, so that accepted name is copied into those records and serialized as an ordinary own property rather than lost through JavaScript's inherited setter. No names are newly reserved.
|
|
16
|
+
|
|
17
|
+
- **`pi-link --version` fails explicitly when package metadata cannot supply a version.** An unreadable or malformed `package.json`, or a missing version, now produces an error and a nonzero exit instead of printing `unknown` and exiting successfully. A healthy installation still prints only its version.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Internal cleanup reuses existing status and delivery helpers.** Status formatting uses the existing identity formatter, disconnect relies on the inbox scheduler's existing timer guard, and client status updates use the shared hub broadcast path instead of duplicating it. Those updates keep their authoritative sender name, recipients and context rules; the hub now stores them through self-delivery after sending to peers, as the shared path already does. Two unreachable CLI `break` statements after `process.exit(0)` were also removed.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 0.5.1 — 2026-09-22
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **pi-link's tools are callable directly under Oh My Pi.** The three tools are registered with `loadMode: "essential"`, so harnesses that hide extension tools by default, such as Oh My Pi, list them with the other callable tools instead of behind a discovery path. Pi has no such field and ignores it.
|
|
30
|
+
|
|
31
|
+
- **Peers see a terminal's context usage change after a model switch or tree navigation.** A terminal now republishes its context when its model changes or it moves to another branch of the session tree. Before, an idle terminal kept showing its previous figure until its next run.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
9
35
|
## 0.5.0 — 2026-09-16
|
|
10
36
|
|
|
11
37
|
### Added
|
package/README.md
CHANGED
|
@@ -663,11 +663,13 @@ The extension hooks into Pi's agent lifecycle events:
|
|
|
663
663
|
- **`tool_execution_end`** → Drops that one call. The display stays `tool:<name>` while other calls remain, moves to the next call when the displayed one ends, and returns to `thinking` after the last one while the agent run continues.
|
|
664
664
|
- **`session_before_compact`** → For a manual compaction, raises the gate that holds inbox delivery. Automatic (threshold/overflow) compaction is left alone: it runs inside the agent run, where Pi already queues and drains steered messages itself.
|
|
665
665
|
- **`session_compact`** → Clears the local compaction gate and force-pushes a `status_update` so peers see the new (post-compaction) context usage immediately.
|
|
666
|
+
- **`model_select`** → Force-pushes a `status_update`: the context window, and so the usage percentage, belongs to the model.
|
|
667
|
+
- **`session_tree`** → Force-pushes a `status_update`: context usage belongs to the active branch.
|
|
666
668
|
- **`session_shutdown`** → Full cleanup via `cleanup()`: closes all sockets, resolves pending promises, and disposes the extension.
|
|
667
669
|
|
|
668
|
-
The five agent and tool handlers — `agent_start`, `agent_end`, `agent_settled` (when Pi still reports the session idle), `tool_execution_start` and `tool_execution_end` — each recompute the status and hand it to `pushStatus()`, which publishes only when the display identity changed since the last publish: a second concurrent tool starting behind the one already shown is silent, and so is a mutation that leaves a raised compaction gate on display. The session handlers keep the separate paths described in their own bullets, and `session_shutdown` cleans up rather than pushing. `disconnect()` is the only thing that clears the stored baseline, and `pushStatus()` returns immediately while the terminal is disconnected, so handler events in that interval restore nothing; once connected again, either a client's forced `welcome` push or the first of those ordinary handler events restores it. `session_compact` always
|
|
670
|
+
The five agent and tool handlers — `agent_start`, `agent_end`, `agent_settled` (when Pi still reports the session idle), `tool_execution_start` and `tool_execution_end` — each recompute the status and hand it to `pushStatus()`, which publishes only when the display identity changed since the last publish: a second concurrent tool starting behind the one already shown is silent, and so is a mutation that leaves a raised compaction gate on display. The session handlers keep the separate paths described in their own bullets, and `session_shutdown` cleans up rather than pushing. `disconnect()` is the only thing that clears the stored baseline, and `pushStatus()` returns immediately while the terminal is disconnected, so handler events in that interval restore nothing; once connected again, either a client's forced `welcome` push or the first of those ordinary handler events restores it. `session_compact`, `model_select` and `session_tree` always push, whether the identity changed or not — including while a remote request's gate still holds the displayed identity.
|
|
669
671
|
|
|
670
|
-
Status updates are push-based: each terminal broadcasts changes to the hub, which fans them out. New joiners receive a status snapshot for all terminals in the `welcome` message. Context updates reuse the same status path, including a
|
|
672
|
+
Status updates are push-based: each terminal broadcasts changes to the hub, which fans them out. New joiners receive a status snapshot for all terminals in the `welcome` message. Context updates reuse the same status path, including forced updates after a compaction, a model change and tree navigation.
|
|
671
673
|
|
|
672
674
|
### Inbox
|
|
673
675
|
|
package/bin/pi-link.mjs
CHANGED
|
@@ -183,14 +183,17 @@ async function loadSessionRecord(filePath, scopeCwd) {
|
|
|
183
183
|
// Returns meta + mtime + path for every readable session in `dir`, or only
|
|
184
184
|
// those from `scopeCwd` when it is given. Custom layout is flat
|
|
185
185
|
// (<dir>/*.jsonl); default layout has one subdir level per encoded cwd
|
|
186
|
-
// (<dir>/<sub>/*.jsonl).
|
|
187
|
-
//
|
|
186
|
+
// (<dir>/<sub>/*.jsonl). A missing `dir` means no sessions yet; any other
|
|
187
|
+
// failure to read it is fatal, since reporting no sessions would be a lie.
|
|
188
|
+
// Errors on individual files/dirs are silently skipped — active or
|
|
189
|
+
// partially-written sessions are tolerated.
|
|
188
190
|
async function scanSessions(dir, isCustom, scopeCwd) {
|
|
189
191
|
let entries;
|
|
190
192
|
try {
|
|
191
193
|
entries = await readdir(dir, { withFileTypes: true });
|
|
192
|
-
} catch {
|
|
193
|
-
return [];
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err.code === "ENOENT") return [];
|
|
196
|
+
fail(`cannot read session directory: ${err.message}`);
|
|
194
197
|
}
|
|
195
198
|
|
|
196
199
|
const tasks = [];
|
|
@@ -315,14 +318,14 @@ function printHelp() {
|
|
|
315
318
|
}
|
|
316
319
|
|
|
317
320
|
function printVersion() {
|
|
321
|
+
let pkg;
|
|
318
322
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
console.log(pkg.version ?? "unknown");
|
|
323
|
-
} catch {
|
|
324
|
-
console.log("unknown");
|
|
323
|
+
pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
324
|
+
} catch (err) {
|
|
325
|
+
fail(`cannot read pi-link's package.json: ${err.message}`);
|
|
325
326
|
}
|
|
327
|
+
if (!pkg.version) fail("pi-link's package.json has no version");
|
|
328
|
+
console.log(pkg.version);
|
|
326
329
|
}
|
|
327
330
|
|
|
328
331
|
function describeMode(mode) {
|
|
@@ -515,11 +518,9 @@ switch (state.mode) {
|
|
|
515
518
|
case "help":
|
|
516
519
|
printHelp();
|
|
517
520
|
process.exit(0);
|
|
518
|
-
break; // unreachable; present to satisfy no-fallthrough lints
|
|
519
521
|
case "version":
|
|
520
522
|
printVersion();
|
|
521
523
|
process.exit(0);
|
|
522
|
-
break; // unreachable; present to satisfy no-fallthrough lints
|
|
523
524
|
case "list":
|
|
524
525
|
await runList(state);
|
|
525
526
|
break;
|
package/index.ts
CHANGED
|
@@ -44,6 +44,9 @@ const BATCH_MAX_ITEMS = 20;
|
|
|
44
44
|
const BATCH_MAX_CHARS = 16_000;
|
|
45
45
|
// Visual rows of an incoming message kept while Pi's global expansion is off.
|
|
46
46
|
const PREVIEW_ROWS = 6;
|
|
47
|
+
// Consumed by harnesses that hide extension tools by default (Oh My Pi);
|
|
48
|
+
// ignored by Pi, whose ToolDefinition has no such field.
|
|
49
|
+
const TOP_LEVEL_TOOL = { loadMode: "essential" } as const;
|
|
47
50
|
|
|
48
51
|
// ─── Protocol ────────────────────────────────────────────────────────────────
|
|
49
52
|
|
|
@@ -418,9 +421,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
421
|
}
|
|
419
422
|
|
|
420
423
|
function formatStatus(s: LinkStatus): string {
|
|
421
|
-
|
|
422
|
-
if (s.kind === "tool") return `tool:${s.toolName} (${dur})`;
|
|
423
|
-
return `${s.kind} (${dur})`;
|
|
424
|
+
return `${statusIdentity(s)} (${formatDuration(s.since)})`;
|
|
424
425
|
}
|
|
425
426
|
|
|
426
427
|
function formatTokens(n: number): string {
|
|
@@ -960,14 +961,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
960
961
|
hubClients.set(clientWs, clientName);
|
|
961
962
|
const list = terminalList();
|
|
962
963
|
|
|
963
|
-
// Confirm to the new client (with status, cwd and context snapshots)
|
|
964
|
-
|
|
964
|
+
// Confirm to the new client (with status, cwd and context snapshots).
|
|
965
|
+
// Keyed by terminal name, so no prototype: a plain {} would swallow "__proto__".
|
|
966
|
+
const statuses: Record<string, LinkStatus> = Object.create(null);
|
|
965
967
|
statuses[terminalName] = deriveStatus(); // hub's own status
|
|
966
968
|
for (const [name, status] of terminalStatuses) statuses[name] = status;
|
|
967
|
-
const cwds: Record<string, string> =
|
|
969
|
+
const cwds: Record<string, string> = Object.create(null);
|
|
968
970
|
if (currentCwd) cwds[terminalName] = currentCwd; // hub's own cwd
|
|
969
971
|
for (const [name, cwd] of terminalCwds) cwds[name] = cwd;
|
|
970
|
-
const contexts: Record<string, ContextSnapshot> =
|
|
972
|
+
const contexts: Record<string, ContextSnapshot> = Object.create(null);
|
|
971
973
|
const hubContext = captureContext();
|
|
972
974
|
if (hubContext) contexts[terminalName] = hubContext; // hub's own context
|
|
973
975
|
for (const [name, c] of terminalContexts) contexts[name] = c;
|
|
@@ -998,21 +1000,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
998
1000
|
// Ignore messages from unregistered clients
|
|
999
1001
|
if (!clientName) return;
|
|
1000
1002
|
|
|
1001
|
-
// Status update
|
|
1003
|
+
// Status update: rebuilt under the authoritative name, fanned out to the other
|
|
1004
|
+
// clients, and stored by the hub through its own self-delivery.
|
|
1002
1005
|
if (msg.type === "status_update") {
|
|
1003
|
-
terminalStatuses.set(clientName, msg.status);
|
|
1004
|
-
if (msg.context) terminalContexts.set(clientName, msg.context);
|
|
1005
|
-
else if (msg.context === null) terminalContexts.delete(clientName);
|
|
1006
1006
|
const normalized: StatusUpdateMsg = {
|
|
1007
1007
|
type: "status_update",
|
|
1008
1008
|
name: clientName,
|
|
1009
1009
|
status: msg.status,
|
|
1010
1010
|
context: msg.context, // undefined omitted by JSON; null forwarded to clear
|
|
1011
1011
|
};
|
|
1012
|
-
|
|
1013
|
-
for (const [otherWs, name] of hubClients) {
|
|
1014
|
-
if (name !== clientName) otherWs.send(json);
|
|
1015
|
-
}
|
|
1012
|
+
hubBroadcast(normalized, clientName);
|
|
1016
1013
|
return;
|
|
1017
1014
|
}
|
|
1018
1015
|
|
|
@@ -1337,7 +1334,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1337
1334
|
updateStatus();
|
|
1338
1335
|
|
|
1339
1336
|
// Inbox survives disconnect; flush unless a local /compact still gates it.
|
|
1340
|
-
|
|
1337
|
+
releaseInbox();
|
|
1341
1338
|
}
|
|
1342
1339
|
|
|
1343
1340
|
function cleanup() {
|
|
@@ -1469,6 +1466,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1469
1466
|
pushStatus(true);
|
|
1470
1467
|
});
|
|
1471
1468
|
|
|
1469
|
+
pi.on("model_select", async () => {
|
|
1470
|
+
pushStatus(true); // the window, and so the usage percentage, belongs to the model
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
pi.on("session_tree", async () => {
|
|
1474
|
+
pushStatus(true); // context usage belongs to the active branch
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1472
1477
|
pi.on("tool_execution_start", async (event) => {
|
|
1473
1478
|
const before = statusIdentity(deriveStatus());
|
|
1474
1479
|
activeTools.set(event.toolCallId, event.toolName);
|
|
@@ -1566,6 +1571,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1566
1571
|
// ── Tools ────────────────────────────────────────────────────────────────
|
|
1567
1572
|
|
|
1568
1573
|
pi.registerTool({
|
|
1574
|
+
...TOP_LEVEL_TOOL,
|
|
1569
1575
|
name: "link_send",
|
|
1570
1576
|
label: "Link Send",
|
|
1571
1577
|
description: [
|
|
@@ -1630,6 +1636,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1630
1636
|
});
|
|
1631
1637
|
|
|
1632
1638
|
pi.registerTool({
|
|
1639
|
+
...TOP_LEVEL_TOOL,
|
|
1633
1640
|
name: "link_compact",
|
|
1634
1641
|
label: "Link Compact",
|
|
1635
1642
|
description: [
|
|
@@ -1742,6 +1749,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1742
1749
|
});
|
|
1743
1750
|
|
|
1744
1751
|
pi.registerTool({
|
|
1752
|
+
...TOP_LEVEL_TOOL,
|
|
1745
1753
|
name: "link_list",
|
|
1746
1754
|
label: "Link List",
|
|
1747
1755
|
description: "List the Pi terminals in your group currently connected to the link.",
|
|
@@ -1751,9 +1759,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1751
1759
|
async execute() {
|
|
1752
1760
|
if (role === "disconnected") return notConnectedResult();
|
|
1753
1761
|
|
|
1754
|
-
|
|
1755
|
-
const
|
|
1756
|
-
const
|
|
1762
|
+
// Keyed by terminal name, so no prototype (see the welcome snapshots).
|
|
1763
|
+
const statuses: Record<string, string> = Object.create(null);
|
|
1764
|
+
const cwds: Record<string, string> = Object.create(null);
|
|
1765
|
+
const contexts: Record<string, ContextSnapshot> = Object.create(null);
|
|
1757
1766
|
const visible = visibleTerminals();
|
|
1758
1767
|
const list = visible
|
|
1759
1768
|
.map((name) => {
|
package/package.json
CHANGED