privateer-agent 0.9.0 → 0.9.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.
- package/extensions/privateer-gate.ts +13 -2
- package/extensions/privateer-privacy.ts +20 -8
- package/package.json +1 -1
- package/src/config/harborDaemon.ts +31 -0
- package/src/harbor/index.ts +85 -6
- package/src/harbor/ipc.ts +50 -0
- package/src/harbor/service.ts +56 -8
- package/src/providers/account.ts +46 -19
- package/src/remote/liveTaskSession.ts +61 -2
- package/src/remote/relayClient.ts +81 -2
- package/src/routines/store.ts +9 -3
- package/src/tools/relayFileTools.ts +24 -0
|
@@ -26,6 +26,7 @@ import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentSt
|
|
|
26
26
|
import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
27
27
|
import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
28
28
|
import { agentDir } from "../src/config/paths.ts";
|
|
29
|
+
import { inHarborDaemon } from "../src/config/harborDaemon.ts";
|
|
29
30
|
import { agentVersion } from "../src/config/version.ts";
|
|
30
31
|
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
31
32
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
@@ -446,8 +447,18 @@ export default function privateerControl(pi: any): void {
|
|
|
446
447
|
// File transfer both ways: send_file_to_client (CLI→app, via the bridge's relay) and
|
|
447
448
|
// save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
|
|
448
449
|
// live here because they share the RemoteBridge / its attachment stream.
|
|
449
|
-
|
|
450
|
-
|
|
450
|
+
//
|
|
451
|
+
// NOT inside the harbor daemon. This extension is auto-discovered from the shared
|
|
452
|
+
// ~/.privateer/agent/extensions into every session the daemon runs, but `bridge` only
|
|
453
|
+
// ever gets a relay from THIS file's /remote-access command — which the daemon never
|
|
454
|
+
// runs. Registering there would shadow (Pi: first registration per name wins, and
|
|
455
|
+
// discovered extensions load before inline factories) the session-scoped pair a live
|
|
456
|
+
// task spawn registers against its own connected relay, so send_file_to_client would
|
|
457
|
+
// always answer "remote access is off" while the app was attached and driving.
|
|
458
|
+
if (!inHarborDaemon()) {
|
|
459
|
+
pi.registerTool?.(makeSendFileTool(bridge));
|
|
460
|
+
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
461
|
+
}
|
|
451
462
|
|
|
452
463
|
// Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
|
|
453
464
|
// no one can approve, so a "default" gate would fail-closed on every tool and the
|
|
@@ -5,15 +5,25 @@
|
|
|
5
5
|
// over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
|
|
6
6
|
// default entry directly.
|
|
7
7
|
//
|
|
8
|
-
// It also
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// It also REPAIRS two provider registrations pi-privacy makes from its own catalog, each
|
|
9
|
+
// of which replaces (not merges) whatever model list that provider already had:
|
|
10
|
+
//
|
|
11
|
+
// - `tinfoil` gets a single seed model, so any other Tinfoil model — notably our
|
|
12
|
+
// default `tinfoil/glm-5-2` — resolves as a "custom model id" with a startup warning
|
|
13
|
+
// and never shows in the picker. We re-register it with the current chat catalog.
|
|
14
|
+
// - `privateer` gets pi-privacy's PUBLIC developer-key channel (api.privateer.pro/v1 +
|
|
15
|
+
// `${PRIVATEER_API_KEY}`, one seed model), which clobbers the ACCOUNT channel our own
|
|
16
|
+
// privateer-account extension registers. That is our default model's provider, so the
|
|
17
|
+
// same "not found for provider privateer" warning followed — and worse, the model Pi
|
|
18
|
+
// synthesized pointed at the public endpoint instead of `/api/agent/v1`. We re-assert
|
|
19
|
+
// the account registration (see registerAccountModels).
|
|
20
|
+
//
|
|
21
|
+
// Both repairs run AFTER pi-privacy inside this same extension, so ours land second and
|
|
22
|
+
// win regardless of the order pi discovers extensions in. This is purely a
|
|
23
|
+
// display/resolution + routing list — posture and attestation are dispatcher-bound and
|
|
24
|
+
// unaffected by the model set.
|
|
15
25
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
16
|
-
import { accountPosture } from "../src/providers/account.ts";
|
|
26
|
+
import { accountPosture, registerAccountModels } from "../src/providers/account.ts";
|
|
17
27
|
|
|
18
28
|
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models), glm-5-2 first — the
|
|
19
29
|
// launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
@@ -82,4 +92,6 @@ export default function privateerPrivacy(pi: any): void {
|
|
|
82
92
|
authHeader: true,
|
|
83
93
|
models: TINFOIL_MODELS.map(tinfoilModel),
|
|
84
94
|
});
|
|
95
|
+
// Put the ACCOUNT channel back over pi-privacy's public developer-key `privateer`.
|
|
96
|
+
registerAccountModels(pi);
|
|
85
97
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// "Am I running inside the harbor daemon?" — a process-level marker, set once by
|
|
2
|
+
// Harbor.start() and read by code that must behave differently there.
|
|
3
|
+
//
|
|
4
|
+
// The harbor daemon shares ~/.privateer/agent with the interactive TUI, so Pi
|
|
5
|
+
// auto-discovers the shipped TUI extensions (extensions/*.ts shims installed by
|
|
6
|
+
// bin/privateer-launch.mjs) into every session the daemon creates. Most of that is
|
|
7
|
+
// harmless, but the gate extension also registers the relay file tools against ITS
|
|
8
|
+
// module-level bridge — the one only `/remote-access` ever attaches a relay to. In the
|
|
9
|
+
// daemon that bridge is permanently unattached, and because Pi resolves duplicate tool
|
|
10
|
+
// names first-registration-wins (discovered extensions load before inline factories), it
|
|
11
|
+
// would shadow the session-scoped pair a live task registers against its own live relay.
|
|
12
|
+
// So the gate stands its file tools down here and each daemon session registers its own
|
|
13
|
+
// (see src/tools/relayFileTools.ts, src/remote/liveTaskSession.ts).
|
|
14
|
+
//
|
|
15
|
+
// An env var rather than a module singleton on purpose: discovered extensions are loaded
|
|
16
|
+
// through jiti with its module cache off, so they may hold a SEPARATE copy of our modules.
|
|
17
|
+
// process.env is the one piece of state both copies are guaranteed to share.
|
|
18
|
+
//
|
|
19
|
+
// IMPORT-SAFETY: no Pi imports, no node builtins — safe to load from anywhere.
|
|
20
|
+
|
|
21
|
+
const ENV = "PRIVATEER_HARBOR_DAEMON";
|
|
22
|
+
|
|
23
|
+
/** Called once by the daemon as it starts. Idempotent. */
|
|
24
|
+
export function markHarborDaemon(): void {
|
|
25
|
+
process.env[ENV] = "1";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** True inside the harbor daemon process (routines, workflows, tasks, live spawns). */
|
|
29
|
+
export function inHarborDaemon(): boolean {
|
|
30
|
+
return process.env[ENV] === "1";
|
|
31
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
loadPendingCloud,
|
|
49
49
|
savePendingCloud,
|
|
50
50
|
type PendingCloud,
|
|
51
|
+
type OutboxKind,
|
|
51
52
|
routineRelayId,
|
|
52
53
|
} from "../routines/store.ts";
|
|
53
54
|
import type { Routine } from "../routines/schema.ts";
|
|
@@ -57,8 +58,9 @@ import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "..
|
|
|
57
58
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
58
59
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
59
60
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
60
|
-
import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
61
|
+
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
61
62
|
import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
|
|
63
|
+
import { markHarborDaemon } from "../config/harborDaemon.ts";
|
|
62
64
|
import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
63
65
|
|
|
64
66
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
@@ -282,6 +284,11 @@ export class Harbor {
|
|
|
282
284
|
};
|
|
283
285
|
|
|
284
286
|
async start(): Promise<void> {
|
|
287
|
+
// Mark the process before anything can create a session: the shipped TUI extensions
|
|
288
|
+
// are auto-discovered from the shared agent dir into every session we run, and the
|
|
289
|
+
// gate one has to stand its relay file tools down here so a live task's own pair
|
|
290
|
+
// (bound to that task's live relay) isn't shadowed. See config/harborDaemon.ts.
|
|
291
|
+
markHarborDaemon();
|
|
285
292
|
// Single-instance lock FIRST, before any other side effect: binding the IPC
|
|
286
293
|
// socket is the machine's mutex. If a live harbor already holds it this throws
|
|
287
294
|
// HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
|
|
@@ -596,7 +603,7 @@ export class Harbor {
|
|
|
596
603
|
return this.originCache;
|
|
597
604
|
}
|
|
598
605
|
|
|
599
|
-
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind:
|
|
606
|
+
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: OutboxKind = "routine"): Promise<boolean> {
|
|
600
607
|
const pub = await this.ensureOutboxPub();
|
|
601
608
|
if (!pub) return false;
|
|
602
609
|
const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
@@ -807,6 +814,16 @@ export class Harbor {
|
|
|
807
814
|
// Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
|
|
808
815
|
// when logged in; ZDR-channel models stay at their honest floor. The live
|
|
809
816
|
// verdict still comes from accountPosture on select — this only lifts the label.
|
|
817
|
+
//
|
|
818
|
+
// ORDER MATTERS: pi-privacy's own catalog registers a `privateer`
|
|
819
|
+
// provider (its PUBLIC developer-key channel, one seed model), and Pi's
|
|
820
|
+
// registerProvider REPLACES a provider's models and request config. It
|
|
821
|
+
// must stay ABOVE makeAccountProvider() so the ACCOUNT channel lands last
|
|
822
|
+
// — otherwise the default model stops resolving and requests go to
|
|
823
|
+
// api.privateer.pro/v1 instead of /api/agent/v1. (The TUI hits this
|
|
824
|
+
// through extension discovery, where the order isn't ours to choose;
|
|
825
|
+
// extensions/privateer-privacy.ts re-asserts the account registration
|
|
826
|
+
// there. See registerAccountModels.)
|
|
810
827
|
makePiPrivacyExtension({
|
|
811
828
|
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
812
829
|
}),
|
|
@@ -1037,6 +1054,19 @@ export class Harbor {
|
|
|
1037
1054
|
parseSpec,
|
|
1038
1055
|
log,
|
|
1039
1056
|
onClosed: (id) => this.liveTasks.delete(id),
|
|
1057
|
+
// A live spawn's feed lives only in the attached app; when the session ends
|
|
1058
|
+
// (closed, reaped, or timed out) its answer would otherwise be gone. Seal the
|
|
1059
|
+
// closing one to the outbox like a submitted task's, so the app's inbox holds
|
|
1060
|
+
// every agent result, not just the unattended ones. Same queue-on-failure path.
|
|
1061
|
+
onResult: ({ title, status, content }) => {
|
|
1062
|
+
void (async () => {
|
|
1063
|
+
const at = new Date().toISOString();
|
|
1064
|
+
const body = redactText(content, collectSecrets(loadHarborConfig().providers));
|
|
1065
|
+
if (!(await this.postOutbox(title, at, status, body, "task"))) {
|
|
1066
|
+
addPendingCloud({ routine: title, at, status, content: body, kind: "task" });
|
|
1067
|
+
}
|
|
1068
|
+
})();
|
|
1069
|
+
},
|
|
1040
1070
|
});
|
|
1041
1071
|
this.liveTasks.set(handle.termId, handle);
|
|
1042
1072
|
this.relay?.sendTaskSpawned(handle.termId, handle.label);
|
|
@@ -1101,8 +1131,8 @@ export class Harbor {
|
|
|
1101
1131
|
const content = formatWorkflowResult(wf.workflow.name, result);
|
|
1102
1132
|
const at = new Date().toISOString();
|
|
1103
1133
|
// Durable delivery: seal to the outbox (queue on failure to re-seal later).
|
|
1104
|
-
if (!(await this.postOutbox(wf.workflow.name, at, status, content, "
|
|
1105
|
-
addPendingCloud({ routine: wf.workflow.name, at, status, content, kind: "
|
|
1134
|
+
if (!(await this.postOutbox(wf.workflow.name, at, status, content, "workflow"))) {
|
|
1135
|
+
addPendingCloud({ routine: wf.workflow.name, at, status, content, kind: "workflow" });
|
|
1106
1136
|
}
|
|
1107
1137
|
if (this.controllerAttached) this.relay?.sendWorkflowResult(wf.workflow.name, content);
|
|
1108
1138
|
log(` workflow "${wf.workflow.name}" ${result.status}${result.reason ? `: ${result.reason}` : ""}`);
|
|
@@ -1192,6 +1222,31 @@ export class Harbor {
|
|
|
1192
1222
|
return { text: notes.length > 0 ? `${out}${formatNotes(notes)}` : out, output, status, error };
|
|
1193
1223
|
}
|
|
1194
1224
|
|
|
1225
|
+
// What the app can actually see. A harbor is only drivable while its relay socket
|
|
1226
|
+
// is up, and every way that can fail — signed out, turned off from the app, refused
|
|
1227
|
+
// by the plan's agent cap, a socket that died without closing — used to be visible
|
|
1228
|
+
// ONLY as a line in a log file nobody reads. Reported alongside pid/uptime so
|
|
1229
|
+
// `privateer harbor status` can never again say "running" about a harbor the app
|
|
1230
|
+
// shows as offline.
|
|
1231
|
+
private relayStatus(): RelayStatus {
|
|
1232
|
+
const termId = routineRelayId();
|
|
1233
|
+
if (this.relayTerminated) {
|
|
1234
|
+
return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
|
|
1235
|
+
}
|
|
1236
|
+
if (!this.relay) {
|
|
1237
|
+
return {
|
|
1238
|
+
termId,
|
|
1239
|
+
connected: false,
|
|
1240
|
+
detail: hasCredentials()
|
|
1241
|
+
? "relay not started"
|
|
1242
|
+
: "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
const conn = this.relay.connectionStatus();
|
|
1246
|
+
if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
|
|
1247
|
+
return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1195
1250
|
private persistRun(id: string, patch: Partial<Routine>): void {
|
|
1196
1251
|
const current = findRoutine(loadRoutines(), id);
|
|
1197
1252
|
if (!current) return;
|
|
@@ -1201,7 +1256,13 @@ export class Harbor {
|
|
|
1201
1256
|
private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
|
|
1202
1257
|
switch (req.cmd) {
|
|
1203
1258
|
case "status":
|
|
1204
|
-
return {
|
|
1259
|
+
return {
|
|
1260
|
+
ok: true,
|
|
1261
|
+
pid: process.pid,
|
|
1262
|
+
uptimeSec: Math.round((Date.now() - this.startedAt) / 1000),
|
|
1263
|
+
relay: this.relayStatus(),
|
|
1264
|
+
routines: loadRoutines(),
|
|
1265
|
+
};
|
|
1205
1266
|
case "list":
|
|
1206
1267
|
return { ok: true, routines: loadRoutines() };
|
|
1207
1268
|
case "add": {
|
|
@@ -1254,11 +1315,29 @@ export function runHarbor(): void {
|
|
|
1254
1315
|
};
|
|
1255
1316
|
process.on("SIGINT", shutdown);
|
|
1256
1317
|
process.on("SIGTERM", shutdown);
|
|
1257
|
-
harbor.start().catch((err) => {
|
|
1318
|
+
harbor.start().catch(async (err) => {
|
|
1258
1319
|
if (err instanceof HarborAlreadyRunningError) {
|
|
1259
1320
|
// A resident harbor already owns this machine — leave it in charge. Exit 0 so a
|
|
1260
1321
|
// manual `privateer harbor run` beside the installed login service isn't an error.
|
|
1322
|
+
//
|
|
1323
|
+
// Say WHICH harbor, and whether it is actually reachable from the app: the
|
|
1324
|
+
// incumbent can be a stale process that still answers IPC while its relay socket
|
|
1325
|
+
// is long dead, and "leaving the existing one in charge" then reads as reassurance
|
|
1326
|
+
// for a harbor the app shows as offline. The incumbent knows — ask it.
|
|
1261
1327
|
process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
|
|
1328
|
+
try {
|
|
1329
|
+
const status = await sendToHarbor({ cmd: "status" }, 3_000);
|
|
1330
|
+
const up = typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
|
|
1331
|
+
process.stderr.write(` incumbent: pid ${status.pid ?? "?"}${up}\n`);
|
|
1332
|
+
process.stderr.write(` relay: ${describeRelay(status.relay)}\n`);
|
|
1333
|
+
if (status.relay && !status.relay.connected) {
|
|
1334
|
+
process.stderr.write(" Stop that process (or `privateer harbor uninstall && privateer harbor install`) to hand the machine to a fresh Harbor.\n");
|
|
1335
|
+
}
|
|
1336
|
+
} catch {
|
|
1337
|
+
// It held the lock a moment ago but won't answer now — a wedged process is
|
|
1338
|
+
// worth naming too, since nothing else will start while it holds the socket.
|
|
1339
|
+
process.stderr.write(" incumbent: holds the lock but is not answering IPC — it may be wedged; stop it and start again.\n");
|
|
1340
|
+
}
|
|
1262
1341
|
process.exit(0);
|
|
1263
1342
|
}
|
|
1264
1343
|
process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
|
package/src/harbor/ipc.ts
CHANGED
|
@@ -22,6 +22,28 @@ export type IpcRequest =
|
|
|
22
22
|
| { cmd: "run-now"; idOrName: string }
|
|
23
23
|
| { cmd: "reload" };
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The harbor's view of its own relay connection, reported by `status`.
|
|
27
|
+
*
|
|
28
|
+
* A harbor answering on this socket is running; that is NOT the same as being
|
|
29
|
+
* reachable from the app, which needs the relay socket up (the server drops a
|
|
30
|
+
* terminal from its presence registry ~60s after it stops hearing from it). The two
|
|
31
|
+
* used to be conflated — "running (answering IPC)" while the app showed the same
|
|
32
|
+
* harbor as offline — so every liveness report carries both now.
|
|
33
|
+
*/
|
|
34
|
+
export interface RelayStatus {
|
|
35
|
+
/** The relay terminal id the app looks for ("routines-…"). */
|
|
36
|
+
termId: string;
|
|
37
|
+
/** Socket open right now, i.e. the app can see and drive this harbor. */
|
|
38
|
+
connected: boolean;
|
|
39
|
+
/** Seconds the current connection has been up. */
|
|
40
|
+
upSec?: number;
|
|
41
|
+
/** Seconds since the server last sent anything (frame, ping or pong). */
|
|
42
|
+
quietSec?: number;
|
|
43
|
+
/** Why it isn't connected, when we know: signed out, turned off from the app, … */
|
|
44
|
+
detail?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
export interface IpcResponse {
|
|
26
48
|
ok: boolean;
|
|
27
49
|
message?: string;
|
|
@@ -29,6 +51,8 @@ export interface IpcResponse {
|
|
|
29
51
|
// Harbor liveness/uptime for `status`.
|
|
30
52
|
pid?: number;
|
|
31
53
|
uptimeSec?: number;
|
|
54
|
+
// Relay reachability for `status` — see RelayStatus.
|
|
55
|
+
relay?: RelayStatus;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
|
|
@@ -164,6 +188,32 @@ export class HarborAlreadyRunningError extends Error {
|
|
|
164
188
|
}
|
|
165
189
|
}
|
|
166
190
|
|
|
191
|
+
// One-line, human rendering of a status reply's relay block — shared by
|
|
192
|
+
// `privateer harbor status` and the second-instance notice so both tell the same
|
|
193
|
+
// story. `undefined` means the harbor answering us predates this field.
|
|
194
|
+
export function describeRelay(relay?: RelayStatus): string {
|
|
195
|
+
if (!relay) return "unknown (this harbor is an older build)";
|
|
196
|
+
if (!relay.connected) {
|
|
197
|
+
return `NOT connected — the app shows this Harbor as inactive${relay.detail ? ` (${relay.detail})` : ""}`;
|
|
198
|
+
}
|
|
199
|
+
const up = typeof relay.upSec === "number" ? `, up ${formatDuration(relay.upSec)}` : "";
|
|
200
|
+
// A connected socket the server hasn't spoken on in a while is the half-open shape;
|
|
201
|
+
// the watchdog drops it within ~75s, so say so rather than reporting a flat "connected".
|
|
202
|
+
const quiet = typeof relay.quietSec === "number" && relay.quietSec > 40 ? `, quiet for ${relay.quietSec}s — checking` : "";
|
|
203
|
+
return `connected — drivable from the Privateer app (${relay.termId}${up}${quiet})`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatDuration(totalSec: number): string {
|
|
207
|
+
const s = Math.max(0, Math.round(totalSec));
|
|
208
|
+
const d = Math.floor(s / 86400);
|
|
209
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
210
|
+
const m = Math.floor((s % 3600) / 60);
|
|
211
|
+
if (d) return `${d}d ${h}h`;
|
|
212
|
+
if (h) return `${h}h ${m}m`;
|
|
213
|
+
if (m) return `${m}m`;
|
|
214
|
+
return `${s}s`;
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
// Convenience: is the harbor reachable right now?
|
|
168
218
|
export async function harborIsRunning(): Promise<boolean> {
|
|
169
219
|
try {
|
package/src/harbor/service.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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 { harborIsRunning } from "./ipc.ts";
|
|
15
|
+
import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
|
|
16
16
|
|
|
17
17
|
const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
|
|
18
18
|
const UNIT = "privateer-harbor.service"; // systemd --user unit name
|
|
@@ -62,7 +62,13 @@ function xmlEscape(s: string): string {
|
|
|
62
62
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
// KeepAlive is `{ SuccessfulExit: false }`, NOT plain `true` — restart on a crash,
|
|
66
|
+
// but leave a CLEAN exit alone. A bare `true` restarts unconditionally, which turns
|
|
67
|
+
// the two clean-exit paths into loops: the harbor that finds another one already
|
|
68
|
+
// holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
|
|
69
|
+
// forever — this is what produced a 7 MB log of "already running"), and a deliberate
|
|
70
|
+
// shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
|
|
71
|
+
export function launchdPlist(): string {
|
|
66
72
|
const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
|
|
67
73
|
const envVars = forwardedEnv();
|
|
68
74
|
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
@@ -83,7 +89,10 @@ ${argXml}
|
|
|
83
89
|
${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
|
|
84
90
|
<true/>
|
|
85
91
|
<key>KeepAlive</key>
|
|
86
|
-
<
|
|
92
|
+
<dict>
|
|
93
|
+
<key>SuccessfulExit</key>
|
|
94
|
+
<false/>
|
|
95
|
+
</dict>
|
|
87
96
|
<key>StandardOutPath</key>
|
|
88
97
|
<string>${log}</string>
|
|
89
98
|
<key>StandardErrorPath</key>
|
|
@@ -199,6 +208,24 @@ export interface ServiceInfo {
|
|
|
199
208
|
installed: boolean;
|
|
200
209
|
unitPath: string;
|
|
201
210
|
logPath: string;
|
|
211
|
+
/** Installed unit predates a fix and should be rewritten — see needsRefresh(). */
|
|
212
|
+
needsRefresh: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
|
|
216
|
+
// against what we'd generate today would flag every service installed by a different
|
|
217
|
+
// copy of the CLI (a dev checkout resolves a different launcher path), which is not a
|
|
218
|
+
// problem and not something the user should be nagged about. The one thing worth
|
|
219
|
+
// flagging is the pre-fix launchd `KeepAlive: true`, which restarts the harbor even
|
|
220
|
+
// after a clean exit — the log-spam loop described above launchdPlist().
|
|
221
|
+
export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): boolean {
|
|
222
|
+
if (platform !== "darwin" || !existsSync(unitPath)) return false;
|
|
223
|
+
try {
|
|
224
|
+
const plist = readFileSync(unitPath, "utf8");
|
|
225
|
+
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(plist);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
function unitPathFor(platform: NodeJS.Platform): string {
|
|
@@ -216,6 +243,7 @@ export function serviceInfo(): ServiceInfo {
|
|
|
216
243
|
installed: !!unitPath && existsSync(unitPath),
|
|
217
244
|
unitPath,
|
|
218
245
|
logPath: harborLogPath(),
|
|
246
|
+
needsRefresh: unitNeedsRefresh(platform, unitPath),
|
|
219
247
|
};
|
|
220
248
|
}
|
|
221
249
|
|
|
@@ -236,20 +264,40 @@ export function uninstallService(): ServiceInfo {
|
|
|
236
264
|
return serviceInfo();
|
|
237
265
|
}
|
|
238
266
|
|
|
239
|
-
// Human-readable status
|
|
240
|
-
// installed
|
|
267
|
+
// Human-readable status for `privateer harbor status`: whether the service is
|
|
268
|
+
// installed, whether a harbor is answering on the IPC socket, and — the part that
|
|
269
|
+
// actually answers "why does the app say inactive?" — whether that harbor is
|
|
270
|
+
// connected to the relay. Answering IPC only proves a local process is alive; the
|
|
271
|
+
// app lists a harbor from the server's presence registry, which a dead relay socket
|
|
272
|
+
// drops within ~60s. Reporting the first as if it implied the second is what made a
|
|
273
|
+
// stale harbor look healthy from the terminal and offline from the phone.
|
|
241
274
|
export async function statusReport(): Promise<string> {
|
|
242
275
|
const info = serviceInfo();
|
|
243
|
-
|
|
276
|
+
let status: IpcResponse | null = null;
|
|
277
|
+
try {
|
|
278
|
+
status = await sendToHarbor({ cmd: "status" }, 3_000);
|
|
279
|
+
} catch {
|
|
280
|
+
status = null; // not running, or wedged — harborIsRunning() below tells them apart
|
|
281
|
+
}
|
|
282
|
+
const live = status ? status.ok : await harborIsRunning();
|
|
283
|
+
const up = status && typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
|
|
284
|
+
const pid = status?.pid ? `pid ${status.pid}${up}` : "answering IPC";
|
|
244
285
|
const lines = [
|
|
245
286
|
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
|
|
246
287
|
`service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
|
|
247
|
-
`harbor: ${live ?
|
|
248
|
-
`logs: ${info.logPath}`,
|
|
288
|
+
`harbor: ${live ? `running (${pid})` : "not reachable"}`,
|
|
249
289
|
];
|
|
290
|
+
if (live) lines.push(`relay: ${describeRelay(status?.relay)}`);
|
|
291
|
+
lines.push(`logs: ${info.logPath}`);
|
|
250
292
|
// Surface a stale-unit hint: file present but nothing answering usually means it
|
|
251
293
|
// failed to boot — the log path above is where to look.
|
|
252
294
|
if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
|
|
295
|
+
if (live && status?.relay && !status.relay.connected) {
|
|
296
|
+
lines.push("hint: Harbor is running but not reachable from the app. Restart it (`privateer harbor uninstall && privateer harbor install`) once the cause above is resolved.");
|
|
297
|
+
}
|
|
298
|
+
if (info.needsRefresh) {
|
|
299
|
+
lines.push("hint: the installed login service restarts Harbor even after a clean exit (older install) — run `privateer harbor install` to refresh it.");
|
|
300
|
+
}
|
|
253
301
|
return lines.join("\n");
|
|
254
302
|
}
|
|
255
303
|
|
package/src/providers/account.ts
CHANGED
|
@@ -429,6 +429,51 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
// A model entry, with a per-model baseUrl override once the EHBP shim is listening:
|
|
433
|
+
// `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
|
|
434
|
+
// instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
|
|
435
|
+
// baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
|
|
436
|
+
// the cleartext path — and the badge stays honestly `tee-unverified` (see accountPosture).
|
|
437
|
+
function modelEntry(id: string) {
|
|
438
|
+
const base = seedModel(id);
|
|
439
|
+
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
440
|
+
const shim = sealedShimBase();
|
|
441
|
+
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// The Pi provider config for the account channel, over a given set of model ids.
|
|
445
|
+
export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
446
|
+
return {
|
|
447
|
+
name: "Privateer account",
|
|
448
|
+
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
449
|
+
api: "openai-completions",
|
|
450
|
+
oauth: privateerOAuthProvider,
|
|
451
|
+
models: ids.map(modelEntry),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Re-assert the account channel's registration from ANOTHER extension.
|
|
456
|
+
//
|
|
457
|
+
// pi-privacy also ships a `privateer` provider (its PRIVACY_PROVIDERS catalog) — the
|
|
458
|
+
// PUBLIC developer-key channel: baseUrl api.privateer.pro/v1, `${PRIVATEER_API_KEY}`,
|
|
459
|
+
// and a single seed model (near/zai-org/GLM-5.1-FP8). Pi's registerProvider FULLY
|
|
460
|
+
// REPLACES a provider's model list and its request config, so whichever registration
|
|
461
|
+
// lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
|
|
462
|
+
// on a typical box puts privateer-privacy after privateer-account. The account channel's
|
|
463
|
+
// whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
|
|
464
|
+
// longer resolved ("not found for provider privateer. Using custom model id") and the
|
|
465
|
+
// synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
|
|
466
|
+
//
|
|
467
|
+
// So privateer-privacy.ts calls this right after pi-privacy runs, exactly as it re-widens
|
|
468
|
+
// `tinfoil`. Idempotent and order-independent: if privacy happens to load first, the
|
|
469
|
+
// account extension's own registration lands afterwards with the same config, and the
|
|
470
|
+
// live-catalog fetch re-registers over both moments later either way.
|
|
471
|
+
export function registerAccountModels(pi: {
|
|
472
|
+
registerProvider?: (name: string, config: unknown) => void;
|
|
473
|
+
}): void {
|
|
474
|
+
pi.registerProvider?.("privateer", accountProviderConfig(seedCatalogIds()));
|
|
475
|
+
}
|
|
476
|
+
|
|
432
477
|
// Extension factory: registers the account provider so `/login` can offer it.
|
|
433
478
|
//
|
|
434
479
|
// We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
|
|
@@ -454,31 +499,13 @@ export function makeAccountProvider() {
|
|
|
454
499
|
on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
|
|
455
500
|
}): void => {
|
|
456
501
|
if (typeof pi.registerProvider !== "function") return;
|
|
457
|
-
// A model entry, with a per-model baseUrl override for sealed models once the
|
|
458
|
-
// EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
|
|
459
|
-
// seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
|
|
460
|
-
// Everything else keeps the provider baseUrl below. Until the shim is up (or when
|
|
461
|
-
// sealed mode is off) sealed models fall back to the cleartext path — and the
|
|
462
|
-
// badge stays honestly `tee-unverified` (see accountPosture).
|
|
463
|
-
const modelEntry = (id: string) => {
|
|
464
|
-
const base = seedModel(id);
|
|
465
|
-
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
466
|
-
const shim = sealedShimBase();
|
|
467
|
-
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
468
|
-
};
|
|
469
502
|
// Seed with the last live catalog when we have one (see seedCatalogIds): this is the
|
|
470
503
|
// list Pi resolves a saved default / a restored session model against at launch,
|
|
471
504
|
// before the live re-registration can reach the registry.
|
|
472
505
|
let lastIds: string[] = seedCatalogIds();
|
|
473
506
|
const register = (ids: string[]): void => {
|
|
474
507
|
lastIds = ids;
|
|
475
|
-
pi.registerProvider!("privateer",
|
|
476
|
-
name: "Privateer account",
|
|
477
|
-
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
478
|
-
api: "openai-completions",
|
|
479
|
-
oauth: privateerOAuthProvider,
|
|
480
|
-
models: ids.map(modelEntry),
|
|
481
|
-
});
|
|
508
|
+
pi.registerProvider!("privateer", accountProviderConfig(ids));
|
|
482
509
|
};
|
|
483
510
|
register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
|
|
484
511
|
// Bring up the sealed shim, then re-register so sealed models pick up their shim
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
} from "../providers/account.ts";
|
|
29
29
|
import { RelayClient, type TaskSpec } from "./relayClient.ts";
|
|
30
30
|
import { RemoteBridge } from "./remoteBridge.ts";
|
|
31
|
+
import { makeRelayFileTools } from "../tools/relayFileTools.ts";
|
|
32
|
+
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
31
33
|
import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
|
|
32
34
|
|
|
33
35
|
export interface LiveTaskHandle {
|
|
@@ -41,6 +43,11 @@ export interface LiveTaskDeps {
|
|
|
41
43
|
parseSpec: (spec: string) => { provider: string; modelId: string };
|
|
42
44
|
log: (msg: string) => void;
|
|
43
45
|
onClosed: (termId: string) => void;
|
|
46
|
+
// Deliver the session's closing answer durably (the harbor seals it to the account
|
|
47
|
+
// outbox, so it lands in the app's inbox). A live spawn's feed is otherwise purely
|
|
48
|
+
// ephemeral: close the screen, reap the session, and everything it said is gone —
|
|
49
|
+
// unlike a submitted task or a routine, whose results are always sealed.
|
|
50
|
+
onResult?: (result: { title: string; status: "ok" | "error"; content: string }) => void;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
// How long to keep a spawned session alive with NO controller ever attaching, and the
|
|
@@ -72,12 +79,42 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
72
79
|
let attachTimer: ReturnType<typeof setTimeout> | undefined;
|
|
73
80
|
let lifeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
74
81
|
|
|
82
|
+
// Files the app sends down mid-session, keyed by the "#n" ref save_attachment writes
|
|
83
|
+
// back out. `sinceLastPrompt` is drained into the next prompt so the model is told what
|
|
84
|
+
// it just received — same contract as the TUI's (extensions/privateer-gate.ts).
|
|
85
|
+
const attachments = new AttachmentStore();
|
|
86
|
+
let sinceLastPrompt: StoredAttachment[] = [];
|
|
87
|
+
|
|
88
|
+
// The assistant text of the most recent turn, accumulated from the event stream and
|
|
89
|
+
// reset at the start of each one, so what we keep is the session's CLOSING answer
|
|
90
|
+
// rather than a transcript. Bounded — the outbox truncates anyway, and a long
|
|
91
|
+
// session's scrollback is not what makes a useful inbox entry.
|
|
92
|
+
const MAX_RESULT_CHARS = 8000;
|
|
93
|
+
let lastAnswer = "";
|
|
94
|
+
let turnErrored = false;
|
|
95
|
+
|
|
75
96
|
const stop = async (): Promise<void> => {
|
|
76
97
|
if (stopped) return;
|
|
77
98
|
stopped = true;
|
|
99
|
+
// Hand the closing answer over BEFORE tearing anything down. Best-effort by
|
|
100
|
+
// design: no answer (nothing ever ran, or the model only used tools) means
|
|
101
|
+
// nothing to deliver, and a delivery failure must never block teardown.
|
|
102
|
+
const answer = lastAnswer.trim();
|
|
103
|
+
if (answer && deps.onResult) {
|
|
104
|
+
try {
|
|
105
|
+
deps.onResult({
|
|
106
|
+
title: title || "Spawned agent",
|
|
107
|
+
status: turnErrored ? "error" : "ok",
|
|
108
|
+
content: answer.slice(0, MAX_RESULT_CHARS),
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
deps.log(`live task ${termId} result delivery failed: ${(e as Error).message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
78
114
|
if (attachTimer) clearTimeout(attachTimer);
|
|
79
115
|
if (lifeTimer) clearTimeout(lifeTimer);
|
|
80
116
|
try { relay?.stop(); } catch { /* already stopped */ }
|
|
117
|
+
attachments.cleanup(); // drop the scratch dir holding inbound file bytes
|
|
81
118
|
// Revoke ONLY this session's account inference session so it doesn't linger in the
|
|
82
119
|
// app's Linked Devices; the harbor's own child session stays alive. Best-effort.
|
|
83
120
|
if (spawnedAccount) {
|
|
@@ -93,9 +130,20 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
93
130
|
const runTurn = async (text: string): Promise<void> => {
|
|
94
131
|
if (turnActive || stopped) return;
|
|
95
132
|
turnActive = true;
|
|
133
|
+
lastAnswer = ""; // keep the CLOSING answer, not the whole session
|
|
134
|
+
turnErrored = false;
|
|
96
135
|
try {
|
|
97
|
-
|
|
136
|
+
// Fold any files the app sent since the last prompt into a reference note, so the
|
|
137
|
+
// model knows they exist and can save_attachment them to disk.
|
|
138
|
+
const atts = sinceLastPrompt;
|
|
139
|
+
sinceLastPrompt = [];
|
|
140
|
+
const note = atts.length
|
|
141
|
+
? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
|
|
142
|
+
`Use the save_attachment tool with the ref number to write one to disk.]`
|
|
143
|
+
: "";
|
|
144
|
+
await session.prompt(text + note);
|
|
98
145
|
} catch (e) {
|
|
146
|
+
turnErrored = true;
|
|
99
147
|
deps.log(`live task ${termId} turn error: ${(e as Error).message}`);
|
|
100
148
|
} finally {
|
|
101
149
|
turnActive = false;
|
|
@@ -122,6 +170,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
122
170
|
bridge.callbacks.onPrompt(spec.prompt);
|
|
123
171
|
}
|
|
124
172
|
},
|
|
173
|
+
onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
|
|
125
174
|
onTerminate: () => void stop(),
|
|
126
175
|
onStatus: (t) => deps.log(`live task ${termId}: ${t}`),
|
|
127
176
|
});
|
|
@@ -157,6 +206,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
157
206
|
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
158
207
|
}),
|
|
159
208
|
makeAccountProvider(),
|
|
209
|
+
// send_file_to_client / save_attachment bound to THIS session's bridge — the one
|
|
210
|
+
// whose relay the app is attached to. The shipped gate extension is discovered
|
|
211
|
+
// into this session too but stands its own pair down inside the daemon, so these
|
|
212
|
+
// are the ones the model gets (see tools/relayFileTools.ts).
|
|
213
|
+
makeRelayFileTools(bridge, attachments),
|
|
160
214
|
] as any,
|
|
161
215
|
},
|
|
162
216
|
});
|
|
@@ -219,7 +273,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
219
273
|
|
|
220
274
|
const adapter = createEngineEventAdapter();
|
|
221
275
|
session.subscribe((ev: any) => {
|
|
222
|
-
for (const ee of adapter.toEngineEvents(ev))
|
|
276
|
+
for (const ee of adapter.toEngineEvents(ev)) {
|
|
277
|
+
// Assistant prose only — reasoning, tool calls and results are deliberately not
|
|
278
|
+
// kept: the inbox entry should read like the agent's answer, not a trace.
|
|
279
|
+
if (ee.type === "text" && lastAnswer.length < MAX_RESULT_CHARS) lastAnswer += ee.text;
|
|
280
|
+
bridge.forwardEvent(ee);
|
|
281
|
+
}
|
|
223
282
|
});
|
|
224
283
|
|
|
225
284
|
relay = new RelayClient(bridge.callbacks, { termId, label });
|
|
@@ -233,6 +233,24 @@ const RECONNECT_MS = 3000;
|
|
|
233
233
|
// record TTL so the app's "blocked" row stays warm between attempts rather than
|
|
234
234
|
// flickering in and out of the plan-limit state.
|
|
235
235
|
const REFUSED_RECONNECT_MS = 60_000;
|
|
236
|
+
// ── Liveness ────────────────────────────────────────────────────────────────────
|
|
237
|
+
// A TCP socket can die without either side being told: a server instance restarts,
|
|
238
|
+
// a NAT/idle timer drops the flow, a laptop sleeps. The kernel keeps reporting
|
|
239
|
+
// ESTABLISHED, `ws` never fires 'close', and the reconnect path above — which only
|
|
240
|
+
// runs on close/error — never runs. That failure mode is invisible AND permanent:
|
|
241
|
+
// the server prunes the terminal from its presence registry after ~60s, so the app
|
|
242
|
+
// shows the harbor as offline while the harbor's own log says "connected", forever.
|
|
243
|
+
//
|
|
244
|
+
// So don't wait to be told. The server pings every 25s, so an alive socket sees
|
|
245
|
+
// inbound traffic at least that often; we ping on our own timer too (the peer's pong
|
|
246
|
+
// counts as inbound). If nothing arrives for LIVENESS_TIMEOUT_MS — three missed
|
|
247
|
+
// server pings — the socket is dead: terminate it and take the normal reconnect path.
|
|
248
|
+
const HEARTBEAT_MS = 20_000;
|
|
249
|
+
const LIVENESS_TIMEOUT_MS = 75_000;
|
|
250
|
+
// Cap the opening handshake too. Without this a black-holed connect leaves `this.ws`
|
|
251
|
+
// set with no open/close/error ever firing, and connect()'s `if (this.ws) return`
|
|
252
|
+
// guard then blocks every future attempt — the same permanent silence by another route.
|
|
253
|
+
const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
236
254
|
// File-transfer ceilings for app→CLI attachments. The app enforces its own caps
|
|
237
255
|
// before sending; these are a defensive backstop so a controller can't exhaust
|
|
238
256
|
// memory with a lying `size` or a flood of concurrent transfers.
|
|
@@ -291,6 +309,11 @@ export class RelayClient {
|
|
|
291
309
|
private closed = false;
|
|
292
310
|
private connecting = false;
|
|
293
311
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
312
|
+
// Liveness watchdog for the open socket (see HEARTBEAT_MS): our own ping timer plus
|
|
313
|
+
// the epoch of the last thing we heard from the server — any frame, ping or pong.
|
|
314
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
315
|
+
private lastInboundAt = 0;
|
|
316
|
+
private connectedAt = 0;
|
|
294
317
|
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
295
318
|
private refusal: string | null = null;
|
|
296
319
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
@@ -377,6 +400,8 @@ export class RelayClient {
|
|
|
377
400
|
this.settleFirstConnect(new Error("relay stopped before registering"));
|
|
378
401
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
|
379
402
|
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
403
|
+
this.stopHeartbeat();
|
|
404
|
+
this.connectedAt = 0;
|
|
380
405
|
this.bufKind = null;
|
|
381
406
|
this.buf = "";
|
|
382
407
|
this.incoming.clear();
|
|
@@ -417,7 +442,7 @@ export class RelayClient {
|
|
|
417
442
|
const wsUrl =
|
|
418
443
|
serverBaseUrl().replace(/^http/, "ws") + `/relay?ticket=${encodeURIComponent(ticket)}`;
|
|
419
444
|
this.debug(`connecting → ${wsUrl}`);
|
|
420
|
-
const ws = new WebSocket(wsUrl);
|
|
445
|
+
const ws = new WebSocket(wsUrl, { handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
|
|
421
446
|
this.ws = ws;
|
|
422
447
|
let opened = false;
|
|
423
448
|
let lastErr = "";
|
|
@@ -425,12 +450,20 @@ export class RelayClient {
|
|
|
425
450
|
ws.on("open", () => {
|
|
426
451
|
opened = true;
|
|
427
452
|
this.refusal = null; // a later refusal is news again
|
|
453
|
+
this.connectedAt = Date.now();
|
|
454
|
+
this.startHeartbeat(ws);
|
|
428
455
|
this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
|
|
429
456
|
this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
|
|
430
457
|
});
|
|
431
|
-
|
|
458
|
+
// Anything the server sends counts as proof of life for the watchdog. `ws`
|
|
459
|
+
// answers server pings with a pong for us, and answers our pings with 'pong'.
|
|
460
|
+
ws.on("message", (data) => { this.lastInboundAt = Date.now(); this.handle(data); });
|
|
461
|
+
ws.on("ping", () => { this.lastInboundAt = Date.now(); });
|
|
462
|
+
ws.on("pong", () => { this.lastInboundAt = Date.now(); });
|
|
432
463
|
ws.on("close", () => {
|
|
464
|
+
this.stopHeartbeat();
|
|
433
465
|
if (this.ws === ws) this.ws = null;
|
|
466
|
+
this.connectedAt = 0;
|
|
434
467
|
this.cb.onDisconnected?.();
|
|
435
468
|
if (!this.closed) {
|
|
436
469
|
this.cb.onStatus?.(
|
|
@@ -484,6 +517,37 @@ export class RelayClient {
|
|
|
484
517
|
if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
|
|
485
518
|
}
|
|
486
519
|
|
|
520
|
+
// Watch one open socket: ping on a timer, and terminate it if the server has gone
|
|
521
|
+
// quiet for longer than any healthy connection ever is (see LIVENESS_TIMEOUT_MS).
|
|
522
|
+
// `terminate()` (not close()) because the point is that the peer may be gone — a
|
|
523
|
+
// close handshake would wait for a reply that never comes. The 'close' it fires
|
|
524
|
+
// takes the ordinary reconnect path, so recovery needs no separate machinery.
|
|
525
|
+
private startHeartbeat(ws: WebSocket): void {
|
|
526
|
+
this.stopHeartbeat();
|
|
527
|
+
this.lastInboundAt = Date.now();
|
|
528
|
+
this.heartbeatTimer = setInterval(() => {
|
|
529
|
+
// A socket we've since replaced or dropped isn't ours to police anymore.
|
|
530
|
+
if (this.ws !== ws) { this.stopHeartbeat(); return; }
|
|
531
|
+
if (ws.readyState !== WebSocket.OPEN) return; // closing — 'close' will clean up
|
|
532
|
+
const quietMs = Date.now() - this.lastInboundAt;
|
|
533
|
+
if (quietMs > LIVENESS_TIMEOUT_MS) {
|
|
534
|
+
this.cb.onStatus?.(
|
|
535
|
+
`Remote access went silent for ${Math.round(quietMs / 1000)}s (the connection died without closing) — dropping it and reconnecting…`,
|
|
536
|
+
);
|
|
537
|
+
this.stopHeartbeat();
|
|
538
|
+
try { ws.terminate(); } catch (_) { /* already gone — 'close' still fires */ }
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
|
|
542
|
+
}, HEARTBEAT_MS);
|
|
543
|
+
// Never hold the process open for a heartbeat alone.
|
|
544
|
+
this.heartbeatTimer.unref?.();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private stopHeartbeat(): void {
|
|
548
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
|
|
549
|
+
}
|
|
550
|
+
|
|
487
551
|
private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
|
|
488
552
|
if (this.closed || this.reconnectTimer) return;
|
|
489
553
|
this.reconnectTimer = setTimeout(() => {
|
|
@@ -746,6 +810,21 @@ export class RelayClient {
|
|
|
746
810
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
747
811
|
}
|
|
748
812
|
|
|
813
|
+
// Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
|
|
814
|
+
// is how long since the server last said anything: a connected socket that has been
|
|
815
|
+
// quiet for longer than the server's 25s ping cadence is the shape of the half-open
|
|
816
|
+
// failure the watchdog exists to catch, so it is worth showing rather than a bare
|
|
817
|
+
// "connected".
|
|
818
|
+
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
|
|
819
|
+
if (!this.isConnected()) return { connected: false };
|
|
820
|
+
const now = Date.now();
|
|
821
|
+
return {
|
|
822
|
+
connected: true,
|
|
823
|
+
upSec: this.connectedAt ? Math.round((now - this.connectedAt) / 1000) : undefined,
|
|
824
|
+
quietSec: this.lastInboundAt ? Math.round((now - this.lastInboundAt) / 1000) : undefined,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
749
828
|
// Push a finished routine result to any attached controller as a text event, so
|
|
750
829
|
// it renders in the app's live feed. Returns whether the socket was open to send
|
|
751
830
|
// on; a durable channel (file/notice) still backs this up, since we can't know
|
package/src/routines/store.ts
CHANGED
|
@@ -204,6 +204,11 @@ export function drainPendingRelay(): PendingRelay[] {
|
|
|
204
204
|
return queue;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// What produced a result delivered to the account outbox. Travels inside the sealed
|
|
208
|
+
// envelope, so the app can label and filter its inbox without the server learning
|
|
209
|
+
// anything. Kept here (not in the harbor) because the pending-cloud queue persists it.
|
|
210
|
+
export type OutboxKind = "routine" | "task" | "workflow";
|
|
211
|
+
|
|
207
212
|
// A `cloud`-delivery result that couldn't be sealed+posted to the account outbox
|
|
208
213
|
// yet (offline, server down, or the app hasn't published its outbox key). Held on
|
|
209
214
|
// disk until a later flush succeeds. Unlike PendingRelay this carries `status`, so
|
|
@@ -214,9 +219,10 @@ export interface PendingCloud {
|
|
|
214
219
|
status: "ok" | "error";
|
|
215
220
|
content: string;
|
|
216
221
|
// What produced this — a scheduled routine (default, for back-compat with items
|
|
217
|
-
// written before ad-hoc tasks existed)
|
|
218
|
-
// so the flush re-seals with the right `kind` and the app
|
|
219
|
-
|
|
222
|
+
// written before ad-hoc tasks existed), an app-submitted one-shot task, or a
|
|
223
|
+
// workflow run. Preserved so the flush re-seals with the right `kind` and the app
|
|
224
|
+
// labels it correctly in the inbox.
|
|
225
|
+
kind?: OutboxKind;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
function pendingCloudPath(): string {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The two relay file tools as a Pi extension factory bound to ONE specific bridge.
|
|
2
|
+
//
|
|
3
|
+
// `send_file_to_client` / `save_attachment` are normally registered by the shipped TUI
|
|
4
|
+
// extension (extensions/privateer-gate.ts), against that extension's module-level
|
|
5
|
+
// RemoteBridge — the one `/remote-access` attaches a relay to. That is right for the TUI
|
|
6
|
+
// and wrong everywhere else: the extension is AUTO-DISCOVERED from ~/.privateer/agent/
|
|
7
|
+
// extensions into every session that shares the agent dir, including the sessions the
|
|
8
|
+
// harbor daemon stands up (live task spawns), which own their OWN bridge + relay. Pi
|
|
9
|
+
// resolves duplicate tool names first-registration-wins and loads discovered extensions
|
|
10
|
+
// before inline factories, so the discovered pair would shadow a session's own and answer
|
|
11
|
+
// "remote access is off" while the session's relay is connected and driving.
|
|
12
|
+
//
|
|
13
|
+
// Hence this factory: a session that has its own bridge registers the pair here, and the
|
|
14
|
+
// gate extension stands down inside the daemon (PRIVATEER_HARBOR_DAEMON).
|
|
15
|
+
import { makeSendFileTool, type SendFileBridge } from "./sendFile.ts";
|
|
16
|
+
import { makeSaveAttachmentTool } from "./saveAttachment.ts";
|
|
17
|
+
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
18
|
+
|
|
19
|
+
export function makeRelayFileTools(bridge: SendFileBridge, attachments: AttachmentStore) {
|
|
20
|
+
return function relayFileTools(pi: any): void {
|
|
21
|
+
pi.registerTool?.(makeSendFileTool(bridge));
|
|
22
|
+
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
23
|
+
};
|
|
24
|
+
}
|