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.
- package/README.md +20 -20
- package/SECURITY.md +1 -1
- package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
- package/bin/privateer-launch.mjs +7 -6
- package/bin/privateer-subagent.mjs +1 -1
- package/extensions/privateer-brand.ts +1 -1
- package/extensions/privateer-connect.ts +14 -3
- package/extensions/privateer-tools.ts +1 -1
- package/package.json +1 -1
- package/src/auth/privateer.ts +2 -2
- package/src/channels/run.ts +5 -5
- package/src/channels/status.ts +7 -7
- package/src/cli/chat.ts +29 -3
- package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
- package/src/config/hosted.ts +5 -5
- package/src/crypto/accountTrust.ts +2 -2
- package/src/crypto/accountVerify.ts +1 -1
- package/src/{daemon → harbor}/index.ts +37 -37
- package/src/{daemon → harbor}/ipc.ts +19 -19
- package/src/{daemon → harbor}/service.ts +61 -28
- package/src/main.ts +1 -1
- package/src/providers/account.ts +1 -1
- package/src/providers/defaultModel.ts +1 -1
- package/src/remote/channelsControl.ts +8 -8
- package/src/remote/controlAuth.ts +1 -1
- package/src/remote/liveTaskSession.ts +4 -4
- package/src/remote/mcpControl.ts +2 -2
- package/src/remote/relayClient.ts +41 -21
- package/src/remote/remoteBridge.ts +17 -7
- package/src/remote/routinesControl.ts +7 -7
- package/src/remote/workflowsControl.ts +6 -6
- package/src/routines/delivery.ts +6 -6
- package/src/routines/schema.ts +3 -3
- package/src/routines/store.ts +3 -3
- package/src/routines/trigger.ts +1 -1
- package/src/tools/routine.ts +8 -8
- package/src/util/fileMentions.ts +232 -0
- package/src/workflows/expr.ts +1 -1
- package/src/workflows/runner.ts +3 -3
- package/src/workflows/schema.ts +1 -1
- 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
|
|
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
|
|
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
|
|
82
|
-
function
|
|
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
|
|
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
|
|
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
|
|
177
|
-
// relay. Run-now is injected here since only the
|
|
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) => !!
|
|
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
|
|
186
|
-
// channels
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
202
|
-
// Run-now is injected here since only the
|
|
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(`
|
|
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
|
|
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
|
|
324
|
+
log("relay terminated from the app; staying offline until the harbor restarts");
|
|
325
325
|
},
|
|
326
|
-
// The account signed this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
1035
|
-
export function
|
|
1036
|
-
const
|
|
1037
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
-
return join(globalDir(), "
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
79
|
-
const path =
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
112
|
+
export class HarborNotRunningError extends Error {
|
|
113
113
|
constructor() {
|
|
114
|
-
super("
|
|
115
|
-
this.name = "
|
|
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
|
|
120
|
-
export async function
|
|
119
|
+
// Convenience: is the harbor reachable right now?
|
|
120
|
+
export async function harborIsRunning(): Promise<boolean> {
|
|
121
121
|
try {
|
|
122
|
-
const res = await
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
15
|
+
import { harborIsRunning } from "./ipc.ts";
|
|
16
16
|
|
|
17
|
-
const LABEL = "pro.privateer.
|
|
18
|
-
const UNIT = "privateer-
|
|
17
|
+
const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
|
|
18
|
+
const UNIT = "privateer-harbor.service"; // systemd --user unit name
|
|
19
19
|
|
|
20
|
-
//
|
|
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-
|
|
23
|
-
function
|
|
24
|
-
const here = dirname(fileURLToPath(import.meta.url)); // …/src/
|
|
25
|
-
return resolve(here, "../../bin/privateer-
|
|
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
|
|
36
|
-
return join(globalDir(), "
|
|
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
|
|
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(),
|
|
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(
|
|
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(),
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
207
|
-
// installed AND whether a
|
|
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
|
|
243
|
+
const live = await harborIsRunning();
|
|
211
244
|
const lines = [
|
|
212
|
-
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer
|
|
245
|
+
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
|
|
213
246
|
`service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
|
|
214
|
-
`
|
|
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
|
|
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
|
|
258
|
+
export function tailHarborLog(maxBytes = 4_000): string {
|
|
226
259
|
try {
|
|
227
|
-
const buf = readFileSync(
|
|
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
|
|
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
|
|
package/src/providers/account.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
7
|
-
* NOT the channels
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|