privateer-agent 0.12.18 → 0.12.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.18",
3
+ "version": "0.12.19",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -651,6 +651,16 @@ export class Harbor {
651
651
  }
652
652
 
653
653
  private async tick(): Promise<void> {
654
+ // Retry the relay every tick, not just at startup. syncRelay() bails when the
655
+ // account isn't signed in on this machine — and a harbor that came up in that
656
+ // state used to stay off the relay FOREVER, because start() was the only caller
657
+ // that mattered (the three IPC commands that also call it are ones the desktop
658
+ // app never sends). Signing in afterwards wrote credentials.json and changed
659
+ // nothing: the harbor kept answering IPC, so the app called it running, while
660
+ // its relay socket had never been opened — the permanent "Connecting" with a
661
+ // harbor that fires no routine and answers no spawn. Cheap and idempotent: it
662
+ // returns immediately once a client exists, or while remote access is off.
663
+ this.syncRelay();
654
664
  void this.flushPendingCloud();
655
665
  const now = Date.now();
656
666
  for (const r of loadRoutines()) {
@@ -1269,19 +1279,35 @@ export class Harbor {
1269
1279
  private relayStatus(): RelayStatus {
1270
1280
  const termId = routineRelayId();
1271
1281
  if (this.relayTerminated) {
1272
- return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1282
+ return { termId, connected: false, reason: "terminated", detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
1273
1283
  }
1274
1284
  if (!this.relay) {
1285
+ // "relay not started" with credentials present is now a sub-tick window, not a
1286
+ // permanent state: tick() re-runs syncRelay(), so a harbor that came up signed
1287
+ // out connects on its own once you sign in. Reported as "connecting" because
1288
+ // that is what it now is.
1289
+ const signedIn = hasCredentials();
1275
1290
  return {
1276
1291
  termId,
1277
1292
  connected: false,
1278
- detail: hasCredentials()
1293
+ reason: signedIn ? "connecting" : "signed-out",
1294
+ detail: signedIn
1279
1295
  ? "relay not started"
1280
- : "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
1296
+ : "no account signed in on this machine — run `privateer` and /login",
1281
1297
  };
1282
1298
  }
1283
1299
  const conn = this.relay.connectionStatus();
1284
- if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
1300
+ // The client knows why it isn't up — a refused ticket, an unreachable server —
1301
+ // and reporting a flat "connecting…" over the top of that is what made a harbor
1302
+ // that will never connect look like one that is about to.
1303
+ if (!conn.connected) {
1304
+ return {
1305
+ termId,
1306
+ connected: false,
1307
+ reason: conn.refused ? "refused" : "connecting",
1308
+ detail: conn.detail ?? "connecting…",
1309
+ };
1310
+ }
1285
1311
  return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
1286
1312
  }
1287
1313
 
package/src/harbor/ipc.ts CHANGED
@@ -42,6 +42,21 @@ export type IpcRequest =
42
42
  | { cmd: "run-now"; idOrName: string }
43
43
  | { cmd: "reload" };
44
44
 
45
+ /**
46
+ * Why a harbor isn't on the relay, as a code rather than a sentence.
47
+ *
48
+ * `detail` below is written for a terminal — it names CLI commands and is English
49
+ * only — so the app can't put it in front of a user. It used to have nothing else
50
+ * to go on, and said "give it a moment" about every one of these, including the
51
+ * three that never clear on their own. This is the machine-readable half.
52
+ *
53
+ * terminated remote access was switched off from the app; needs a restart
54
+ * signed-out no account credentials on this machine
55
+ * refused the server said no (plan's agent cap, rejected ticket) — standing
56
+ * connecting genuinely still trying; this one really is worth a moment
57
+ */
58
+ export type RelayReason = "terminated" | "signed-out" | "refused" | "connecting";
59
+
45
60
  /**
46
61
  * The harbor's view of its own relay connection, reported by `status`.
47
62
  *
@@ -62,6 +77,8 @@ export interface RelayStatus {
62
77
  quietSec?: number;
63
78
  /** Why it isn't connected, when we know: signed out, turned off from the app, … */
64
79
  detail?: string;
80
+ /** The same fact as `detail`, for a caller that has to localize it. */
81
+ reason?: RelayReason;
65
82
  }
66
83
 
67
84
  export interface IpcResponse {
@@ -383,6 +383,11 @@ export class RelayClient {
383
383
  private reconnectDelay = RECONNECT_MS;
384
384
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
385
385
  private refusal: string | null = null;
386
+ // Why the last connection attempt failed, kept for connectionStatus(). A refusal
387
+ // (4xx) outranks it — that one is a decision, not a hiccup — but without either,
388
+ // every un-connected relay reports as a bare "connecting…", which is the same
389
+ // sentence whether the socket opens in two seconds or never opens again.
390
+ private lastFailure: string | null = null;
386
391
  // Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
387
392
  private bufKind: "text" | "reasoning" | null = null;
388
393
  private buf = "";
@@ -518,6 +523,7 @@ export class RelayClient {
518
523
  ws.on("open", () => {
519
524
  opened = true;
520
525
  this.refusal = null; // a later refusal is news again
526
+ this.lastFailure = null; // whatever kept us out is history
521
527
  this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
522
528
  this.connectedAt = Date.now();
523
529
  this.startHeartbeat(ws);
@@ -537,6 +543,9 @@ export class RelayClient {
537
543
  // attach/detach frame said. Re-learned on the next attach or inbound frame.
538
544
  this.controllerHere = false;
539
545
  this.cb.onDisconnected?.();
546
+ // A socket that never opened failed for a reason worth reporting; one that
547
+ // opened and dropped is an ordinary blip the reconnect handles.
548
+ this.lastFailure = opened ? null : lastErr || "the relay connection closed before it opened";
540
549
  if (!this.closed) {
541
550
  this.cb.onStatus?.(
542
551
  opened
@@ -560,6 +569,7 @@ export class RelayClient {
560
569
  const status = (err as { status?: number })?.status;
561
570
  const refused = typeof status === "number" && status >= 400 && status < 500;
562
571
  if (refused) {
572
+ this.lastFailure = msg;
563
573
  this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
564
574
  // A refusal is a decision, not a hiccup: hammering the same request every few
565
575
  // seconds can't change it, and for a harbor — whose onStatus goes to a log file,
@@ -578,6 +588,7 @@ export class RelayClient {
578
588
  }
579
589
  // Transient (network/route/5xx): stay on the fast retry.
580
590
  this.refusal = null;
591
+ this.lastFailure = msg;
581
592
  this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
582
593
  this.scheduleReconnect();
583
594
  } finally {
@@ -970,8 +981,19 @@ export class RelayClient {
970
981
  // quiet for longer than the server's 25s ping cadence is the shape of the half-open
971
982
  // failure the watchdog exists to catch, so it is worth showing rather than a bare
972
983
  // "connected".
973
- connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
974
- if (!this.isConnected()) return { connected: false };
984
+ connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number; detail?: string; refused?: boolean } {
985
+ // Not connected: say WHY when we know. A standing refusal (the plan's agent cap,
986
+ // a rejected ticket) is the answer that matters most — it will not clear on its
987
+ // own, and it's the one case a caller should present as a decision rather than a
988
+ // wait — so it wins over the last transient error and is flagged as itself.
989
+ if (!this.isConnected()) {
990
+ const detail = this.refusal ?? this.lastFailure;
991
+ return {
992
+ connected: false,
993
+ ...(detail ? { detail } : {}),
994
+ ...(this.refusal ? { refused: true } : {}),
995
+ };
996
+ }
975
997
  const now = Date.now();
976
998
  return {
977
999
  connected: true,