privateer-agent 0.14.0 → 0.14.1

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
@@ -479,10 +479,30 @@ privateer harbor uninstall
479
479
  ```
480
480
 
481
481
  Installs as a **launchd user agent** on macOS or a **`systemd --user` unit** on Linux — no
482
- root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.)
482
+ root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.) Logs go to
483
+ `~/.privateer/harbor.log` on macOS (kept under 5 MB, one previous copy as `harbor.log.1`) and
484
+ to the journal on Linux: `journalctl --user -u privateer-harbor`.
483
485
 
484
486
  Everything the harbor does still runs through the permission gate. Actions needing approval
485
- surface in the app; routines you approved once run on their own schedule.
487
+ surface in the app; routines you approved once run on their own schedule. Unattended runs
488
+ are confined to their working directory and can't read credential stores (`~/.ssh`,
489
+ `~/.aws`, Privateer's own login and config files), whatever tools they're allowed.
490
+
491
+ **Chat channels** (Telegram, Slack, Discord, WhatsApp) run beside it, as their own service:
492
+
493
+ ```bash
494
+ privateer channels install # login service; re-run after changing channels from the app
495
+ privateer channels status # which platforms are live right now
496
+ privateer channels run # or in the foreground
497
+ ```
498
+
499
+ **On a VPS:** run both as a dedicated, unprivileged user, never root. A routine you allow
500
+ `bash` runs with that user's full rights. Sign in over SSH with `privateer` and `/login` (it
501
+ prints a code to approve in the app). Nothing needs an inbound port, except WhatsApp's
502
+ webhook. The Linux unit sets `NoNewPrivileges`, `UMask=0077` and a restart limit. It skips
503
+ systemd's namespace sandboxing on purpose, because that fails to start on kernels without
504
+ unprivileged user namespaces. In a container with no systemd, use `privateer harbor run`
505
+ under the container's own restart policy.
486
506
 
487
507
  ## Workflows
488
508
 
@@ -0,0 +1,9 @@
1
+ // Types for bin/model-args.mjs — plain .mjs because bin/ runs under a bare `node`.
2
+
3
+ export function modelArgs(o: {
4
+ launchArgs: string[];
5
+ envModel?: string;
6
+ savedDefault?: string | null;
7
+ signedIn: boolean;
8
+ computed: string;
9
+ }): string[];
@@ -0,0 +1,43 @@
1
+ // Which `--model` the launcher hands Pi, if any.
2
+ //
3
+ // Plain .mjs for the same reason as headless-flags.mjs: bin/ runs under a bare `node`,
4
+ // and the launcher runs on import, so the rule lives where a test can reach it.
5
+ // See tests/modelArgs.test.ts.
6
+ //
7
+ // Precedence:
8
+ // 1. a --model the user typed (already in the args) → add nothing
9
+ // 2. PRIVATEER_MODEL → the computed model
10
+ // 3. a saved pick in settings.json → usually add nothing and let Pi resolve it, which
11
+ // falls back sanely when a BYO model has vanished from the registry. EXCEPT a
12
+ // `privateer/*` pick on a signed-in machine, which is passed explicitly (below).
13
+ // 4. nothing saved → the computed model
14
+ //
15
+ // Why the exception. Pi resolves a saved default only when that provider HAS AUTH at
16
+ // the moment the session starts (model-resolver.js findInitialModel, step 3), and if not
17
+ // it silently takes the first model that does: with an OPENROUTER_API_KEY around, that
18
+ // is Pi's openrouter default, moonshotai/kimi-k2.6. The account provider's auth is the
19
+ // one `privateer` entry in auth.json, which is machine-global and removed whenever ANY
20
+ // Privateer process exits (it revokes its own account session on the way out). So a
21
+ // launch that happened to follow another process's exit came up on OpenRouter with no
22
+ // message at all, while settings.json still said privateer/near/…. A `-p` run then sent
23
+ // its work to a ZDR provider instead of the enclave the user picked; under --private it
24
+ // refused every tool and said the model was "zdr-policy", which is how this was found.
25
+ //
26
+ // An explicit --model resolves against every REGISTERED model, auth or not
27
+ // (resolveCliModel → getModels()), and the account credential is armed at
28
+ // session_start, before the first prompt. Being signed in is the condition because
29
+ // that is when arming will succeed; signed out, the saved pick couldn't run anyway and
30
+ // Pi's own resolution is no worse.
31
+
32
+ /**
33
+ * @param {{ launchArgs: string[], envModel?: string, savedDefault?: string | null, signedIn: boolean, computed: string }} o
34
+ * @returns {string[]} args to prepend: [] or ["--model", spec]
35
+ */
36
+ export function modelArgs({ launchArgs, envModel, savedDefault, signedIn, computed }) {
37
+ if (launchArgs.includes("--model")) return [];
38
+ if (envModel) return ["--model", computed];
39
+ if (savedDefault) {
40
+ return signedIn && savedDefault.startsWith("privateer/") ? ["--model", savedDefault] : [];
41
+ }
42
+ return ["--model", computed];
43
+ }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the channels runner (Telegram/Slack/Discord/WhatsApp → agent turns).
3
+ // Mirrors bin/privateer-harbor.mjs: load dev keys from the repo .env WITHOUT changing
4
+ // cwd, register tsx so TS resolves regardless of the invocation cwd, then hand off to
5
+ // the channels CLI dispatcher (whose `run` imports ./boot.ts before any Pi code).
6
+ //
7
+ // Invoked two ways: interactively via the launcher (`privateer channels …`), and by the
8
+ // installed launchd/systemd service (`node privateer-channels.mjs run`). This is what
9
+ // makes channels runnable from an npm install; it used to need a source checkout
10
+ // (`npm run channels`, tsx and a .env).
11
+ import { register } from "tsx/esm/api";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { dirname, resolve } from "node:path";
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const repo = resolve(here, "..");
17
+
18
+ try {
19
+ process.loadEnvFile(resolve(repo, ".env"));
20
+ } catch {
21
+ /* no .env — rely on the ambient environment / ~/.privateer */
22
+ }
23
+
24
+ // Same reason as the harbor: channel sessions load the moat in code, so subagent
25
+ // children need the moat-injecting wrapper rather than a bare `pi`.
26
+ process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
27
+
28
+ register();
29
+ const { runChannelsCli } = await import(pathToFileURL(resolve(repo, "src/cli/channelsCli.ts")).href);
30
+ await runChannelsCli(process.argv.slice(2));
@@ -31,6 +31,7 @@ import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
31
  import { routeUpdate } from "./update-route.mjs";
32
32
  import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
33
33
  import { runToCompletion } from "./run-to-completion.mjs";
34
+ import { modelArgs } from "./model-args.mjs";
34
35
  import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
35
36
  import { configureCompileCache } from "./startup-cache.mjs";
36
37
 
@@ -555,6 +556,16 @@ else if (sub === "harbor" || sub === "daemon") {
555
556
  runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)], { forwardSignals: true });
556
557
  }
557
558
 
559
+ // --- `privateer channels [run|install|uninstall|status]` -------------------
560
+ // The chat-platform bridges (Telegram/Slack/Discord/WhatsApp). Same shape as the
561
+ // harbor: a resident process, its own launcher, its own optional login service.
562
+ else if (sub === "channels") {
563
+ sweepLegacyShims();
564
+ sweepBashLogs();
565
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
566
+ runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-channels.mjs"), ...args.slice(1)], { forwardSignals: true });
567
+ }
568
+
558
569
  // --- `privateer verify` ----------------------------------------------------
559
570
  // Check the install that is on this disk right now. Every other trust signal we
560
571
  // publish is an install-time one (npm provenance at `npm i`, a checksum inside
