privateer-agent 0.12.17 → 0.12.19

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.
@@ -29,6 +29,7 @@ import path from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
31
  import { routeUpdate } from "./update-route.mjs";
32
+ import { runToCompletion } from "./run-to-completion.mjs";
32
33
 
33
34
  const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
34
35
  const REPO = path.resolve(HERE, "..");
@@ -132,18 +133,6 @@ if (sub === "--version" || sub === "-V") {
132
133
  }
133
134
 
134
135
  // Faithfully propagate a child's exit/signal, mirroring bash `exec`.
135
- function runToCompletion(cmd, cmdArgs, opts = {}) {
136
- const child = spawn(cmd, cmdArgs, { stdio: "inherit", env: process.env, ...opts });
137
- child.on("exit", (code, signal) => {
138
- if (signal) process.kill(process.pid, signal);
139
- else process.exit(code ?? 0);
140
- });
141
- child.on("error", (e) => {
142
- console.error(`privateer: failed to launch — ${e.message}`);
143
- process.exit(1);
144
- });
145
- }
146
-
147
136
  // npm gives no usable progress, so on a TTY show a braille spinner while it runs
148
137
  // and keep its output buffered — shown only if the install fails. Non-TTY (CI,
149
138
  // piped) keeps the old passthrough behaviour. The global package is replaced in
@@ -291,7 +280,9 @@ if (sub === "update") {
291
280
  else if (sub === "harbor" || sub === "daemon") {
292
281
  sweepLegacyShims(); // a harbor-only machine upgrades too — see the function's note
293
282
  const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
294
- runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
283
+ // A resident background process, stopped by launchd/systemd/scripts with a plain
284
+ // `kill` — which reaches only this launcher. See runToCompletion.
285
+ runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)], { forwardSignals: true });
295
286
  }
296
287
 
297
288
  // --- `privateer verify` ----------------------------------------------------
@@ -314,7 +305,9 @@ else if (sub === "verify") {
314
305
  else if (sub === "acp") {
315
306
  sweepLegacyShims(); // silent: only ever removes files
316
307
  const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
317
- runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)]);
308
+ // Long-lived and driven over stdio by an editor, which stops it by terminating
309
+ // the process rather than by a keystroke. Same leak, same fix.
310
+ runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)], { forwardSignals: true });
318
311
  }
319
312
 
320
313
  // --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
