privateer-agent 0.12.7 → 0.12.10

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/README.md CHANGED
@@ -12,9 +12,6 @@
12
12
  <a href="https://www.npmjs.com/package/privateer-agent">
13
13
  <img src="https://img.shields.io/npm/v/privateer-agent" alt="npm" />
14
14
  </a>
15
- <a href="https://www.npmjs.com/package/privateer-agent">
16
- <img src="https://img.shields.io/npm/dm/privateer-agent" alt="npm downloads" />
17
- </a>
18
15
  <a href="https://github.com/privateer-agent/privateer-agent/releases">
19
16
  <img src="https://img.shields.io/badge/changelog-what's%20new-5b8def" alt="Changelog" />
20
17
  </a>
@@ -125,7 +122,7 @@ silently. The moat is swappable; the floor under it holds.
125
122
  shots stay continuous, cut them together, then score and narrate the result. Generated media
126
123
  is handed straight back as files on your machine; none of it is stored in our cloud. See
127
124
  [docs/media-generation.md](docs/media-generation.md).
128
- - **Talk to it.** `/speak on` reads answers aloud as they're written; **ctrl+x** is push-to-talk
125
+ - **Talk to it.** `/speak on` reads answers aloud as they're written; **alt+t** is push-to-talk
129
126
  and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
130
127
  machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
131
128
  makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
@@ -187,9 +184,31 @@ npm view privateer-agent dist.attestations # published from CI with npm proven
187
184
  npm audit signatures # verify registry signatures + provenance
188
185
  ```
189
186
 
190
- The package also declares **no install scripts** no `postinstall`, nothing. Installing
191
- it writes files and executes nothing; `npm install -g privateer-agent --ignore-scripts`
192
- gives an identical result. Code runs only when you run `privateer`.
187
+ And **after** installing the check nobody else offers, because every signal above is
188
+ an install-time one:
189
+
190
+ ```bash
191
+ privateer verify # is the install on THIS disk still the one we published?
192
+ ```
193
+
194
+ It reports the install shape, whether this exact version is published and carries a
195
+ provenance attestation, whether any dependency has drifted from its pinned version,
196
+ and which launch-time patches are applied. Inconclusive checks say so rather than
197
+ counting as a pass; `--offline` skips the ones that need the registry.
198
+
199
+ The package also declares **no install scripts** — no `postinstall`, nothing — so
200
+ nothing we publish executes at install time, and `--ignore-scripts` gives an identical
201
+ result. That is a claim about *our* tarball, not your whole install: a handful of
202
+ packages in the ~500-package dependency tree do declare install hooks, and none of them
203
+ are needed here, so the install we recommend and test is
204
+
205
+ ```bash
206
+ npm install -g privateer-agent --ignore-scripts
207
+ ```
208
+
209
+ Or skip the dependency graph entirely with a bundle (the `curl | sh` one-liner above),
210
+ which verifies a published SHA-256 and, from the next release on, a signed build
211
+ attestation before it unpacks anything.
193
212
 
194
213
  See [SECURITY.md](SECURITY.md) for the threat model, the permission gate, and how to
195
214
  report a vulnerability.
@@ -295,11 +314,11 @@ with `/signout`; manage linked terminals from the app.
295
314
  ## Talk to it — voice, both directions
296
315
 
297
316
  `/speak on` and answers are read aloud **as they are written**, sentence by sentence, with
298
- code blocks, tables and URLs stripped. Press **ctrl+x** and talk; the mic closes when you
317
+ code blocks, tables and URLs stripped. Press **alt+t** and talk; the mic closes when you
299
318
  stop talking and the transcript lands in the composer for you to read before Enter sends it.
300
319
 
301
320
  ```
302
- /speak on read answers aloud ctrl+x push to talk (press again to send)
321
+ /speak on read answers aloud alt+t push to talk (press again to send)
303
322
  /speak voice <name> pick a speaker /talk the same thing, typed
304
323
  /speak rate <n> 0.5–3× pace /talk loop on conversation mode, hands-free
305
324
  /speak stream off wait for the full answer instead of speaking as it arrives
@@ -610,7 +629,7 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
610
629
  | `/signin` · `/signout` | sign in to a Privateer account (device flow) / sign out |
611
630
  | `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
612
631
  | `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
613
- | `/speak` · `/talk` | read answers aloud / voice input (**ctrl+x** is push-to-talk) |
632
+ | `/speak` · `/talk` | read answers aloud / voice input (**alt+t** is push-to-talk) |
614
633
  | `/extensions` | list loaded Pi extensions |
615
634
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
616
635
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
package/SECURITY.md CHANGED
@@ -12,11 +12,27 @@ Privateer is a terminal coding agent: it reads and writes files, runs shell comm
12
12
  talks to model providers on your behalf. That is the point of it, and it is also the
13
13
  threat model. Two things are worth verifying rather than taking on faith.
14
14
 