@@ -817,14 +828,15 @@ else {
817
828
  // 1. a --model the user typed on the privateer command line (already in `args`)
818
829
  // 2. PRIVATEER_MODEL — deliberate override, folded into MODEL above
819
830
  // 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
820
- // falls back sanely if that model has vanished from the registry)
831
+ // falls back sanely if that model has vanished from the registry). Except a
832
+ // signed-in `privateer/*` pick, passed explicitly — see bin/model-args.mjs for
833
+ // the silent OpenRouter fallback that prevents
821
834
  // 4. nothing saved (first run / fresh home) → the computed MODEL above
822
835
  //
823
836
  // A FUNCTION, called per launch: a fresh agent (bin/fresh-supervisor.mjs) re-reads it,
824
837
  // so a model picked in the old session boots the new one instead of being stomped by
825
838
  // a first-run --model computed before that pick existed.
826
839
  const modelArgsFor = (launchArgs) => {
827
- const userPassedModel = launchArgs.includes("--model");
828
840
  let savedDefault = null;
829
841
  try {
830
842
  const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
@@ -835,7 +847,7 @@ else {
835
847
  savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
836
848
  }
837
849
  } catch { /* absent/unreadable → no saved pick */ }
838
- return userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
850
+ return modelArgs({ launchArgs, envModel: process.env.PRIVATEER_MODEL, savedDefault, signedIn, computed: MODEL });
839
851
  };
840
852
 
841
853
  // Dev convenience: load provider keys from the repo's .env if present.
@@ -866,7 +878,9 @@ else {
866
878
  // command, or `privateer fresh` from another terminal can swap this agent for a new
867
879
  // process with the old tree reaped. Print/export/version runs have nothing to swap.
868
880
  const tuiArgs = (launchArgs) => [...nodeArgs, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
869
- if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args));
881
+ // forwardSignals: a `kill` reaches only this launcher, and a -p run left behind keeps
882
+ // working (and spending) with nobody waiting for its answer.
883
+ if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args), { forwardSignals: true });
870
884
  else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
871
885
  }
872
886
 
@@ -1047,7 +1061,8 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
1047
1061
  "Commands:",
1048
1062
  ` ${cmd} init [directory] Create a starter PRIVATEER.md project-context file`,
1049
1063
  ` ${cmd} update [--all] Fetch the latest release or newer tool packs`,
1050
- ` ${cmd} harbor <command> Manage the resident background Harbor daemon (run/install/uninstall/status)`,
1064
+ ` ${cmd} harbor <command> Manage Harbor, the always-on agent behind the app (run/install/uninstall/status)`,
1065
+ ` ${cmd} channels <command> Run chat channels: Telegram, Slack, Discord, WhatsApp (run/install/uninstall/status)`,
1051
1066
  ` ${cmd} verify Check local installation integrity and patch state`,
1052
1067
  ` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
1053
1068
  ` ${cmd} fresh [n] Replace a running terminal's agent with a new one (stops everything it started)`,
@@ -41,6 +41,7 @@ import { childSpendAllows } from "../src/permissions/childSpend.ts";
41
41
  import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
42
42
  import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
43
43
  import { privateModeFromEnv } from "../src/permissions/privateMode.ts";
44
+ import { REPLY_TIMEOUT_ENV, StallWatchdog, replyTimeoutMs } from "../src/engine/stallWatchdog.ts";
44
45
  import type { PermissionRequest } from "../src/permissions/gate.ts";
45
46
  import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
46
47
  import { describeErrorText } from "../src/engine/errors.ts";
@@ -772,11 +773,44 @@ export default function privateerControl(pi: any): void {
772
773
  };
773
774
  });
774
775
 
776
+ // A headless run must not wait forever on a provider that went silent (see
777
+ // src/engine/stallWatchdog.ts). Armed per request, re-armed by every streamed event,
778
+ // off between requests. On a stall: say so on stderr, abort the turn, exit non-zero.
779
+ let stallCtx: any;
780
+ const stallMs = replyTimeoutMs();
781
+ const watchdog = new StallWatchdog(stallMs, () => {
782
+ const secs = Math.round(stallMs / 1000);
783
+ process.stderr.write(
784
+ `privateer: no reply from ${currentSpec || "the model"} for ${secs}s — stopped. The provider accepted the ` +
785
+ `request and then sent nothing.\n Re-run, or pick another model with --model. ` +
786
+ `${REPLY_TIMEOUT_ENV}=<seconds> changes the limit (0 turns it off).\n`,
787
+ );
788
+ process.exitCode = 1;
789
+ try {
790
+ stallCtx?.abort?.();
791
+ } catch {
792
+ /* the turn is already ending */
793
+ }
794
+ });
795
+ pi.on("before_provider_request", (_e: any, ctx: any) => {
796
+ if (!headlessRun) return;
797
+ stallCtx = ctx;
798
+ watchdog.arm();
799
+ });
800
+ pi.on("message_start", (ev: any) => {
801
+ if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
802
+ });
803
+ pi.on("message_update", () => {
804
+ if (headlessRun) watchdog.arm();
805
+ });
806
+ pi.on("message_end", () => watchdog.disarm());
807
+
775
808
  // Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
776
809
  // "Connection error." — and exits. Follow it with what that means and what to do,
777
810
  // on stderr so stdout stays the answer (or the JSON stream).
