privateer-agent 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The agent's Nostr identity.
3
+ *
4
+ * A Nostr secret key is not a bot token. It is a PERMANENT identity: it cannot be
5
+ * rotated without becoming a different participant, everything it ever signed stays
6
+ * attributable to it, and there is no issuer to revoke it. So it gets the same
7
+ * treatment as the terminal identity key — minted locally, written 0600, and never
8
+ * sent anywhere — rather than living in plaintext config.json beside the revocable
9
+ * platform bot tokens.
10
+ *
11
+ * DEFAULT PATH: the agent generates its own keypair and reports the npub, which the
12
+ * user pastes into their Buzz workspace so an Owner can add it as a Bot member. The
13
+ * secret never crosses a wire, not even the sealed app→terminal channel.
14
+ *
15
+ * IMPORT PATH: a user who already has a Buzz identity can send an nsec through the
16
+ * app's sealed-secret flow; importBuzzKey() moves it into the 0600 file so it stops
17
+ * living in config.json.
18
+ *
19
+ * Construction mirrors crypto/terminalKey.ts deliberately, including the 0600
20
+ * TOCTOU-avoiding write and the process-lifetime cache.
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, chmodSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { schnorr } from "@noble/curves/secp256k1";
26
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
27
+ import { globalDir } from "../config/paths.ts";
28
+ import { npubEncode, npubDecode, nsecEncode, nsecDecode } from "./bech32.ts";
29
+
30
+ interface BuzzKeyFile {
31
+ v: 1;
32
+ secretKey: string; // base64, 32 raw bytes — never leaves this machine
33
+ }
34
+
35
+ export interface BuzzIdentity {
36
+ secretHex: string;
37
+ pubkeyHex: string;
38
+ npub: string;
39
+ }
40
+
41
+ function keyPath(): string {
42
+ return join(globalDir(), "buzz-key.json");
43
+ }
44
+
45
+ let cached: BuzzIdentity | undefined;
46
+
47
+ // ── pure ────────────────────────────────────────────────────────────────────────
48
+
49
+ /** A fresh 32-byte secp256k1 secret key. */
50
+ export function generateSecretKey(): Uint8Array {
51
+ return schnorr.utils.randomSecretKey();
52
+ }
53
+
54
+ /** The 32-byte x-only public key for a secret, as lowercase hex — a Nostr pubkey. */
55
+ export function publicKeyHex(secret: Uint8Array | string): string {
56
+ return bytesToHex(schnorr.getPublicKey(typeof secret === "string" ? hexToBytes(secret) : secret));
57
+ }
58
+
59
+ /**
60
+ * Normalize a configured identity to lowercase hex.
61
+ *
62
+ * Allowlists are written by humans, who will paste whichever form Buzz showed them —
63
+ * so accept both npub and raw hex and store one canonical form. Returns undefined
64
+ * for anything that isn't a valid 32-byte key, so a typo'd entry fails closed
65
+ * (dropped from the allowlist) rather than silently matching nothing forever.
66
+ */
67
+ export function toHexPubkey(npubOrHex: string): string | undefined {
68
+ const s = npubOrHex.trim();
69
+ if (/^[0-9a-fA-F]{64}$/.test(s)) return s.toLowerCase();
70
+ if (s.startsWith("npub1")) {
71
+ try {
72
+ return bytesToHex(npubDecode(s));
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /** Accept either an nsec or raw hex secret; throws on anything else. */
81
+ export function secretFromNsec(nsecOrHex: string): Uint8Array {
82
+ const s = nsecOrHex.trim();
83
+ if (/^[0-9a-fA-F]{64}$/.test(s)) return hexToBytes(s.toLowerCase());
84
+ if (s.startsWith("nsec1")) return nsecDecode(s);
85
+ throw new Error("expected an nsec1… key or 64 hex characters");
86
+ }
87
+
88
+ function identityFrom(secret: Uint8Array): BuzzIdentity {
89
+ const pubkeyHex = publicKeyHex(secret);
90
+ return { secretHex: bytesToHex(secret), pubkeyHex, npub: npubEncode(hexToBytes(pubkeyHex)) };
91
+ }
92
+
93
+ // ── persisted ───────────────────────────────────────────────────────────────────
94
+
95
+ function persist(secret: Uint8Array): BuzzIdentity {
96
+ const file: BuzzKeyFile = { v: 1, secretKey: Buffer.from(secret).toString("base64") };
97
+ // 0600 from creation — see terminalKey.ts for why `mode` plus a follow-up chmod.
98
+ writeFileSync(keyPath(), JSON.stringify(file), { mode: 0o600 });
99
+ try {
100
+ chmodSync(keyPath(), 0o600);
101
+ } catch {
102
+ /* best effort — e.g. non-POSIX FS */
103
+ }
104
+ cached = identityFrom(secret);
105
+ return cached;
106
+ }
107
+
108
+ /** Load the persisted identity, minting and persisting one on first use. */
109
+ export function loadOrCreateBuzzKey(): BuzzIdentity {
110
+ if (cached) return cached;
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as BuzzKeyFile;
113
+ if (parsed?.v === 1 && parsed.secretKey) {
114
+ const buf = Buffer.from(parsed.secretKey, "base64");
115
+ if (buf.length === 32) {
116
+ cached = identityFrom(new Uint8Array(buf));
117
+ return cached;
118
+ }
119
+ }
120
+ } catch {
121
+ /* missing or malformed → mint a fresh keypair below */
122
+ }
123
+ return persist(generateSecretKey());
124
+ }
125
+
126
+ /**
127
+ * Adopt an existing identity, replacing any current one.
128
+ *
129
+ * Called when a user supplies an nsec through the app: the value arrives sealed,
130
+ * lands here, and the caller then deletes it from config.json so the only copy on
131
+ * disk is the 0600 file.
132
+ */
133
+ export function importBuzzKey(nsecOrHex: string): BuzzIdentity {
134
+ return persist(secretFromNsec(nsecOrHex));
135
+ }
136
+
137
+ /**
138
+ * Every textual form of the persisted secret, for the outbound redactor.
139
+ *
140
+ * The agent can read its own key file — it's a file on the machine it operates —
141
+ * so without this it could quote its permanent identity into a public channel.
142
+ * Both encodings are returned because either could plausibly appear in output.
143
+ * Non-minting: no key file means nothing to redact.
144
+ */
145
+ export function buzzRedactionSecrets(): string[] {
146
+ const secretHex = cached?.secretHex ?? readPersistedSecretHex();
147
+ if (!secretHex) return [];
148
+ return [secretHex, nsecEncode(hexToBytes(secretHex))];
149
+ }
150
+
151
+ function readPersistedSecretHex(): string | undefined {
152
+ try {
153
+ const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as BuzzKeyFile;
154
+ const buf = Buffer.from(parsed?.secretKey ?? "", "base64");
155
+ if (parsed?.v === 1 && buf.length === 32) return bytesToHex(new Uint8Array(buf));
156
+ } catch {
157
+ /* not configured yet */
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ /**
163
+ * Read the persisted npub WITHOUT minting one.
164
+ *
165
+ * Side-effect-free by design: the app lists every platform, configured or not, and
166
+ * merely rendering an empty Buzz card must not conjure a permanent identity.
167
+ */
168
+ export function peekBuzzNpub(): string | undefined {
169
+ if (cached) return cached.npub;
170
+ const secretHex = readPersistedSecretHex();
171
+ return secretHex ? identityFrom(hexToBytes(secretHex)).npub : undefined;
172
+ }
@@ -0,0 +1,60 @@
1
+ // NIP-10 threading and NIP-27 mention helpers — reading meaning out of an event's
2
+ // positional tag arrays, and building the tags for a reply.
3
+ //
4
+ // Pure and adapter-agnostic: the Buzz adapter uses these, but nothing here is
5
+ // Buzz-specific.
6
+
7
+ import type { Tag } from "./event.ts";
8
+
9
+ /**
10
+ * Extract a reply's thread position from its "e" tags.
11
+ *
12
+ * Two encodings exist in the wild and both must be handled:
13
+ * MARKERED (current) ["e", <id>, <relay>, "root"] / [… "reply"]
14
+ * POSITIONAL (legacy) the FIRST "e" tag is the root, the LAST is the direct
15
+ * parent; with exactly one, it is both.
16
+ * A markered tag anywhere wins — mixing the two is malformed, and trusting the
17
+ * explicit marker is the safer read.
18
+ */
19
+ export function threadRefs(tags: Tag[]): { root?: string; reply?: string } {
20
+ const eTags = tags.filter((t) => t[0] === "e" && typeof t[1] === "string" && t[1].length > 0);
21
+ if (eTags.length === 0) return {};
22
+
23
+ const markered = eTags.filter((t) => t[3] === "root" || t[3] === "reply");
24
+ if (markered.length > 0) {
25
+ return {
26
+ root: markered.find((t) => t[3] === "root")?.[1],
27
+ reply: markered.find((t) => t[3] === "reply")?.[1],
28
+ };
29
+ }
30
+
31
+ // Legacy positional form.
32
+ if (eTags.length === 1) return { root: eTags[0][1], reply: eTags[0][1] };
33
+ return { root: eTags[0][1], reply: eTags[eTags.length - 1][1] };
34
+ }
35
+
36
+ /** Every pubkey this event tags — i.e. everyone it @-mentions. */
37
+ export function pTags(tags: Tag[]): string[] {
38
+ return tags.filter((t) => t[0] === "p" && typeof t[1] === "string" && t[1].length > 0).map((t) => t[1]);
39
+ }
40
+
41
+ /** Blossom content hashes attached to this event (["x", <sha256>]). */
42
+ export function xTags(tags: Tag[]): string[] {
43
+ return tags.filter((t) => t[0] === "x" && typeof t[1] === "string" && t[1].length > 0).map((t) => t[1]);
44
+ }
45
+
46
+ /**
47
+ * Build the tags for a reply.
48
+ *
49
+ * `root` is the thread root and `parent` the message being answered; when only one
50
+ * is known, pass it as both — a reply that marks a root but no parent reads as a
51
+ * top-level post to most clients. Mentioned pubkeys are deduped, since duplicate "p"
52
+ * tags inflate relay-side mention indexes for no benefit.
53
+ */
54
+ export function replyTags(root?: string, parent?: string, mentionPubkeys: string[] = []): Tag[] {
55
+ const out: Tag[] = [];
56
+ if (root) out.push(["e", root, "", "root"]);
57
+ if (parent && parent !== root) out.push(["e", parent, "", "reply"]);
58
+ for (const pk of [...new Set(mentionPubkeys)]) out.push(["p", pk]);
59
+ return out;
60
+ }
@@ -1,5 +1,7 @@
1
1
  import { resolve, isAbsolute, relative, dirname, join, basename } from "node:path";
2
2
  import { realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
3
5
  import { isProtectedPath } from "./protected.ts";
4
6
  import type { PermissionRequest } from "./gate.ts";
5
7
 
@@ -42,8 +44,57 @@ function realBase(abs: string): string {
42
44
  }
43
45
  }
44
46
 
47
+ // ⚠️ SECURITY-CRITICAL: this MUST resolve a path string to exactly the same place
48
+ // Pi's file tools do, or the gate judges one file and the tool touches another.
49
+ //
50
+ // Pi resolves every tool path through `resolveToCwd` → `resolvePath` →
51
+ // `normalizePath` (node_modules/@earendil-works/pi-coding-agent/dist/utils/paths.js
52
+ // and dist/core/tools/path-utils.js), which EXPANDS `~`, STRIPS a leading `@`, and
53
+ // converts `file://` URLs. `node:path` treats all three as RELATIVE, so a classifier
54
+ // using bare resolve() computed `<cwd>/~/.ssh/authorized_keys` — comfortably "inside"
55
+ // the working directory — while `write` truly targeted `$HOME/.ssh/authorized_keys`.
56
+ // That made `outside` false and `protected` false (isProtectedPath matches basenames,
57
+ // and `authorized_keys` isn't one), so:
58
+ // • reads returned null from classifyToolCall — NO gate at all, in every posture
59
+ // including plan/readonly, at the default read-only tool ceiling; and
60
+ // • writes auto-allowed under acceptEdits / bypass / no-quarter.
61
+ // The adversary here is the MODEL (prompt injection), not the user, so "the operator
62
+ // could read that file anyway" does not apply.
63
+ //
64
+ // Pi does not export normalizePath (package exports are "." and "./rpc-entry" only),
65
+ // so it is mirrored here. KEEP IN SYNC — tests/classifyPathParity.test.ts asserts this
66
+ // function agrees with Pi's own resolver, so a Pi upgrade that changes normalization
67
+ // fails the suite instead of silently reopening the hole.
68
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
69
+
70
+ function normalizeLikePi(input: string, opts: { unicodeSpaces?: boolean; stripAt?: boolean } = {}): string {
71
+ let s = input;
72
+ if (opts.unicodeSpaces) s = s.replace(UNICODE_SPACES, " ");
73
+ if (opts.stripAt && s.startsWith("@")) s = s.slice(1);
74
+ const home = homedir();
75
+ if (s === "~") return home;
76
+ if (s.startsWith("~/") || (process.platform === "win32" && s.startsWith("~\\"))) return join(home, s.slice(2));
77
+ if (/^file:\/\//.test(s)) {
78
+ // Pi lets fileURLToPath throw here, which fails the tool call. We must not throw
79
+ // (that would break the gate), so fall back to the raw string: it then resolves
80
+ // inside cwd, but the tool errors on the same input, so there is no divergence
81
+ // a caller can exploit.
82
+ try {
83
+ return fileURLToPath(s);
84
+ } catch {
85
+ return s;
86
+ }
87
+ }
88
+ return s;
89
+ }
90
+
45
91
  function resolveInCwd(cwd: string, p: string): string {
46
- return realBase(isAbsolute(p) ? p : resolve(cwd, p));
92
+ // Mirrors resolvePath(): the TARGET gets the tools' options
93
+ // ({normalizeUnicodeSpaces, stripAtPrefix}); the BASE gets normalizePath's defaults
94
+ // (tilde/file:// only), because Pi normalizes baseDir with no options.
95
+ const target = normalizeLikePi(p, { unicodeSpaces: true, stripAt: true });
96
+ const base = normalizeLikePi(cwd);
97
+ return realBase(isAbsolute(target) ? resolve(target) : resolve(base, target));
47
98
  }
48
99
 
49
100
  function isInsideDir(root: string, abs: string): boolean {
@@ -23,24 +23,18 @@
23
23
  */
24
24
  import { readFileSync, writeFileSync } from "node:fs";
25
25
  import { configPath } from "../config/paths.ts";
26
+ // The platform roster, per-platform secret field names, and the "is this block
27
+ // startable" predicate all live in ONE place, shared with the channels runtime —
28
+ // see channels/platforms.ts. Re-exported here so existing importers of
29
+ // CHANNEL_PLATFORMS/ChannelPlatform from this module keep working unchanged.
30
+ import { CHANNEL_PLATFORMS, SECRET_FIELDS, isChannelPlatform, type ChannelPlatform } from "../channels/platforms.ts";
26
31
 
27
- // The platforms channels/run.ts knows how to start. Order is the app's display
28
- // order. Keep in sync with the `startChannel` calls in run.ts.
29
- export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
30
- export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
32
+ export { CHANNEL_PLATFORMS };
33
+ export type { ChannelPlatform };
31
34
 
32
35
  const POSTURES = ["readonly", "approve", "auto"] as const;
33
36
  export type ChannelPosture = (typeof POSTURES)[number];
34
37
 
35
- // The secret (never-echoed) fields per platform — the union of the token blocks
36
- // run.ts requires to START each platform. `secretsSet` reports presence of these.
37
- const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
38
- telegram: ["botToken"],
39
- slack: ["appToken", "botToken"],
40
- discord: ["botToken"],
41
- whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
42
- };
43
-
44
38
  // Non-secret projection of one platform's config, sent to the app. No token
45
39
  // values, ever — only which secret fields are already present (`secretsSet`).
46
40
  export interface RemoteChannel {
@@ -79,9 +73,7 @@ export interface ChannelsControl {
79
73
  remove(platform: ChannelPlatform): { ok: boolean; message?: string };
80
74
  }
81
75
 
82
- function isPlatform(v: unknown): v is ChannelPlatform {
83
- return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
84
- }
76
+ const isPlatform = isChannelPlatform;
85
77
 
86
78
  function normalizePosture(v: unknown): ChannelPosture | undefined {
87
79
  return typeof v === "string" && (POSTURES as readonly string[]).includes(v) ? (v as ChannelPosture) : undefined;
@@ -31,6 +31,8 @@ import { RemoteBridge } from "./remoteBridge.ts";
31
31
  import { makeRelayFileTools } from "../tools/relayFileTools.ts";
32
32
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
33
33
  import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
34
+ import { createUIContext } from "../ext/headlessUi.ts";
35
+ import { noQuarterActive } from "../permissions/noQuarter.ts";
34
36
 
35
37
  export interface LiveTaskHandle {
36
38
  termId: string;
@@ -161,7 +163,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
161
163
  attached = true;
162
164
  if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
163
165
  relay?.sendSnapshot([]);
164
- relay?.sendContext({ model: modelSpec, version: agentVersion() });
166
+ // cwd rides along here (and nowhere else see RelayClient.sendContext): this
167
+ // session's working directory was either named by the driver in the spawn form
168
+ // or defaulted to the harbor's own, and they have no other way to see which.
169
+ // Every file this agent touches is under it.
170
+ relay?.sendContext({ model: modelSpec, cwd, version: agentVersion() });
165
171
  relay?.sendCommands([]);
166
172
  // Deliver the spawn's initial prompt exactly once, THROUGH the bridge's own prompt
167
173
  // path so it counts as a driven turn (remote=true → tools relay to the app).
@@ -189,6 +195,13 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
189
195
  },
190
196
  getRemote: bridge.getRemote,
191
197
  getNoQuarter: bridge.getNoQuarter,
198
+ // Session-wide TOTAL bypass when the harbor itself was launched `--no-quarter`
199
+ // (env PRIVATEER_NO_QUARTER): every action auto-approves with no prompt, here as
200
+ // in the TUI. Without this the flag meant nothing on a spawn — the app's own
201
+ // no-quarter toggle (getNoQuarter above) was the only switch that reached this
202
+ // gate, so an operator who had lowered the moat harbor-wide still got prompted
203
+ // for everything. Off unless the flag is set, which launchd/systemd never does.
204
+ getSkipAllPermissions: noQuarterActive,
192
205
  remoteAsk: bridge.remoteAsk,
193
206
  blockedWhenRemote: isRemoteUnsafeTool,
194
207
  onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
@@ -248,7 +261,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
248
261
  // Relay the extension mid-turn UI (select/confirm/input) to the app when driven, so an
249
262
  // extension asking a question doesn't silently cancel. Mirrors cli/chat.ts's uiContext.
250
263
  const driven = (): boolean => bridge.getRemote() && bridge.isConnected();
251
- const uiContext = {
264
+ const uiContext = createUIContext({
252
265
  async select(t: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
253
266
  if (!options.length) return undefined;
254
267
  if (!driven()) return undefined;
@@ -268,7 +281,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
268
281
  notify(message: string): void {
269
282
  if (driven()) bridge.sendNotice(message);
270
283
  },
271
- };
284
+ });
272
285
  await (session as any).bindExtensions({ uiContext });
273
286
 
274
287
  const adapter = createEngineEventAdapter();
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import WebSocket from "ws";
19
19
  import { randomUUID } from "node:crypto";
20
+ import { homedir } from "node:os";
20
21
  import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
21
22
  import type { EngineEvent } from "../engine/events.ts";
22
23
  import type { PermissionRequest } from "../permissions/gate.ts";
@@ -227,6 +228,15 @@ export interface RelayCallbacks {
227
228
  }
228
229
 
229
230
  const RECONNECT_MS = 3000;
231
+ // Ceiling for the reconnect backoff below. A blip should be invisible (first retry at
232
+ // RECONNECT_MS), but a relay that is genuinely down must not be hammered every 3s for
233
+ // hours by every harbor on the fleet — and when it comes back, they must not all
234
+ // stampede it in the same 3s window. So: grow the delay on consecutive failures, cap it
235
+ // here, jitter it, and reset the moment a socket opens.
236
+ const RECONNECT_MAX_MS = 30_000;
237
+ const RECONNECT_GROWTH = 1.7;
238
+ // Fraction of the delay to randomize (±), so restarts don't resynchronize the fleet.
239
+ const RECONNECT_JITTER = 0.25;
230
240
  // Retry cadence after the relay REFUSES us (4xx — in practice the plan's live-agent
231
241
  // cap). Slow, because only an account change can clear it, but not never: the harbor
232
242
  // should come up on its own once a slot frees. Kept well under the server's denial
@@ -241,12 +251,27 @@ const REFUSED_RECONNECT_MS = 60_000;
241
251
  // the server prunes the terminal from its presence registry after ~60s, so the app
242
252
  // shows the harbor as offline while the harbor's own log says "connected", forever.
243
253
  //
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.
254
+ // So don't wait to be told. The server pings every 25s, so an alive socket sees inbound
255
+ // traffic at least that often; we ping on our own timer too and a healthy peer pongs
256
+ // immediately, so in practice inbound arrives every ~20s. If nothing arrives for
257
+ // LIVENESS_TIMEOUT_MS the socket is dead: terminate it and take the normal reconnect path.
258
+ //
259
+ // The timeout is sized against the SERVER's presence TTL, not against comfort. The server
260
+ // refreshes a 60s presence key on its own 25s heartbeat tick (server.js relayHeartbeat →
261
+ // relayHub.markOnline, PRESENCE_TTL_SECS = 60), and the app's agent list reads exactly
262
+ // that key. So the budget to detect a dead socket AND finish reconnecting is 60s from the
263
+ // last tick that reached us — otherwise the key lapses and the harbor blinks out of the
264
+ // app even though recovery is already under way. At the old 75s this was guaranteed: a
265
+ // silent death always cost ~20-40s of visible "offline". 50s of silence is already
266
+ // conclusive (two-plus missed exchanges) and leaves ~10s for the reconnect to re-register.
267
+ //
268
+ // Detection granularity is its own timer: polling silence on the 20s PING cadence added
269
+ // up to a whole extra ping interval of latency, and after a laptop wake (timers frozen
270
+ // while asleep — the common cause of this whole failure mode) it decided how long the
271
+ // harbor stayed dark. Checking every 5s costs nothing and bounds that.
248
272
  const HEARTBEAT_MS = 20_000;
249
- const LIVENESS_TIMEOUT_MS = 75_000;
273
+ const LIVENESS_CHECK_MS = 5_000;
274
+ const LIVENESS_TIMEOUT_MS = 50_000;
250
275
  // Cap the opening handshake too. Without this a black-holed connect leaves `this.ws`
251
276
  // set with no open/close/error ever firing, and connect()'s `if (this.ws) return`
252
277
  // guard then blocks every future attempt — the same permanent silence by another route.
@@ -267,6 +292,20 @@ function clip(s: string, max: number): string {
267
292
  return s.slice(0, max) + `\n… (${s.length - max} more chars)`;
268
293
  }
269
294
 
295
+ // Rewrite a path under the home directory as `~/…` — the CLI's own display form.
296
+ // Used on the cwd we report in a context frame: the driver still sees which folder
297
+ // the agent is working in, without the account name in the absolute path crossing
298
+ // the relay. Only an exact home prefix is collapsed (`/Users/pat2` is left alone);
299
+ // a path outside home is returned unchanged.
300
+ function homeCollapsed(p: string): string {
301
+ let home = "";
302
+ try { home = homedir(); } catch { /* no home → nothing to collapse */ }
303
+ if (!home || !p.startsWith(home)) return p;
304
+ const rest = p.slice(home.length);
305
+ if (rest === "") return "~";
306
+ return rest.startsWith("/") || rest.startsWith("\\") ? `~${rest}` : p;
307
+ }
308
+
270
309
  function asText(output: unknown): string {
271
310
  return typeof output === "string" ? output : JSON.stringify(output);
272
311
  }
@@ -314,6 +353,8 @@ export class RelayClient {
314
353
  private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
315
354
  private lastInboundAt = 0;
316
355
  private connectedAt = 0;
356
+ // Current backoff delay for the next unqualified scheduleReconnect(); reset on 'open'.
357
+ private reconnectDelay = RECONNECT_MS;
317
358
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
318
359
  private refusal: string | null = null;
319
360
  // Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
@@ -402,6 +443,7 @@ export class RelayClient {
402
443
  if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
403
444
  this.stopHeartbeat();
404
445
  this.connectedAt = 0;
446
+ this.reconnectDelay = RECONNECT_MS; // a restarted client shouldn't inherit old backoff
405
447
  this.bufKind = null;
406
448
  this.buf = "";
407
449
  this.incoming.clear();
@@ -450,6 +492,7 @@ export class RelayClient {
450
492
  ws.on("open", () => {
451
493
  opened = true;
452
494
  this.refusal = null; // a later refusal is news again
495
+ this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
453
496
  this.connectedAt = Date.now();
454
497
  this.startHeartbeat(ws);
455
498
  this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
@@ -525,11 +568,13 @@ export class RelayClient {
525
568
  private startHeartbeat(ws: WebSocket): void {
526
569
  this.stopHeartbeat();
527
570
  this.lastInboundAt = Date.now();
571
+ let lastPingAt = Date.now();
528
572
  this.heartbeatTimer = setInterval(() => {
529
573
  // A socket we've since replaced or dropped isn't ours to police anymore.
530
574
  if (this.ws !== ws) { this.stopHeartbeat(); return; }
531
575
  if (ws.readyState !== WebSocket.OPEN) return; // closing — 'close' will clean up
532
- const quietMs = Date.now() - this.lastInboundAt;
576
+ const now = Date.now();
577
+ const quietMs = now - this.lastInboundAt;
533
578
  if (quietMs > LIVENESS_TIMEOUT_MS) {
534
579
  this.cb.onStatus?.(
535
580
  `Remote access went silent for ${Math.round(quietMs / 1000)}s (the connection died without closing) — dropping it and reconnecting…`,
@@ -538,8 +583,13 @@ export class RelayClient {
538
583
  try { ws.terminate(); } catch (_) { /* already gone — 'close' still fires */ }
539
584
  return;
540
585
  }
541
- try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
542
- }, HEARTBEAT_MS);
586
+ // The check runs on LIVENESS_CHECK_MS; the ping stays on its own slower cadence so
587
+ // tightening detection doesn't multiply the traffic we put on the wire.
588
+ if (now - lastPingAt >= HEARTBEAT_MS) {
589
+ lastPingAt = now;
590
+ try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
591
+ }
592
+ }, LIVENESS_CHECK_MS);
543
593
  // Never hold the process open for a heartbeat alone.
544
594
  this.heartbeatTimer.unref?.();
545
595
  }
@@ -548,12 +598,28 @@ export class RelayClient {
548
598
  if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
549
599
  }
550
600
 
551
- private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
601
+ // Schedule the next connect attempt. With no explicit delay this walks the backoff
602
+ // ladder (reset to RECONNECT_MS by a successful 'open'), so the first retry after a
603
+ // blip is still ~3s and only a genuinely unreachable relay backs off. An explicit
604
+ // delay — the REFUSED_RECONNECT_MS path — is a deliberate cadence for a decision that
605
+ // won't change on its own, so it bypasses the ladder rather than compounding with it.
606
+ private scheduleReconnect(delayMs?: number): void {
552
607
  if (this.closed || this.reconnectTimer) return;
608
+ let wait: number;
609
+ if (typeof delayMs === "number") {
610
+ wait = delayMs;
611
+ } else {
612
+ wait = this.reconnectDelay;
613
+ this.reconnectDelay = Math.min(Math.round(this.reconnectDelay * RECONNECT_GROWTH), RECONNECT_MAX_MS);
614
+ }
615
+ // Jitter every wait (including the fixed ones): the point is to break up fleet-wide
616
+ // synchronization after a server restart, which is exactly when many clients are
617
+ // sitting on the same timer.
618
+ const jittered = Math.max(250, Math.round(wait * (1 + (Math.random() * 2 - 1) * RECONNECT_JITTER)));
553
619
  this.reconnectTimer = setTimeout(() => {
554
620
  this.reconnectTimer = undefined;
555
621
  void this.connect();
556
- }, delayMs);
622
+ }, jittered);
557
623
  }
558
624
 
559
625
  private handle(data: WebSocket.RawData): void {
@@ -926,13 +992,22 @@ export class RelayClient {
926
992
  // Push this terminal's live context (selected model, agent version) to a
927
993
  // controller so the app's session banner reflects reality instead of a stub.
928
994
  // Sent on controller attach — like the snapshot/no_quarter resync. NON-PII ONLY
929
- // by design: deliberately NO cwd / hostname / username, matching terminalLabel's
995
+ // by design: deliberately NO hostname / username, matching terminalLabel's
930
996
  // stance (the server/controller learns as little as possible about the machine).
931
997
  // Empty/absent fields are omitted so the app renders less rather than blank.
932
- sendContext(ctx: { model?: string; version?: string; terminalPub?: string }): void {
998
+ //
999
+ // `cwd` is the one scoped exception, and ONLY a harbor-spawned live session passes
1000
+ // it (see liveTaskSession): the driver chose that directory in the spawn form — or,
1001
+ // having left it blank, needs to see which one the harbor picked — because it is
1002
+ // where everything that session reads, writes and `@`-mentions lives, and unlike an
1003
+ // interactive terminal there is no human sitting in it to already know. It is
1004
+ // home-collapsed (`~/…`) on the way out, so the banner reads like the CLI's own and
1005
+ // the OS username still never crosses the relay.
1006
+ sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
933
1007
  const frame: Record<string, unknown> = { type: "context" };
934
1008
  if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
935
1009
  if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
1010
+ if (typeof ctx.cwd === "string" && ctx.cwd) frame.cwd = clip(homeCollapsed(ctx.cwd), 300);
936
1011
  // The terminal's identity public key (base64). NOT PII — it's a public key, and
937
1012
  // the app uses it to confirm this terminal is the one it PINNED at link time
938
1013
  // before sealing any secret to it (channel tokens). A malicious relay can swap