privateer-agent 0.14.0 → 0.14.2

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.
@@ -0,0 +1,91 @@
1
+ // What to do when a run ends on a reply the model never finished.
2
+ //
3
+ // A provider that stops a reply with `finish_reason: "length"` hit the turn's output
4
+ // cap. The TUI renders it as "Response was truncated before completion." and pi then
5
+ // does one of two things:
6
+ //
7
+ // - output ended BELOW the model's maxTokens: pi reads it as context pressure and
8
+ // compacts and retries the turn once (agent-session _checkCompaction,
9
+ // isRecoverableLength). That case is pi's, and this module stays out of it.
10
+ // - output used the WHOLE budget: pi treats it as final and the run just ends. The
11
+ // agent stops mid-task with nothing to resume it but the user typing "continue".
12
+ //
13
+ // The second case is what real sessions hit, and nearly always the same way: a
14
+ // thinking model spends the entire budget reasoning (measured: z-ai/glm-5.3-flash,
15
+ // 16384 of 16384 output tokens, 45–67k characters of reasoning, no text and no tool
16
+ // call — nine times across one user's logs). Raising maxTokens to the model's real cap
17
+ // (providers/account.ts) makes that rarer; this makes it recoverable when it happens.
18
+ //
19
+ // So: when a run ends on a full-budget length stop, queue one follow-up asking the
20
+ // model to pick up where it was cut off, and keep its reasoning short. Pi continues a
21
+ // message queued from an agent_end handler inside the same run (_handlePostAgentRun →
22
+ // hasQueuedMessages), so headless `-p` runs wait for it too.
23
+ //
24
+ // Bounded: at most MAX_AUTO_CONTINUES in a row. A model that truncates every time
25
+ // would otherwise loop forever, billing a full budget per lap. Any reply that ends
26
+ // some other way resets the count.
27
+ //
28
+ // IMPORT-SAFETY: no Pi imports — the shapes below are the few fields read.
29
+
30
+ export const MAX_AUTO_CONTINUES = 2;
31
+
32
+ export interface TruncationAssistant {
33
+ role?: string;
34
+ stopReason?: string;
35
+ usage?: { output?: number };
36
+ content?: { type?: string }[];
37
+ }
38
+
39
+ export type TruncationDecision =
40
+ | { action: "none" }
41
+ | { action: "continue"; attempt: number; prompt: string }
42
+ | { action: "give-up" };
43
+
44
+ export function lastAssistant(messages: unknown): TruncationAssistant | undefined {
45
+ if (!Array.isArray(messages)) return undefined;
46
+ for (let i = messages.length - 1; i >= 0; i--) {
47
+ const m = messages[i] as TruncationAssistant | undefined;
48
+ if (m?.role === "assistant") return m;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * Whether a length stop used the model's whole output budget. Mirrors the negation of
55
+ * pi-ai's isRecoverableLength: below the budget is pi's compact-and-retry, at it is
56
+ * ours. An unknown budget (0) means we cannot tell, so we leave it alone.
57
+ */
58
+ export function isFullBudgetLengthStop(message: TruncationAssistant | undefined, modelMaxTokens: number): boolean {
59
+ if (message?.stopReason !== "length") return false;
60
+ if (!(modelMaxTokens > 0)) return false;
61
+ const output = message.usage?.output;
62
+ return typeof output === "number" && output >= modelMaxTokens;
63
+ }
64
+
65
+ /**
66
+ * The follow-up text. Says what happened in terms the model can act on: a cut-off
67
+ * spent on reasoning needs a terser plan, a cut-off mid-tool-call needs the call
68
+ * re-issued (its arguments were incomplete and never ran).
69
+ */
70
+ export function continuationPrompt(message: TruncationAssistant): string {
71
+ const kinds = new Set((message.content ?? []).map((c) => c?.type));
72
+ const base = "Your previous reply hit the output limit and was cut off before it finished.";
73
+ if (kinds.has("toolCall")) {
74
+ return `${base} The tool call at the end was incomplete and did not run — issue it again in full. Keep any reasoning brief.`;
75
+ }
76
+ if (!kinds.has("text")) {
77
+ return `${base} It was spent entirely on reasoning. Don't restate it: keep thinking short and go straight to the next tool call or your answer.`;
78
+ }
79
+ return `${base} Continue exactly where you left off, without repeating what you already wrote. Keep any reasoning brief.`;
80
+ }
81
+
82
+ export function decideTruncationRecovery(
83
+ messages: unknown,
84
+ modelMaxTokens: number,
85
+ consecutive: number,
86
+ ): TruncationDecision {
87
+ const last = lastAssistant(messages);
88
+ if (!isFullBudgetLengthStop(last, modelMaxTokens)) return { action: "none" };
89
+ if (consecutive >= MAX_AUTO_CONTINUES) return { action: "give-up" };
90
+ return { action: "continue", attempt: consecutive + 1, prompt: continuationPrompt(last!) };
91
+ }
@@ -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
  }