778
811
  pi.on("agent_end", (ev: any) => {
779
- if (!headlessRun) return;
812
+ watchdog.disarm();
813
+ if (!headlessRun || watchdog.stalled) return; // a stall already said what happened
780
814
  const msgs: any[] = Array.isArray(ev?.messages) ? ev.messages : [];
781
815
  const last = [...msgs].reverse().find((m) => m?.role === "assistant");
782
816
  if (last?.stopReason !== "error" || typeof last.errorMessage !== "string") return;
@@ -21,11 +21,12 @@
21
21
  // /hints on|off persists { hints: { enabled } } in ~/.privateer/config.json (ours;
22
22
  // Pi never reads that file) — read-modify-write, preserving unrelated keys.
23
23
 
24
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { readFileSync, mkdirSync } from "node:fs";
25
25
  import { join } from "node:path";
26
26
  import { keyText } from "@earendil-works/pi-coding-agent";
27
27
  import { configPath, globalDir } from "../src/config/paths.ts";
28
28
  import { desktopAppPath } from "../src/config/desktopApp.ts";
29
+ import { writeFileAtomic } from "../src/util/atomicWrite.ts";
29
30
 
30
31
  const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
31
32
  const EVERY_MS = 12_000;
@@ -120,7 +121,7 @@ function persistEnabled(enabled: boolean): void {
120
121
  cfg.hints = { ...(cfg.hints ?? {}), enabled };
121
122
  try {
122
123
  mkdirSync(globalDir(), { recursive: true });
123
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + "\n");
124
+ writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
124
125
  } catch {
125
126
  /* best effort — the toggle still holds for this session */
126
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
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",
@@ -53,6 +53,8 @@ import { modelRegistryOf } from "../providers/piAuthStore.ts";
53
53
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
54
54
  import { CRYPTO_TOOL_NAMES } from "../tools/crypto.ts";
55
55
  import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
56
+ import { rotateLogIfLarge } from "../harbor/logRotate.ts";
57
+ import { logFilePath } from "../harbor/service.ts";
56
58
 
57
59
  // Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
58
60
  // a turn nobody is watching can't mutate the filesystem or shell out. Now that the
@@ -147,10 +149,19 @@ async function main() {
147
149
 
148
150
  // ── config ──────────────────────────────────────────────────────────────────
149
151
  let cfg: any = {};
152
+ let raw: string;
150
153
  try {
151
- cfg = JSON.parse(readFileSync(configPath(), "utf8"));
154
+ raw = readFileSync(configPath(), "utf8");
152
155
  } catch {
153
- log(`no config at ${configPath()} — add a channels block (see run.ts header).`);
156
+ // Nothing configured yet is a clean exit (see the no-bridges case below for why).
157
+ log(`no config at ${configPath()} — add a channels block (see run.ts header), or set channels up from the app.`);
158
+ process.exit(0);
159
+ }
160
+ try {
161
+ cfg = JSON.parse(raw);
162
+ } catch (e) {
163
+ // A config that exists but won't parse IS a failure: say so, and exit non-zero.
164
+ log(`can't parse ${configPath()}: ${e instanceof Error ? e.message : String(e)}`);
154
165
  process.exit(1);
155
166
  }
156
167
  const ch = cfg.channels ?? {};
@@ -404,15 +415,23 @@ async function main() {
404
415
  }
405
416
 
406
417
  if (bridges.length === 0) {
407
- log("no channels started — configure a channels.<platform> block in config.json.");
408
- process.exit(1);
418
+ // Exit 0, not 1: under the login service (`privateer channels install`) a failure
419
+ // exit is restarted, and "nothing configured yet" doesn't get better by retrying —
420
+ // it got restarted until systemd's start limit, filling the log with this line.
421
+ // Configure a channel (from the app or config.json), then start it again.
422
+ log("no channels started — configure a channels.<platform> block in config.json (or from the app), then run `privateer channels install` again.");
423
+ process.exit(0);
409
424
  }
410
425
 
411
426
  // Heartbeat: announce the live platforms now and refresh on a cadence so the app's
412
427
  // channels manager can tell running from merely-configured. A stale/absent file
413
428
  // reads as offline (see channels/status.ts).
414
429
  writeChannelsStatus(startedPlatforms);
415
- const heartbeat = setInterval(() => writeChannelsStatus(startedPlatforms), HEARTBEAT_MS);
430
+ const heartbeat = setInterval(() => {
431
+ writeChannelsStatus(startedPlatforms);
432
+ // Bound the log launchd appends to (macOS service; a no-op elsewhere). See logRotate.ts.
433
+ if (rotateLogIfLarge(logFilePath("channels"))) log("rotated channels.log (previous kept as channels.log.1)");
434
+ }, HEARTBEAT_MS);
416
435
  heartbeat.unref?.();
417
436
 
418
437
  const shutdown = () => {
@@ -0,0 +1,67 @@
1
+ // `privateer channels [run|install|uninstall|status]` dispatcher.
2
+ //
3
+ // ORDERING CONTRACT: `run` dynamically imports channels/run.ts, which imports
4
+ // ./boot.ts first (env pin + attestation dispatcher) and starts on import. The service
5
+ // subcommands touch no Pi code (harbor/service.ts is import-safe), so they don't pay
6
+ // the session-stack import cost.
7
+
8
+ function usage(): string {
9
+ return [
10
+ "Usage: privateer channels [command]",
11
+ "",
12
+ " run Run the channels in the foreground (default). Bridges every",
13
+ " channels.<platform> block in ~/.privateer/config.json.",
14
+ " install Install it as a login service so it starts at login and stays up.",
15
+ " Re-run after changing channels from the app: it restarts them.",
16
+ " uninstall Remove the login service.",
17
+ " status Show whether the service is installed and which channels are live.",
18
+ ].join("\n");
19
+ }
20
+
21
+ export async function runChannelsCli(argv: string[]): Promise<void> {
22
+ const sub = argv[0] ?? "run";
23
+ switch (sub) {
24
+ case "run": {
25
+ await import("../channels/run.ts"); // starts on import, installs its own signal handlers
26
+ return;
27
+ }
28
+ case "install": {
29
+ const { installService } = await import("../harbor/service.ts");
30
+ try {
31
+ const info = installService("channels");
32
+ process.stdout.write(
33
+ `Channels installed as a login service.\n unit: ${info.unitPath}\n logs: ${info.logPath}\n` +
34
+ "They start now and at every login. Manage with `privateer channels status|uninstall`.\n",
35
+ );
36
+ } catch (err) {
37
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
38
+ process.exit(1);
39
+ }
40
+ return;
41
+ }
42
+ case "uninstall": {
43
+ const { uninstallService } = await import("../harbor/service.ts");
44
+ try {
45
+ uninstallService("channels");
46
+ process.stdout.write("Channels login service removed.\n");
47
+ } catch (err) {
48
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
49
+ process.exit(1);
50
+ }
51
+ return;
52
+ }
53
+ case "status": {
54
+ const { channelsStatusReport } = await import("../harbor/service.ts");
55
+ process.stdout.write((await channelsStatusReport()) + "\n");
56
+ return;
57
+ }
58
+ case "-h":
59
+ case "--help":
60
+ case "help":
61
+ process.stdout.write(usage() + "\n");
62
+ return;
63
+ default:
64
+ process.stderr.write(`Unknown channels command: ${sub}\n\n${usage()}\n`);
65
+ process.exit(1);
66
+ }
67
+ }
@@ -18,9 +18,10 @@
18
18
  * Missing file, unreadable file and malformed JSON all mean "no choice" — the account
19
19
  * default — never a crash in the middle of a tool call.
20
20
  */
21
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
21
+ import { readFileSync, mkdirSync } from "node:fs";
22
22
  import { dirname } from "node:path";
23
23
  import { configPath } from "./paths.ts";
24
+ import { writeFileAtomic } from "../util/atomicWrite.ts";
24
25
 
25
26
  export type MediaKind = "image" | "video";
26
27
 
@@ -55,7 +56,7 @@ export function setMediaModelPref(kind: MediaKind, id: string | null): void {
55
56
  const next: Record<string, unknown> = { ...cfg, media };
56
57
  if (Object.keys(media).length === 0) delete next.media;
57
58
  mkdirSync(dirname(configPath()), { recursive: true });
58
- writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
59
+ writeFileAtomic(configPath(), JSON.stringify(next, null, 2) + "\n");
59
60
  }
60
61
 
61
62
  /** The model a call should send: its own `model`, else this machine's choice, else none. */
@@ -28,8 +28,9 @@
28
28
  // made in an editor is picked up on the next scan, exactly like one made through the
29
29
  // command — there is no "restart to apply" step to explain.
30
30
 
31
- import { readFileSync, statSync, writeFileSync } from "node:fs";
31
+ import { readFileSync, statSync } from "node:fs";
32
32
  import { configPath } from "./paths.ts";
33
+ import { writeFileAtomic } from "../util/atomicWrite.ts";
33
34
 
34
35
  let cached: string[] = [];
35
36
  let cachedStamp = "";
@@ -89,7 +90,7 @@ function write(entries: string[]): void {
89
90
  /* new or unreadable — a fresh object, so one bad file doesn't lose the edit */
90
91
  }
91
92
  const privacy = { ...((cfg.privacy as Record<string, unknown>) ?? {}), piiAllow: entries };
92
- writeFileSync(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
+ writeFileAtomic(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
94
  invalidate();
94
95
  }
95
96
 
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.14.0",
3
+ "reviewedFor": "0.14.1",
4
4
  "items": [
5
+ { "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
5
6
  { "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
6
7
  { "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
7
- { "text": "Voice follows the window you're in", "since": "0.13.0" },
8
- { "text": "Searchable model picker —", "cmd": "/models", "since": "0.12.53" }
8
+ { "text": "Voice follows the window you're in", "since": "0.13.0" }
9
9
  ]
10
10
  }
@@ -0,0 +1,61 @@
1
+ // A stall watchdog for headless runs.
2
+ //
3
+ // `privateer -p` waits on the model with no limit. A provider that accepts the request
4
+ // and then sends nothing (measured on the account channel: glm-5-2 stalled 5 of 14
5
+ // requests, see providers/defaultModel.ts) leaves the run hanging forever with nothing
6
+ // on stdout. That is what a 14-minute "hang" on 2026-10-02 looked like from the outside;
7
+ // the session file never appeared, because Pi writes it only once a first reply lands.
8
+ //
9
+ // So: armed when a request goes out, re-armed by every streamed event (text, thinking,
10
+ // tool-call deltas all count, so a slow but live stream never trips it), disarmed when
11
+ // the message ends. Between requests (tool execution) it is off — a long local command
12
+ // is not a provider stall. If it fires, the caller aborts the turn and says why.
13
+ //
14
+ // The limit: PRIVATEER_REPLY_TIMEOUT seconds, default 180, 0 turns it off. Silence, not
15
+ // total time, is what is measured, so a long answer is never cut off for being long.
16
+
17
+ export const REPLY_TIMEOUT_ENV = "PRIVATEER_REPLY_TIMEOUT";
18
+ export const DEFAULT_REPLY_TIMEOUT_MS = 180_000;
19
+
20
+ /** The stall limit in ms from the environment; 0 means off. Garbage falls back to the default. */
21
+ export function replyTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
22
+ const raw = (env[REPLY_TIMEOUT_ENV] ?? "").trim();
23
+ if (!raw) return DEFAULT_REPLY_TIMEOUT_MS;
24
+ const n = Number(raw);
25
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_REPLY_TIMEOUT_MS;
26
+ return Math.round(n * 1000);
27
+ }
28
+
29
+ export class StallWatchdog {
30
+ private timer: ReturnType<typeof setTimeout> | undefined;
31
+ private fired = false;
32
+
33
+ constructor(
34
+ private readonly ms: number,
35
+ private readonly onStall: () => void,
36
+ private readonly timers: { set: typeof setTimeout; clear: typeof clearTimeout } = { set: setTimeout, clear: clearTimeout },
37
+ ) {}
38
+
39
+ /** A request went out, or something streamed: (re)start the silence clock. */
40
+ arm(): void {
41
+ if (this.ms <= 0 || this.fired) return;
42
+ this.disarm();
43
+ this.timer = this.timers.set(() => {
44
+ this.timer = undefined;
45
+ this.fired = true;
46
+ this.onStall();
47
+ }, this.ms);
48
+ // Never the reason a finished run stays alive (see docs/one-shot-exit.md).
49
+ (this.timer as { unref?: () => void })?.unref?.();
50
+ }
51
+
52
+ /** The message ended, or the run did: nothing to wait on. */
53
+ disarm(): void {
54
+ if (this.timer !== undefined) this.timers.clear(this.timer);
55
+ this.timer = undefined;
56
+ }
57
+
58
+ get stalled(): boolean {
59
+ return this.fired;
60
+ }
61
+ }
@@ -66,6 +66,8 @@ import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlre
66
66
  import { serializeBuild } from "./buildLock.ts";
67
67
  import { createAccountLease } from "./accountLease.ts";
68
68
  import { closeSession } from "./closeSession.ts";
69
+ import { rotateLogIfLarge } from "./logRotate.ts";
70
+ import { logFilePath } from "./service.ts";
69
71
  import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
70
72
  import { relayExposureAllowed } from "../config/relayExposure.ts";
71
73
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
@@ -715,6 +717,8 @@ export class Harbor {
715
717
  // returns immediately once a client exists, or while remote access is off.
716
718
  this.syncRelay();
717
719
  void this.flushPendingCloud();
720
+ // Bound the log launchd appends us to (see logRotate.ts). A stat a minute.
721
+ if (rotateLogIfLarge(logFilePath("harbor"))) log("rotated harbor.log (previous kept as harbor.log.1)");
718
722
  const now = Date.now();
719
723
  for (const r of loadRoutines()) {
720
724
  if (!r.enabled || this.running.has(r.id)) continue;
@@ -0,0 +1,31 @@
1
+ // Keep a service's log file bounded.
2
+ //
3
+ // On macOS launchd appends the harbor's stdout/stderr to ~/.privateer/harbor.log and
4
+ // nothing ever trimmed it — one boot loop once grew it to 7 MB of the same line. The
5
+ // harbor can't rename the file out from under launchd (launchd keeps writing to the
6
+ // renamed file), so this copies it to <log>.1 and truncates it in place. launchd opened
7
+ // it O_APPEND, so its next write lands at the new end rather than leaving a hole.
8
+ // The few lines written between the copy and the truncate are lost; a log is allowed
9
+ // that. On Linux the unit logs to the journal and there is no file — a no-op.
10
+ //
11
+ // IMPORT-SAFETY: node builtins only.
12
+ import { copyFileSync, statSync, truncateSync, chmodSync } from "node:fs";
13
+
14
+ export const LOG_MAX_BYTES = Number(process.env.PRIVATEER_LOG_MAX_BYTES) || 5 * 1024 * 1024;
15
+
16
+ /** Rotate `path` when it has grown past `maxBytes`. True when it rotated. Never throws. */
17
+ export function rotateLogIfLarge(path: string, maxBytes: number = LOG_MAX_BYTES): boolean {
18
+ try {
19
+ if (statSync(path).size <= maxBytes) return false;
20
+ copyFileSync(path, `${path}.1`);
21
+ try {
22
+ chmodSync(`${path}.1`, 0o600);
23
+ } catch {
24
+ /* non-POSIX */
25
+ }
26
+ truncateSync(path, 0);
27
+ return true;
28
+ } catch {
29
+ return false; // no file (Linux, a manual foreground run), or unwritable
30
+ }
31
+ }
@@ -4,32 +4,57 @@
4
4
  // user agent; Linux → systemd --user unit. No root: everything lives under the
5
5
  // user's own home and login session.
6
6
  //
7
+ // The channels runner (chat platforms → agent turns) installs the same way, as its own
8
+ // unit: every function here takes a ServiceKind, defaulting to the harbor, so the
9
+ // desktop app's calls (installService(), serviceInfo(), …) are unchanged.
10
+ //
7
11
  // ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
8
12
  // so the harbor CLI can load it without going through boot.ts.
9
- import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
10
14
  import { spawnSync } from "node:child_process";
11
- import { homedir } from "node:os";
15
+ import { homedir, userInfo } from "node:os";
12
16
  import { join, dirname, resolve, basename } from "node:path";
13
17
  import { fileURLToPath } from "node:url";
14
18
  import { globalDir } from "../config/paths.ts";
15
19
  import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
16
20
 
17
- const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
18
- const UNIT = "privateer-harbor.service"; // systemd --user unit name
19
-
20
- // Pre-rename service identity ("daemon"). Kept ONLY so install/uninstall can evict a
21
- // service a user installed before the harbor rename — otherwise it lingers as an
22
- // orphaned launchd agent / systemd unit still running the old launcher. Never written,
23
- // only torn down.
24
- const OLD_LABEL = "pro.privateer.daemon";
25
- const OLD_UNIT = "privateer-daemon.service";
21
+ export type ServiceKind = "harbor" | "channels";
22
+
23
+ interface ServiceSpec {
24
+ label: string; // launchd label / reverse-dns id
25
+ unit: string; // systemd --user unit name
26
+ launcher: string; // bin/ file the unit runs with "run"
27
+ description: string;
28
+ // Pre-rename identity, kept ONLY so install/uninstall can evict a service a user
29
+ // installed before the harbor rename ("daemon") — otherwise it lingers as an orphaned
30
+ // launchd agent / systemd unit still running the old launcher. Never written.
31
+ oldLabel?: string;
32
+ oldUnit?: string;
33
+ }
26
34
 
27
- // Absolute path to the node launcher that boots + runs the harbor (bin/privateer-harbor.mjs).
35
+ const SPECS: Record<ServiceKind, ServiceSpec> = {
36
+ harbor: {
37
+ label: "pro.privateer.harbor",
38
+ unit: "privateer-harbor.service",
39
+ launcher: "privateer-harbor.mjs",
40
+ description: "Privateer resident agent harbor (routines + app-driven task spawns)",
41
+ oldLabel: "pro.privateer.daemon",
42
+ oldUnit: "privateer-daemon.service",
43
+ },
44
+ channels: {
45
+ label: "pro.privateer.channels",
46
+ unit: "privateer-channels.service",
47
+ launcher: "privateer-channels.mjs",
48
+ description: "Privateer channels (Telegram/Slack/Discord/WhatsApp chats to agent turns)",
49
+ },
50
+ };
51
+
52
+ // Absolute path to the node launcher that boots + runs the service (bin/privateer-*.mjs).
28
53
  // Resolved from THIS module so it's correct for both a dev checkout and a global npm
29
54
  // install (…/node_modules/privateer-agent/bin/privateer-harbor.mjs).
30
- function harborLauncherPath(): string {
55
+ function launcherPath(kind: ServiceKind): string {
31
56
  const here = dirname(fileURLToPath(import.meta.url)); // …/src/harbor
32
- return resolve(here, "../../bin/privateer-harbor.mjs");
57
+ return resolve(here, "../../bin", SPECS[kind].launcher);
33
58
  }
34
59
 
35
60
  // The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
@@ -39,8 +64,16 @@ function nodeBinaryPath(): string {
39
64
  return process.execPath;
40
65
  }
41
66
 
42
- function harborLogPath(): string {
43
- return join(globalDir(), "harbor.log");
67
+ // The log FILE the service writes to (exported for the in-process rotation, logRotate.ts). macOS only: launchd appends stdout/stderr here.
68
+ // On Linux the unit logs to the journal (see logLocation), which rotates on its own.
69
+ export function logFilePath(kind: ServiceKind = "harbor"): string {
70
+ return join(globalDir(), `${kind}.log`);
71
+ }
72
+
73
+ // Where to read this service's log, for humans: a file on macOS, a journalctl command
74
+ // on Linux. Reporting harbor.log on Linux pointed people at a file nothing ever wrote.
75
+ function logLocation(kind: ServiceKind, platform: NodeJS.Platform = process.platform): string {
76
+ return platform === "linux" ? `journalctl --user -u ${SPECS[kind].unit}` : logFilePath(kind);
44
77
  }
45
78
 
46
79
  // Whether the interpreter we're baking in is an Electron binary rather than a plain
@@ -79,8 +112,8 @@ function forwardedEnv(): Record<string, string> {
79
112
 
80
113
  // ── macOS (launchd) ─────────────────────────────────────────────────────────────
81
114
 
82
- function launchAgentPath(): string {
83
- return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
115
+ function launchAgentPath(kind: ServiceKind = "harbor"): string {
116
+ return join(homedir(), "Library", "LaunchAgents", `${SPECS[kind].label}.plist`);
84
117
  }
85
118
 
86
119
  function xmlEscape(s: string): string {
@@ -93,20 +126,20 @@ function xmlEscape(s: string): string {
93
126
  // holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
94
127
  // forever — this is what produced a 7 MB log of "already running"), and a deliberate
95
128
  // shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
96
- export function launchdPlist(): string {
97
- const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
129
+ export function launchdPlist(kind: ServiceKind = "harbor"): string {
130
+ const args = [nodeBinaryPath(), launcherPath(kind), "run"];
98
131
  const envVars = forwardedEnv();
99
132
  const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
100
133
  const envXml = Object.entries(envVars)
101
134
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
102
135
  .join("\n");
103
- const log = xmlEscape(harborLogPath());
136
+ const log = xmlEscape(logFilePath(kind));
104
137
  return `<?xml version="1.0" encoding="UTF-8"?>
105
138
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106
139
  <plist version="1.0">
107
140
  <dict>
108
141
  <key>Label</key>
109
- <string>${LABEL}</string>
142
+ <string>${SPECS[kind].label}</string>
110
143
  <key>ProgramArguments</key>
111
144
  <array>
112
145
  ${argXml}
@@ -129,19 +162,21 @@ ${Object.keys(envVars).length ? ` <key>EnvironmentVariables</key>\n <dict>\n${
129
162
 
130
163
  // Evict a pre-rename launchd agent (pro.privateer.daemon) if one is installed, so the
131
164
  // harbor rename doesn't leave the old service running the old launcher alongside it.
132
- function evictOldLaunchd(): void {
133
- const oldPlist = join(homedir(), "Library", "LaunchAgents", `${OLD_LABEL}.plist`);
165
+ function evictOldLaunchd(kind: ServiceKind): void {
166
+ const old = SPECS[kind].oldLabel;
167
+ if (!old) return;
168
+ const oldPlist = join(homedir(), "Library", "LaunchAgents", `${old}.plist`);
134
169
  if (existsSync(oldPlist)) {
135
170
  spawnSync("launchctl", ["unload", "-w", oldPlist], { stdio: "ignore" });
136
171
  rmSync(oldPlist, { force: true });
137
172
  }
138
173
  }
139
174
 
140
- function installLaunchd(): void {
141
- evictOldLaunchd();
142
- const plist = launchAgentPath();
175
+ function installLaunchd(kind: ServiceKind): void {
176
+ evictOldLaunchd(kind);
177
+ const plist = launchAgentPath(kind);
143
178
  mkdirSync(dirname(plist), { recursive: true });
144
- writeFileSync(plist, launchdPlist());
179
+ writeFileSync(plist, launchdPlist(kind));
145
180
  // Unload a prior copy (ignore failure — it may not be loaded), then load with -w so
146
181
  // it's enabled across reboots.
147
182
  spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
@@ -151,9 +186,9 @@ function installLaunchd(): void {
151
186
  }
152
187
  }
153
188
 
154
- function uninstallLaunchd(): void {
155
- evictOldLaunchd();
156
- const plist = launchAgentPath();
189
+ function uninstallLaunchd(kind: ServiceKind): void {
190
+ evictOldLaunchd(kind);
191
+ const plist = launchAgentPath(kind);
157
192
  if (existsSync(plist)) {
158
193
  spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
159
194
  rmSync(plist, { force: true });
@@ -162,65 +197,122 @@ function uninstallLaunchd(): void {
162
197
 
163
198
  // ── Linux (systemd --user) ───────────────────────────────────────────────────────
164
199
 
165
- function systemdUnitPath(): string {
200
+ function systemdUnitPath(kind: ServiceKind = "harbor"): string {
166
201
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
167
- return join(base, "systemd", "user", UNIT);
202
+ return join(base, "systemd", "user", SPECS[kind].unit);
203
+ }
204
+
205
+ // One systemd word: double-quoted with C-style escapes, and `%` doubled — systemd
206
+ // expands %-specifiers inside ExecStart= and Environment=, so a path or value with a
207
+ // literal % would otherwise be rewritten (or rejected) when the unit loads.
208
+ function sdQuote(s: string): string {
209
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
168
210
  }
169
211
 
170
- function systemdUnit(): string {
171
- const exec = [nodeBinaryPath(), harborLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
212
+ // The unit, hardened as far as a --user unit can go everywhere:
213
+ // • NoNewPrivileges — nothing the agent runs can gain privileges (no sudo, no
214
+ // setuid helpers). A routine allowed `bash` runs with exactly the user's rights.
215
+ // • UMask=0077 — every file it creates (results, logs, staged media) is owner-only.
216
+ // • StartLimit* — Restart=on-failure with no limit restarted a harbor that crashes
217
+ // at boot every few seconds forever; five tries in five minutes, then it stays
218
+ // down and `systemctl --user status` says why.
219
+ // • KillMode=mixed — SIGTERM reaches the harbor alone, so its shutdown handler can
220
+ // revoke its sessions; anything still running (MCP servers it spawned) is killed
221
+ // after TimeoutStopSec.
222
+ // Deliberately NOT here: ProtectSystem/ProtectHome/PrivateTmp and the rest of the
223
+ // namespace family. In a user manager they need unprivileged user namespaces, and
224
+ // where those are off (many VPS kernels, hardened distros) systemd refuses to start
225
+ // the unit at all (status=226/NAMESPACE). Run the harbor as a dedicated user instead
226
+ // — that is the boundary that works on every box.
227
+ function systemdUnit(kind: ServiceKind = "harbor"): string {
228
+ const exec = [nodeBinaryPath(), launcherPath(kind), "run"].map(sdQuote).join(" ");
172
229
  const envLines = Object.entries(forwardedEnv())
173
- .map(([k, v]) => `Environment=${k}=${v}`)
230
+ .map(([k, v]) => `Environment=${sdQuote(`${k}=${v}`)}`)
174
231
  .join("\n");
175
232
  return `[Unit]
176
- Description=Privateer resident agent harbor (routines + app-driven task spawns)
233
+ Description=${SPECS[kind].description}
177
234
  After=network-online.target
178
235
  Wants=network-online.target
236
+ StartLimitIntervalSec=300
237
+ StartLimitBurst=5
179
238
 
180
239
  [Service]
181
240
  Type=simple
182
241
  ExecStart=${exec}
183
242
  Restart=on-failure
184
- RestartSec=5
185
- ${envLines}
186
-
243
+ RestartSec=10
244
+ UMask=0077
245
+ NoNewPrivileges=yes
246
+ KillMode=mixed
247
+ TimeoutStopSec=15
248
+ ${envLines ? `${envLines}\n` : ""}
187
249
  [Install]
188
250
  WantedBy=default.target
189
251
  `;
190
252
  }
191
253
 
254
+ /** The generated systemd unit text (exported for tests). */
255
+ export function systemdUnitText(kind: ServiceKind = "harbor"): string {
256
+ return systemdUnit(kind);
257
+ }
258
+
192
259
  // Evict a pre-rename systemd --user unit (privateer-daemon.service) if present, so the
193
260
  // harbor rename doesn't leave the old unit enabled alongside the new one.
194
- function evictOldSystemd(): void {
261
+ function evictOldSystemd(kind: ServiceKind): void {
262
+ const old = SPECS[kind].oldUnit;
263
+ if (!old) return;
195
264
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
196
- const oldUnit = join(base, "systemd", "user", OLD_UNIT);
265
+ const oldUnit = join(base, "systemd", "user", old);
197
266
  if (existsSync(oldUnit)) {
198
- spawnSync("systemctl", ["--user", "disable", "--now", OLD_UNIT], { stdio: "ignore" });
267
+ spawnSync("systemctl", ["--user", "disable", "--now", old], { stdio: "ignore" });
199
268
  rmSync(oldUnit, { force: true });
200
269
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
201
270
  }
202
271
  }
203
272
 
204
- function installSystemd(): void {
205
- evictOldSystemd();
206
- const unit = systemdUnitPath();
273
+ // The login name to linger. $USER is unset under cron, `su -c`, some container shells
274
+ // and CI — and `loginctl enable-linger ""` silently did nothing, so the service died
275
+ // with the SSH session that installed it. The passwd entry is the reliable answer.
276
+ function loginName(): string {
277
+ try {
278
+ return userInfo().username;
279
+ } catch {
280
+ return process.env.USER || process.env.LOGNAME || "";
281
+ }
282
+ }
283
+
284
+ function installSystemd(kind: ServiceKind): void {
285
+ // A box with no systemd user manager (a Docker/LXC container, WSL without systemd)
286
+ // used to fail with systemctl's own cryptic error. Say what to do instead.
287
+ const probe = spawnSync("systemctl", ["--user", "show-environment"], { encoding: "utf8" });
288
+ if (probe.error || probe.status !== 0) {
289
+ throw new Error(
290
+ `No systemd user manager here (${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}). ` +
291
+ `Run \`privateer ${kind} run\` under your own supervisor (a container's restart policy, supervisord, tmux) instead.`,
292
+ );
293
+ }
294
+ evictOldSystemd(kind);
295
+ const unit = systemdUnitPath(kind);
207
296
  mkdirSync(dirname(unit), { recursive: true });
208
- writeFileSync(unit, systemdUnit());
297
+ writeFileSync(unit, systemdUnit(kind));
209
298
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
210
299
  // enable-linger so the user service keeps running with no active login session —
211
300
  // the whole point of "reachable even when no shell is open". Best-effort: it needs
212
301
  // no root on most distros, but don't fail the install if it's disallowed.
213
- spawnSync("loginctl", ["enable-linger", process.env.USER || ""], { stdio: "ignore" });
214
- const r = spawnSync("systemctl", ["--user", "enable", "--now", UNIT], { encoding: "utf8" });
302
+ const user = loginName();
303
+ if (user) spawnSync("loginctl", ["enable-linger", user], { stdio: "ignore" });
304
+ // Clear a StartLimit hit from an earlier, broken install, so --now actually starts it.
305
+ spawnSync("systemctl", ["--user", "reset-failed", SPECS[kind].unit], { stdio: "ignore" });
306
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", SPECS[kind].unit], { encoding: "utf8" });
215
307
  if (r.status !== 0) {
216
308
  throw new Error(`systemctl enable failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
217
309
  }
218
310
  }
219
311
 
220
- function uninstallSystemd(): void {
221
- evictOldSystemd();
222
- const unit = systemdUnitPath();
223
- spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
312
+ function uninstallSystemd(kind: ServiceKind): void {
313
+ evictOldSystemd(kind);
314
+ const unit = systemdUnitPath(kind);
315
+ spawnSync("systemctl", ["--user", "disable", "--now", SPECS[kind].unit], { stdio: "ignore" });
224
316
  if (existsSync(unit)) rmSync(unit, { force: true });
225
317
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
226
318
  }
@@ -273,10 +365,14 @@ export function unitProgramPaths(platform: NodeJS.Platform, unitPath: string): s
273
365
  if (platform === "linux") {
274
366
  const line = body.match(/^ExecStart=(.*)$/m);
275
367
  if (!line) return null;
276
- // Written as single-quoted words with '\'' for an embedded quote (systemdUnit()).
277
- const args = [...line[1].matchAll(/'((?:[^']|'\\'')*)'|(\S+)/g)].map((m) =>
278
- m[1] !== undefined ? m[1].replace(/'\\''/g, "'") : m[2],
279
- );
368
+ // Today's units write double-quoted words with C escapes and %% (sdQuote). Units
369
+ // installed before that wrote single-quoted words with '\'' for an embedded quote;
370
+ // both are still out there, so both parse.
371
+ const args = [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"|'((?:[^']|'\\'')*)'|(\S+)/g)].map((m) => {
372
+ if (m[1] !== undefined) return m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%");
373
+ if (m[2] !== undefined) return m[2].replace(/'\\''/g, "'");
374
+ return m[3];
375
+ });
280
376
  return args.length ? args : null;
281
377
  }
282
378
  return null;
@@ -326,46 +422,69 @@ export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): b
326
422
  return false;
327
423
  }
328
424
  if (platform === "darwin" && /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(body)) return true;
425
+ // The Linux twin of the always-restart plist: no StartLimitBurst means a harbor that
426
+ // crashes at boot is restarted every few seconds, forever.
427
+ if (platform === "linux" && !/^StartLimitBurst=/m.test(body)) return true;
329
428
  const args = unitProgramPaths(platform, unitPath);
330
429
  if (args?.[0] && interpreterIsElectron(args[0]) && !/ELECTRON_RUN_AS_NODE/.test(body)) return true;
331
430
  return false;
332
431
  }
333
432
 
334
- function unitPathFor(platform: NodeJS.Platform): string {
335
- if (platform === "darwin") return launchAgentPath();
336
- if (platform === "linux") return systemdUnitPath();
433
+ function unitPathFor(platform: NodeJS.Platform, kind: ServiceKind): string {
434
+ if (platform === "darwin") return launchAgentPath(kind);
435
+ if (platform === "linux") return systemdUnitPath(kind);
337
436
  return "";
338
437
  }
339
438
 
340
- export function serviceInfo(): ServiceInfo {
439
+ export function serviceInfo(kind: ServiceKind = "harbor"): ServiceInfo {
341
440
  const platform = process.platform;
342
- const unitPath = unitPathFor(platform);
441
+ const unitPath = unitPathFor(platform, kind);
343
442
  return {
344
443
  platform,
345
444
  supported: platform === "darwin" || platform === "linux",
346
445
  installed: !!unitPath && existsSync(unitPath),
347
446
  unitPath,
348
- logPath: harborLogPath(),
447
+ logPath: logLocation(kind, platform),
349
448
  needsRefresh: unitNeedsRefresh(platform, unitPath),
350
449
  stale: !!unitPath && unitIsStale(platform, unitPath),
351
450
  };
352
451
  }
353
452
 
354
453
  // Install the service for the current platform. Idempotent (rewrites + reloads).
355
- export function installService(): ServiceInfo {
454
+ export function installService(kind: ServiceKind = "harbor"): ServiceInfo {
356
455
  const platform = process.platform;
357
- if (platform === "darwin") installLaunchd();
358
- else if (platform === "linux") installSystemd();
359
- else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer harbor\` yourself, or keep a terminal open.`);
360
- return serviceInfo();
456
+ if (platform === "darwin") installLaunchd(kind);
457
+ else if (platform === "linux") installSystemd(kind);
458
+ else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer ${kind}\` yourself, or keep a terminal open.`);
459
+ return serviceInfo(kind);
361
460
  }
362
461
 
363
- export function uninstallService(): ServiceInfo {
462
+ export function uninstallService(kind: ServiceKind = "harbor"): ServiceInfo {
364
463
  const platform = process.platform;
365
- if (platform === "darwin") uninstallLaunchd();
366
- else if (platform === "linux") uninstallSystemd();
464
+ if (platform === "darwin") uninstallLaunchd(kind);
465
+ else if (platform === "linux") uninstallSystemd(kind);
367
466
  else throw new Error(`No service to remove on ${platform}.`);
368
- return serviceInfo();
467
+ return serviceInfo(kind);
468
+ }
469
+
470
+ // `privateer channels status`: the unit, and which platforms the runner is serving
471
+ // right now (its heartbeat — channels/status.ts). Imported lazily: status.ts is
472
+ // import-safe, but this module should not pay for it on the harbor's paths.
473
+ export async function channelsStatusReport(): Promise<string> {
474
+ const info = serviceInfo("channels");
475
+ const { readRunningPlatforms } = await import("../channels/status.ts");
476
+ const live = [...readRunningPlatforms()];
477
+ const lines = [
478
+ `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer channels` manually)"}`,
479
+ `service: ${info.installed ? `installed (${info.unitPath})${info.stale ? " — STALE: run `privateer channels uninstall`" : info.needsRefresh ? " — needs rewriting; run `privateer channels install`" : ""}` : "not installed"}`,
480
+ `channels: ${live.length ? `serving ${live.join(", ")}` : "not running (no fresh heartbeat)"}`,
481
+ `logs: ${info.logPath}`,
482
+ ];
483
+ if (info.installed && !live.length) {
484
+ lines.push("hint: installed but not serving — check the log. With no channels.<platform> block in config.json it exits on purpose.");
485
+ }
486
+ lines.push("note: channel changes from the app apply on restart (`privateer channels install` restarts it).");
487
+ return lines.join("\n");
369
488
  }
370
489
 
371
490
  // Human-readable status for `privateer harbor status`: whether the service is
@@ -406,12 +525,20 @@ export async function statusReport(): Promise<string> {
406
525
  }
407
526
 
408
527
  // Best-effort read of the tail of the harbor log (for a `status --log` affordance or
409
- // error surfacing). Returns "" if absent.
528
+ // error surfacing). Returns "" if absent. Reads only the last `maxBytes` — the log can
529
+ // be megabytes, and this used to load all of it to keep 4 KB.
410
530
  export function tailHarborLog(maxBytes = 4_000): string {
531
+ let fd: number | undefined;
411
532
  try {
412
- const buf = readFileSync(harborLogPath(), "utf8");
413
- return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
533
+ fd = openSync(logFilePath("harbor"), "r");
534
+ const size = fstatSync(fd).size;
535
+ const len = Math.min(size, maxBytes);
536
+ const buf = Buffer.alloc(len);
537
+ readSync(fd, buf, 0, len, size - len);
538
+ return buf.toString("utf8");
414
539
  } catch {
415
540
  return "";
541
+ } finally {
542
+ if (fd !== undefined) closeSync(fd);
416
543
  }
417
544
  }
@@ -21,13 +21,14 @@
21
21
  * Framework-agnostic: nothing here imports React or the relay. The caller owns the
22
22
  * frame plumbing and the running-presence read.
23
23
  */
24
- import { readFileSync, writeFileSync } from "node:fs";
24
+ import { readFileSync } from "node:fs";
25
25
  import { configPath } from "../config/paths.ts";
26
26
  // The platform roster, per-platform secret field names, and the "is this block
27
27
  // startable" predicate all live in ONE place, shared with the channels runtime —
28
28
  // see channels/platforms.ts. Re-exported here so existing importers of
29
29
  // CHANNEL_PLATFORMS/ChannelPlatform from this module keep working unchanged.
30
30
  import { CHANNEL_PLATFORMS, SECRET_FIELDS, isChannelPlatform, type ChannelPlatform } from "../channels/platforms.ts";
31
+ import { writeFileAtomic } from "../util/atomicWrite.ts";
31
32
 
32
33
  export { CHANNEL_PLATFORMS };
33
34
  export type { ChannelPlatform };
@@ -161,7 +162,7 @@ export function makeChannelsControl(opts: {
161
162
 
162
163
  cfg.channels[draft.platform] = block;
163
164
  try {
164
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
165
+ writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
165
166
  } catch (e) {
166
167
  return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
167
168
  }
@@ -174,7 +175,7 @@ export function makeChannelsControl(opts: {
174
175
  if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
175
176
  delete cfg.channels[platform];
176
177
  try {
177
- writeFileSync(configPath(), JSON.stringify(cfg, null, 2));
178
+ writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
178
179
  } catch (e) {
179
180
  return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
180
181
  }
@@ -35,9 +35,10 @@
35
35
  * Framework-agnostic: nothing here imports React or the relay. The caller owns the
36
36
  * frame plumbing and the sealed-secret open.
37
37
  */
38
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
38
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
39
39
  import { join, dirname } from "node:path";
40
40
  import { agentDir } from "../config/paths.ts";
41
+ import { writeFileAtomic } from "../util/atomicWrite.ts";
41
42
 
42
43
  export type McpTransport = "stdio" | "http";
43
44
 
@@ -200,7 +201,7 @@ export function makeMcpControl(opts?: {
200
201
 
201
202
  function writeSource(src: SourceFile): void {
202
203
  mkdirSync(dirname(sourcePath()), { recursive: true });
203
- writeFileSync(sourcePath(), JSON.stringify(src, null, 2) + "\n");
204
+ writeFileAtomic(sourcePath(), JSON.stringify(src, null, 2) + "\n");
204
205
  project(src);
205
206
  }
206
207
 
@@ -220,7 +221,7 @@ export function makeMcpControl(opts?: {
220
221
  mcpServers[name] = toStandard(e);
221
222
  }
222
223
  mkdirSync(dirname(projectionPath()), { recursive: true });
223
- writeFileSync(
224
+ writeFileAtomic(
224
225
  projectionPath(),
225
226
  JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
226
227
  );
@@ -1,4 +1,5 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { writeFileAtomic } from "../util/atomicWrite.ts";
2
3
  import { join } from "node:path";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { globalDir } from "../config/paths.ts";
@@ -64,11 +65,9 @@ export function saveRoutines(routines: Routine[]): void {
64
65
  mkdirSync(dir, { recursive: true });
65
66
  tryChmod(dir, 0o700);
66
67
  const payload: RoutineFile = { routines };
67
- writeFileSync(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", {
68
- encoding: "utf8",
69
- mode: 0o600,
70
- });
71
- tryChmod(routinesFilePath(), 0o600);
68
+ // Atomic: a half-written routines.json reads as "no routines" (loadRoutines), and the
69
+ // next save would then persist only the one routine it was given.
70
+ writeFileAtomic(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
72
71
  }
73
72
 
74
73
  // Look up by id first, then by (case-insensitive) name for CLI convenience.
@@ -99,18 +98,48 @@ export function removeRoutine(idOrName: string): Routine | null {
99
98
  return target;
100
99
  }
101
100
 
102
- // Write a run's result to the routine's output dir: a dated file plus latest.md.
101
+ // Dated result files kept per routine. An hourly routine on an always-on box wrote one
102
+ // every hour, forever; latest.md is what anything reads, and this many is history enough.
103
+ export const ROUTINE_OUTPUT_KEEP = Number(process.env.PRIVATEER_ROUTINE_OUTPUT_KEEP) || 30;
104
+
105
+ // Write a run's result to the routine's output dir: a dated file plus latest.md, then
106
+ // prune the dated files to the newest ROUTINE_OUTPUT_KEEP. Owner-only, like the rest of
107
+ // the global dir: a result can quote whatever the routine read.
103
108
  // Returns the absolute path of latest.md.
104
109
  export function writeRoutineOutput(name: string, content: string): string {
105
110
  const dir = routineOutputDir(name);
106
111
  mkdirSync(dir, { recursive: true });
107
112
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
108
- writeFileSync(join(dir, `${stamp}.md`), content, "utf8");
113
+ writeFileSync(join(dir, `${stamp}.md`), content, { encoding: "utf8", mode: 0o600 });
109
114
  const latest = join(dir, "latest.md");
110
- writeFileSync(latest, content, "utf8");
115
+ writeFileAtomic(latest, content, { mode: 0o600 });
116
+ pruneRoutineOutput(dir, ROUTINE_OUTPUT_KEEP);
111
117
  return latest;
112
118
  }
113
119
 
120
+ // Our dated names (ISO with ':' and '.' → '-') sort chronologically as strings, so the
121
+ // oldest are first. Only files of that shape are touched — anything else a user put in
122
+ // the folder is theirs. Best-effort: a prune failure never fails a delivery.
123
+ const DATED_OUTPUT = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/;
124
+
125
+ export function pruneRoutineOutput(dir: string, keep: number): number {
126
+ let removed = 0;
127
+ try {
128
+ const dated = readdirSync(dir).filter((f) => DATED_OUTPUT.test(f)).sort();
129
+ for (const f of dated.slice(0, Math.max(0, dated.length - keep))) {
130
+ try {
131
+ unlinkSync(join(dir, f));
132
+ removed++;
133
+ } catch {
134
+ /* already gone */
135
+ }
136
+ }
137
+ } catch {
138
+ /* unreadable dir */
139
+ }
140
+ return removed;
141
+ }
142
+
114
143
  // A pending routine result queued for the next interactive session ("notice"
115
144
  // delivery). The TUI drains these on startup so results surface even when no
116
145
  // terminal was attached at fire time.
@@ -144,7 +173,7 @@ export function addNotice(notice: RoutineNotice): void {
144
173
  notices.push(notice);
145
174
  // Keep the queue bounded so an offline stretch can't grow it without limit.
146
175
  const trimmed = notices.slice(-50);
147
- writeFileSync(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
176
+ writeFileAtomic(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
148
177
  tryChmod(noticesPath(), 0o600);
149
178
  }
150
179
 
@@ -189,7 +218,7 @@ export function addPendingRelay(entry: PendingRelay): void {
189
218
  const queue = loadPendingRelay();
190
219
  queue.push(entry);
191
220
  const trimmed = queue.slice(-50); // bound the backlog
192
- writeFileSync(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
221
+ writeFileAtomic(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
193
222
  tryChmod(pendingRelayPath(), 0o600);
194
223
  }
195
224
 
@@ -271,6 +300,6 @@ export function addPendingCloud(entry: PendingCloud): void {
271
300
  export function savePendingCloud(entries: PendingCloud[]): void {
272
301
  const dir = join(globalDir(), "routines");
273
302
  mkdirSync(dir, { recursive: true });
274
- writeFileSync(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
303
+ writeFileAtomic(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
275
304
  tryChmod(pendingCloudPath(), 0o600);
276
305
  }
@@ -0,0 +1,46 @@
1
+ // Replace a file all at once: write a sibling temp file, then rename it over the target.
2
+ //
3
+ // A plain writeFileSync truncates first and writes second, so a crash, a full disk or a
4
+ // kill in between leaves a short or empty file. For the files this is used on that is
5
+ // worse than losing the write: loadRoutines() reads a half-written routines.json as "no
6
+ // routines", and the next save from the app then writes just the one routine it holds —
7
+ // every other routine gone. rename() is atomic on one filesystem, so a reader sees the
8
+ // old file or the new one and never anything between.
9
+ //
10
+ // Permissions carry over from the file being replaced (a config.json someone chmod'ed
11
+ // 0600 stays 0600). A new file gets `mode`, default 0600: everything written through
12
+ // here is config that can hold tokens.
13
+ //
14
+ // IMPORT-SAFETY: node builtins only.
15
+ import { chmodSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { randomBytes } from "node:crypto";
17
+
18
+ export function writeFileAtomic(path: string, data: string, opts: { mode?: number } = {}): void {
19
+ // Follow a symlinked config to the file it names, so we replace the file rather than
20
+ // the link (which would quietly detach a dotfiles-managed config).
21
+ let target = path;
22
+ let mode = opts.mode ?? 0o600;
23
+ try {
24
+ target = realpathSync(path);
25
+ if (opts.mode === undefined) mode = statSync(target).mode & 0o777;
26
+ } catch {
27
+ /* new file */
28
+ }
29
+ const tmp = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
30
+ try {
31
+ writeFileSync(tmp, data, { encoding: "utf8", mode });
32
+ try {
33
+ chmodSync(tmp, mode); // writeFileSync's mode is filtered by the umask
34
+ } catch {
35
+ /* non-POSIX filesystem */
36
+ }
37
+ renameSync(tmp, target);
38
+ } catch (e) {
39
+ try {
40
+ unlinkSync(tmp);
41
+ } catch {
42
+ /* never created */
43
+ }
44
+ throw e;
45
+ }
46
+ }