privateer-agent 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 WIDENS the tinfoil provider's model list. pi-privacy registers `tinfoil` with
9
- // a single seed model, so any other Tinfoil model notably our default `tinfoil/glm-5-2`
10
- // — resolves as a "custom model id" with a startup warning and never shows in the picker.
11
- // We re-register tinfoil with its current chat catalog AFTER pi-privacy runs (a second
12
- // registerProvider call replaces the provider's model list; pi-privacy registers
13
- // synchronously, so ours lands second and wins). This is purely a display/resolution
14
- // list posture and attestation are dispatcher-bound and unaffected by the model set.
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.0",
3
+ "version": "0.9.1",
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",
@@ -57,7 +57,7 @@ import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "..
57
57
  import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
58
58
  import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
59
59
  import { redactText, collectSecrets } from "../util/redact.ts";
60
- import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
60
+ import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
61
61
  import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
62
62
  import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
63
63
 
@@ -807,6 +807,16 @@ export class Harbor {
807
807
  // Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
808
808
  // when logged in; ZDR-channel models stay at their honest floor. The live
809
809
  // verdict still comes from accountPosture on select — this only lifts the label.
810
+ //
811
+ // ORDER MATTERS: pi-privacy's own catalog registers a `privateer`
812
+ // provider (its PUBLIC developer-key channel, one seed model), and Pi's
813
+ // registerProvider REPLACES a provider's models and request config. It
814
+ // must stay ABOVE makeAccountProvider() so the ACCOUNT channel lands last
815
+ // — otherwise the default model stops resolving and requests go to
816
+ // api.privateer.pro/v1 instead of /api/agent/v1. (The TUI hits this
817
+ // through extension discovery, where the order isn't ours to choose;
818
+ // extensions/privateer-privacy.ts re-asserts the account registration
819
+ // there. See registerAccountModels.)
810
820
  makePiPrivacyExtension({
811
821
  privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
812
822
  }),
@@ -1192,6 +1202,31 @@ export class Harbor {
1192
1202
  return { text: notes.length > 0 ? `${out}${formatNotes(notes)}` : out, output, status, error };
1193
1203
  }
1194
1204
 
1205
+ // What the app can actually see. A harbor is only drivable while its relay socket
1206
+ // is up, and every way that can fail — signed out, turned off from the app, refused
1207
+ // by the plan's agent cap, a socket that died without closing — used to be visible
1208
+ // ONLY as a line in a log file nobody reads. Reported alongside pid/uptime so
1209
+ // `privateer harbor status` can never again say "running" about a harbor the app
1210
+ // shows as offline.
1211
+ private relayStatus(): RelayStatus {
1212
+ const termId = routineRelayId();
1213
+ if (this.relayTerminated) {
1214
+ return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1215
+ }
1216
+ if (!this.relay) {
1217
+ return {
1218
+ termId,
1219
+ connected: false,
1220
+ detail: hasCredentials()
1221
+ ? "relay not started"
1222
+ : "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
1223
+ };
1224
+ }
1225
+ const conn = this.relay.connectionStatus();
1226
+ if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
1227
+ return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
1228
+ }
1229
+
1195
1230
  private persistRun(id: string, patch: Partial<Routine>): void {
1196
1231
  const current = findRoutine(loadRoutines(), id);
1197
1232
  if (!current) return;
@@ -1201,7 +1236,13 @@ export class Harbor {
1201
1236
  private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
1202
1237
  switch (req.cmd) {
1203
1238
  case "status":
1204
- return { ok: true, pid: process.pid, uptimeSec: Math.round((Date.now() - this.startedAt) / 1000), routines: loadRoutines() };
1239
+ return {
1240
+ ok: true,
1241
+ pid: process.pid,
1242
+ uptimeSec: Math.round((Date.now() - this.startedAt) / 1000),
1243
+ relay: this.relayStatus(),
1244
+ routines: loadRoutines(),
1245
+ };
1205
1246
  case "list":
1206
1247
  return { ok: true, routines: loadRoutines() };
1207
1248
  case "add": {
@@ -1254,11 +1295,29 @@ export function runHarbor(): void {
1254
1295
  };
1255
1296
  process.on("SIGINT", shutdown);
1256
1297
  process.on("SIGTERM", shutdown);
1257
- harbor.start().catch((err) => {
1298
+ harbor.start().catch(async (err) => {
1258
1299
  if (err instanceof HarborAlreadyRunningError) {
1259
1300
  // A resident harbor already owns this machine — leave it in charge. Exit 0 so a
1260
1301
  // manual `privateer harbor run` beside the installed login service isn't an error.
1302
+ //
1303
+ // Say WHICH harbor, and whether it is actually reachable from the app: the
1304
+ // incumbent can be a stale process that still answers IPC while its relay socket
1305
+ // is long dead, and "leaving the existing one in charge" then reads as reassurance
1306
+ // for a harbor the app shows as offline. The incumbent knows — ask it.
1261
1307
  process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
1308
+ try {
1309
+ const status = await sendToHarbor({ cmd: "status" }, 3_000);
1310
+ const up = typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
1311
+ process.stderr.write(` incumbent: pid ${status.pid ?? "?"}${up}\n`);
1312
+ process.stderr.write(` relay: ${describeRelay(status.relay)}\n`);
1313
+ if (status.relay && !status.relay.connected) {
1314
+ process.stderr.write(" Stop that process (or `privateer harbor uninstall && privateer harbor install`) to hand the machine to a fresh Harbor.\n");
1315
+ }
1316
+ } catch {
1317
+ // It held the lock a moment ago but won't answer now — a wedged process is
1318
+ // worth naming too, since nothing else will start while it holds the socket.
1319
+ process.stderr.write(" incumbent: holds the lock but is not answering IPC — it may be wedged; stop it and start again.\n");
1320
+ }
1262
1321
  process.exit(0);
1263
1322
  }
1264
1323
  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 {
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
63
63
  }
64
64
 
65
- function launchdPlist(): string {
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
- <true/>
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 line for `privateer harbor status`: whether the service is
240
- // installed AND whether a harbor is actually answering on the IPC socket right now.
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
- const live = await harborIsRunning();
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 ? "running (answering IPC)" : "not reachable"}`,
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
 
@@ -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
@@ -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
- ws.on("message", (data) => this.handle(data));
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