privateer-agent 0.12.7 → 0.12.8

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.
@@ -26,8 +26,9 @@ import { spawn, spawnSync } from "node:child_process";
26
26
  import fs from "node:fs";
27
27
  import os from "node:os";
28
28
  import path from "node:path";
29
- import { fileURLToPath } from "node:url";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
+ import { routeUpdate } from "./update-route.mjs";
31
32
 
32
33
  const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
33
34
  const REPO = path.resolve(HERE, "..");
@@ -202,10 +203,9 @@ function updateNpmPackage() {
202
203
  });
203
204
  }
204
205
 
205
- // --- `privateer update` ----------------------------------------------------
206
- // Fetch the latest release and exit. Bundle installs re-run the download+extract
207
- // installer; npm installs update the global package.
208
- if (sub === "update") {
206
+ // Update the CLI itself: fetch the latest release and exit. Bundle installs re-run the
207
+ // download+extract installer; npm installs update the global package.
208
+ function updateSelf() {
209
209
  if (BUNDLED) {
210
210
  // PRIVATEER_UPDATE=1 flips the installer into update mode: weigh-anchor banner,
211
211
  // "X → Y" version reporting, and an early exit (no download) when already current.
@@ -222,6 +222,67 @@ if (sub === "update") {
222
222
  // both paths exit via their child's exit handler.
223
223
  }
224
224
 
225
+ // Update TOOL PACKS (Pi "packages": npm/git sources contributing extensions, skills,
226
+ // prompts, themes) by handing the job to Pi's package-manager CLI, which owns npm/git
227
+ // installs, scopes and project trust. `then` runs only on a clean exit — that's how
228
+ // `--all` chains packs → self without either half being silently skipped.
229
+ //
230
+ // PI_CODING_AGENT_DIR is NOT optional here: without it Pi resolves the agent dir to a
231
+ // standalone ~/.pi/agent and would update packages belonging to a different install
232
+ // entirely, leaving the Privateer terminal's own packs untouched (and reporting success).
233
+ function updatePacks(cliArgs, then) {
234
+ const CLI = resolveDep(REPO, "@earendil-works/pi-coding-agent", "dist", "cli.js");
235
+ if (!CLI || !fs.existsSync(CLI)) {
236
+ console.error(
237
+ "privateer: couldn't find pi-coding-agent — the install looks incomplete.\n" +
238
+ " Try reinstalling: npm install -g privateer-agent@latest",
239
+ );
240
+ process.exit(1);
241
+ }
242
+ ensurePatches(); // project `.privateer/` config dirs are a patch; -l scope needs them
243
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
244
+ const env = { ...process.env, PI_CODING_AGENT_DIR: AGENT_DIR };
245
+ const child = spawn(NODE_BIN, [...nodeArgs, CLI, "update", ...cliArgs], { stdio: "inherit", env });
246
+ child.on("exit", (code, signal) => {
247
+ if (signal) process.kill(process.pid, signal);
248
+ else if (code === 0 && then) then();
249
+ else process.exit(code ?? 0);
250
+ });
251
+ child.on("error", (e) => {
252
+ console.error(`privateer: failed to launch the package manager — ${e.message}`);
253
+ process.exit(1);
254
+ });
255
+ }
256
+
257
+ // --- `privateer update [--extensions | --all | <pack>]` --------------------
258
+ // Two different things wear the same verb — the CLI itself and the tool packs — so the
259
+ // grammar and the reasoning behind it live in bin/update-route.mjs, next to its test.
260
+ // Tool packs can also be updated from inside a running terminal with /update, with no
261
+ // restart at all; see extensions/privateer-update.ts.
262
+ if (sub === "update") {
263
+ const rest = args.slice(1);
264
+ const route = routeUpdate(rest);
265
+ if (route === "help") {
266
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
267
+ console.log(
268
+ [
269
+ `${cmd} update — fetch the latest release, or newer tool packs.`,
270
+ "",
271
+ ` ${cmd} update update the Privateer CLI itself`,
272
+ ` ${cmd} update --extensions update every installed tool pack`,
273
+ ` ${cmd} update <pack> update one pack (npm name or git URL)`,
274
+ ` ${cmd} update --all tool packs, then the CLI`,
275
+ "",
276
+ "Inside a running terminal, /update fetches tool packs in place — no restart.",
277
+ ].join("\n"),
278
+ );
279
+ process.exit(0);
280
+ }
281
+ if (route === "all") updatePacks(["--extensions"], updateSelf);
282
+ else if (route === "packs") updatePacks(rest);
283
+ else updateSelf();
284
+ }
285
+
225
286
  // --- `privateer harbor [run|install|uninstall|status]` ---------------------
226
287
  // The resident background harbor (routines + app-driven headless task spawns). Boots
227
288
  // straight into src/harbor via bin/privateer-harbor.mjs — the harbor loads the moat as
@@ -402,6 +463,15 @@ else {
402
463
 
403
464
  // Dev convenience: load provider keys from the repo's .env if present.
404
465
  const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
466
+
467
+ // The boot splash. `--import` so it runs before Pi's entry module — most of the wait
468
+ // it covers IS that module graph loading, so a splash started any later would miss it.
469
+ // TUI branch only: harbor/acp/subagent children have no terminal to animate on (and
470
+ // acp's stdout is a JSON-RPC stream). pathToFileURL, not the bare path — a Windows
471
+ // absolute path reads as the URL scheme "d:"; see bin/privateer.mjs for the same trap.
472
+ const splash = path.join(HERE, "privateer-splash.mjs");
473
+ if (fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
474
+
405
475
  runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
406
476
  }
407
477
 
@@ -0,0 +1,247 @@
1
+ // Boot splash — the wave that runs while Pi's TUI is still coming up.
2
+ //
3
+ // WHY THIS EXISTS. A cold `privateer` measured ~30s between the shell prompt and the
4
+ // first painted frame, all of it silent:
5
+ //
6
+ // 0.1s launcher banner (the login/keyless notice) — the last thing the user sees
7
+ // ~11s Pi's own module graph loads (bare `pi` with zero extensions costs this)
8
+ // ~19s …plus the 16 moat/tool-pack extensions we pass as `-e`
9
+ // ~30s session_start handlers finish and the TUI paints its first frame
10
+ //
11
+ // Worse than the wait is its shape: Pi enables raw mode and HIDES THE CURSOR at the
12
+ // ~19s mark, so the last third is a terminal with no prompt, no cursor and no output.
13
+ // Every report of this reads as "privateer hangs".
14
+ //
15
+ // Loaded with `node --import` (see the TUI branch of bin/privateer-launch.mjs) so it
16
+ // runs BEFORE Pi's entry module — the module loading it covers is most of the wait, and
17
+ // a splash started any later would miss it.
18
+ //
19
+ // WHY A WORKER THREAD. The first version of this drew from a setInterval and animated
20
+ // nothing: Pi's boot is a synchronous module-loading storm (compileSourceTextModule,
21
+ // readFileUtf8, the CJS lexer — see the profile), so the main thread's event loop never
22
+ // gets a turn between here and the first frame. A timer that only fires once the wait is
23
+ // over is not a loading indicator. The animation therefore lives on its own thread, with
24
+ // its own loop, which keeps drawing while the main thread is wedged. The main thread
25
+ // still owns the two things that must be synchronous — noticing Pi's output and getting
26
+ // out of its way — and steers the worker through a SharedArrayBuffer.
27
+ //
28
+ // HOW IT KNOWS WHEN TO STOP. Nothing tells us "the TUI is up", so we watch Pi's own
29
+ // output: process.stdout is patched here, before Pi ever touches it. Two signals:
30
+ // • `\x1b[?2004h` (bracketed paste) — TUI.start(). Raw mode is on, the cursor is
31
+ // hidden, extensions' session_start handlers are now running. The wave switches to
32
+ // its second message and keeps going; the first frame is still seconds away.
33
+ // • ≥ FRAME_BYTES of stdout AFTER that point — the first frame is being written. We
34
+ // stop the worker, erase the line and get out of the way in the same tick, before
35
+ // the frame reaches the terminal.
36
+ // Anything else Pi writes (a stray log line) just clears our line first, so the wave
37
+ // never lands in front of real output.
38
+ //
39
+ // The wave is drawn on STDERR; stdout belongs to the TUI's canvas.
40
+
41
+ import { Worker } from "node:worker_threads";
42
+
43
+ const enabled =
44
+ process.stdout.isTTY &&
45
+ process.stderr.isTTY &&
46
+ !process.env.PRIVATEER_NO_SPLASH &&
47
+ !process.env.CI;
48
+
49
+ // Bytes of stdout after TUI.start() that mean "this is the first frame, not a control
50
+ // sequence". Everything Pi writes between raw mode and the frame is short (the paste
51
+ // toggle, a Kitty protocol query, the cursor hide, an OSC window title — 42 bytes all
52
+ // told on the run this was measured from); the frame itself is thousands.
53
+ const FRAME_BYTES = 200;
54
+
55
+ // Nothing is shown for this long. A launch that fails fast (`--help`, a flag Pi
56
+ // rejects, a broken install) is done well inside it and never sees a wave flash across
57
+ // its output.
58
+ const HOLD_MS = 600;
59
+
60
+ if (enabled) {
61
+ const err = process.stderr;
62
+ const errWrite = err.write.bind(err);
63
+ const outWrite = process.stdout.write.bind(process.stdout);
64
+
65
+ // Shared state, the only channel to the drawing thread. Slots, in order: stop
66
+ // requested / which message to draw / the worker has stopped and will not write
67
+ // again / the worker has drawn at least once (so there is a line to erase and a
68
+ // hidden cursor to restore).
69
+ const STOP = 0, PHASE = 1, ACK = 2, DREW = 3;
70
+ const sab = new SharedArrayBuffer(4 * Int32Array.BYTES_PER_ELEMENT);
71
+ const state = new Int32Array(sab);
72
+
73
+ // Room for " ⚓ " + wave + message + elapsed, clamped so a narrow terminal doesn't
74
+ // wrap (a wrapped line survives our `\r\x1b[K` erase only on its last row).
75
+ const width = Math.max(12, Math.min(28, (err.columns || 80) - 34));
76
+
77
+ // The worker source is plain logic with no escape sequences of its own — every ANSI
78
+ // string is handed over in workerData, so nothing here has to survive two rounds of
79
+ // backslash escaping.
80
+ const worker = new Worker(
81
+ `
82
+ const fs = require("node:fs");
83
+ const { workerData: w } = require("node:worker_threads");
84
+ const s = new Int32Array(w.sab);
85
+ const STOP = 0, PHASE = 1, ACK = 2, DREW = 3;
86
+ const t0 = Date.now();
87
+ let frame = 0;
88
+
89
+ // A swell travelling right to left, in eighth-blocks. Two summed sines at different
90
+ // wavelengths — one sine on its own reads as a metronome. Two-tone rather than a
91
+ // per-cell gradient, so a frame is a handful of escapes and not one per column.
92
+ function wave(phase) {
93
+ let out = "", tone = "";
94
+ for (let x = 0; x < w.width; x++) {
95
+ const y = (Math.sin(x * 0.45 - phase) * 0.65 + Math.sin(x * 0.21 - phase * 0.6) * 0.35 + 1) / 2;
96
+ const i = Math.max(0, Math.min(7, Math.round(y * 7)));
97
+ const want = i >= 4 ? w.crest : w.trough;
98
+ if (want !== tone) { out += want; tone = want; }
99
+ out += w.blocks[i];
100
+ }
101
+ return out + w.off;
102
+ }
103
+
104
+ function draw() {
105
+ const secs = Math.round((Date.now() - t0) / 1000);
106
+ const msg = w.msgs[Atomics.load(s, PHASE)];
107
+ const age = secs >= 3 ? w.dim + " " + secs + "s" + w.off : "";
108
+ fs.writeSync(2, w.cr + " " + w.anchor + " " + wave(frame++ * 0.35) + " " + w.dim + msg + "…" + w.off + age + w.clearEol);
109
+ }
110
+
111
+ // Atomics.wait doubles as the sleep: an exact 80ms tick that the main thread can cut
112
+ // short the instant it needs the line back.
113
+ Atomics.wait(s, STOP, 0, w.hold);
114
+ if (!Atomics.load(s, STOP)) {
115
+ fs.writeSync(2, w.hideCursor);
116
+ Atomics.store(s, DREW, 1);
117
+ while (!Atomics.load(s, STOP)) {
118
+ draw();
119
+ Atomics.wait(s, STOP, 0, 80);
120
+ }
121
+ }
122
+ Atomics.store(s, ACK, 1);
123
+ Atomics.notify(s, ACK);
124
+ `,
125
+ {
126
+ eval: true,
127
+ stdout: false,
128
+ workerData: {
129
+ sab,
130
+ width,
131
+ hold: HOLD_MS,
132
+ blocks: "▁▂▃▄▅▆▇█",
133
+ msgs: ["hoisting sail", "raising the colours"],
134
+ anchor: "\x1b[38;5;69m⚓\x1b[0m",
135
+ crest: "\x1b[38;5;109m",
136
+ trough: "\x1b[38;5;67m",
137
+ dim: "\x1b[2m",
138
+ off: "\x1b[0m",
139
+ cr: "\r",
140
+ clearEol: "\x1b[K",
141
+ hideCursor: "\x1b[?25l",
142
+ },
143
+ },
144
+ );
145
+ worker.unref(); // never the reason this process stays alive
146
+ worker.on("error", () => Atomics.store(state, STOP, 1)); // a splash is never worth a crash
147
+
148
+ let running = true;
149
+ let started = false; // seen TUI.start()
150
+ let bytesAfterStart = 0;
151
+ let appHidCursor = false; // Pi hid the cursor — leave it hidden on the way out
152
+
153
+ // Park the drawing thread and WAIT for it to confirm, so the caller can write to the
154
+ // terminal knowing nothing else will. Without the acknowledgement the worker could
155
+ // land one last frame on top of Pi's first paint.
156
+ function park() {
157
+ Atomics.store(state, STOP, 1);
158
+ Atomics.notify(state, STOP);
159
+ if (!Atomics.load(state, ACK)) Atomics.wait(state, ACK, 0, 50);
160
+ }
161
+
162
+ function clearLine() {
163
+ if (Atomics.load(state, DREW)) errWrite("\r\x1b[K");
164
+ }
165
+
166
+ function stop() {
167
+ if (!running) return;
168
+ running = false;
169
+ park();
170
+ clearLine();
171
+ // Only give the cursor back if Pi hasn't deliberately hidden it — the TUI hides it
172
+ // for the whole session and would never get the chance to hide it again.
173
+ if (Atomics.load(state, DREW) && !appHidCursor) errWrite("\x1b[?25h");
174
+ process.stdout.write = outWrite;
175
+ err.write = errWrite;
176
+ worker.terminate();
177
+ }
178
+
179
+ // ── watch Pi's output ─────────────────────────────────────────────────────
180
+ const size = (chunk) =>
181
+ typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk?.length ?? 0;
182
+ const text = (chunk) =>
183
+ typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("latin1") : "";
184
+
185
+ process.stdout.write = function (chunk, ...rest) {
186
+ if (running) {
187
+ const s = text(chunk);
188
+ if (s.includes("\x1b[?25l")) appHidCursor = true;
189
+ if (started) {
190
+ bytesAfterStart += size(chunk);
191
+ // Erase our line BEFORE the write lands, so Pi's output — a log line now, the
192
+ // first frame in a moment — never has half a wave in front of it.
193
+ if (bytesAfterStart >= FRAME_BYTES) stop();
194
+ else clearLine();
195
+ } else if (s.includes("\x1b[?2004h")) {
196
+ started = true;
197
+ Atomics.store(state, PHASE, 1);
198
+ clearLine();
199
+ } else {
200
+ clearLine();
201
+ }
202
+ }
203
+ return outWrite(chunk, ...rest);
204
+ };
205
+
206
+ // Pi's own warnings go to stderr, on the line we're animating.
207
+ err.write = function (chunk, ...rest) {
208
+ if (running) clearLine();
209
+ return errWrite(chunk, ...rest);
210
+ };
211
+
212
+ process.on("exit", stop);
213
+
214
+ // ── setRawMode EIO ────────────────────────────────────────────────────────
215
+ // The other half of the long silent boot. Pi grabs raw mode ~19s in, and the tcsetattr
216
+ // behind it returns EIO when the terminal is no longer ours to configure — an orphaned
217
+ // process group (the shell that started us has exited), or a controlling terminal that
218
+ // was revoked outright (window or tab closed, ssh dropped, session torn down). A boot
219
+ // that spends half a minute unattended is exactly when that happens. Pi lets the error
220
+ // out as an uncaught exception, so the user's reward for waiting is a native stack
221
+ // trace ending in node:tty. Nothing can rescue the TUI — it has no way to read keys —
222
+ // but it can say what happened in a sentence. (A merely BACKGROUNDED process is a
223
+ // different case: it gets SIGTTOU and stops, and `fg` resumes it as normal.)
224
+ const setRawMode = process.stdin.setRawMode?.bind(process.stdin);
225
+ if (setRawMode) {
226
+ process.stdin.setRawMode = function (mode) {
227
+ try {
228
+ return setRawMode(mode);
229
+ } catch (e) {
230
+ if (e?.code !== "EIO") throw e;
231
+ stop();
232
+ errWrite(
233
+ [
234
+ "",
235
+ " ⚓ Privateer couldn't take the helm — this terminal stopped accepting keyboard",
236
+ " control while the agent was still loading (setRawMode EIO).",
237
+ "",
238
+ " That usually means the window, tab or ssh session it started in went away.",
239
+ ` Run \x1b[1m${process.env.PRIVATEER_CMD || "privateer"}\x1b[0m again from a terminal you're sitting in front of.`,
240
+ "",
241
+ ].join("\n") + "\n",
242
+ );
243
+ process.exit(1);
244
+ }
245
+ };
246
+ }
247
+ }
@@ -0,0 +1,6 @@
1
+ // Types for the `privateer update` argv router. Same reason as apply-patches.d.mts:
2
+ // the implementation is plain .mjs because bin/ runs under a bare `node`, before any
3
+ // transpiler exists — but the tests that pin the grammar are TypeScript.
4
+
5
+ /** Which half of `privateer update` the argv after the subcommand asks for. */
6
+ export function routeUpdate(rest: string[] | undefined): "self" | "packs" | "all" | "help";
@@ -0,0 +1,35 @@
1
+ // Which half of `privateer update` did the user mean?
2
+ //
3
+ // "update" used to mean ONE thing — replace the CLI — and the launcher swallowed every
4
+ // flag after it. So `privateer update --extensions`, the command Pi's own startup banner
5
+ // told people to run (as `pi update --extensions`, naming a binary that is installed
6
+ // nowhere on a Privateer machine), silently reinstalled the CLI instead of fetching the
7
+ // tool pack that had an update waiting. Splitting the verb the way Pi's own grammar
8
+ // already does is the fix; this is the decision, kept apart from the launcher so it can
9
+ // be tested without spawning anything (tests/updates.test.ts).
10
+ //
11
+ // privateer update → "self" (unchanged: the common case)
12
+ // privateer update --self | self | pi → "self"
13
+ // privateer update --extensions → "packs"
14
+ // privateer update <pack> | --extension <pack> → "packs"
15
+ // privateer update --all → "all" (packs, then the CLI)
16
+ //
17
+ // "all" is deliberately not Pi's "--all": to Pi that includes updating pi-coding-agent
18
+ // globally via npm, which is not how this CLI is installed. Ours means tool packs plus
19
+ // `privateer update`, so the launcher translates it and chains the self-update after.
20
+
21
+ /**
22
+ * @param {string[]} rest — argv after the `update` subcommand
23
+ * @returns {"self" | "packs" | "all" | "help"}
24
+ */
25
+ export function routeUpdate(rest) {
26
+ const args = rest ?? [];
27
+ // `update --help` must not fall through to "no pack flags → update the CLI", which
28
+ // would answer a question by reinstalling the program.
29
+ if (args.includes("--help") || args.includes("-h")) return "help";
30
+ if (args.includes("--all")) return "all";
31
+ // A bare word that isn't "self"/"pi" is a pack name (Pi's positional source form).
32
+ const namesPack = args.some((a) => !a.startsWith("-") && a !== "self" && a !== "pi");
33
+ if (args.includes("--extensions") || args.includes("--extension") || namesPack) return "packs";
34
+ return "self";
35
+ }
@@ -37,6 +37,7 @@ import {
37
37
  import { resolveSignedInModel, savedPiDefaultSpec } from "../src/providers/defaultModel.ts";
38
38
  import { canOpenBrowser, openInBrowser } from "../src/util/openBrowser.ts";
39
39
  import { discoverContextFiles, onContextChanged } from "../src/context.ts";
40
+ import { onPackUpdatesChanged, pendingCliUpdate, pendingPackUpdates } from "../src/updates.ts";
40
41
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
41
42
 
42
43
  const VERSION: string = (() => {
@@ -225,32 +226,30 @@ function accountLine(p: Palette, modelProvider?: string): string {
225
226
  return `${p.DIM}not signed in · ${p.INK}/login${p.DIM} to connect your account${p.RESET}`;
226
227
  }
227
228
 
228
- // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
229
- // enough for our npm releases; anything unparseable sorts as 0 and is treated as older.
230
- function isNewer(a: string, b: string): boolean {
231
- const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
232
- const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
233
- for (let i = 0; i < 3; i++) {
234
- if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
235
- if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
236
- }
237
- return false;
238
- }
239
-
240
229
  // The "update available" banner line, or "" when we're current / offline / unchecked.
241
230
  // Reads the cache the launcher refreshes in the background (see bin/privateer-tui) —
242
231
  // never fetches here, so the banner stays synchronous and never blocks on the network.
243
232
  function updateNotice(p: Palette): string {
244
- try {
245
- const home = process.env.PRIVATEER_HOME || join(homedir(), ".privateer");
246
- const { latest } = JSON.parse(readFileSync(join(home, "update-check.json"), "utf8"));
247
- if (typeof latest === "string" && isNewer(latest, VERSION)) {
248
- return `${p.YELLOW}↑ v${latest} available${p.DIM} · run ${p.RESET}${p.INK}privateer update${p.RESET}`;
249
- }
250
- } catch {
251
- // no cache yet, unreadable, or malformed show nothing.
252
- }
253
- return "";
233
+ const latest = pendingCliUpdate(VERSION);
234
+ if (!latest) return "";
235
+ return `${p.YELLOW}↑ v${latest} available${p.DIM} · run ${p.RESET}${p.INK}privateer update${p.RESET}`;
236
+ }
237
+
238
+ // The tool-pack sibling of that line: a pack (an npm/git package contributing extensions,
239
+ // skills, prompts or themes) has a newer version installed-able. Pi's own surface for this
240
+ // was a warning-bordered box below the banner naming a command that doesn't exist here —
241
+ // see extensions/privateer-update.ts, which owns the check and the /update that acts on it.
242
+ // One line, same rule as the release notice: nothing at all when there's nothing to fetch.
243
+ // Empty at startup and fills in when the background check lands (onPackUpdatesChanged
244
+ // re-renders the header), so a slow registry never holds up the banner.
245
+ function packNotice(p: Palette): string {
246
+ const packs = pendingPackUpdates();
247
+ if (packs.length === 0) return "";
248
+ const what =
249
+ packs.length === 1
250
+ ? `update ready for ${clean(packs[0].displayName)}`
251
+ : `${packs.length} tool pack updates ready`;
252
+ return `${p.YELLOW}⚑ ${what}${p.DIM} · ${p.RESET}${p.INK}/update${p.RESET}`;
254
253
  }
255
254
 
256
255
  // The PRIVATEER.md line under the block: green anchor when a project-context file is
@@ -307,6 +306,8 @@ function renderBanner(width: number, p: Palette, mark: string[], modelProvider?:
307
306
  ];
308
307
  const notice = updateNotice(p);
309
308
  if (notice) text.push(notice);
309
+ const packs = packNotice(p);
310
+ if (packs) text.push(packs);
310
311
  // A blank spacer, then the What's New block — set off below the identity lines.
311
312
  text.push("", ...whatsNewRows(p));
312
313
 
@@ -684,6 +685,10 @@ export default function privateerBrand(pi: any): void {
684
685
  // banner so its context line flips from the "/init" hint to "PRIVATEER.md loaded".
685
686
  onContextChanged(() => refresh(ctxRef));
686
687
 
688
+ // privateer-update's background check came back (or an /update cleared the list) —
689
+ // re-render so the ⚑ line appears or disappears without waiting for the next event.
690
+ onPackUpdatesChanged(() => refresh(ctxRef));
691
+
687
692
  // The terminal is quitting (Ctrl+C, Ctrl+D, /quit, SIGTERM …). Pi awaits this
688
693
  // handler inside runtimeHost.dispose() BEFORE process.exit, so it's our one
689
694
  // reliable window to revoke the server-side sessions this run created — the
@@ -0,0 +1,187 @@
1
+ // `/update` — fetch newer tool packs without leaving the terminal.
2
+ //
3
+ // A "tool pack" is what Pi calls a package: an npm or git source that contributes
4
+ // extensions, skills, prompts or themes (pi-hermes-memory, a private tools repo, …).
5
+ // Pi already checked for updates at startup and drew its own yellow box about it. We
6
+ // suppress that box (patches/, in interactive-mode's run()) and own the surface here,
7
+ // for two reasons:
8
+ //
9
+ // 1. THE BOX NAMED A COMMAND THAT DOES NOT EXIST. It said "Run pi update --extensions".
10
+ // No `pi` binary is installed on a Privateer machine, and `privateer update` means
11
+ // the CLI's own self-update — so the one instruction on screen either failed with
12
+ // "command not found" or reinstalled the wrong thing. The launcher now routes
13
+ // `privateer update --extensions` properly (bin/privateer-launch.mjs), and in the
14
+ // TUI the answer is simply /update.
15
+ // 2. IT WAS THE WRONG SIZE. A warning-bordered box under the banner, for "a pack has a
16
+ // newer version". Privateer's own release notice is a single line INSIDE the banner;
17
+ // packs get a line beside it (extensions/privateer-brand.ts) and nothing more.
18
+ //
19
+ // WHY THIS CAN HAPPEN LIVE. Nothing here needs a restart, and that is a property of Pi's
20
+ // own machinery rather than a trick of ours:
21
+ // - DefaultPackageManager.update() installs into the same on-disk locations the
22
+ // resolver reads from, so the new code is simply *there* when something looks again.
23
+ // - ctx.reload() runs the same path as /reload: resourceLoader.reload() re-runs
24
+ // packageManager.resolve() AND calls clearExtensionCache(), and extensions are loaded
25
+ // through jiti with moduleCache:false — so every extension file is re-read from disk,
26
+ // not served from a module cache. Keybindings, skills, prompts and themes come back
27
+ // with it, and the chat is rebuilt from the session's messages, so scrollback and
28
+ // context survive.
29
+ // The honest caveat: in Node (not the bundle) jiti leaves `tryNative` on, so a dependency
30
+ // DEEP inside an updated npm pack that Node already loaded natively can stay the old copy
31
+ // in memory. The pack's own source always reloads; if something still looks stale after
32
+ // an update, a restart is the guaranteed fix, and we say so rather than pretending.
33
+ //
34
+ // The CLI itself is the one thing that genuinely cannot be swapped live — updating it
35
+ // replaces the program that is running — so /update reports it and points at the shell.
36
+
37
+ import { DefaultPackageManager, SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
38
+ import { agentVersion } from "../src/config/version.ts";
39
+ import {
40
+ type PackUpdate,
41
+ pendingCliUpdate,
42
+ pendingPackUpdates,
43
+ setPendingPackUpdates,
44
+ } from "../src/updates.ts";
45
+
46
+ // A check costs an npm/git round trip per configured pack. Once at startup is the point;
47
+ // the extra checks come from reload (including the one our own /update triggers), where
48
+ // re-asking the registry seconds after we already know the answer is pure waste.
49
+ const RECHECK_AFTER_MS = 60_000;
50
+ let lastCheckedAt = 0;
51
+
52
+ // Build a package manager against THIS session's cwd and trust decision. Mirrors what
53
+ // interactive-mode does for its own startup check: an untrusted project's packages must
54
+ // stay out, and trust is the session's answer (ctx.isProjectTrusted), not a fresh guess.
55
+ function packageManagerFor(ctx: any): any {
56
+ const cwd = ctx?.cwd ?? process.cwd();
57
+ const agentDir = getAgentDir();
58
+ const settingsManager = SettingsManager.create(cwd, agentDir, {
59
+ projectTrusted: ctx?.isProjectTrusted?.() ?? false,
60
+ });
61
+ return new DefaultPackageManager({ cwd, agentDir, settingsManager });
62
+ }
63
+
64
+ async function runCheck(ctx: any, opts: { force?: boolean } = {}): Promise<readonly PackUpdate[]> {
65
+ if (!opts.force && Date.now() - lastCheckedAt < RECHECK_AFTER_MS) return pendingPackUpdates();
66
+ // --offline / PI_OFFLINE means "no startup network", and a registry check is exactly
67
+ // that. Pi's own check bails the same way.
68
+ if (process.env.PI_OFFLINE) return pendingPackUpdates();
69
+ lastCheckedAt = Date.now();
70
+ const updates = await packageManagerFor(ctx).checkForAvailableUpdates();
71
+ const packs: PackUpdate[] = updates.map((u: any) => ({
72
+ source: u.source,
73
+ displayName: u.displayName,
74
+ type: u.type,
75
+ scope: u.scope,
76
+ }));
77
+ setPendingPackUpdates(packs);
78
+ return packs;
79
+ }
80
+
81
+ // "pi-hermes-memory", "pi-hermes-memory and pi-speak", "pi-hermes-memory, pi-speak and 2 more".
82
+ function nameList(packs: readonly PackUpdate[]): string {
83
+ const names = packs.map((p) => p.displayName);
84
+ if (names.length <= 2) return names.join(" and ");
85
+ return `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
86
+ }
87
+
88
+ // The CLI half of "what's out of date", appended to /update's replies so the answer to
89
+ // "am I current?" is complete in one place. Null when the CLI is current or unchecked.
90
+ function cliNote(): string | null {
91
+ const latest = pendingCliUpdate(agentVersion());
92
+ return latest
93
+ ? `Privateer v${latest} is out too — that one replaces the running program, so run \`privateer update\` from a shell.`
94
+ : null;
95
+ }
96
+
97
+ export default function privateerUpdate(pi: any): void {
98
+ // Check in the background on startup and after a reload. Never on a headless surface
99
+ // (harbor, ACP, print, channels): there's no banner to flag it on and nobody to type
100
+ // /update, so the network call would buy nothing.
101
+ pi.on("session_start", (event: any, ctx: any) => {
102
+ if (!ctx?.hasUI) return;
103
+ if (event?.reason !== "startup" && event?.reason !== "reload") return;
104
+ void runCheck(ctx).catch(() => {
105
+ // Offline, a private registry that won't answer, a git remote behind a VPN — an
106
+ // update check is never worth an error in the user's face. The flag just stays down.
107
+ });
108
+ });
109
+
110
+ pi.registerCommand?.("update", {
111
+ description: "Fetch tool pack updates in place: /update [<pack> | check]",
112
+ handler: async (args: string, ctx: any) => {
113
+ const arg = String(args ?? "").trim();
114
+ const notify = (msg: string, kind: "info" | "warning" | "error" = "info") =>
115
+ ctx?.ui?.notify?.(msg, kind);
116
+
117
+ if (arg === "check") {
118
+ try {
119
+ const packs = await runCheck(ctx, { force: true });
120
+ const cli = cliNote();
121
+ notify(
122
+ packs.length
123
+ ? `${packs.length} tool pack update${packs.length === 1 ? "" : "s"} ready: ${nameList(packs)} — /update to fetch${cli ? `. ${cli}` : ""}`
124
+ : `Tool packs are current.${cli ? ` ${cli}` : ""}`,
125
+ );
126
+ } catch (err) {
127
+ notify(`Update check failed: ${err instanceof Error ? err.message : String(err)}`, "error");
128
+ }
129
+ return;
130
+ }
131
+
132
+ // A named pack is passed straight through to the package manager, which matches it
133
+ // against configured sources and reports a better "no such pack" than we could.
134
+ // With no argument we act on what the check found, re-checking first if it never
135
+ // ran (a terminal that started offline, or a pack installed since launch).
136
+ let targets = pendingPackUpdates();
137
+ if (!arg) {
138
+ try {
139
+ targets = await runCheck(ctx, { force: pendingPackUpdates().length === 0 });
140
+ } catch {
141
+ // fall through with whatever the last check knew — update() re-checks anyway.
142
+ }
143
+ if (targets.length === 0) {
144
+ const cli = cliNote();
145
+ notify(`Tool packs are current.${cli ? ` ${cli}` : ""}`);
146
+ return;
147
+ }
148
+ }
149
+
150
+ const label = arg || nameList(targets);
151
+ ctx?.ui?.setStatus?.("update", `⚑ updating ${arg || `${targets.length} pack${targets.length === 1 ? "" : "s"}`}`);
152
+ try {
153
+ const pm = packageManagerFor(ctx);
154
+ // Progress goes to the footer, not the transcript: an install emits several
155
+ // events per pack and each one as a notification would bury the chat.
156
+ pm.setProgressCallback((e: any) => {
157
+ if (e?.type === "error") return; // update() throws; the catch below reports it
158
+ ctx?.ui?.setStatus?.("update", `⚑ ${e?.action ?? "update"} ${e?.source ?? ""}`.trimEnd());
159
+ });
160
+ await pm.update(arg || undefined);
161
+ pm.setProgressCallback(undefined);
162
+ } catch (err) {
163
+ ctx?.ui?.setStatus?.("update", undefined);
164
+ notify(`Update failed: ${err instanceof Error ? err.message : String(err)}`, "error");
165
+ return;
166
+ }
167
+ ctx?.ui?.setStatus?.("update", undefined);
168
+ // Whatever was pending is on disk now. Clear the flag before the reload so the
169
+ // banner that comes back is already correct, rather than flying a stale one until
170
+ // the post-reload check answers.
171
+ setPendingPackUpdates([]);
172
+ lastCheckedAt = 0;
173
+
174
+ // The reload is refused mid-turn (Pi guards on streaming/compaction), so don't ask
175
+ // for one — say plainly that the new code is on disk and waiting.
176
+ if (ctx?.isIdle?.() === false) {
177
+ notify(`Updated ${label}. Run /reload when this turn finishes to load it.`);
178
+ return;
179
+ }
180
+
181
+ notify(`Updated ${label} — reloading.`);
182
+ // ⚠️ ctx is STALE after this line (Pi invalidates it when the runtime is replaced).
183
+ // Nothing may touch it, so this is the last statement in the handler.
184
+ await ctx.reload();
185
+ },
186
+ });
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -904,8 +904,76 @@ index 3355a47..8c6bba9 100644
904
904
  }
905
905
  getResourcePattern(item) {
906
906
  const scope = item.metadata.scope;
907
+ diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
908
+ index 68d0308..f7208f1 100644
909
+ --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
910
+ +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
911
+ @@ -1,5 +1,5 @@
912
+ import { isAbsolute, relative, resolve, sep } from "node:path";
913
+ -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
914
+ +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
915
+ import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
916
+ import { theme } from "../theme/theme.js";
917
+ /**
918
+ @@ -13,6 +13,43 @@ function sanitizeStatusText(text) {
919
+ .replace(/ +/g, " ")
920
+ .trim();
921
+ }
922
+ +/**
923
+ + * Lay extension statuses out over as many lines as the terminal needs, keeping
924
+ + * each status whole. Upstream joined them into ONE line and truncated it to the
925
+ + * width, which silently hid whatever didn't fit — and what didn't fit was often
926
+ + * the loudest indicator on screen (Privateer's "permission gate OFF" flag). A
927
+ + * status too wide to fit on a line of its own is wrapped, never clipped.
928
+ + */
929
+ +function packStatusLines(statuses, width) {
930
+ + if (width <= 0)
931
+ + return [];
932
+ + const lines = [];
933
+ + let current = "";
934
+ + let currentWidth = 0;
935
+ + const flush = () => {
936
+ + if (current)
937
+ + lines.push(current);
938
+ + current = "";
939
+ + currentWidth = 0;
940
+ + };
941
+ + for (const status of statuses) {
942
+ + if (!status)
943
+ + continue;
944
+ + const statusWidth = visibleWidth(status);
945
+ + if (statusWidth > width) {
946
+ + flush();
947
+ + lines.push(...wrapTextWithAnsi(status, width));
948
+ + continue;
949
+ + }
950
+ + const separator = current ? 1 : 0;
951
+ + if (currentWidth + separator + statusWidth > width)
952
+ + flush();
953
+ + current = current ? `${current} ${status}` : status;
954
+ + currentWidth += separator + statusWidth;
955
+ + }
956
+ + flush();
957
+ + return lines;
958
+ +}
959
+ /**
960
+ * Format token counts for compact footer display.
961
+ */
962
+ @@ -211,9 +248,9 @@ export class FooterComponent {
963
+ const sortedStatuses = Array.from(extensionStatuses.entries())
964
+ .sort(([a], [b]) => a.localeCompare(b))
965
+ .map(([, text]) => sanitizeStatusText(text));
966
+ - const statusLine = sortedStatuses.join(" ");
967
+ - // Truncate to terminal width with dim ellipsis for consistency with footer style
968
+ - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
969
+ + // Wrap onto extra lines instead of truncating: a status is there because
970
+ + // something wants to be seen, so none of them may be cut off.
971
+ + lines.push(...packStatusLines(sortedStatuses, width));
972
+ }
973
+ return lines;
974
+ }
907
975
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
908
- index 5d65200..f827cd7 100644
976
+ index 5d65200..397a7b7 100644
909
977
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
910
978
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
911
979
  @@ -10,7 +10,7 @@ import { getProviders, } from "@earendil-works/pi-ai/compat";
@@ -948,7 +1016,28 @@ index 5d65200..f827cd7 100644
948
1016
  .map((command) => ({
949
1017
  type: "warning",
950
1018
  message: command.invocationName === command.name
951
- @@ -2042,7 +2053,17 @@ export class InteractiveMode {
1019
+ @@ -549,12 +560,14 @@ export class InteractiveMode {
1020
+ this.showNewVersionNotification(newRelease);
1021
+ }
1022
+ });
1023
+ - // Start package update check asynchronously
1024
+ - this.checkForPackageUpdates().then((updates) => {
1025
+ - if (updates.length > 0) {
1026
+ - this.showPackageUpdateNotification(updates);
1027
+ - }
1028
+ - });
1029
+ + // Privateer owns the package ("tool pack") update surface — see
1030
+ + // extensions/privateer-update.ts. Upstream ran its own check here and drew a
1031
+ + // warning-bordered box telling the user to run `pi update --extensions`: a
1032
+ + // binary that is installed nowhere on a Privateer machine, so the only
1033
+ + // instruction on screen could not be followed. We replace it with a single
1034
+ + // line in the startup banner plus /update, which applies packs in place. The
1035
+ + // CHECK is removed too, not just the box, so this costs no registry round
1036
+ + // trip — the extension does exactly one, on the same startup.
1037
+ // Check tmux keyboard setup asynchronously
1038
+ this.checkTmuxKeyboardSetup().then((warning) => {
1039
+ if (warning) {
1040
+ @@ -2042,7 +2055,17 @@ export class InteractiveMode {
952
1041
  if (text === "/model" || text.startsWith("/model ")) {
953
1042
  const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
954
1043
  this.editor.setText("");
@@ -967,7 +1056,7 @@ index 5d65200..f827cd7 100644
967
1056
  return;
968
1057
  }
969
1058
  if (text === "/export" || text.startsWith("/export ")) {
970
- @@ -2105,14 +2126,44 @@ export class InteractiveMode {
1059
+ @@ -2105,14 +2128,44 @@ export class InteractiveMode {
971
1060
  this.editor.setText("");
972
1061
  return;
973
1062
  }
@@ -1015,7 +1104,7 @@ index 5d65200..f827cd7 100644
1015
1104
  return;
1016
1105
  }
1017
1106
  if (text === "/new") {
1018
- @@ -2650,7 +2701,7 @@ export class InteractiveMode {
1107
+ @@ -2650,7 +2703,7 @@ export class InteractiveMode {
1019
1108
  if (this.chatContainer.children.length > 0) {
1020
1109
  this.chatContainer.addChild(new Spacer(1));
1021
1110
  }
@@ -1055,71 +1144,3 @@ index f81b4b4..9712a37 100644
1055
1144
  -a, --approve Trust project-local files for this command
1056
1145
  -na, --no-approve Ignore project-local files for this command
1057
1146
 
1058
- diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1059
- index 68d0308..f7208f1 100644
1060
- --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1061
- +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
1062
- @@ -1,5 +1,5 @@
1063
- import { isAbsolute, relative, resolve, sep } from "node:path";
1064
- -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1065
- +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
1066
- import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
1067
- import { theme } from "../theme/theme.js";
1068
- /**
1069
- @@ -13,6 +13,43 @@ function sanitizeStatusText(text) {
1070
- .replace(/ +/g, " ")
1071
- .trim();
1072
- }
1073
- +/**
1074
- + * Lay extension statuses out over as many lines as the terminal needs, keeping
1075
- + * each status whole. Upstream joined them into ONE line and truncated it to the
1076
- + * width, which silently hid whatever didn't fit — and what didn't fit was often
1077
- + * the loudest indicator on screen (Privateer's "permission gate OFF" flag). A
1078
- + * status too wide to fit on a line of its own is wrapped, never clipped.
1079
- + */
1080
- +function packStatusLines(statuses, width) {
1081
- + if (width <= 0)
1082
- + return [];
1083
- + const lines = [];
1084
- + let current = "";
1085
- + let currentWidth = 0;
1086
- + const flush = () => {
1087
- + if (current)
1088
- + lines.push(current);
1089
- + current = "";
1090
- + currentWidth = 0;
1091
- + };
1092
- + for (const status of statuses) {
1093
- + if (!status)
1094
- + continue;
1095
- + const statusWidth = visibleWidth(status);
1096
- + if (statusWidth > width) {
1097
- + flush();
1098
- + lines.push(...wrapTextWithAnsi(status, width));
1099
- + continue;
1100
- + }
1101
- + const separator = current ? 1 : 0;
1102
- + if (currentWidth + separator + statusWidth > width)
1103
- + flush();
1104
- + current = current ? `${current} ${status}` : status;
1105
- + currentWidth += separator + statusWidth;
1106
- + }
1107
- + flush();
1108
- + return lines;
1109
- +}
1110
- /**
1111
- * Format token counts for compact footer display.
1112
- */
1113
- @@ -211,9 +248,9 @@ export class FooterComponent {
1114
- const sortedStatuses = Array.from(extensionStatuses.entries())
1115
- .sort(([a], [b]) => a.localeCompare(b))
1116
- .map(([, text]) => sanitizeStatusText(text));
1117
- - const statusLine = sortedStatuses.join(" ");
1118
- - // Truncate to terminal width with dim ellipsis for consistency with footer style
1119
- - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
1120
- + // Wrap onto extra lines instead of truncating: a status is there because
1121
- + // something wants to be seen, so none of them may be cut off.
1122
- + lines.push(...packStatusLines(sortedStatuses, width));
1123
- }
1124
- return lines;
1125
- }
@@ -12,6 +12,7 @@
12
12
  { "name": "privateer-connect", "entry": "extensions/privateer-connect.ts", "note": "/connect — MCP connector manager" },
13
13
  { "name": "privateer-media", "entry": "extensions/privateer-media.ts", "note": "image/video/speech/music + ffmpeg compose" },
14
14
  { "name": "privateer-hints", "entry": "extensions/privateer-hints.ts", "note": "rotating tips in the working line + /hints" },
15
+ { "name": "privateer-update", "entry": "extensions/privateer-update.ts", "note": "tool pack updates in place — banner flag + /update" },
15
16
  { "name": "privateer-speak", "entry": "extensions/privateer-speak.ts", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
16
17
  { "name": "rpiv-web-tools", "dep": ["@juicesharp/rpiv-web-tools", "index.ts"], "note": "private web tools (user's own provider key)" },
17
18
  { "name": "rpiv-ask-user-question", "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "note": "ask_user_question" },
package/src/context.ts CHANGED
@@ -153,8 +153,16 @@ export function writeTemplate(dir: string = process.cwd()): WriteResult {
153
153
  // Lets /init (in the context extension) tell the banner (in the brand extension) that
154
154
  // PRIVATEER.md state changed, so the header re-renders its loaded/hint line immediately —
155
155
  // without either extension importing the other. Mirrors priv.onSignedIn.
156
+ //
157
+ // ⚠️ The listener set CANNOT be plain module state. Pi gives every extension its own jiti
158
+ // instance with moduleCache:false, so this module is instantiated once per extension that
159
+ // imports it: /init's copy and the banner's copy are different objects, and an emit on one
160
+ // never reaches a listener registered on the other — the refresh silently did nothing.
161
+ // globalThis is the one thing the two copies share. Same fix, same reason, as the pack
162
+ // state in src/updates.ts.
156
163
  type Listener = () => void;
157
- const listeners = new Set<Listener>();
164
+ const LISTENERS = Symbol.for("privateer.context.listeners");
165
+ const listeners: Set<Listener> = (((globalThis as any)[LISTENERS] ??= new Set<Listener>()) as Set<Listener>);
158
166
 
159
167
  export function onContextChanged(fn: Listener): void {
160
168
  listeners.add(fn);
package/src/updates.ts ADDED
@@ -0,0 +1,106 @@
1
+ // What's out of date — the two kinds of update a Privateer terminal can have pending,
2
+ // owned in one place so every surface gives the same answer.
3
+ //
4
+ // 1. THE CLI ITSELF (privateer-agent). Read from the cache the launcher refreshes in
5
+ // the background at most ~daily (bin/privateer-launch.mjs refreshUpdateCache). We
6
+ // never fetch here, so the banner stays synchronous and offline-safe. Applying it
7
+ // REPLACES the running program, so it can only ever be `privateer update` from a
8
+ // shell — never live.
9
+ // 2. TOOL PACKS (Pi "packages": extensions/skills/prompts/themes installed from npm or
10
+ // git). Checked in-process by extensions/privateer-update.ts, held here in memory,
11
+ // and applied live — see that file for why a running terminal can swap them out.
12
+ //
13
+ // The two consumers are different extensions (the banner draws the pending state, /update
14
+ // acts on it), so the pack list gets the same listener idiom as src/context.ts's
15
+ // onContextChanged: a setter fires listeners, and neither extension imports the other.
16
+ // In memory rather than on disk ON PURPOSE — a cache file outlives the update that
17
+ // cleared it, and a banner still flying the flag after you've fetched everything is worse
18
+ // than one that shows up a second late.
19
+ //
20
+ // IMPORT-SAFETY: no Pi imports, no side effects — safe to load from a jiti-loaded
21
+ // extension and from pre-boot code alike.
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { globalDir } from "./config/paths.ts";
26
+
27
+ // ── the CLI release ──────────────────────────────────────────────────────────
28
+
29
+ // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
30
+ // enough for our npm releases; anything unparseable sorts as 0 and is treated as older.
31
+ function isNewer(a: string, b: string): boolean {
32
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
33
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
34
+ for (let i = 0; i < 3; i++) {
35
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
36
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * The newer privateer-agent release the launcher's cache knows about, or null when we're
43
+ * current / offline / never checked. Reads only — the refresh is the launcher's job.
44
+ */
45
+ export function pendingCliUpdate(current: string): string | null {
46
+ try {
47
+ const { latest } = JSON.parse(readFileSync(join(globalDir(), "update-check.json"), "utf8"));
48
+ if (typeof latest === "string" && isNewer(latest, current)) return latest;
49
+ } catch {
50
+ // no cache yet, unreadable, or malformed — nothing pending as far as we know.
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // ── tool packs ───────────────────────────────────────────────────────────────
56
+
57
+ /** One installed pack with a newer version available. Mirrors Pi's PackageUpdate. */
58
+ export interface PackUpdate {
59
+ /** The configured source string ("pi-hermes-memory", "github:owner/repo", …). */
60
+ source: string;
61
+ /** What to show a human — Pi's own label for the pack. */
62
+ displayName: string;
63
+ type: "npm" | "git";
64
+ scope: "user" | "project";
65
+ }
66
+
67
+ type Listener = () => void;
68
+
69
+ // ⚠️ MODULE STATE IS NOT SHARED BETWEEN EXTENSIONS. Pi loads every extension through its
70
+ // OWN jiti instance with moduleCache:false (core/extensions/loader.js), so a module two
71
+ // extensions both import is instantiated TWICE — a plain module-level `let` here would
72
+ // leave the banner reading a different copy from the one /update writes, and the flag
73
+ // would never appear. Verified rather than assumed: two jiti instances importing the same
74
+ // relative module see completely independent state, and a globalThis-keyed store is what
75
+ // crosses between them. Symbol.for so both copies resolve the same key.
76
+ const STATE = Symbol.for("privateer.updates.packs");
77
+ interface PackState {
78
+ pending: readonly PackUpdate[];
79
+ listeners: Set<Listener>;
80
+ }
81
+ const state: PackState = (((globalThis as any)[STATE] ??= {
82
+ pending: [],
83
+ listeners: new Set<Listener>(),
84
+ }) as PackState);
85
+
86
+ /** Packs with an update waiting, as of the last check. Empty until the check lands. */
87
+ export function pendingPackUpdates(): readonly PackUpdate[] {
88
+ return state.pending;
89
+ }
90
+
91
+ /** Record the result of a check (or of an update that cleared the list) and notify. */
92
+ export function setPendingPackUpdates(next: readonly PackUpdate[]): void {
93
+ state.pending = [...next];
94
+ for (const fn of state.listeners) {
95
+ try {
96
+ fn();
97
+ } catch {
98
+ // a broken listener must not break the check that called us.
99
+ }
100
+ }
101
+ }
102
+
103
+ /** Called whenever the pending list changes — the banner re-renders from it. */
104
+ export function onPackUpdatesChanged(fn: Listener): void {
105
+ state.listeners.add(fn);
106
+ }