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 +21 -0
- package/README.md +3 -2
- package/bin/privateer-acp.mjs +2 -2
- package/bin/privateer-harbor.mjs +2 -2
- package/bin/privateer-launch.mjs +77 -7
- package/bin/privateer-splash.mjs +247 -0
- package/bin/privateer.mjs +9 -2
- 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-connect.ts +22 -2
- package/extensions/privateer-spawn-skills.ts +39 -0
- package/extensions/privateer-update.ts +187 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +93 -4
- package/src/acp/run.ts +1 -1
- package/src/channels/run.ts +1 -1
- package/src/cli/chat.ts +23 -8
- package/src/config/moatManifest.json +1 -0
- package/src/config/spawns.ts +187 -0
- package/src/context.ts +9 -1
- package/src/ext/permissionGate.ts +8 -1
- package/src/harbor/ipc.ts +27 -1
- package/src/mcp/catalog.ts +67 -8
- package/src/permissions/modeGate.ts +26 -7
- package/src/providers/account.ts +5 -4
- package/src/providers/defaultModel.ts +36 -15
- package/src/providers/modelCatalog.ts +115 -0
- package/src/remote/skillsControl.ts +17 -0
- package/src/updates.ts +106 -0
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
|
-
|
|
574
|
-
|
|
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
|
package/bin/privateer-acp.mjs
CHANGED
|
@@ -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();
|
package/bin/privateer-harbor.mjs
CHANGED
|
@@ -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));
|
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
|
|
@@ -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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
:
|
|
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
|
|