@@ -0,0 +1,18 @@
1
+ // Types for the launcher's child-process handoff. Same reason as update-route.d.mts:
2
+ // the implementation is plain .mjs because bin/ runs under a bare `node`, before any
3
+ // transpiler exists — but the test that pins the signal behaviour is TypeScript.
4
+
5
+ import type { SpawnOptions } from "node:child_process";
6
+
7
+ export interface RunToCompletionOptions extends SpawnOptions {
8
+ /** Pass SIGTERM/SIGHUP on to the child and wait for it, instead of dying alone and
9
+ * leaving it reparented to init. Off by default — only the long-lived headless
10
+ * children (harbor, acp) need it; see the implementation's note on why SIGINT and
11
+ * SIGQUIT are deliberately NOT forwarded. */
12
+ forwardSignals?: boolean;
13
+ }
14
+
15
+ /** Spawn a child and hand it this process's lifetime: its exit code, its signal
16
+ * death, and — with `forwardSignals` — the termination signals sent to us. Never
17
+ * returns normally; the process exits with the child. */
18
+ export function runToCompletion(cmd: string, cmdArgs: string[], opts?: RunToCompletionOptions): void;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Spawn a child and hand it this process's lifetime — including its termination
3
+ * signals.
4
+ *
5
+ * Extracted from the launcher for the same reason update-route.mjs was: the
6
+ * reasoning is subtle enough to want a test next to it, and the launcher itself
7
+ * runs on import so nothing inside it can be unit-tested in place.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+
11
+ // How long a forwarded SIGTERM has to work before we stop being polite. A harbor
12
+ // shuts down in milliseconds; this only matters for a child wedged mid-write.
13
+ const SIGNAL_GRACE_MS = 5000;
14
+
15
+ /**
16
+ * `forwardSignals` — pass a termination signal on to the child and wait for it.
17
+ *
18
+ * WHY THIS EXISTS. Every subcommand here re-execs a second Node process, and
19
+ * without this the launcher dies alone: the child is reparented to init and keeps
20
+ * running. For the TUI that is nearly invisible (Ctrl-C is delivered by the
21
+ * terminal to the whole foreground process group, so both die anyway), but for the
22
+ * long-lived headless children it is a real leak — `privateer harbor` is started by
23
+ * launchd/systemd and by scripts, both of which stop a process with a plain
24
+ * `kill`, and that signal reaches only the launcher.
25
+ *
26
+ * Measured 2026-08-23 while building the E2E CLI fixture: killing
27
+ * `privateer harbor run` left the harbor alive, reparented to init, and STILL
28
+ * CONNECTED TO THE RELAY — holding the account's live-agent slot (one, on the free
29
+ * plan) with no way to find it except by pid. The harbor already handles SIGTERM
30
+ * and shuts down cleanly; nothing was ever passing it on.
31
+ *
32
+ * **Only SIGTERM and SIGHUP are forwarded, deliberately.** SIGINT and SIGQUIT are
33
+ * generated by the terminal and delivered to the entire foreground process group,
34
+ * so the child has already had them — forwarding would deliver a SECOND one, and a
35
+ * TUI that treats the first Ctrl-C as "clear the line" and the second as "quit"
36
+ * would exit on a single keypress.
37
+ *
38
+ * The parent then waits for the child rather than exiting under it, so the exit
39
+ * code still belongs to the child, and escalates to SIGKILL after a grace period:
40
+ * a child that ignores SIGTERM must not be able to outlive the launcher, which is
41
+ * the whole failure being fixed.
42
+ */
43
+ export function runToCompletion(cmd, cmdArgs, opts = {}) {
44
+ const { forwardSignals = false, ...spawnOpts } = opts;
45
+ const child = spawn(cmd, cmdArgs, { stdio: "inherit", env: process.env, ...spawnOpts });
46
+
47
+ let killTimer = null;
48
+ if (forwardSignals) {
49
+ for (const sig of ["SIGTERM", "SIGHUP"]) {
50
+ process.on(sig, () => {
51
+ try { child.kill(sig); } catch { /* already gone */ }
52
+ // Do NOT exit here: installing a listener suppresses Node's default
53
+ // terminate-now behaviour, so we stay up until the child's own exit fires
54
+ // below and can report its code.
55
+ if (killTimer) return;
56
+ killTimer = setTimeout(() => {
57
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
58
+ }, SIGNAL_GRACE_MS);
59
+ killTimer.unref?.();
60
+ });
61
+ }
62
+ }
63
+
64
+ child.on("exit", (code, signal) => {
65
+ if (killTimer) clearTimeout(killTimer);
66
+ if (signal) process.kill(process.pid, signal);
67
+ else process.exit(code ?? 0);
68
+ });
69
+ child.on("error", (e) => {
70
+ console.error(`privateer: failed to launch — ${e.message}`);
71
+ process.exit(1);
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.17",
3
+ "version": "0.12.19",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  * app's seal(): X25519 → HKDF-SHA256 → AES-256-GCM.
19
19
  */
20
20
 
21
- import { readFileSync, writeFileSync, chmodSync } from "node:fs";
21
+ import { readFileSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { x25519 } from "@noble/curves/ed25519";
24
24
  import { globalDir } from "../config/paths.ts";
@@ -67,6 +67,14 @@ function loadOrCreate(): { publicKey: Uint8Array; secretKey: Uint8Array } {
67
67
  const secretKey = x25519.utils.randomPrivateKey();
68
68
  const publicKey = x25519.getPublicKey(secretKey);
69
69
  const file: TerminalKeyFile = { v: 1, publicKey: b64(publicKey), secretKey: b64(secretKey) };
70
+ // The home may not exist yet: the FIRST thing a fresh machine does is /login, and
71
+ // requestDeviceCode swallows a throw from here (a login must not fail over a key it
72
+ // can live without). Without the mkdir that swallow turned an absent directory into
73
+ // a login that silently carried no pubkey — so the app had nothing to pin and every
74
+ // app-sealed secret to this terminal stayed impossible until the next re-link.
75
+ // saveCredentials writes into the same directory and has always created it; this is
76
+ // the same rule, one step earlier in the flow.
77
+ mkdirSync(globalDir(), { recursive: true, mode: 0o700 });
70
78
  // Create 0600 from the start — passing `mode` to writeFileSync avoids the TOCTOU
71
79
  // window where a fresh file briefly carries umask perms (group/world-readable)
72
80
  // before a follow-up chmod. `mode` only applies on CREATE, so also chmod to fix an
@@ -651,6 +651,16 @@ export class Harbor {
651
651
  }
652
652
 
653
653
  private async tick(): Promise<void> {
654
+ // Retry the relay every tick, not just at startup. syncRelay() bails when the
655
+ // account isn't signed in on this machine — and a harbor that came up in that
656
+ // state used to stay off the relay FOREVER, because start() was the only caller
657
+ // that mattered (the three IPC commands that also call it are ones the desktop
658
+ // app never sends). Signing in afterwards wrote credentials.json and changed
659
+ // nothing: the harbor kept answering IPC, so the app called it running, while
660
+ // its relay socket had never been opened — the permanent "Connecting" with a
661
+ // harbor that fires no routine and answers no spawn. Cheap and idempotent: it
662
+ // returns immediately once a client exists, or while remote access is off.
663
+ this.syncRelay();
654
664
  void this.flushPendingCloud();
655
665
  const now = Date.now();
656
666
  for (const r of loadRoutines()) {
@@ -1269,19 +1279,35 @@ export class Harbor {
1269
1279
  private relayStatus(): RelayStatus {
1270
1280
  const termId = routineRelayId();
1271
1281
  if (this.relayTerminated) {
1272
- return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1282
+ return { termId, connected: false, reason: "terminated", detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1273
1283
  }
1274
1284
  if (!this.relay) {
1285
+ // "relay not started" with credentials present is now a sub-tick window, not a
1286
+ // permanent state: tick() re-runs syncRelay(), so a harbor that came up signed
1287
+ // out connects on its own once you sign in. Reported as "connecting" because
1288
+ // that is what it now is.
1289
+ const signedIn = hasCredentials();
1275
1290
  return {
1276
1291
  termId,
1277
1292
  connected: false,
1278
- detail: hasCredentials()
1293
+ reason: signedIn ? "connecting" : "signed-out",
1294
+ detail: signedIn
1279
1295
  ? "relay not started"
1280
- : "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
1296
+ : "no account signed in on this machine — run `privateer` and /login",
1281
1297
  };
1282
1298
  }
1283
1299
  const conn = this.relay.connectionStatus();
1284
- if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
1300
+ // The client knows why it isn't up — a refused ticket, an unreachable server —
1301
+ // and reporting a flat "connecting…" over the top of that is what made a harbor
1302
+ // that will never connect look like one that is about to.
1303
+ if (!conn.connected) {
1304
+ return {
1305
+ termId,
1306
+ connected: false,
1307
+ reason: conn.refused ? "refused" : "connecting",
1308
+ detail: conn.detail ?? "connecting…",
1309
+ };
1310
+ }
1285
1311
  return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
1286
1312
  }
1287
1313
 
package/src/harbor/ipc.ts CHANGED
@@ -42,6 +42,21 @@ export type IpcRequest =
42
42
  | { cmd: "run-now"; idOrName: string }
43
43
  | { cmd: "reload" };
44
44
 
45
+ /**
46
+ * Why a harbor isn't on the relay, as a code rather than a sentence.
47
+ *
48
+ * `detail` below is written for a terminal — it names CLI commands and is English
49
+ * only — so the app can't put it in front of a user. It used to have nothing else
50
+ * to go on, and said "give it a moment" about every one of these, including the
51
+ * three that never clear on their own. This is the machine-readable half.
52
+ *
53
+ * terminated remote access was switched off from the app; needs a restart
54
+ * signed-out no account credentials on this machine
55
+ * refused the server said no (plan's agent cap, rejected ticket) — standing
56
+ * connecting genuinely still trying; this one really is worth a moment
57
+ */
58
+ export type RelayReason = "terminated" | "signed-out" | "refused" | "connecting";
59
+
45
60
  /**
46
61
  * The harbor's view of its own relay connection, reported by `status`.
47
62
  *
@@ -62,6 +77,8 @@ export interface RelayStatus {
62
77
  quietSec?: number;
63
78
  /** Why it isn't connected, when we know: signed out, turned off from the app, … */
64
79
  detail?: string;
80
+ /** The same fact as `detail`, for a caller that has to localize it. */
81
+ reason?: RelayReason;
65
82
  }
66
83
 
67
84
  export interface IpcResponse {
@@ -383,6 +383,11 @@ export class RelayClient {
383
383
  private reconnectDelay = RECONNECT_MS;
384
384
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
385
385
  private refusal: string | null = null;
386
+ // Why the last connection attempt failed, kept for connectionStatus(). A refusal
387
+ // (4xx) outranks it — that one is a decision, not a hiccup — but without either,
388
+ // every un-connected relay reports as a bare "connecting…", which is the same
389
+ // sentence whether the socket opens in two seconds or never opens again.
390
+ private lastFailure: string | null = null;
386
391
  // Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
387
392
  private bufKind: "text" | "reasoning" | null = null;
388
393
  private buf = "";
@@ -518,6 +523,7 @@ export class RelayClient {
518
523
  ws.on("open", () => {
519
524
  opened = true;
520
525
  this.refusal = null; // a later refusal is news again
526
+ this.lastFailure = null; // whatever kept us out is history
521
527
  this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
522
528
  this.connectedAt = Date.now();
523
529
  this.startHeartbeat(ws);
@@ -537,6 +543,9 @@ export class RelayClient {
537
543
  // attach/detach frame said. Re-learned on the next attach or inbound frame.
538
544
  this.controllerHere = false;
539
545
  this.cb.onDisconnected?.();
546
+ // A socket that never opened failed for a reason worth reporting; one that
547
+ // opened and dropped is an ordinary blip the reconnect handles.
548
+ this.lastFailure = opened ? null : lastErr || "the relay connection closed before it opened";
540
549
  if (!this.closed) {
541
550
  this.cb.onStatus?.(
542
551
  opened
@@ -560,6 +569,7 @@ export class RelayClient {
560
569
  const status = (err as { status?: number })?.status;
561
570
  const refused = typeof status === "number" && status >= 400 && status < 500;
562
571
  if (refused) {
572
+ this.lastFailure = msg;
563
573
  this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
564
574
  // A refusal is a decision, not a hiccup: hammering the same request every few
565
575
  // seconds can't change it, and for a harbor — whose onStatus goes to a log file,
@@ -578,6 +588,7 @@ export class RelayClient {
578
588
  }
579
589
  // Transient (network/route/5xx): stay on the fast retry.
580
590
  this.refusal = null;
591
+ this.lastFailure = msg;
581
592
  this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
582
593
  this.scheduleReconnect();
583
594
  } finally {
@@ -970,8 +981,19 @@ export class RelayClient {
970
981
  // quiet for longer than the server's 25s ping cadence is the shape of the half-open
971
982
  // failure the watchdog exists to catch, so it is worth showing rather than a bare
972
983
  // "connected".
973
- connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
974
- if (!this.isConnected()) return { connected: false };
984
+ connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number; detail?: string; refused?: boolean } {
985
+ // Not connected: say WHY when we know. A standing refusal (the plan's agent cap,
986
+ // a rejected ticket) is the answer that matters most — it will not clear on its
987
+ // own, and it's the one case a caller should present as a decision rather than a
988
+ // wait — so it wins over the last transient error and is flagged as itself.
989
+ if (!this.isConnected()) {
990
+ const detail = this.refusal ?? this.lastFailure;
991
+ return {
992
+ connected: false,
993
+ ...(detail ? { detail } : {}),
994
+ ...(this.refusal ? { refused: true } : {}),
995
+ };
996
+ }
975
997
  const now = Date.now();
976
998
  return {
977
999
  connected: true,