privateer-agent 0.12.6 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hayden Bulk Tech Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -570,8 +570,9 @@ posts a warning to the transcript, and while it's on the footer carries a perman
570
570
  Toggling takes effect from the next gated action — an approval already on screen still
571
571
  needs an answer. It's a physical-terminal switch: a phone driving this terminal over
572
572
  `/remote-access` can't reach it. The app has its own no-quarter toggle for driven turns,
573
- which is `bypass` exactly never less so dangerous shell and destructive actions
574
- still surface an Allow there, precisely as they do under `/mode bypass` locally.
573
+ and it means the same thing this flag does: the moat down, dangerous shell and destructive
574
+ actions included stronger than `/mode bypass`, which keeps those two above it. A hard
575
+ plan-mode deny is the one thing it doesn't talk around.
575
576
 
576
577
  > shift+tab is Pi's default "cycle thinking level" chord; Privateer takes it for this.
577
578
  > Thinking level is still under `/settings`, or bind `app.thinking.cycle` to another key
@@ -18,7 +18,7 @@
18
18
  // process.loadEnvFile and tsx's register() are both silent; keep it that way, and
19
19
  // send any diagnostic you add to stderr.
20
20
  import { register } from "tsx/esm/api";
21
- import { fileURLToPath } from "node:url";
21
+ import { fileURLToPath, pathToFileURL } from "node:url";
22
22
  import { dirname, resolve } from "node:path";
23
23
 
24
24
  const here = dirname(fileURLToPath(import.meta.url));
@@ -36,5 +36,5 @@ try {
36
36
  process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
37
37
 
38
38
  register();
39
- const { runAcp } = await import(resolve(repo, "src/acp/run.ts"));
39
+ const { runAcp } = await import(pathToFileURL(resolve(repo, "src/acp/run.ts")).href);
40
40
  await runAcp();
@@ -7,7 +7,7 @@
7
7
  // Invoked two ways: interactively via the bash launcher (`privateer harbor …`), and
8
8
  // by the installed launchd/systemd service (`node privateer-harbor.mjs run`).
9
9
  import { register } from "tsx/esm/api";
10
- import { fileURLToPath } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { dirname, resolve } from "node:path";
12
12
 
13
13
  const here = dirname(fileURLToPath(import.meta.url));
@@ -26,5 +26,5 @@ try {
26
26
  process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
27
27
 
28
28
  register();
29
- const { runHarborCli } = await import(resolve(repo, "src/cli/harborCli.ts"));
29
+ const { runHarborCli } = await import(pathToFileURL(resolve(repo, "src/cli/harborCli.ts")).href);
30
30
  await runHarborCli(process.argv.slice(2));
@@ -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
@@ -341,11 +402,11 @@ else {
341
402
  const signedIn = fs.existsSync(CRED);
342
403
  // Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
343
404
  // file carries the measurements behind the choice.
344
- const ACCOUNT_MODEL = "privateer/tinfoil/kimi-k2-6";
405
+ const ACCOUNT_MODEL = "privateer/tinfoil/gpt-oss-120b";
345
406
  const MODEL = process.env.PRIVATEER_MODEL
346
407
  ? process.env.PRIVATEER_MODEL
347
408
  : haveTinfoilKey()
348
- ? "tinfoil/kimi-k2-6"
409
+ ? "tinfoil/gpt-oss-120b"
349
410
  : signedIn
350
411
  ? ACCOUNT_MODEL
351
412
  : haveKey("ANTHROPIC_API_KEY")
@@ -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
+ }
package/bin/privateer.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // code and dev keys from the repo. Prefer the `bin/pv` wrapper, which also picks a
5
5
  // Node >= 22 (the Pi stack's floor).
6
6
  import { register } from "tsx/esm/api";
7
- import { fileURLToPath } from "node:url";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { dirname, resolve } from "node:path";
9
9
 
10
10
  const here = dirname(fileURLToPath(import.meta.url));
@@ -19,4 +19,11 @@ try {
19
19
  }
20
20
 
21
21
  register(); // resolves the repo's tsx regardless of the invocation cwd
22
- await import(resolve(repo, "src/cli/chat.ts"));
22
+ // pathToFileURL, NOT the bare path: on Windows an absolute path starts with a
23
+ // drive letter, and dynamic import() reads "D:\..." as the URL scheme "d:" —
24
+ // ERR_UNSUPPORTED_ESM_URL_SCHEME, before a single line of ours runs. POSIX
25
+ // absolute paths happen to work, which is exactly why this survived so long:
26
+ // every launcher here had it, and `--version` is intercepted upstream in
27
+ // privateer-launch.mjs, so the Windows smoke test booted fine while the actual
28
+ // command was dead. Pinned by tests/launcherImports.test.ts.
29
+ await import(pathToFileURL(resolve(repo, "src/cli/chat.ts")).href);
@@ -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
@@ -297,6 +297,20 @@ function pendingFromCatalog(e: CatalogEntry): Pending {
297
297
  hint: `Replaces the placeholder ${e.fill}`,
298
298
  initial: e.fill,
299
299
  });
300
+ } else if (e.needs === "url") {
301
+ // A server hosted by an app already running HERE. Nothing to paste, but the
302
+ // endpoint is the one thing that can differ per machine (Unreal's port and path
303
+ // are editable in Editor Preferences), and a wrong port is a connector that
304
+ // saves cleanly and never answers — the failure this catalog exists to avoid.
305
+ // Pre-filled with the documented default, so <enter> is the common case.
306
+ steps.push({
307
+ key: "url",
308
+ prompt: "Server URL",
309
+ hint: `Default is ${e.url} — change it only if you moved it.`,
310
+ initial: e.url,
311
+ validate: (v) =>
312
+ /^https?:\/\//i.test(v.trim()) ? undefined : "That needs to be an http:// or https:// URL.",
313
+ });
300
314
  }
301
315
  return {
302
316
  title: e.label,
@@ -307,12 +321,17 @@ function pendingFromCatalog(e: CatalogEntry): Pending {
307
321
  for (const [k, v] of Object.entries(answers)) {
308
322
  if (k.startsWith("env:")) env[k.slice(4)] = v;
309
323
  }
310
- return draftFromCatalog(e, { env, fill: answers.fill });
324
+ return draftFromCatalog(e, { env, fill: answers.fill, url: answers.url });
311
325
  },
312
326
  note:
313
327
  e.needs === "oauth"
314
328
  ? `Authorize it in a browser on THIS machine: /mcp-auth ${e.name}`
315
- : undefined,
329
+ : e.localHttp
330
+ // Not a warning — a localHttp connector is correct the moment it saves. But
331
+ // it answers only while its host app is up, so a later failure means "the
332
+ // app is closed", and saying that now beats debugging it then.
333
+ ? `Works whenever ${e.label} is running on this machine. Nothing to authorize.`
334
+ : undefined,
316
335
  };
317
336
  }
318
337
 
@@ -329,6 +348,7 @@ const NEEDS_LABEL: Record<string, string> = {
329
348
  token: "needs a token",
330
349
  path: "needs a path",
331
350
  oauth: "browser sign-in",
351
+ url: "confirm the URL",
332
352
  none: "no setup",
333
353
  };
334
354