15
- **The package runs no install scripts.** There is no `preinstall`, `install`,
16
- `postinstall` or `prepare` hook. `npm install -g privateer-agent` writes files and
17
- executes nothing; installing with `--ignore-scripts` produces an identical result. Code
18
- runs only when you run `privateer`. (Dependency patching happens at first launch — see
19
- `bin/apply-patches.mjs` and `docs/shipping.md`.)
15
+ **This package runs no install scripts.** There is no `preinstall`, `install`,
16
+ `postinstall` or `prepare` hook in `privateer-agent`: nothing we publish executes at
17
+ install time, and installing with `--ignore-scripts` produces an identical result. Our
18
+ code runs only when you run `privateer`. (Dependency patching happens at first launch —
19
+ see `bin/apply-patches.mjs` and `docs/shipping.md`.)
20
+
21
+ That is a claim about *our* tarball, not about your whole install, and the difference
22
+ matters. `npm install -g privateer-agent` resolves a tree of roughly 500 packages, and
23
+ seven of them do declare install scripts — `esbuild`, `koffi`, `fsevents`, `protobufjs`
24
+ (×2) and `@google/genai` (×2). On a default npm config those execute on your machine, as
25
+ they would for anything else that depends on them. Every one is a build-from-source or
26
+ compatibility-warning fallback we don't need, so install with scripts off:
27
+
28
+ ```bash
29
+ npm install -g privateer-agent --ignore-scripts
30
+ ```
31
+
32
+ That is the install we test and the shape CI publishes from, and it produces a working
33
+ `privateer`. To skip the dependency graph altogether, use a release bundle
34
+ (`curl -fsSL https://privateer.pro/install.sh | sh`): a fixed set of files with a pinned
35
+ Node, no resolution step on your machine at all.
20
36
 
21
37
  **Releases carry npm provenance.** Published from `.github/workflows/release.yml` with
22
38
  `npm publish --provenance`, so npm holds a signed Sigstore attestation binding the
@@ -29,6 +45,26 @@ npm view privateer-agent dist.attestations # attestation metadata exists
29
45
  npm audit signatures # verifies registry signatures + provenance
30
46
  ```
31
47
 
48
+ **You can re-check the install later.** Everything above is an install-time signal, which
49
+ is not much use to someone asking the question weeks afterwards. `privateer verify` reads
50
+ the install actually on disk and reports the shape it took, whether this version is
51
+ published with provenance, whether any dependency has drifted from its pinned version,
52
+ and which launch-time patches are applied. It distinguishes "verified" from "couldn't
53
+ check" and never reports the second as the first. It is not a security boundary — anything
54
+ that can rewrite `node_modules` can rewrite the checker — but it catches the accidental
55
+ and opportunistic cases, which is nearly all of them.
56
+
57
+ **Bundle downloads are verified before they unpack.** `install.sh` / `install.ps1` fetch
58
+ the release's published SHA-256 and refuse to install on a mismatch, a missing digest, or
59
+ a machine with no way to compute one (`PRIVATEER_SKIP_CHECKSUM=1` overrides, loudly). A
60
+ checksum served from the same origin as the file proves integrity but not provenance, so
61
+ from the release following this change the bundles also carry a GitHub build attestation,
62
+ which the installers verify automatically when `gh` is available:
63
+
64
+ ```bash
65
+ gh attestation verify privateer-darwin-arm64.tar.gz --repo privateer-agent/privateer-agent
66
+ ```
67
+
32
68
  If a version lacks provenance, it did not come from this workflow. Treat that as
33
69
  suspicious and report it — with one documented exception: **0.6.7 is the first release
34
70
  published this way.** Trusted publishing was misconfigured until then, so every earlier
@@ -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
@@ -233,6 +294,16 @@ else if (sub === "harbor" || sub === "daemon") {
233
294
  runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
234
295
  }
235
296
 
297
+ // --- `privateer verify` ----------------------------------------------------
298
+ // Check the install that is on this disk right now. Every other trust signal we
299
+ // publish is an install-time one (npm provenance at `npm i`, a checksum inside
300
+ // install.sh, a badge on a web page nobody revisits); this is the one a user can
301
+ // run afterwards, on the machine they're worried about. Runs BEFORE ensurePatches
302
+ // so it can report the real patch state rather than the state it just created.
303
+ else if (sub === "verify") {
304
+ runToCompletion(NODE_BIN, [path.join(REPO, "bin", "privateer-verify.mjs"), ...args.slice(1)]);
305
+ }
306
+
236
307
  // --- `privateer acp` -------------------------------------------------------
237
308
  // Privateer as an Agent Client Protocol server, spawned by an ACP host (Buzz's
238
309
  // `buzz-acp`, Zed, …) and driven over newline-delimited JSON-RPC on stdio.
@@ -402,6 +473,15 @@ else {
402
473
 
403
474
  // Dev convenience: load provider keys from the repo's .env if present.
404
475
  const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
476
+
477
+ // The boot splash. `--import` so it runs before Pi's entry module — most of the wait
478
+ // it covers IS that module graph loading, so a splash started any later would miss it.
479
+ // TUI branch only: harbor/acp/subagent children have no terminal to animate on (and
480
+ // acp's stdout is a JSON-RPC stream). pathToFileURL, not the bare path — a Windows
481
+ // absolute path reads as the URL scheme "d:"; see bin/privateer.mjs for the same trap.
482
+ const splash = path.join(HERE, "privateer-splash.mjs");
483
+ if (fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
484
+
405
485
  runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
406
486
  }
407
487
 
@@ -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,10 @@
1
+ // Types for `privateer verify`. The implementation is plain .mjs for the same
2
+ // reason apply-patches.mjs is: bin/ runs under a bare `node`, before any transpiler.
3
+
4
+ /**
5
+ * Check the install rooted at `repo` and print a report. Returns a process exit
6
+ * code: 0 when nothing FAILED (inconclusive checks are not failures), 1 otherwise.
7
+ *
8
+ * `offline` skips every check that needs the npm registry.
9
+ */
10
+ export function verify(opts: { repo: string; offline?: boolean }): Promise<number>;