privateer-agent 0.6.9 → 0.7.0

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.
Files changed (41) hide show
  1. package/README.md +20 -20
  2. package/SECURITY.md +1 -1
  3. package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
  4. package/bin/privateer-launch.mjs +7 -6
  5. package/bin/privateer-subagent.mjs +1 -1
  6. package/extensions/privateer-brand.ts +1 -1
  7. package/extensions/privateer-connect.ts +14 -3
  8. package/extensions/privateer-tools.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/auth/privateer.ts +2 -2
  11. package/src/channels/run.ts +5 -5
  12. package/src/channels/status.ts +7 -7
  13. package/src/cli/chat.ts +29 -3
  14. package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
  15. package/src/config/hosted.ts +5 -5
  16. package/src/crypto/accountTrust.ts +2 -2
  17. package/src/crypto/accountVerify.ts +1 -1
  18. package/src/{daemon → harbor}/index.ts +37 -37
  19. package/src/{daemon → harbor}/ipc.ts +19 -19
  20. package/src/{daemon → harbor}/service.ts +61 -28
  21. package/src/main.ts +1 -1
  22. package/src/providers/account.ts +1 -1
  23. package/src/providers/defaultModel.ts +1 -1
  24. package/src/remote/channelsControl.ts +8 -8
  25. package/src/remote/controlAuth.ts +1 -1
  26. package/src/remote/liveTaskSession.ts +4 -4
  27. package/src/remote/mcpControl.ts +2 -2
  28. package/src/remote/relayClient.ts +41 -21
  29. package/src/remote/remoteBridge.ts +17 -7
  30. package/src/remote/routinesControl.ts +7 -7
  31. package/src/remote/workflowsControl.ts +6 -6
  32. package/src/routines/delivery.ts +6 -6
  33. package/src/routines/schema.ts +3 -3
  34. package/src/routines/store.ts +3 -3
  35. package/src/routines/trigger.ts +1 -1
  36. package/src/tools/routine.ts +8 -8
  37. package/src/util/fileMentions.ts +232 -0
  38. package/src/workflows/expr.ts +1 -1
  39. package/src/workflows/runner.ts +3 -3
  40. package/src/workflows/schema.ts +1 -1
  41. package/src/workflows/store.ts +1 -1
@@ -2,7 +2,7 @@ import type { Server } from "node:net";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- // Pi session stack. The daemon MUST be launched after ./boot.ts (env +
5
+ // Pi session stack. The harbor MUST be launched after ./boot.ts (env +
6
6
  // attestation dispatcher) — these are evaluated on import.
