privateer-agent 0.11.0 → 0.12.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.
@@ -37,7 +37,7 @@ import {
37
37
 
38
38
  // Seed/fallback catalog: registered synchronously so the account provider has real
39
39
  // models the instant it loads (before the live /api/models fetch resolves) — in
40
- // particular the default, tinfoil/glm-5-2, resolves at startup without a "model not
40
+ // particular ACCOUNT_DEFAULT_MODEL_ID resolves at startup without a "model not
41
41
  // found" warning, which matters more than ever now that a signed-OUT terminal also
42
42
  // launches on it. The first two entries are the TEE tiers (Tinfoil, then NEAR); the
43
43
  // rest are the familiar names. Also the fallback list if the live listing is
@@ -46,6 +46,11 @@ import {
46
46
  const DEFAULT_MODELS = [
47
47
  ACCOUNT_DEFAULT_MODEL_ID,
48
48
  ACCOUNT_NEAR_MODEL_ID,
49
+ // The default until 2026-08-01 (see TINFOIL_MODEL_ID). It stays in the floor so a
50
+ // user who saved it as their own default still resolves it synchronously at launch,
51
+ // rather than falling through to "first model with configured auth" — the BYO dead
52
+ // end this seed list exists to prevent.
53
+ "tinfoil/glm-5-2",
49
54
  "anthropic/claude-opus-5",
50
55
  "anthropic/claude-sonnet-5",
51
56
  "openai/gpt-5.6-sol",
@@ -56,7 +61,9 @@ function seedModel(id: string) {
56
61
  return {
57
62
  id,
58
63
  name: id,
59
- reasoning: false,
64
+ // reasoning + how to steer it, for the enclave models where we verified the
65
+ // control shape live; `reasoning: false` (Pi's "not a thinking model") for the rest.
66
+ ...(thinkingProfile(id) ?? { reasoning: false as const }),
60
67
  input: ["text"] as ("text" | "image")[],
61
68
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
62
69
  contextWindow: 128000,
@@ -64,6 +71,83 @@ function seedModel(id: string) {
64
71
  };
65
72
  }
66
73
 
74
+ // ── Thinking control ─────────────────────────────────────────────────────────
75
+ //
76
+ // Every account model used to register with `reasoning: false`, and that one field
77
+ // silently pinned the whole catalog to maximum thinking. Pi gates EVERY
78
+ // thinking-control branch on `model.reasoning` (pi-ai api/openai-completions.js
79
+ // buildParams) and AgentSession.cycleThinkingLevel() returns undefined without it. So
80
+ // Privateer sent no thinking parameter at all — a thinking model ran at whatever its
81
+ // server-side default was, forever — and the user's thinking toggle was inert.
82
+ //
83
+ // What that cost, measured live against the account channel on 2026-08-01 with
84
+ // "Write a haiku about the sea": the default model emitted 77 reasoning deltas and
85
+ // ZERO content, spending all 300 tokens thinking. The same prompt with thinking off
86
+ // answered in 18 tokens / 1.8s.
87
+ //
88
+ // Annotating a model is a promise that the dial actually moves, so ONLY shapes
89
+ // verified against the live enclave appear below. Pi's default level is "medium", so
90
+ // nothing here turns thinking off behind the user's back — it makes the toggle real.
91
+ interface ThinkingProfile {
92
+ reasoning: true;
93
+ thinkingLevelMap?: Record<string, string | null>;
94
+ compat?: { thinkingFormat: string };
95
+ }
96
+
97
+ // The vLLM chat-template family (GLM, Qwen). Honours
98
+ // `chat_template_kwargs.enable_thinking`, which is exactly what Pi's
99
+ // "qwen-chat-template" format emits. Verified — enable_thinking=false → 0 reasoning
100
+ // deltas and a direct answer, true → thinking restored, neither errors — on
101
+ // tinfoil/glm-5-2, near/zai-org/GLM-5.1-FP8, near/Qwen/Qwen3.6-35B-A3B-FP8 and
102
+ // phala/z-ai/glm-5.2.
103
+ //
104
+ // The switch is binary (there is no effort dial), so publish exactly two levels
105
+ // instead of five that all mean "on": a null in thinkingLevelMap marks a level
106
+ // unsupported and pi-ai's getSupportedThinkingLevels drops it.
107
+ const CHAT_TEMPLATE_THINKING: ThinkingProfile = {
108
+ reasoning: true,
109
+ thinkingLevelMap: { minimal: null, low: null, high: null, xhigh: null },
110
+ compat: { thinkingFormat: "qwen-chat-template" },
111
+ };
112
+
113
+ // gpt-oss (harmony) is the other way round: it IGNORES chat_template_kwargs and
114
+ // honours `reasoning_effort` — which is Pi's default format for our baseUrl, so this
115
+ // profile deliberately carries no compat override. Verified on tinfoil/gpt-oss-120b:
116
+ // low → 9 reasoning deltas, high → 61.
117
+ //
118
+ // Harmony has no "none", so "off" is pinned to the floor rather than left unset —
119
+ // unset would send nothing and let the model fall back to its own default (medium),
120
+ // i.e. an "off" that thinks harder than "low". This is the toggle's lowest setting,
121
+ // not silence.
122
+ const REASONING_EFFORT_THINKING: ThinkingProfile = {
123
+ reasoning: true,
124
+ thinkingLevelMap: { off: "low", minimal: "low", xhigh: null },
125
+ };
126
+
127
+ // Only the TEE prefixes are annotated. Those are enclaves we drive directly and can
128
+ // probe. The rest of the catalog is proxied to a third-party gateway whose thinking
129
+ // shape we have NOT verified from here, and an unsupported parameter fails the whole
130
+ // turn — decisively worse than a turn that thinks too much. They keep the old
131
+ // behaviour exactly.
132
+ //
133
+ // Two deliberate omissions inside the TEE set: `*-instruct` ids are the
134
+ // non-thinking variants, and tinfoil/kimi-k2-6 reasons but ignored BOTH levers when
135
+ // probed, so annotating it would hand the user a dial connected to nothing.
136
+ const TEE_MODEL = /^(tinfoil|phala|near)\//;
137
+
138
+ export function thinkingProfile(id: string): ThinkingProfile | null {
139
+ if (!TEE_MODEL.test(id)) return null;
140
+ if (/instruct/i.test(id)) return null;
141
+ const profile = /gpt-oss/i.test(id)
142
+ ? REASONING_EFFORT_THINKING
143
+ : /glm|qwen/i.test(id)
144
+ ? CHAT_TEMPLATE_THINKING
145
+ : null;
146
+ // Hand out a COPY. These entries end up on hundreds of registered models, and a
147
+ // shared nested object is one careless mutation away from retuning the whole catalog.
148
+ return profile && { ...profile, thinkingLevelMap: { ...profile.thinkingLevelMap }, ...(profile.compat ? { compat: { ...profile.compat } } : {}) };
149
+ }
150
+
67
151
  // ── Catalog cache ────────────────────────────────────────────────────────────
68
152
  //
69
153
  // The live catalog (241 models and counting) can only be registered once the network
@@ -396,7 +480,7 @@ const TEE_PREFIXES = ["near/", "tinfoil/", "phala/"];
396
480
  // Which privacy channel an account model routes through: confidential compute (TEE)
397
481
  // for the prefixes above, else a server-side ZDR channel. Ported from tree-cli
398
482
  // resolve.ts, then widened — it used to say `near/` only, which quietly labelled the
399
- // default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
483
+ // then-default model (tinfoil/glm-5-2, a TEE model) as a mere ZDR policy claim.
400
484
  export function privateerChannel(modelId: string): "tee" | "zdr" {
401
485
  return TEE_PREFIXES.some((p) => modelId.startsWith(p)) ? "tee" : "zdr";
402
486
  }
@@ -498,7 +582,7 @@ export function accountProviderConfig(ids: string[]): Record<string, unknown> {
498
582
  // REPLACES a provider's model list and its request config, so whichever registration
499
583
  // lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
500
584
  // on a typical box puts privateer-privacy after privateer-account. The account channel's
501
- // whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
585
+ // whole catalog was then replaced by that one model, so the account default no
502
586
  // longer resolved ("not found for provider privateer. Using custom model id") and the
503
587
  // synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
504
588
  //
@@ -15,13 +15,29 @@ import { join } from "node:path";
15
15
  import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
17
17
 
18
- // Tinfoil's most capable chat model, and Privateer's default everywhere. Tinfoil runs
19
- // GLM 5.2 inside an attestable TEE (the serving enclave's quote is published and the
20
- // live TLS key is bound to it), which is the strongest privacy tier we offer — so the
21
- // most capable model on that tier is what a privacy-first agent should boot on.
18
+ // A capable Tinfoil chat model, and Privateer's default everywhere. Tinfoil runs it
19
+ // inside an attestable TEE (the serving enclave's quote is published and the live TLS
20
+ // key is bound to it), which is the strongest privacy tier we offer — so a capable
21
+ // model on that tier is what a privacy-first agent should boot on.
22
22
  // One definition, three consumers: this resolver, providers/account.ts's seed catalog,
23
23
  // and bin/privateer-launch.mjs (which mirrors the id — keep them in step).
24
- export const TINFOIL_MODEL_ID = "tinfoil/glm-5-2";
24
+ //
25
+ // It was `tinfoil/glm-5-2` until 2026-08-01, and the swap is a LATENCY decision, not a
26
+ // capability one. Measured over 22 requests spaced 20s apart on the account channel,
27
+ // glm-5-2 stalled before its first token on 9 of them — 33s to 98s each, with the
28
+ // model demonstrably warm 20 seconds earlier, so it is contention in that deployment
29
+ // rather than a cold start anything here can warm up. kimi-k2-6 and gpt-oss-120b, same
30
+ // enclave provider, same tier, same transport, stalled 0 times in 20 (medians 1.2s and
31
+ // 1.0s). The same run reproduced glm-5-2's stalls on BOTH the sealed and the cleartext
32
+ // path, which is what rules out the shim, the relay and the proxy as the cause.
33
+ //
34
+ // Two consequences worth knowing when revisiting this: glm-5-2 remains in the live
35
+ // catalog and is one pick away for anyone who wants it, and kimi-k2-6 reasons on every
36
+ // turn with no working off switch (see thinkingProfile in providers/account.ts — its
37
+ // levers were probed and none of them moved the reasoning volume). Its reasoning is
38
+ // short and it always reaches an answer, which is why that is acceptable here and was
39
+ // not for glm-5-2.
40
+ export const TINFOIL_MODEL_ID = "tinfoil/kimi-k2-6";
25
41
 
26
42
  // Same model, reached two ways:
27
43
  // - TINFOIL_DEFAULT_SPEC — direct to inference.tinfoil.sh with the user's own
@@ -63,6 +79,31 @@ export interface ResolveDefaultModelOptions {
63
79
  env?: NodeJS.ProcessEnv;
64
80
  // Override the signed-in check (testing). Defaults to hasCredentials().
65
81
  signedIn?: boolean;
82
+ // The user's saved Pi default ("provider/id"). undefined (the default) reads it
83
+ // from agentDir()/settings.json; null skips it entirely — resolveSignedInModel
84
+ // uses null because the sign-in TARGET must stay the confidential model (the
85
+ // decision to stay on a saved pick is made explicitly at its call sites, where
86
+ // the account channel still gets armed either way).
87
+ saved?: string | null;
88
+ }
89
+
90
+ // The user's own persisted model pick: Pi writes defaultProvider + defaultModel to
91
+ // agentDir()/settings.json on EVERY interactive switch (AgentSession.setModel →
92
+ // setDefaultModelAndProvider — both the built-in selector and pi-privacy's /models
93
+ // picker land there). That makes it the strongest non-env signal of deliberate
94
+ // intent we have, so resolveDefaultModel ranks it right after PRIVATEER_MODEL.
95
+ // Returns "provider/id", or null when either half is missing.
96
+ export function savedPiDefaultSpec(): string | null {
97
+ try {
98
+ const raw = readFileSync(join(agentDir(), "settings.json"), "utf8").trim();
99
+ if (!raw) return null;
100
+ const s = JSON.parse(raw) as Record<string, unknown>;
101
+ const provider = typeof s.defaultProvider === "string" ? s.defaultProvider.trim() : "";
102
+ const modelId = typeof s.defaultModel === "string" ? s.defaultModel.trim() : "";
103
+ return provider && modelId ? `${provider}/${modelId}` : null;
104
+ } catch {
105
+ return null;
106
+ }
66
107
  }
67
108
 
68
109
  // Resolve the model spec ("provider/id") to use when no model is named. Pure and
@@ -71,10 +112,13 @@ export interface ResolveDefaultModelOptions {
71
112
  // so the launcher, the REPL, and the next-launch seed all agree):
72
113
  // 1. explicit user choice (config/channel) — deliberate, always wins
73
114
  // 2. PRIVATEER_MODEL env — dev/global override
74
- // 3. Tinfoil key present → Tinfoil GLM 5.2 — strongest (client-attested) privacy
75
- // 4. signed into Privateer → the same model over the subscription
76
- // 5. a BYO provider whose key is present — anthropic, openai, openrouter
77
- // 6. nothing at all → the account default anyway — so the failure names Privateer
115
+ // 3. the SAVED Pi default (settings.json) — the model the user last picked
116
+ // interactively; Pi persists every switch, so honoring it here is what makes a
117
+ // /models pick actually stick across launches on every entry point
118
+ // 4. Tinfoil key present → the Tinfoil default — strongest (client-attested) privacy
119
+ // 5. signed into Privateer → the same model over the subscription
120
+ // 6. a BYO provider whose key is present — anthropic, openai, openrouter
121
+ // 7. nothing at all → the account default anyway — so the failure names Privateer
78
122
  // and /login is the visible fix, instead of a keyless OpenRouter dead end
79
123
  export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): string {
80
124
  const env = opts.env ?? process.env;
@@ -85,6 +129,9 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
85
129
  const fromEnv = env.PRIVATEER_MODEL?.trim();
86
130
  if (fromEnv) return fromEnv;
87
131
 
132
+ const saved = opts.saved === undefined ? savedPiDefaultSpec() : opts.saved;
133
+ if (saved) return saved;
134
+
88
135
  // Privacy-first: a Tinfoil key means we can run verifiable TEE inference right now,
89
136
  // which we prefer even over the account's NEAR channel — same order the launcher uses.
90
137
  if (env.TINFOIL_API_KEY?.trim()) return TINFOIL_DEFAULT_SPEC;
@@ -110,8 +157,11 @@ export function resolveDefaultModel(opts: ResolveDefaultModelOptions = {}): stri
110
157
  // model sign-in should activate RIGHT AWAY: Tinfoil GLM 5.2, direct when a Tinfoil key
111
158
  // is present and over the subscription otherwise — no BYO key needed.
112
159
  // PRIVATEER_MODEL still wins — a deliberate override is never stomped.
160
+ // `saved: null` on purpose: this is the sign-in TARGET, and the target is always the
161
+ // confidential model. Whether to actually move a session that sits on a deliberate
162
+ // saved pick is decided at the call sites (which arm the account channel either way).
113
163
  export function resolveSignedInModel(env: NodeJS.ProcessEnv = process.env): string {
114
- return resolveDefaultModel({ env, signedIn: true });
164
+ return resolveDefaultModel({ env, signedIn: true, saved: null });
115
165
  }
116
166
 
117
167
  // Split a "provider/id" spec on its first slash (model ids themselves contain "/", so
@@ -135,7 +185,12 @@ function splitSpec(spec: string): { provider: string; modelId: string } | null {
135
185
  // `defaultModel` key), so a deliberate /model choice is never stomped. Best-effort —
136
186
  // any read/parse/write failure is swallowed; a missing seed just means the user picks
137
187
  // a model once via /model. Returns the spec written, or null if we left it alone.
138
- export function ensurePiDefaultModel(spec: string = ACCOUNT_DEFAULT_SPEC): string | null {
188
+ //
189
+ // The default seed is resolveSignedInModel(), not the static account spec: now that
190
+ // the launcher HONORS a saved default (omits --model when one exists), seeding
191
+ // `privateer/…` for a Tinfoil-keyed user would demote them from direct
192
+ // client-attested inference to the subscription proxy on every later launch.
193
+ export function ensurePiDefaultModel(spec: string = resolveSignedInModel()): string | null {
139
194
  const parts = splitSpec(spec);
140
195
  if (!parts) return null;
141
196
  const settingsPath = join(agentDir(), "settings.json");
@@ -8,11 +8,11 @@
8
8
  * of these and route the extensions_* relay frames through it.
9
9
  *
10
10
  * Only the user's OWN packages surface here. The Privateer moat (privateer-*,
11
- * pi-privacy, rpiv-web-tools, …) is installed by bin/privateer-tui as shim .ts
12
- * files in the agent dir's extensions/ folder — auto-discovered, NOT recorded in
13
- * settings.json "packages". listConfiguredPackages() reads only "packages", so the
14
- * moat is naturally excluded. We keep a RESERVED name guard as defence in depth in
15
- * case a user ever hand-adds one of our package names.
11
+ * rpiv-web-tools, …) never appears in settings.json "packages": the launcher passes it to
12
+ * Pi as `-e` arguments, and the in-process entries build it from factories. So
13
+ * listConfiguredPackages(), which reads only "packages", excludes it naturally. The
14
+ * RESERVED guard is defence in depth for a user who hand-adds one of our names — which
15
+ * would load a second copy of an extension the moat already supplies.
16
16
  *
17
17
  * Framework-agnostic: nothing here imports React or the relay. The caller owns the
18
18
  * frame plumbing and hands us a SettingsManager (the REPL reuses the session's;
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
22
22
  import type { ProgressEvent, SettingsManager } from "@earendil-works/pi-coding-agent";
23
+ import { reservedNames } from "../config/moatManifest.ts";
23
24
 
24
25
  // One installed extension as surfaced to the app. NON-PII: a package source
25
26
  // (npm:/git: spec) plus its scope — no cwd, no absolute paths beyond what Pi
@@ -49,25 +50,12 @@ export interface ExtensionsControl {
49
50
  // Package names we never manage from the app: the Privateer moat + adopted packs
50
51
  // installed as shims by the launcher. A guard only — listConfiguredPackages()
51
52
  // already omits them since they aren't settings "packages".
52
- const RESERVED = new Set([
53
- "privateer-brand",
54
- "privateer-context",
55
- "privateer-gate",
56
- "privateer-account",
57
- "privateer-posture",
58
- "privateer-tools",
59
- "privateer-privacy",
60
- "pi-privacy",
61
- "pi-web-access",
62
- "rpiv-web-tools",
63
- "@juicesharp/rpiv-web-tools",
64
- "rpiv-ask-user-question",
65
- "@juicesharp/rpiv-ask-user-question",
66
- "pi-mcp-adapter",
67
- "pi-hypa",
68
- "@hypabolic/pi-hypa",
69
- "pi-subagents",
70
- ]);
53
+ //
54
+ // Derived from the shipping manifest rather than hand-listed. The hand-written version
55
+ // had drifted: privateer-models and privateer-connect were shimmed by the launcher but
56
+ // missing here, so the app would have offered to install a user package under a name the
57
+ // launcher overwrites on every launch. See src/config/moatManifest.ts.
58
+ const RESERVED = new Set(reservedNames());
71
59
 
72
60
  // The bare package name inside a source spec, for the RESERVED check. Strips the
73
61
  // npm:/git: scheme and any @version / #ref suffix; leaves scoped names intact.
@@ -18,19 +18,13 @@ import {
18
18
  import { agentDir } from "../config/paths.ts";
19
19
  import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
- import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
- import { makePiPrivacyExtension } from "pi-privacy";
23
- import {
24
- makeAccountProvider,
25
- privateerChannel,
26
- rememberAccountCredential,
27
- dropPersistedAccountCredential,
28
- } from "../providers/account.ts";
21
+ import { isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
+ import { moatResourceOptions } from "../config/moat.ts";
23
+ import { rememberAccountCredential, dropPersistedAccountCredential } from "../providers/account.ts";
29
24
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
30
25
  import { RemoteBridge } from "./remoteBridge.ts";
31
- import { makeRelayFileTools } from "../tools/relayFileTools.ts";
32
26
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
33
- import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
27
+ import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
34
28
  import { createUIContext } from "../ext/headlessUi.ts";
35
29
  import { noQuarterActive } from "../permissions/noQuarter.ts";
36
30
 
@@ -207,24 +201,21 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
207
201
  onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
208
202
  };
209
203
 
204
+ // relayFiles binds send_file_to_client / save_attachment to THIS session's bridge — the
205
+ // one whose relay the app is attached to. The shipped gate extension is discovered into
206
+ // this session too but stands its own pair down inside the daemon, so these are the ones
207
+ // the model gets (see tools/relayFileTools.ts). Media generation is on for a live spawn:
208
+ // it is driven from the app, so "make me a video of X" is one of the things it is FOR —
209
+ // and the gate still routes each call to the phone for approval like any other write.
210
210
  const services = await createAgentSessionServices({
211
211
  cwd,
212
212
  agentDir: agentDir(),
213
213
  resourceLoaderOptions: {
214
- extensionFactories: [
215
- makePermissionGate(gate),
216
- // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
217
- // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
218
- makePiPrivacyExtension({
219
- privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
220
- }),
221
- makeAccountProvider(),
222
- // send_file_to_client / save_attachment bound to THIS session's bridge — the one
223
- // whose relay the app is attached to. The shipped gate extension is discovered
224
- // into this session too but stands its own pair down inside the daemon, so these
225
- // are the ones the model gets (see tools/relayFileTools.ts).
226
- makeRelayFileTools(bridge, attachments),
227
- ] as any,
214
+ ...((await moatResourceOptions({
215
+ kind: "live-task",
216
+ gate,
217
+ relayFiles: { bridge, attachments },
218
+ })) as any),
228
219
  },
229
220
  });
230
221
  servicesRef = services as any;
@@ -118,6 +118,11 @@ export interface RelayCallbacks {
118
118
  onNoQuarter?: (on: boolean) => void;
119
119
  // A controller attached — push a transcript snapshot so it can catch up.
120
120
  onControllerAttached: () => void;
121
+ // The last controller went away (app closed / socket reaped). The terminal keeps
122
+ // running; the owner should stop assuming anything it sends up is being read, and
123
+ // deliver finished work durably instead (the cloud outbox → the app's Inbox).
124
+ // Optional so callbacks that predate the frame keep compiling.
125
+ onControllerDetached?: () => void;
121
126
  // The app ran a slash command from its composer (e.g. "/model provider/id").
122
127
  // Routed to the same command dispatcher the local REPL uses. Optional so
123
128
  // callbacks that predate the app command UI keep compiling.
@@ -353,6 +358,11 @@ export class RelayClient {
353
358
  private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
354
359
  private lastInboundAt = 0;
355
360
  private connectedAt = 0;
361
+ // Is somebody actually on the other end? An open socket is NOT the same thing —
362
+ // the server holds our frames' route open and simply drops what it forwards when
363
+ // no controller is attached, so without this an unwatched turn writes its answer
364
+ // into a socket nobody is reading. See hasController() and the `handle` note.
365
+ private controllerHere = false;
356
366
  // Current backoff delay for the next unqualified scheduleReconnect(); reset on 'open'.
357
367
  private reconnectDelay = RECONNECT_MS;
358
368
  // Last refusal reason reported, so a 4xx is logged once instead of on every retry.
@@ -507,6 +517,9 @@ export class RelayClient {
507
517
  this.stopHeartbeat();
508
518
  if (this.ws === ws) this.ws = null;
509
519
  this.connectedAt = 0;
520
+ // A dead socket means no controller is reachable, whatever the last
521
+ // attach/detach frame said. Re-learned on the next attach or inbound frame.
522
+ this.controllerHere = false;
510
523
  this.cb.onDisconnected?.();
511
524
  if (!this.closed) {
512
525
  this.cb.onStatus?.(
@@ -660,6 +673,12 @@ export class RelayClient {
660
673
  return;
661
674
  }
662
675
  this.debug(`recv ${frame.type}`);
676
+ // Presence, learned from the traffic itself. The server forwards a frame to us
677
+ // only when a controller sent it (or when it announces one attaching), so ANY
678
+ // inbound frame is proof somebody is on the other end — which matters because
679
+ // `controller_attached` is missed by a terminal whose own socket reconnected
680
+ // mid-session. Cleared by `controller_detached` and by a dead socket.
681
+ if (frame.type !== "controller_detached") this.controllerHere = true;
663
682
  switch (frame.type) {
664
683
  case "prompt":
665
684
  // Forward even an empty/whitespace prompt: a file-only send carries no text,
@@ -685,6 +704,16 @@ export class RelayClient {
685
704
  case "controller_attached":
686
705
  this.cb.onControllerAttached();
687
706
  break;
707
+ // The app's socket went away (closed the app, lost the network, backgrounded
708
+ // long enough for the server's heartbeat to reap it). The relay stays up — the
709
+ // terminal is still reachable — but anything we send now lands nowhere, so the
710
+ // owner delivers finished work through the outbox instead. Published only when
711
+ // the LAST controller left (CAS-guarded server-side), so a take-over doesn't
712
+ // masquerade as "nobody is watching".
713
+ case "controller_detached":
714
+ this.controllerHere = false;
715
+ this.cb.onControllerDetached?.();
716
+ break;
688
717
  case "command":
689
718
  if (typeof frame.text === "string") this.cb.onCommand?.(frame.text);
690
719
  break;
@@ -876,6 +905,16 @@ export class RelayClient {
876
905
  return this.ws?.readyState === WebSocket.OPEN;
877
906
  }
878
907
 
908
+ // Is the app actually on the other end right now? Requires an open socket AND a
909
+ // controller known to be attached — learned from `controller_attached`, from any
910
+ // frame a controller sent us, and un-learned by `controller_detached` or a dropped
911
+ // socket. Conservative in the useful direction: it only reads true when we have
912
+ // positive evidence someone is there, so "deliver it durably instead" is the
913
+ // default for a turn whose audience we can't account for.
914
+ hasController(): boolean {
915
+ return this.isConnected() && this.controllerHere;
916
+ }
917
+
879
918
  // Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
880
919
  // is how long since the server last said anything: a connected socket that has been
881
920
  // quiet for longer than the server's 25s ping cadence is the shape of the half-open
@@ -16,11 +16,20 @@ import type { PermissionRequest } from "../permissions/gate.ts";
16
16
  import type { AskOutcome } from "../permissions/modeGate.ts";
17
17
  import type { RelayCallbacks } from "./relayClient.ts";
18
18
 
19
+ // How much of a driven turn's reply we hold for possible outbox delivery. The
20
+ // sealed item is capped at 45k plaintext anyway; this just stops a pathological
21
+ // turn from growing the buffer without bound.
22
+ const MAX_TURN_CAPTURE = 60_000;
23
+
19
24
  // The outbound surface the bridge needs; RelayClient implements all of it.
20
25
  export interface RelayLike {
21
26
  requestApproval(id: string, req: PermissionRequest): void;
22
27
  sendEvent(ev: EngineEvent): void;
23
28
  isConnected(): boolean;
29
+ // Is a controller actually attached (not merely "our socket is up")? Optional:
30
+ // a relay that can't tell is treated as attached, so an unknown audience never
31
+ // turns into a duplicate of something the app already displayed.
32
+ hasController?(): boolean;
24
33
  sendNoQuarter(on: boolean): void;
25
34
  sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
26
35
  sendNotice(text: string): void;
@@ -99,6 +108,15 @@ export interface RemoteBridgeConfig {
99
108
  onFilesSearch?: (id: string, query: string) => void;
100
109
  // A controller (re)attached — the owner should push a transcript snapshot.
101
110
  onControllerAttached?: () => void;
111
+ // The last controller went away. Informational for the owner (the bridge already
112
+ // fails pending approvals closed); a driven turn in flight keeps running.
113
+ onControllerDetached?: () => void;
114
+ // A driven turn finished with NOBODY on the other end — the app was closed, killed,
115
+ // or its socket was reaped while the agent worked. Everything the turn streamed up
116
+ // was dropped by the relay, so the owner should deliver this durably instead (seal
117
+ // it to the account outbox → the app's Inbox). Called once per unwatched turn, after
118
+ // the turn settles; `prompt` is what was asked, `content` the reply as streamed.
119
+ onUnwatchedResult?: (result: { prompt: string; content: string }) => void;
102
120
  onStatus?: (text: string) => void;
103
121
  // A file finished transferring down from the app. The owner registers it (e.g. into
104
122
  // an AttachmentStore) so the save_attachment tool can persist it.
@@ -113,6 +131,17 @@ export class RemoteBridge {
113
131
  private readonly pendingSelects = new Map<string, (v: string | null) => void>();
114
132
  private readonly pendingInputs = new Map<string, (v: string | null) => void>();
115
133
  private pendingAttachments: RemoteAttachment[] = [];
134
+ // The driven turn in flight, kept only so it can be delivered to the outbox if it
135
+ // turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
136
+ // 45k anyway, and this must not grow with a runaway turn.
137
+ //
138
+ // `turnDriven` is deliberately NOT `remote`: a mid-turn disconnect clears `remote`
139
+ // (so the gate stops waiting on a controller that's gone) and that is exactly the
140
+ // case this feature exists for — the turn was still driven by the app, and its
141
+ // answer still has to reach the account. Only settleTurn clears it.
142
+ private turnDriven = false;
143
+ private turnPrompt = "";
144
+ private turnText = "";
116
145
 
117
146
  constructor(private readonly cfg: RemoteBridgeConfig) {}
118
147
 
@@ -128,6 +157,9 @@ export class RemoteBridge {
128
157
  readonly callbacks: Required<RelayCallbacks> = {
129
158
  onPrompt: (text) => {
130
159
  this.remote = true; // a remote turn is now in flight → gate relays each action
160
+ this.turnDriven = true;
161
+ this.turnPrompt = text;
162
+ this.turnText = "";
131
163
  const attachments = this.pendingAttachments;
132
164
  this.pendingAttachments = [];
133
165
  this.cfg.onPrompt(text, attachments);
@@ -195,6 +227,16 @@ export class RemoteBridge {
195
227
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
196
228
  },
197
229
  onControllerAttached: () => this.cfg.onControllerAttached?.(),
230
+ // The app left while we're still running. Same posture as a dropped socket: stop
231
+ // treating the turn as remote (the gate must not wait on a controller that isn't
232
+ // there) and fail every pending approval closed. The turn itself keeps going —
233
+ // and settleTurn will deliver its answer to the outbox, since `turnDriven` (unlike
234
+ // `remote`) survives the departure.
235
+ onControllerDetached: () => {
236
+ this.remote = false;
237
+ this.rejectAllPending();
238
+ this.cfg.onControllerDetached?.();
239
+ },
198
240
  onAttachment: (file) => {
199
241
  this.pendingAttachments.push(file);
200
242
  this.cfg.onAttachment?.(file);
@@ -307,13 +349,36 @@ export class RemoteBridge {
307
349
 
308
350
  // Mark the end of a turn so the next (possibly local) turn isn't treated as
309
351
  // remote. Call after each driven turn completes.
352
+ //
353
+ // Also the one moment we can tell whether the turn had an audience. If the app
354
+ // drove it and is now gone, everything the turn streamed up was dropped by the
355
+ // relay — so hand the answer to the owner for durable delivery rather than letting
356
+ // a completed piece of work evaporate because someone closed their phone.
310
357
  settleTurn(): void {
358
+ const driven = this.turnDriven;
359
+ const prompt = this.turnPrompt;
360
+ const content = this.turnText.trim();
311
361
  this.remote = false;
362
+ this.turnDriven = false;
363
+ this.turnPrompt = "";
364
+ this.turnText = "";
365
+ if (!driven || !content) return;
366
+ // Unknown (a relay that can't report presence) counts as watched: better to skip
367
+ // delivery than to duplicate something the app already showed in its feed.
368
+ const watched = this.relay?.hasController ? this.relay.hasController() : !!this.relay?.isConnected();
369
+ if (watched) return;
370
+ this.cfg.onUnwatchedResult?.({ prompt, content });
312
371
  }
313
372
 
314
373
  // Forward an EngineEvent up to the app. Safe to call for every event of every
315
374
  // turn (local included) — the relay only sends when a socket is open.
316
375
  forwardEvent(ev: EngineEvent): void {
376
+ // Keep the driven turn's reply as it streams, in case settleTurn finds nobody
377
+ // was there to read it. Text only: the outbox item is the answer, not a
378
+ // transcript of every tool call.
379
+ if (this.turnDriven && ev.type === "text" && this.turnText.length < MAX_TURN_CAPTURE) {
380
+ this.turnText += String(ev.text ?? "");
381
+ }
317
382
  this.relay?.sendEvent(ev);
318
383
  }
319
384