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.
- package/bin/privateer-launch.mjs +75 -5
- package/bin/privateer-splash.mjs +247 -0
- package/bin/update-route.d.mts +6 -0
- package/bin/update-route.mjs +35 -0
- package/extensions/privateer-brand.ts +27 -22
- package/extensions/privateer-update.ts +187 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +93 -72
- package/src/config/moatManifest.json +1 -0
- package/src/context.ts +9 -1
- package/src/updates.ts +106 -0
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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.
|
|
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..
|
|
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
|
-
@@ -
|
|
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 +
|
|
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 +
|
|
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
|
|
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
|
+
}
|