7
7
  import {
8
8
  createAgentSessionServices,
@@ -71,15 +71,15 @@ const MAX_CLOUD_PLAINTEXT = 45_000;
71
71
  // a stuck graph from pinning a `running` slot forever when the controller wanders off.
72
72
  const GATE_TIMEOUT_MS = 5 * 60_000;
73
73
 
74
- interface DaemonConfig {
74
+ interface HarborConfig {
75
75
  defaultModel: string;
76
76
  webhooks?: Record<string, { url: string; secret?: string; headers?: Record<string, string> }>;
77
77
  providers?: Record<string, { apiKey?: string } | undefined>;
78
78
  }
79
79
 
80
80
  // Minimal config read (webhooks + providers-for-redaction + default model). The
81
- // full config layer is a Phase-7 port; the daemon only needs these fields.
82
- function loadDaemonConfig(): DaemonConfig {
81
+ // full config layer is a Phase-7 port; the harbor only needs these fields.
82
+ function loadHarborConfig(): HarborConfig {
83
83
  try {
84
84
  const raw = JSON.parse(readFileSync(configPath(), "utf8"));
85
85
  return {
@@ -156,7 +156,7 @@ export function taskControlArgs(spec: TaskSpec): Record<string, unknown> {
156
156
  };
157
157
  }
158
158
 
159
- export class Daemon {
159
+ export class Harbor {
160
160
  private server?: Server;
161
161
  private timer?: ReturnType<typeof setInterval>;
162
162
  private readonly startedAt = Date.now();
@@ -170,36 +170,36 @@ export class Daemon {
170
170
  // socket stays open).
171
171
  private lastActivityAt = Date.now();
172
172
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
173
- // terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
173
+ // terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
174
174
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
175
175
 
176
- // App-facing routine management (list/save/delete/pause/run) over the daemon's
177
- // relay. Run-now is injected here since only the daemon can actually fire one;
176
+ // App-facing routine management (list/save/delete/pause/run) over the harbor's
177
+ // relay. Run-now is injected here since only the harbor can actually fire one;
178
178
  // webhook validation reads config fresh so a just-declared endpoint is honored.
179
179
  private readonly routines = makeRoutinesControl({
180
180
  defaultCwd: () => process.cwd(),
181
- webhookExists: (name) => !!loadDaemonConfig().webhooks?.[name],
181
+ webhookExists: (name) => !!loadHarborConfig().webhooks?.[name],
182
182
  runNow: (routine) => void this.runRoutine(routine),
183
183
  });
184
184
 
185
- // App-facing channel management (list/save/remove) over the daemon's relay. The
186
- // channels daemon (channels/run.ts) is a SEPARATE process that may be down, so
185
+ // App-facing channel management (list/save/remove) over the harbor's relay. The
186
+ // channels harbor (channels/run.ts) is a SEPARATE process that may be down, so
187
187
  // this edits config.json directly; `runningPlatforms` is a best-effort heartbeat
188
188
  // read for a live/offline badge, never a dependency. Edits apply on the channels
189
- // daemon's next restart (its deliberate fail-safe posture).
189
+ // harbor's next restart (its deliberate fail-safe posture).
190
190
  private readonly channels = makeChannelsControl({
191
191
  runningPlatforms: () => readRunningPlatforms(),
192
192
  });
193
193
 
194
194
  // App-facing MCP connector management (list/save/set_enabled/remove) over the
195
- // daemon's relay — the daemon is the Node HOST that actually runs the adapter (a
195
+ // harbor's relay — the harbor is the Node HOST that actually runs the adapter (a
196
196
  // phone/web client can't). Edits the SHARED agent/mcp-desktop.json + mcp.json, so a
197
197
  // machine has one MCP config whether it was set from the desktop (IPC) or the phone
198
198
  // (relay). Tokens ride in a SEALED box (applyMcpSave) — the relay never sees them.
199
199
  private readonly mcp = makeMcpControl();
200
200
 
201
- // App-facing workflow management (list/get/save/remove/run) over the daemon's relay.
202
- // Run-now is injected here since only the daemon owns the runner + its seams. A
201
+ // App-facing workflow management (list/get/save/remove/run) over the harbor's relay.
202
+ // Run-now is injected here since only the harbor owns the runner + its seams. A
203
203
  // workflow can carry a `script` step (RCE if forged), so every mutation is
204
204
  // account-signed + verified (guardControl) before reaching this control.
205
205
  private readonly workflows = makeWorkflowsControl({
@@ -236,7 +236,7 @@ export class Daemon {
236
236
  this.syncRelay();
237
237
  void this.flushPendingCloud();
238
238
  const count = loadRoutines().filter((r) => r.enabled).length;
239
- log(`daemon started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
239
+ log(`harbor started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
240
240
  void this.tick();
241
241
  }
242
242
 
@@ -261,7 +261,7 @@ export class Daemon {
261
261
  onInterrupt: () => {},
262
262
  // A workflow human_gate / script-approval is surfaced as a select_request; the app
263
263
  // answers with select_response (option name) or an approval_response (allow/deny).
264
- // Both resolve the pending gate — otherwise the daemon relay ignores approvals.
264
+ // Both resolve the pending gate — otherwise the harbor relay ignores approvals.
265
265
  onApprovalResponse: (id, decision) => this.resolveGate(id, decision === "deny" ? "deny" : "approve"),
266
266
  onSelectResponse: (id, value) => this.resolveGate(id, value),
267
267
  onControllerAttached: () => this.onControllerAttached(),
@@ -321,20 +321,20 @@ export class Daemon {
321
321
  this.controllerAttached = false;
322
322
  this.relay?.stop();
323
323
  this.relay = undefined;
324
- log("relay terminated from the app; staying offline until the daemon restarts");
324
+ log("relay terminated from the app; staying offline until the harbor restarts");
325
325
  },
326
- // The account signed this daemon out server-side (revoked from the app's Linked
326
+ // The account signed this harbor out server-side (revoked from the app's Linked
327
327
  // Devices). Beyond ending remote access (onTerminate), this wipes the machine
328
328
  // login: drop the relay and clear credentials, so routines/tasks stop cleanly
329
329
  // instead of dead-ending on a 401 each run. Stays idle until you /login on this
330
- // machine and restart the daemon (the relayTerminated guard, as with onTerminate).
330
+ // machine and restart the harbor (the relayTerminated guard, as with onTerminate).
331
331
  onRevoked: () => {
332
332
  this.relayTerminated = true;
333
333
  this.controllerAttached = false;
334
334
  this.relay?.stop();
335
335
  this.relay = undefined;
336
336
  handleServerRevoke();
337
- log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the daemon");
337
+ log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the harbor");
338
338
  },
339
339
  onStatus: (text) => log(`relay: ${text}`),
340
340
  onDisconnected: () => {
@@ -448,7 +448,7 @@ export class Daemon {
448
448
  return this.channels.save(withSecrets as any).message;
449
449
  }
450
450
 
451
- // Verify an account-signed mutating control frame (H2) against this daemon's termId,
451
+ // Verify an account-signed mutating control frame (H2) against this harbor's termId,
452
452
  // then run the mutation. Fail-closed: an unsigned/forged/stale frame returns the
453
453
  // refusal message and the mutation NEVER runs. `routines_*` and `channels_remove`
454
454
  // route through here; `channels_save` has its own verify (sealed secrets) above.
@@ -577,7 +577,7 @@ export class Daemon {
577
577
  }
578
578
 
579
579
  // ── Harbor hosted mode ──────────────────────────────────────────────────────
580
- // A hosted daemon runs on-demand: it reports its earliest upcoming fire time so
580
+ // A hosted harbor runs on-demand: it reports its earliest upcoming fire time so
581
581
  // the server can wake it while suspended, and idle-suspends when there's no work.
582
582
  // No-op on a user's own machine (isHosted() === false).
583
583
 
@@ -652,7 +652,7 @@ export class Daemon {
652
652
  this.markActivity(); // hosted: work in progress — don't idle-suspend under it
653
653
  log(`running routine "${routine.name}"`);
654
654
 
655
- const config = loadDaemonConfig();
655
+ const config = loadHarborConfig();
656
656
  const modelSpec = routine.model ?? config.defaultModel;
657
657
  const split = splitRoutineTools(routine.tools);
658
658
  // MCP tools (server__tool) join the allow-list: the mcpAdapter loaded in runSession
@@ -770,7 +770,7 @@ export class Daemon {
770
770
  status = "error";
771
771
  error = err instanceof Error ? err.message : String(err);
772
772
  } finally {
773
- // Revoke ONLY this run's account inference session (the daemon's own child API
773
+ // Revoke ONLY this run's account inference session (the harbor's own child API
774
774
  // session — relay/outbox — stays alive until shutdown). Drop Pi's persisted copy
775
775
  // too so a later run's fallback never reuses a revoked token. Best-effort.
776
776
  if (spawnedAccount) {
@@ -788,7 +788,7 @@ export class Daemon {
788
788
  // just executes. Concurrency-guarded by a `task:<title>` key in `this.running` (never
789
789
  // collides with routine ids, which are uuids).
790
790
  async runTask(spec: TaskSpec): Promise<void> {
791
- const config = loadDaemonConfig();
791
+ const config = loadHarborConfig();
792
792
  const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
793
793
  const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
794
794
  const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
@@ -825,7 +825,7 @@ export class Daemon {
825
825
  void (async () => {
826
826
  try {
827
827
  const handle = await createLiveTaskSession(spec, {
828
- defaultModel: loadDaemonConfig().defaultModel,
828
+ defaultModel: loadHarborConfig().defaultModel,
829
829
  parseSpec,
830
830
  log,
831
831
  onClosed: (id) => this.liveTasks.delete(id),
@@ -845,7 +845,7 @@ export class Daemon {
845
845
 
846
846
  // Run a saved workflow graph to completion (workflows_run / the injected runNow). The
847
847
  // signed-frame gate (guardControl, STRICT) already ran before this is reached. Wires the
848
- // runner's injected seams to the daemon's real capabilities: agent steps → runSession
848
+ // runner's injected seams to the harbor's real capabilities: agent steps → runSession
849
849
  // (SAFE_TOOLS gate), gates → relay approvals, scripts → a gated child process (only when
850
850
  // attended + approved; the runner fail-closes an unattended script itself), and the
851
851
  // result is sealed to the outbox + mirrored live, exactly like an ad-hoc task.
@@ -863,9 +863,9 @@ export class Daemon {
863
863
  runScript: (step, cwd) => this.runScript(step, cwd),
864
864
  askGate: (step, promptText) => this.askGate(step.options.map((o) => ({ name: o.name, description: o.description })), promptText),
865
865
  attended: () => this.controllerAttached,
866
- // Preserve the daemon's one-at-a-time discipline: fan-out (parallel/for_each) runs
866
+ // Preserve the harbor's one-at-a-time discipline: fan-out (parallel/for_each) runs
867
867
  // sequentially here, so a workflow never spawns concurrent headless sessions on the
868
- // resident daemon. (The standalone runner defaults to 4; a UI host can raise it.)
868
+ // resident harbor. (The standalone runner defaults to 4; a UI host can raise it.)
869
869
  concurrency: 1,
870
870
  // An effectful step reached while unattended: seal a "needs approval" notice so the
871
871
  // user catches up, and (if a controller is somehow attached) surface it live.
@@ -878,7 +878,7 @@ export class Daemon {
878
878
  },
879
879
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
880
880
  log: (m) => log(` [wf ${wf.workflow.name}] ${m}`),
881
- // Live progress: announce each step start in the daemon terminal's feed.
881
+ // Live progress: announce each step start in the harbor terminal's feed.
882
882
  onEvent: (ev) => {
883
883
  if (ev.type === "step_start") this.relay?.sendNotice(`▶ ${ev.name}`);
884
884
  },
@@ -969,7 +969,7 @@ export class Daemon {
969
969
  // (so a later step can route on `{{ step.output.field }}`). Non-JSON output leaves
970
970
  // `output` empty and lives in `text` — the raw/display path.
971
971
  private async runWorkflowAgent(spec: AgentRunSpec): Promise<AgentRunResult> {
972
- const config = loadDaemonConfig();
972
+ const config = loadHarborConfig();
973
973
  const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
974
974
  const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
975
975
  const tools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
@@ -1031,13 +1031,13 @@ export class Daemon {
1031
1031
  }
1032
1032
  }
1033
1033
 
1034
- // Entry point for `privateer daemon`. Caller must have imported ./boot.ts first.
1035
- export function runDaemon(): void {
1036
- const daemon = new Daemon();
1037
- daemon.start();
1034
+ // Entry point for `privateer harbor`. Caller must have imported ./boot.ts first.
1035
+ export function runHarbor(): void {
1036
+ const harbor = new Harbor();
1037
+ harbor.start();
1038
1038
  const shutdown = () => {
1039
1039
  log("shutting down");
1040
- daemon.stop();
1040
+ harbor.stop();
1041
1041
  void revokeLocalSessions().finally(() => process.exit(0));
1042
1042
  };
1043
1043
  process.on("SIGINT", shutdown);
@@ -4,12 +4,12 @@ import { join } from "node:path";
4
4
  import { globalDir } from "../config/paths.ts";
5
5
  import type { Routine } from "../routines/schema.ts";
6
6
 
7
- // The CLI/TUI talks to the resident daemon over a unix domain socket. The protocol
7
+ // The CLI/TUI talks to the resident harbor over a unix domain socket. The protocol
8
8
  // is one JSON request per connection, answered with one JSON response, both
9
9
  // newline-terminated. Kept tiny and local — nothing crosses the machine boundary.
10
10
 
11
- export function daemonSocketPath(): string {
12
- return join(globalDir(), "daemon.sock");
11
+ export function harborSocketPath(): string {
12
+ return join(globalDir(), "harbor.sock");
13
13
  }
14
14
 
15
15
  export type IpcRequest =
@@ -26,16 +26,16 @@ export interface IpcResponse {
26
26
  ok: boolean;
27
27
  message?: string;
28
28
  routines?: Routine[];
29
- // Daemon liveness/uptime for `status`.
29
+ // Harbor liveness/uptime for `status`.
30
30
  pid?: number;
31
31
  uptimeSec?: number;
32
32
  }
33
33
 
34
34
  export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
35
35
 
36
- // Start the daemon-side socket server. Returns the Server so the caller can close it.
36
+ // Start the harbor-side socket server. Returns the Server so the caller can close it.
37
37
  export function startIpcServer(handler: IpcHandler): Server {
38
- const path = daemonSocketPath();
38
+ const path = harborSocketPath();
39
39
  // A stale socket file from a previous crash would block bind; remove it first.
40
40
  if (existsSync(path)) {
41
41
  try {
@@ -73,20 +73,20 @@ export function startIpcServer(handler: IpcHandler): Server {
73
73
  return server;
74
74
  }
75
75
 
76
- // Client side: send one request, resolve with the response. Rejects if the daemon
76
+ // Client side: send one request, resolve with the response. Rejects if the harbor
77
77
  // isn't running (no socket / connection refused) so callers can offer to start it.
78
- export function sendToDaemon(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
79
- const path = daemonSocketPath();
78
+ export function sendToHarbor(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
79
+ const path = harborSocketPath();
80
80
  return new Promise<IpcResponse>((resolve, reject) => {
81
81
  if (!existsSync(path)) {
82
- reject(new DaemonNotRunningError());
82
+ reject(new HarborNotRunningError());
83
83
  return;
84
84
  }
85
85
  const sock = createConnection(path);
86
86
  let buf = "";
87
87
  const timer = setTimeout(() => {
88
88
  sock.destroy();
89
- reject(new Error("daemon did not respond in time"));
89
+ reject(new Error("harbor did not respond in time"));
90
90
  }, timeoutMs);
91
91
  sock.on("connect", () => sock.end(JSON.stringify(req) + "\n"));
92
92
  sock.on("data", (chunk) => {
@@ -97,29 +97,29 @@ export function sendToDaemon(req: IpcRequest, timeoutMs = 5_000): Promise<IpcRes
97
97
  try {
98
98
  resolve(JSON.parse(buf.trim()) as IpcResponse);
99
99
  } catch {
100
- reject(new Error("malformed response from daemon"));
100
+ reject(new Error("malformed response from harbor"));
101
101
  }
102
102
  });
103
103
  sock.on("error", (err: NodeJS.ErrnoException) => {
104
104
  clearTimeout(timer);
105
105
  // ECONNREFUSED means a stale socket file with no listener behind it.
106
- if (err.code === "ENOENT" || err.code === "ECONNREFUSED") reject(new DaemonNotRunningError());
106
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") reject(new HarborNotRunningError());
107
107
  else reject(err);
108
108
  });
109
109
  });
110
110
  }
111
111
 
112
- export class DaemonNotRunningError extends Error {
112
+ export class HarborNotRunningError extends Error {
113
113
  constructor() {
114
- super("Privateer daemon is not running. Start it with `privateer daemon`.");
115
- this.name = "DaemonNotRunningError";
114
+ super("Harbor is not running. Start it with `privateer harbor`.");
115
+ this.name = "HarborNotRunningError";
116
116
  }
117
117
  }
118
118
 
119
- // Convenience: is the daemon reachable right now?
120
- export async function daemonIsRunning(): Promise<boolean> {
119
+ // Convenience: is the harbor reachable right now?
120
+ export async function harborIsRunning(): Promise<boolean> {
121
121
  try {
122
- const res = await sendToDaemon({ cmd: "status" }, 2_000);
122
+ const res = await sendToHarbor({ cmd: "status" }, 2_000);
123
123
  return res.ok;
124
124
  } catch {
125
125
  return false;
@@ -1,28 +1,35 @@
1
- // Install the resident daemon as a per-user OS service so it auto-starts at login
1
+ // Install the resident harbor as a per-user OS service so it auto-starts at login
2
2
  // and survives the terminal closing — the difference between "the CLI is running"
3
- // and "the daemon is reachable from the app even when no CLI is". macOS → launchd
3
+ // and "the harbor is reachable from the app even when no CLI is". macOS → launchd
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
7
  // ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
8
- // so the daemon CLI can load it without going through boot.ts.
8
+ // so the harbor CLI can load it without going through boot.ts.
9
9
  import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { homedir } from "node:os";
12
12
  import { join, dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { globalDir } from "../config/paths.ts";
15
- import { daemonIsRunning } from "./ipc.ts";
15
+ import { harborIsRunning } from "./ipc.ts";
16
16
 
17
- const LABEL = "pro.privateer.daemon"; // launchd label / reverse-dns id
18
- const UNIT = "privateer-daemon.service"; // systemd --user unit name
17
+ const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
18
+ const UNIT = "privateer-harbor.service"; // systemd --user unit name
19
19
 
20
- // Absolute path to the node launcher that boots + runs the daemon (bin/privateer-daemon.mjs).
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";
26
+
27
+ // Absolute path to the node launcher that boots + runs the harbor (bin/privateer-harbor.mjs).
21
28
  // Resolved from THIS module so it's correct for both a dev checkout and a global npm
22
- // install (…/node_modules/privateer-agent/bin/privateer-daemon.mjs).
23
- function daemonLauncherPath(): string {
24
- const here = dirname(fileURLToPath(import.meta.url)); // …/src/daemon
25
- return resolve(here, "../../bin/privateer-daemon.mjs");
29
+ // install (…/node_modules/privateer-agent/bin/privateer-harbor.mjs).
30
+ function harborLauncherPath(): string {
31
+ const here = dirname(fileURLToPath(import.meta.url)); // …/src/harbor
32
+ return resolve(here, "../../bin/privateer-harbor.mjs");
26
33
  }
27
34
 
28
35
  // The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
@@ -32,12 +39,12 @@ function nodeBinaryPath(): string {
32
39
  return process.execPath;
33
40
  }
34
41
 
35
- function daemonLogPath(): string {
36
- return join(globalDir(), "daemon.log");
42
+ function harborLogPath(): string {
43
+ return join(globalDir(), "harbor.log");
37
44
  }
38
45
 
39
46
  // Env we forward into the service so a non-default home / server URL survives. Kept
40
- // tiny and explicit — the daemon reads the rest from ~/.privateer.
47
+ // tiny and explicit — the harbor reads the rest from ~/.privateer.
41
48
  function forwardedEnv(): Record<string, string> {
42
49
  const env: Record<string, string> = {};
43
50
  if (process.env.PRIVATEER_HOME) env.PRIVATEER_HOME = process.env.PRIVATEER_HOME;
@@ -56,13 +63,13 @@ function xmlEscape(s: string): string {
56
63
  }
57
64
 
58
65
  function launchdPlist(): string {
59
- const args = [nodeBinaryPath(), daemonLauncherPath(), "run"];
66
+ const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
60
67
  const envVars = forwardedEnv();
61
68
  const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
62
69
  const envXml = Object.entries(envVars)
63
70
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
64
71
  .join("\n");
65
- const log = xmlEscape(daemonLogPath());
72
+ const log = xmlEscape(harborLogPath());
66
73
  return `<?xml version="1.0" encoding="UTF-8"?>
67
74
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
68
75
  <plist version="1.0">
@@ -86,7 +93,18 @@ ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVa
86
93
  `;
87
94
  }
88
95
 
96
+ // Evict a pre-rename launchd agent (pro.privateer.daemon) if one is installed, so the
97
+ // harbor rename doesn't leave the old service running the old launcher alongside it.
98
+ function evictOldLaunchd(): void {
99
+ const oldPlist = join(homedir(), "Library", "LaunchAgents", `${OLD_LABEL}.plist`);
100
+ if (existsSync(oldPlist)) {
101
+ spawnSync("launchctl", ["unload", "-w", oldPlist], { stdio: "ignore" });
102
+ rmSync(oldPlist, { force: true });
103
+ }
104
+ }
105
+
89
106
  function installLaunchd(): void {
107
+ evictOldLaunchd();
90
108
  const plist = launchAgentPath();
91
109
  mkdirSync(dirname(plist), { recursive: true });
92
110
  writeFileSync(plist, launchdPlist());
@@ -100,6 +118,7 @@ function installLaunchd(): void {
100
118
  }
101
119
 
102
120
  function uninstallLaunchd(): void {
121
+ evictOldLaunchd();
103
122
  const plist = launchAgentPath();
104
123
  if (existsSync(plist)) {
105
124
  spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
@@ -115,12 +134,12 @@ function systemdUnitPath(): string {
115
134
  }
116
135
 
117
136
  function systemdUnit(): string {
118
- const exec = [nodeBinaryPath(), daemonLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
137
+ const exec = [nodeBinaryPath(), harborLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
119
138
  const envLines = Object.entries(forwardedEnv())
120
139
  .map(([k, v]) => `Environment=${k}=${v}`)
121
140
  .join("\n");
122
141
  return `[Unit]
123
- Description=Privateer resident agent daemon (routines + app-driven task spawns)
142
+ Description=Privateer resident agent harbor (routines + app-driven task spawns)
124
143
  After=network-online.target
125
144
  Wants=network-online.target
126
145
 
@@ -136,7 +155,20 @@ WantedBy=default.target
136
155
  `;
137
156
  }
138
157
 
158
+ // Evict a pre-rename systemd --user unit (privateer-daemon.service) if present, so the
159
+ // harbor rename doesn't leave the old unit enabled alongside the new one.
160
+ function evictOldSystemd(): void {
161
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
162
+ const oldUnit = join(base, "systemd", "user", OLD_UNIT);
163
+ if (existsSync(oldUnit)) {
164
+ spawnSync("systemctl", ["--user", "disable", "--now", OLD_UNIT], { stdio: "ignore" });
165
+ rmSync(oldUnit, { force: true });
166
+ spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
167
+ }
168
+ }
169
+
139
170
  function installSystemd(): void {
171
+ evictOldSystemd();
140
172
  const unit = systemdUnitPath();
141
173
  mkdirSync(dirname(unit), { recursive: true });
142
174
  writeFileSync(unit, systemdUnit());
@@ -152,6 +184,7 @@ function installSystemd(): void {
152
184
  }
153
185
 
154
186
  function uninstallSystemd(): void {
187
+ evictOldSystemd();
155
188
  const unit = systemdUnitPath();
156
189
  spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
157
190
  if (existsSync(unit)) rmSync(unit, { force: true });
@@ -182,7 +215,7 @@ export function serviceInfo(): ServiceInfo {
182
215
  supported: platform === "darwin" || platform === "linux",
183
216
  installed: !!unitPath && existsSync(unitPath),
184
217
  unitPath,
185
- logPath: daemonLogPath(),
218
+ logPath: harborLogPath(),
186
219
  };
187
220
  }
188
221
 
@@ -191,7 +224,7 @@ export function installService(): ServiceInfo {
191
224
  const platform = process.platform;
192
225
  if (platform === "darwin") installLaunchd();
193
226
  else if (platform === "linux") installSystemd();
194
- else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer daemon\` yourself, or keep a terminal open.`);
227
+ else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer harbor\` yourself, or keep a terminal open.`);
195
228
  return serviceInfo();
196
229
  }
197
230
 
@@ -203,15 +236,15 @@ export function uninstallService(): ServiceInfo {
203
236
  return serviceInfo();
204
237
  }
205
238
 
206
- // Human-readable status line for `privateer daemon status`: whether the service is
207
- // installed AND whether a daemon is actually answering on the IPC socket right now.
239
+ // Human-readable status line for `privateer harbor status`: whether the service is
240
+ // installed AND whether a harbor is actually answering on the IPC socket right now.
208
241
  export async function statusReport(): Promise<string> {
209
242
  const info = serviceInfo();
210
- const live = await daemonIsRunning();
243
+ const live = await harborIsRunning();
211
244
  const lines = [
212
- `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer daemon` manually)"}`,
245
+ `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
213
246
  `service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
214
- `daemon: ${live ? "running (answering IPC)" : "not reachable"}`,
247
+ `harbor: ${live ? "running (answering IPC)" : "not reachable"}`,
215
248
  `logs: ${info.logPath}`,
216
249
  ];
217
250
  // Surface a stale-unit hint: file present but nothing answering usually means it
@@ -220,11 +253,11 @@ export async function statusReport(): Promise<string> {
220
253
  return lines.join("\n");
221
254
  }
222
255
 
223
- // Best-effort read of the tail of the daemon log (for a `status --log` affordance or
256
+ // Best-effort read of the tail of the harbor log (for a `status --log` affordance or
224
257
  // error surfacing). Returns "" if absent.
225
- export function tailDaemonLog(maxBytes = 4_000): string {
258
+ export function tailHarborLog(maxBytes = 4_000): string {
226
259
  try {
227
- const buf = readFileSync(daemonLogPath(), "utf8");
260
+ const buf = readFileSync(harborLogPath(), "utf8");
228
261
  return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
229
262
  } catch {
230
263
  return "";
package/src/main.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Phase 1 skeleton: this prints the resolved boot state so `npm start` proves
6
6
  // the boot chain end-to-end (env pinned, dispatcher installed) without a TUI.
7
- // Phases 4/6 replace the body with the daemon/relay wiring and the pi-tui app.
7
+ // Phases 4/6 replace the body with the harbor/relay wiring and the pi-tui app.
8
8
 
9
9
  import "./boot.ts";
10
10
 
@@ -322,7 +322,7 @@ export function makeAccountProvider() {
322
322
  // auth.json entry (see the LIFECYCLE HAZARD note in src/auth/privateer.ts). So a
323
323
  // signed-in user who quits and relaunches lands on privateer/* with no key at
324
324
  // all, and the first prompt dead-ends on "No API key found for privateer." — even
325
- // though the banner says "connected". The REPL (cli/chat.ts) and the daemon
325
+ // though the banner says "connected". The REPL (cli/chat.ts) and the harbor
326
326
  // already spawn one at startup; this gives the TUI the same seed.
327
327
  pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
328
328
  };
@@ -1,6 +1,6 @@
1
1
  // The single source of truth for "which model do we default to?" — shared by every
2
2
  // entry point that has to pick a model when the user hasn't named one: the REPL
3
- // (cli/chat.ts), the daemon (routines), the channels runner, and the login-time hook
3
+ // (cli/chat.ts), the harbor (routines), the channels runner, and the login-time hook
4
4
  // that seeds Pi's TUI default (ensurePiDefaultModel).
5
5
  //
6
6
  // The bug this fixes: each of those sites used to hardcode `openrouter/openai/gpt-4o-
@@ -3,13 +3,13 @@
3
3
  * messaging-channel config (Telegram/Slack/Discord/WhatsApp) rather than scheduled
4
4
  * routines.
5
5
  *
6
- * Like routines, this is owned by the ALWAYS-ON daemon relay (daemon/index.ts),
7
- * NOT the channels daemon (channels/run.ts) — which may be down, and which the app
6
+ * Like routines, this is owned by the ALWAYS-ON harbor relay (harbor/index.ts),
7
+ * NOT the channels harbor (channels/run.ts) — which may be down, and which the app
8
8
  * must be able to configure before it has ever run. So this control edits the
9
- * `channels` block of ~/.privateer/config.json; the channels daemon adopts changes
9
+ * `channels` block of ~/.privateer/config.json; the channels harbor adopts changes
10
10
  * on its next RESTART, matching run.ts's deliberate "no in-chat toggle, restart is
11
11
  * the fail-safe reset" posture. `running` is a best-effort read of the channels
12
- * daemon's heartbeat (channels/status.ts), never a dependency.
12
+ * harbor's heartbeat (channels/status.ts), never a dependency.
13
13
  *
14
14
  * SECRETS: bot tokens are WRITE-ONLY from the app's perspective. list() NEVER
15
15
  * returns a token value — it reports `configured`, `running`, and `secretsSet`
@@ -46,7 +46,7 @@ const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
46
46
  export interface RemoteChannel {
47
47
  platform: ChannelPlatform;
48
48
  configured: boolean; // a config block exists for this platform
49
- running: boolean; // the channels daemon is currently serving it
49
+ running: boolean; // the channels harbor is currently serving it
50
50
  adminCount: number;
51
51
  memberCount: number;
52
52
  posture: ChannelPosture;
@@ -94,7 +94,7 @@ function cleanStrList(v: unknown): string[] | undefined {
94
94
  }
95
95
 
96
96
  export function makeChannelsControl(opts: {
97
- // Which platforms the channels daemon is serving right now (heartbeat read).
97
+ // Which platforms the channels harbor is serving right now (heartbeat read).
98
98
  // Absent → everything reports not-running.
99
99
  runningPlatforms?: () => Set<string>;
100
100
  }): ChannelsControl {
@@ -173,7 +173,7 @@ export function makeChannelsControl(opts: {
173
173
  } catch (e) {
174
174
  return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
175
175
  }
176
- return { ok: true, message: `Saved ${draft.platform}. Restart the channels daemon to apply.` };
176
+ return { ok: true, message: `Saved ${draft.platform}. Restart the channels harbor to apply.` };
177
177
  },
178
178
 
179
179
  remove(platform: ChannelPlatform): { ok: boolean; message?: string } {
@@ -186,7 +186,7 @@ export function makeChannelsControl(opts: {
186
186
  } catch (e) {
187
187
  return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
188
188
  }
189
- return { ok: true, message: `Removed ${platform}. Restart the channels daemon to apply.` };
189
+ return { ok: true, message: `Removed ${platform}. Restart the channels harbor to apply.` };
190
190
  },
191
191
  };
192
192
  }
@@ -9,7 +9,7 @@
9
9
  // skill). channels_save has its own bespoke verify (it also carries sealed secrets);
10
10
  // everything else routes through here.
11
11
  //
12
- // Both loci call this: the daemon (routines_*/channels_remove, termId = routineRelayId)
12
+ // Both loci call this: the harbor (routines_*/channels_remove, termId = routineRelayId)
13
13
  // and each interactive terminal (extensions_*/skills_*, termId = the relay's id).
14
14
  //
15
15
  // Fail-closed: no pinned account key, a missing signature, a bad signature, or a stale