privateer-agent 0.9.3 → 0.11.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.
@@ -13,7 +13,7 @@
13
13
  // no polyfills, unlike the RN app.
14
14
  //
15
15
  // Two-layer attestation, fail-secure:
16
- // (1) verifyReportBinding — the report's crypto binding (keyset digest,
16
+ // (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
17
17
  // report_data == statement(nonce), endorsement sig). Self-attesting alone.
18
18
  // (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
19
19
  // against Intel collateral and binds the quote's report_data to (1)'s statement
@@ -22,7 +22,6 @@
22
22
 
23
23
  import type { Report } from "@phala/dcap-qvl";
24
24
  import {
25
- verifyReportBinding,
26
25
  openE2eeChannel,
27
26
  toHex,
28
27
  fromHex,
@@ -30,6 +29,11 @@ import {
30
29
  type ReportVerification,
31
30
  type E2eeChannel,
32
31
  } from "./phala/aci-verifier/index.ts";
32
+ // Not the vendored verifyReportBinding directly: the deployed gateway signs its
33
+ // keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
34
+ // refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
35
+ // the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
36
+ import { verifyAciReportBinding } from "./phala/reportBinding.ts";
33
37
  import { serverBaseUrl } from "../auth/privateer.ts";
34
38
 
35
39
  const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
@@ -94,7 +98,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
94
98
  if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
95
99
  const report = (await res.json()) as AttestationReport;
96
100
 
97
- const verification = await verifyReportBinding(report, nonce);
101
+ const verification = await verifyAciReportBinding(report, nonce);
98
102
  if (!verification.ok) {
99
103
  const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
100
104
  throw new Error(`phala attestation binding failed: ${failed}`);
@@ -40,13 +40,22 @@ import { iterateSSE } from "./phala/sse.ts";
40
40
  // (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
41
41
  export type SealedProvider = "tinfoil" | "phala";
42
42
 
43
- // Sealed mode is OFF until verified end-to-end against a live relay (a real EHBP
44
- // round-trip needs the deployed relay + TINFOIL_API_KEY; see the live checklist in
45
- // docs/tee-privateer-tinfoil-ehbp.md). Off = the current plaintext path + honest
46
- // yellow badge, untouched. Flip with PRIVATEER_SEALED=1.
43
+ // Sealed mode is ON by default as of 2026-07-31, when the live checklist in
44
+ // docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
45
+ // both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
46
+ // TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
47
+ // refused rather than silently greened, and the server bills the turn.
48
+ //
49
+ // What it buys: the prompt is sealed to the enclave the client itself attested, so the
50
+ // badge is a quote WE checked rather than the account's word — green instead of
51
+ // "Trusted Execution (unconfirmed)".
52
+ //
53
+ // PRIVATEER_SEALED=0 (or =false) drops back to the cleartext `/api/agent/v1` path and
54
+ // the honest yellow badge. Note that is a real downgrade for `phala/*`, which is
55
+ // sealed-only and simply disappears from the catalog (see isServableAccountModel).
47
56
  export function sealedEnabled(): boolean {
48
57
  const v = process.env.PRIVATEER_SEALED;
49
- return v === "1" || v === "true";
58
+ return !(v === "0" || v === "false");
50
59
  }
51
60
 
52
61
  // The sealed provider a model id routes through, or null if it isn't a sealed
@@ -151,7 +160,12 @@ export function buildForward(
151
160
  // Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
152
161
  }
153
162
  const headers: Record<string, string> = {
154
- "Content-Type": "application/json",
163
+ // MUST carry the charset. EHBP seals the body but headers travel in cleartext
164
+ // (tinfoil/dist/encrypted-body-fetch.js: "EHBP only seals the body"), so this
165
+ // Content-Type is what the enclave's router actually validates — and it rejects a
166
+ // bare `application/json` with "Unsupported Media Type: Only 'application/json' is
167
+ // allowed" (verified live 2026-07-31: bare → 400, any `charset=` variant → 200).
168
+ "Content-Type": "application/json; charset=utf-8",
155
169
  "X-Sealed-Model": sealedModel,
156
170
  };
157
171
  if (authHeader) headers.Authorization = authHeader;
@@ -165,6 +179,7 @@ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
165
179
 
166
180
  let shimBase: string | null = null;
167
181
  let shimStarting: Promise<string> | null = null;
182
+ let shimServer: http.Server | null = null;
168
183
 
169
184
  // The shim's base URL once listening, else null. account.ts reads this to decide
170
185
  // whether a sealed model can point its baseUrl at the shim yet.
@@ -179,6 +194,19 @@ export function ensureSealedShim(): Promise<string> {
179
194
  return shimStarting;
180
195
  }
181
196
 
197
+ // Close the shim and forget it, so a later ensureSealedShim() starts a fresh one.
198
+ // The listener is `unref`'d and never blocks exit, so this is not needed for
199
+ // shutdown — it exists so a caller that stops sealing (or a test) can drop the
200
+ // socket deterministically rather than leaving a port open for the process lifetime.
201
+ export function stopSealedShim(): Promise<void> {
202
+ const server = shimServer;
203
+ shimServer = null;
204
+ shimBase = null;
205
+ shimStarting = null;
206
+ if (!server) return Promise.resolve();
207
+ return new Promise((resolve) => server.close(() => resolve()));
208
+ }
209
+
182
210
  function startShim(): Promise<string> {
183
211
  return new Promise((resolve, reject) => {
184
212
  const server = http.createServer((req, res) => {
@@ -192,6 +220,7 @@ function startShim(): Promise<string> {
192
220
  server.listen(0, "127.0.0.1", () => {
193
221
  const addr = server.address();
194
222
  if (addr && typeof addr === "object") {
223
+ shimServer = server;
195
224
  shimBase = `http://127.0.0.1:${addr.port}`;
196
225
  resolve(shimBase);
197
226
  } else {
@@ -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;
@@ -61,6 +61,8 @@ const RESERVED = new Set([
61
61
  "pi-web-access",
62
62
  "rpiv-web-tools",
63
63
  "@juicesharp/rpiv-web-tools",
64
+ "rpiv-ask-user-question",
65
+ "@juicesharp/rpiv-ask-user-question",
64
66
  "pi-mcp-adapter",
65
67
  "pi-hypa",
66
68
  "@hypabolic/pi-hypa",
@@ -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,23 @@ 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, sent by BOTH session kinds: it is where
1000
+ // everything the agent reads, writes and `@`-mentions lives, so a driver who can't
1001
+ // see it is guessing at the blast radius of every prompt they send. (It used to be
1002
+ // harbor-spawned sessions only — on the theory that a human sits in an interactive
1003
+ // terminal and already knows the folder. They don't when they're driving it from a
1004
+ // phone, which is the entire point of this transport.) It is home-collapsed (`~/…`)
1005
+ // on the way out, so the banner reads like the CLI's own and the OS username still
1006
+ // never crosses the relay.
1007
+ sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
933
1008
  const frame: Record<string, unknown> = { type: "context" };
934
1009
  if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
935
1010
  if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
1011
+ if (typeof ctx.cwd === "string" && ctx.cwd) frame.cwd = clip(homeCollapsed(ctx.cwd), 300);
936
1012
  // The terminal's identity public key (base64). NOT PII — it's a public key, and
937
1013
  // the app uses it to confirm this terminal is the one it PINNED at link time
938
1014
  // before sealing any secret to it (channel tokens). A malicious relay can swap