privateer-agent 0.12.35 → 0.12.37

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.
@@ -170,6 +170,26 @@ if (NO_QUARTER) {
170
170
  );
171
171
  }
172
172
 
173
+ // `--no-privacy` / `--privacy-off` — completely disable pi-privacy for this session.
174
+ // Stripped before Pi's cli.js sees it. Exports PRIVATEER_PRIVACY_OFF=1 and PI_PRIVACY_OFF=1;
175
+ // all PII scanning, prompts, and tool exfiltration/result checks are bypassed.
176
+ const NO_PRIVACY = args.some((a) => a === "--no-privacy" || a === "--privacy-off");
177
+ if (NO_PRIVACY) {
178
+ for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-privacy" || args[i] === "--privacy-off") args.splice(i, 1);
179
+ process.env.PRIVATEER_PRIVACY_OFF = "1";
180
+ process.env.PI_PRIVACY_OFF = "1";
181
+ process.stderr.write(
182
+ [
183
+ "",
184
+ " ⚓ \x1b[1;33mPrivacy off\x1b[0m — pi-privacy DISABLED for this session.",
185
+ " PII scanning, tool exfiltration guards, and result credential redaction are OFF.",
186
+ " Outbound requests and tool calls will NOT prompt or redact PII / IP addresses.",
187
+ " Run /privacy on in-session to restore privacy protections.",
188
+ "",
189
+ ].join("\n") + "\n",
190
+ );
191
+ }
192
+
173
193
  // `--allow-computer-control` — arm GUI control (screenshots, mouse, keyboard) for this
174
194
  // session. Stripped before Pi's cli.js sees it, exactly like --no-quarter above.
175
195
  //
@@ -9,6 +9,7 @@
9
9
  import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
10
  import { accountPosture } from "../src/providers/account.ts";
11
11
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
12
+ import { onPrivacyDisabledChange, privacyDisabled } from "../src/config/privacyDisabled.ts";
12
13
 
13
14
  const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
14
15
 
@@ -35,11 +36,39 @@ async function badgeFor(provider: string, modelId: string, p: Palette): Promise<
35
36
  return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
36
37
  }
37
38
 
39
+ export async function updatePostureBadge(ctx: any, provider?: string, modelId?: string): Promise<void> {
40
+ if (!ctx?.ui?.setStatus) return;
41
+ if (privacyDisabled()) {
42
+ ctx.ui.setStatus("privacy", "⚑ privacy off");
43
+ ctx.ui.setStatus("pi-privacy", undefined);
44
+ return;
45
+ }
46
+ const p = provider ?? ctx?.model?.provider;
47
+ const m = modelId ?? ctx?.model?.id;
48
+ if (!p || !m) {
49
+ ctx.ui.setStatus("privacy", undefined);
50
+ return;
51
+ }
52
+ try {
53
+ ctx.ui.setStatus("privacy", "⛉ …");
54
+ const badge = await badgeFor(p, m, paletteFor(ctx?.ui?.theme));
55
+ ctx.ui.setStatus("privacy", badge);
56
+ } catch {
57
+ ctx.ui.setStatus("privacy", undefined);
58
+ }
59
+ }
60
+
38
61
  export default function privateerPosture(pi: any): void {
39
62
  // "latest wins" so rapid model cycling (Ctrl+P) doesn't leave a stale badge.
40
63
  let seq = 0;
41
64
  const update = async (provider?: string, modelId?: string, ctx?: any) => {
42
- if (!provider || !modelId || !ctx?.ui?.setStatus) return;
65
+ if (!ctx?.ui?.setStatus) return;
66
+ if (privacyDisabled()) {
67
+ ctx.ui.setStatus("privacy", "⚑ privacy off");
68
+ ctx.ui.setStatus("pi-privacy", undefined);
69
+ return;
70
+ }
71
+ if (!provider || !modelId) return;
43
72
  const mine = ++seq;
44
73
  try {
45
74
  ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
@@ -50,6 +79,21 @@ export default function privateerPosture(pi: any): void {
50
79
  }
51
80
  };
52
81
 
53
- pi.on("model_select", (event: any, ctx: any) => update(event?.model?.provider, event?.model?.id, ctx));
54
- pi.on("session_start", (_event: any, ctx: any) => update(ctx?.model?.provider, ctx?.model?.id, ctx));
82
+ // Keep the last ctx a real event handed us: the badge has to be repainted when the
83
+ // privacy flag moves, and the flag can now move with no event behind it at all —
84
+ // the app's shield toggle writes it over the relay (RemoteBridge's onPrivacy), so
85
+ // `/privacy` is no longer the only driver and the CLI's own badge would otherwise
86
+ // sit on "⛉ Trusted Execution" over a session whose filter a phone just took down.
87
+ let lastCtx: any = null;
88
+ pi.on("model_select", (event: any, ctx: any) => {
89
+ lastCtx = ctx;
90
+ return update(event?.model?.provider, event?.model?.id, ctx);
91
+ });
92
+ pi.on("session_start", (_event: any, ctx: any) => {
93
+ lastCtx = ctx;
94
+ return update(ctx?.model?.provider, ctx?.model?.id, ctx);
95
+ });
96
+ onPrivacyDisabledChange(() => {
97
+ void update(lastCtx?.model?.provider, lastCtx?.model?.id, lastCtx);
98
+ });
55
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.35",
3
+ "version": "0.12.37",
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",
@@ -0,0 +1,46 @@
1
+ diff --git a/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js b/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js
2
+ index 216a45f..a1e3214 100644
3
+ --- a/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js
4
+ +++ b/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js
5
+ @@ -1,11 +1,38 @@
6
+ import { estimateContextTokens } from "../utils/estimate.js";
7
+ const CONTEXT_SAFETY_TOKENS = 4096;
8
+ -const MIN_MAX_TOKENS = 1;
9
+ +// Privateer patch: floor the answer budget at a usable answer, not at one token.
10
+ +//
11
+ +// Stock pi floored this at 1, so the moment the (chars/4) context estimate reached
12
+ +// the declared window every turn asked the provider for ONE token: the whole prompt
13
+ +// still sent, still billed, one token back with finish_reason "length" — which the
14
+ +// TUI renders as "Response was truncated before completion." Compaction gets one
15
+ +// attempt, and when the estimate is still over afterwards the session stays there,
16
+ +// burning a full-price prompt per turn and producing nothing. Privateer reaches it
17
+ +// early because every account model is registered with a flat contextWindow of
18
+ +// 128000 (src/providers/account.ts seedModel) — /api/models publishes no per-model
19
+ +// window — so a larger-windowed model hits this ceiling with room to spare.
20
+ +//
21
+ +// The floor only raises `available`; the Math.min still honours a deliberately small
22
+ +// ask (a summariser, a title), and it is smaller than the CONTEXT_SAFETY_TOKENS
23
+ +// already subtracted above, so a floored request was inside that margin anyway. When
24
+ +// `available` is genuinely negative the context really does not fit and the provider
25
+ +// says so — a context-length error, which pi routes to compaction via
26
+ +// isContextOverflow. That is the designed recovery, and strictly better than a
27
+ +// silent one-token stub that costs the same.
28
+ +//
29
+ +// The floor is MIN_ANSWER_TOKENS, this module's OWN constant — declared below at its
30
+ +// original site, deliberately not redeclared here (a second top-level `const` of that
31
+ +// name is a SyntaxError that takes the whole app down). Reading it from below is safe:
32
+ +// clampMaxTokensToContext is only ever called at request time, long after module
33
+ +// evaluation, so the temporal dead zone has closed. Same value, same meaning, and it
34
+ +// stays in sync if upstream retunes it.
35
+ +//
36
+ +// Mirrors src/engine/contextBudget.ts, which is where this is specified and tested.
37
+ export function clampMaxTokensToContext(model, context, maxTokens) {
38
+ if (model.contextWindow <= 0)
39
+ - return Math.max(MIN_MAX_TOKENS, maxTokens);
40
+ + return Math.max(1, maxTokens);
41
+ const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
42
+ - return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
43
+ + return Math.min(maxTokens, Math.max(MIN_ANSWER_TOKENS, available));
44
+ }
45
+ export function buildBaseOptions(model, context, options, apiKey) {
46
+ const samplingParams = model.samplingParams || options?.samplingParams
@@ -68,10 +68,10 @@ index f04269f..536e030 100644
68
68
  export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
69
69
  export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
70
70
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
71
- index fd82b71..5e0821c 100644
71
+ index fd82b71..7c10ba9 100644
72
72
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
73
73
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
74
- @@ -38,6 +38,111 @@ import { createLocalBashOperations } from "./tools/bash.js";
74
+ @@ -38,6 +38,122 @@ import { createLocalBashOperations } from "./tools/bash.js";
75
75
  import { createAllToolDefinitions } from "./tools/index.js";
76
76
  import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js";
77
77
  import { addUsageToTotals, createUsageTotals } from "./usage-totals.js";
@@ -139,6 +139,17 @@ index fd82b71..5e0821c 100644
139
139
  + const status = Number(m[1]);
140
140
  + return status >= 400 && status < 500 && !PV_TRANSIENT_CLIENT_STATUS.has(status);
141
141
  +}
142
+ +// Privateer patch: undici's idle-timeout errors. Mirrors isIdleTimeoutError in
143
+ +// src/engine/errors.ts. The text is exact and stable — undici throws
144
+ +// BodyTimeoutError / HeadersTimeoutError with message "Body/Headers Timeout Error"
145
+ +// and code UND_ERR_BODY_TIMEOUT / UND_ERR_HEADERS_TIMEOUT — and it carries NO HTTP
146
+ +// status. pi's retry regex therefore reads the word "timeout" and silently re-sends
147
+ +// the whole turn across the full budget: three 5-minute stalls before the user sees
148
+ +// anything at all. A stalled transport is a fact; a body substring is a guess.
149
+ +export function isIdleTimeoutError(text) {
150
+ + const s = typeof text === "string" ? text : "";
151
+ + return /(?:^|\b)(?:Body|Headers) Timeout Error\b/i.test(s) || /^UND_ERR_(?:BODY|HEADERS)_TIMEOUT$/.test(s.trim());
152
+ +}
142
153
  +// Privateer patch: throttle handling. Mirrors isThrottleFailure / retryAfterMs /
143
154
  +// retryDelayMs in src/engine/errors.ts — see the incident note there.
144
155
  +export function isThrottleFailure(text) {
@@ -183,7 +194,7 @@ index fd82b71..5e0821c 100644
183
194
  /**
184
195
  * Parse a skill block from message text.
185
196
  * Returns null if the text doesn't contain a skill block.
186
- @@ -102,6 +207,8 @@ export class AgentSession {
197
+ @@ -102,6 +218,8 @@ export class AgentSession {
187
198
  // Retry state
188
199
  _retryAbortController = undefined;
189
200
  _retryAttempt = 0;
@@ -192,7 +203,7 @@ index fd82b71..5e0821c 100644
192
203
  // Bash execution state
193
204
  _bashAbortControllers = new Set();
194
205
  _pendingBashMessages = [];
195
- @@ -186,6 +293,14 @@ export class AgentSession {
206
+ @@ -186,6 +304,14 @@ export class AgentSession {
196
207
  }
197
208
  const isOAuth = this._modelRuntime.isUsingOAuth(model.provider);
198
209
  if (isOAuth) {
@@ -207,7 +218,7 @@ index fd82b71..5e0821c 100644
207
218
  throw new Error(`Authentication failed for "${model.provider}". ` +
208
219
  `Credentials may have expired or network is unavailable. ` +
209
220
  `Run '/login ${model.provider}' to re-authenticate.`);
210
- @@ -380,6 +495,16 @@ export class AgentSession {
221
+ @@ -380,6 +506,16 @@ export class AgentSession {
211
222
  }
212
223
  }
213
224
  }
@@ -224,7 +235,7 @@ index fd82b71..5e0821c 100644
224
235
  // Emit to extensions first
225
236
  await this._emitExtensionEvent(event);
226
237
  // Notify all listeners
227
- @@ -801,6 +926,33 @@ export class AgentSession {
238
+ @@ -801,6 +937,33 @@ export class AgentSession {
228
239
  finalError: msg.errorMessage,
229
240
  });
230
241
  this._retryAttempt = 0;
@@ -258,7 +269,7 @@ index fd82b71..5e0821c 100644
258
269
  }
259
270
  if (await this._checkCompaction(msg)) {
260
271
  return true;
261
- @@ -882,6 +1034,14 @@ export class AgentSession {
272
+ @@ -882,6 +1045,14 @@ export class AgentSession {
262
273
  if (!hasConfiguredAuth) {
263
274
  const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
264
275
  if (isOAuth) {
@@ -273,7 +284,7 @@ index fd82b71..5e0821c 100644
273
284
  throw new Error(`Authentication failed for "${this.model.provider}". ` +
274
285
  `Credentials may have expired or network is unavailable. ` +
275
286
  `Run '/login ${this.model.provider}' to re-authenticate.`);
276
- @@ -891,7 +1051,13 @@ export class AgentSession {
287
+ @@ -891,7 +1062,13 @@ export class AgentSession {
277
288
  // Check if we need to compact before sending (catches aborted responses).
278
289
  // The user's new prompt is sent below, so do not call agent.continue() here.
279
290
  const lastAssistant = this._findLastAssistantMessage();
@@ -288,7 +299,7 @@ index fd82b71..5e0821c 100644
288
299
  await this._checkCompaction(lastAssistant, false);
289
300
  }
290
301
  // Build messages array (custom message if any, then user message)
291
- @@ -2242,6 +2408,27 @@ export class AgentSession {
302
+ @@ -2242,6 +2419,34 @@ export class AgentSession {
292
303
  // Context overflow is handled by compaction, not retry.
293
304
  if (isContextOverflow(message, this.model?.contextWindow ?? 0))
294
305
  return false;
@@ -312,11 +323,18 @@ index fd82b71..5e0821c 100644
312
323
  + // rate limit is affected. Mirrors isAccountCapCode in src/engine/errors.ts.
313
324
  + if (this.model?.provider === "privateer" &&
314
325
  + /"code"\s*:\s*"[A-Z0-9_]*(?:CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP)[A-Z0-9_]*"|limit of [^.]*reached|upgrade or top ?up|usage limit reached/i.test(message.errorMessage ?? ""))
326
+ + return false;
327
+ + // Privateer patch: a stalled stream is a transport fact, not a body substring.
328
+ + // undici's "Body/Headers Timeout Error" contains "timeout", so pi spent the
329
+ + // whole retry budget re-sending a turn the transport had already given up on —
330
+ + // a 5-minute stall became three, and only then did the user see a message.
331
+ + // Mirrors isIdleTimeoutError in src/engine/errors.ts.
332
+ + if (isIdleTimeoutError(message.errorMessage))
315
333
  + return false;
316
334
  return isRetryableAssistantError(message);
317
335
  }
318
336
  /**
319
- @@ -2287,7 +2474,11 @@ export class AgentSession {
337
+ @@ -2287,7 +2492,11 @@ export class AgentSession {
320
338
  this._retryAttempt--;
321
339
  return false;
322
340
  }
@@ -375,7 +393,7 @@ index 197bccc..bc9ac3f 100644
375
393
  //# sourceMappingURL=auth-guidance.js.map
376
394
 
377
395
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js
378
- index bb022d4..382ff2b 100644
396
+ index bb022d4..207f3ba 100644
379
397
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js
380
398
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js
381
399
  @@ -18,7 +18,7 @@ import { createJiti } from "jiti/static";
@@ -1657,7 +1675,7 @@ index c33fc39..060a38f 100644
1657
1675
  if (content) {
1658
1676
  text += `\n\n${content}`;
1659
1677
  diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
1660
- index a27754f..61a1fff 100644
1678
+ index a27754f..3403ca8 100644
1661
1679
  --- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
1662
1680
  +++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
1663
1681
  @@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui";
@@ -1683,7 +1701,7 @@ index a27754f..61a1fff 100644
1683
1701
  if (!sessionManager.usesDefaultSessionDir()) {
1684
1702
  args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
1685
1703
  }
1686
- @@ -232,6 +237,54 @@ export function createInteractiveTuiReference(getTui) {
1704
+ @@ -232,6 +237,64 @@ export function createInteractiveTuiReference(getTui) {
1687
1705
  getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
1688
1706
  });
1689
1707
  }
@@ -1716,8 +1734,18 @@ index a27754f..61a1fff 100644
1716
1734
  +function pvDescribeErrorText(text) {
1717
1735
  + const s = typeof text === "string" ? text : "";
1718
1736
  + const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
1719
- + if (!Number.isFinite(status))
1737
+ + if (!Number.isFinite(status)) {
1738
+ + // Privateer patch: no HTTP status, but undici's idle-timeout message is exact
1739
+ + // and is the one error a stalled turn produces — printing it raw leaves the
1740
+ + // user with two words and no next step. Mirrors isIdleTimeoutError /
1741
+ + // describeErrorText in src/engine/errors.ts.
1742
+ + if (/(?:^|\b)(?:Body|Headers) Timeout Error\b/i.test(s))
1743
+ + return {
1744
+ + message: "The provider stopped responding — the connection went idle.",
1745
+ + hint: "No data arrived for the whole idle-timeout window, so the turn was cut off. Send it again, or run /model to switch providers (a slow model can be given longer in /settings → HTTP idle timeout).",
1746
+ + };
1720
1747
  + return null;
1748
+ + }
1721
1749
  + if (status === 429) {
1722
1750
  + const stated = pvRetryAfterMs(s);
1723
1751
  + return {
@@ -1738,7 +1766,7 @@ index a27754f..61a1fff 100644
1738
1766
  export class InteractiveMode {
1739
1767
  runtimeHost;
1740
1768
  renderer;
1741
- @@ -439,9 +492,15 @@ export class InteractiveMode {
1769
+ @@ -439,9 +502,15 @@ export class InteractiveMode {
1742
1770
  }
1743
1771
  getBuiltInCommandConflictDiagnostics(extensionRunner) {
1744
1772
  const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
@@ -1755,7 +1783,7 @@ index a27754f..61a1fff 100644
1755
1783
  .map((command) => ({
1756
1784
  type: "warning",
1757
1785
  message: command.invocationName === command.name
1758
- @@ -815,20 +874,18 @@ export class InteractiveMode {
1786
+ @@ -815,20 +884,18 @@ export class InteractiveMode {
1759
1787
  this.showNewVersionNotification(newRelease);
1760
1788
  }
1761
1789
  });
@@ -1788,7 +1816,7 @@ index a27754f..61a1fff 100644
1788
1816
  // Check tmux keyboard setup asynchronously
1789
1817
  this.checkTmuxKeyboardSetup().then((warning) => {
1790
1818
  if (warning) {
1791
- @@ -2393,7 +2450,17 @@ export class InteractiveMode {
1819
+ @@ -2393,7 +2460,17 @@ export class InteractiveMode {
1792
1820
  if (text === "/model" || text.startsWith("/model ")) {
1793
1821
  const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
1794
1822
  this.editor.setText("");
@@ -1807,7 +1835,7 @@ index a27754f..61a1fff 100644
1807
1835
  return;
1808
1836
  }
1809
1837
  if (text === "/thinking" || text.startsWith("/thinking ")) {
1810
- @@ -2465,12 +2532,42 @@ export class InteractiveMode {
1838
+ @@ -2465,12 +2542,42 @@ export class InteractiveMode {
1811
1839
  if (text === "/login" || text.startsWith("/login ")) {
1812
1840
  const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined;
1813
1841
  this.editor.setText("");
@@ -1852,7 +1880,30 @@ index a27754f..61a1fff 100644
1852
1880
  return;
1853
1881
  }
1854
1882
  if (text === "/new") {
1855
- @@ -3164,7 +3261,7 @@ export class InteractiveMode {
1883
+ @@ -2788,9 +2895,19 @@ export class InteractiveMode {
1884
+ if (entries[0]?.type !== "compaction") {
1885
+ throw new Error("Completed compaction is missing from the session context");
1886
+ }
1887
+ - this.chatContainer.clear();
1888
+ - // The latest compaction is prepended for model context; append it below at its chronological position.
1889
+ - this.renderSessionEntries(entries.slice(1));
1890
+ + // Privateer patch: a compaction must never erase what the user can see.
1891
+ + // Stock Pi cleared the whole chat container here and re-rendered only
1892
+ + // buildContextEntries().slice(1) — the MODEL's new context, a summary plus
1893
+ + // the firstKeptEntryId tail. That is the right set of messages to send to
1894
+ + // the model and the wrong set to leave on screen: every prompt, answer and
1895
+ + // tool result above the keep boundary vanished from the transcript the
1896
+ + // instant the summary appeared, so the user watched the thing they typed get
1897
+ + // swallowed and replaced by a summary — worst of all when the compaction was
1898
+ + // triggered by that very prompt. The model context is allowed to shrink; the
1899
+ + // transcript is the record of what actually happened and does not. The kept
1900
+ + // tail is already on screen (it was rendered as it happened), so re-rendering
1901
+ + // it would duplicate it — keep every child and append the summary at its
1902
+ + // chronological position, which is the boundary ("now").
1903
+ this.addMessageToChat(createCompactionSummaryMessage(event.result.summary, event.result.tokensBefore, new Date().toISOString()));
1904
+ if (event.result.usage) {
1905
+ this.addCompactionCostNotice({
1906
+ @@ -3164,7 +3281,7 @@ export class InteractiveMode {
1856
1907
  if (this.chatContainer.children.length > 0) {
1857
1908
  this.chatContainer.addChild(new Spacer(1));
1858
1909
  }
@@ -1861,7 +1912,7 @@ index a27754f..61a1fff 100644
1861
1912
  }
1862
1913
  async getUserInput() {
1863
1914
  const queuedInput = this.pendingUserInputs.shift();
1864
- @@ -3504,7 +3601,17 @@ export class InteractiveMode {
1915
+ @@ -3504,7 +3621,17 @@ export class InteractiveMode {
1865
1916
  }
1866
1917
  showError(errorMessage) {
1867
1918
  this.chatContainer.addChild(new Spacer(1));
@@ -0,0 +1,66 @@
1
+ // Privacy toggle: completely disable pi-privacy across agent sessions.
2
+ //
3
+ // When active, all pi-privacy hooks are completely bypassed:
4
+ // - Outbound PII scanning and prompts are disabled (IP addresses, emails, and
5
+ // other non-sensitive data that trigger detection can be sent as-is with NO interference).
6
+ // - Unattended mode does NOT auto-redact PII.
7
+ // - Tool exfiltration guards and warnings are disabled.
8
+ // - Tool result credential scanning / redaction is disabled.
9
+ // - Model downgrade guards are disabled.
10
+ //
11
+ // THE ENV VAR IS THE STORE:
12
+ // Exactly like `src/permissions/noQuarter.ts`, state is stored in `process.env.PRIVATEER_PRIVACY_OFF`
13
+ // and `process.env.PI_PRIVACY_OFF`:
14
+ // 1. ACROSS PROCESSES: subagent children inherit the env var and run without privacy gates.
15
+ // 2. ACROSS EXTENSIONS: different jiti instances and late-loaded extensions all share process.env.
16
+ // 3. LAUNCH CONFIG: users can launch with `PRIVATEER_PRIVACY_OFF=1` or `PI_PRIVACY_OFF=1`
17
+ // or `--privacy-off` / `--no-privacy` CLI flags.
18
+
19
+ const ENV = "PRIVATEER_PRIVACY_OFF";
20
+ const ALT_ENV = "PI_PRIVACY_OFF";
21
+
22
+ /** True while pi-privacy is completely disabled for this session. Read live, never cached. */
23
+ export function privacyDisabled(): boolean {
24
+ return process.env[ENV] === "1" || process.env[ALT_ENV] === "1";
25
+ }
26
+
27
+ // WHO ELSE NEEDS TO KNOW. The env var is the store, but it is not observable — and the
28
+ // switch now has two drivers on the same session: `/privacy off` typed into a composer,
29
+ // and the app's own shield toggle (RemoteBridge's onPrivacy). Whichever one moves it, the
30
+ // app's switch has to follow, or the desktop shows a shield over a session whose filter
31
+ // its own composer just turned off. Listeners fire on a real CHANGE only, so a re-assert
32
+ // is not an event; failures are swallowed, because a subscriber that throws must not
33
+ // leave the flag half-applied.
34
+ const listeners = new Set<(disabled: boolean) => void>();
35
+
36
+ /** Watch the disabled state. Returns an unsubscribe. Fires only when the value changes. */
37
+ export function onPrivacyDisabledChange(fn: (disabled: boolean) => void): () => void {
38
+ listeners.add(fn);
39
+ return () => listeners.delete(fn);
40
+ }
41
+
42
+ /**
43
+ * Set the disabled state. Modifies process.env so every module copy and child process agrees.
44
+ * Returns the new state.
45
+ */
46
+ export function setPrivacyDisabled(disabled: boolean): boolean {
47
+ const before = privacyDisabled();
48
+ if (disabled) {
49
+ process.env[ENV] = "1";
50
+ process.env[ALT_ENV] = "1";
51
+ } else {
52
+ delete process.env[ENV];
53
+ delete process.env[ALT_ENV];
54
+ }
55
+ if (before !== disabled) {
56
+ for (const fn of listeners) {
57
+ try { fn(disabled); } catch { /* a watcher must not break the toggle */ }
58
+ }
59
+ }
60
+ return disabled;
61
+ }
62
+
63
+ /** Flip the disabled state. Returns the new state. */
64
+ export function togglePrivacyDisabled(): boolean {
65
+ return setPrivacyDisabled(!privacyDisabled());
66
+ }
@@ -36,6 +36,8 @@ import { cliPalette, detectScheme } from "../ui/palette.ts";
36
36
  import { accountPosture, privateerChannel } from "../providers/account.ts";
37
37
  import { hasCredentials } from "../auth/privateer.ts";
38
38
  import { writePiDefaultModel } from "../providers/defaultModel.ts";
39
+ import { privacyDisabled, setPrivacyDisabled } from "./privacyDisabled.ts";
40
+ import { updatePostureBadge } from "../../extensions/privateer-posture.ts";
39
41
 
40
42
  // Color-coat pi-privacy's auto-redact notice as the moat acting on your behalf: the red
41
43
  // no-quarter flag (same glyph and color as the no-quarter banner in chat.ts and the gate
@@ -67,6 +69,7 @@ export function sharedPrivacyOptions() {
67
69
  const ambient = loadConfig();
68
70
  return {
69
71
  ...ambient,
72
+ disabled: privacyDisabled,
70
73
  // The account channel's real posture. pi-privacy ships a `privateer` provider, but it
71
74
  // is the PUBLIC developer-key channel (sk-priv-…, server-proxied and unverifiable
72
75
  // end-to-end), so from the package alone every privateer/* model floors to
@@ -131,69 +134,161 @@ export function sharedPrivacyOptions() {
131
134
  // Registered from HERE rather than from the extension file, for the reason this module
132
135
  // exists at all: the factory-built sessions and the discovered extension must not drift.
133
136
  function registerPrivacyCommand(pi: any): void {
134
- pi.registerCommand?.("privacy", {
135
- description: "Values the PII gate must not treat as personal data: /privacy [allow <value> | unallow <value>]",
136
- handler: (args: string, ctx: any) => {
137
- const raw = String(args ?? "").trim();
138
- const [verb, ...rest] = raw.split(/\s+/);
139
- const value = rest.join(" ").trim();
140
- const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
141
-
142
- if (verb === "allow" || verb === "unallow") {
143
- if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
144
- const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
145
- return notify(r.message, r.ok ? "info" : "warning");
146
- }
147
- if (verb) return notify(`unknown option "${verb}" — usage: /privacy [allow <value> | unallow <value>]`, "warning");
137
+ const handler = async (args: string, ctx: any) => {
138
+ const raw = String(args ?? "").trim();
139
+ const [verb, ...rest] = raw.split(/\s+/);
140
+ const value = rest.join(" ").trim();
141
+ const notify = (msg: string, level: "info" | "warning" = "info") => ctx?.ui?.notify?.(msg, level);
148
142
 
143
+ if (verb === "off" || verb === "disable") {
144
+ setPrivacyDisabled(true);
145
+ await updatePostureBadge(ctx);
146
+ return notify(
147
+ "⚑ pi-privacy is completely OFF for this session. Outbound requests will not be scanned or gated for PII, " +
148
+ "and tool exfiltration, result, and downgrade guards are disabled. Run /privacy on to restore.",
149
+ "warning",
150
+ );
151
+ }
152
+
153
+ if (verb === "on" || verb === "enable" || verb === "restore") {
154
+ setPrivacyDisabled(false);
155
+ await updatePostureBadge(ctx);
156
+ return notify(
157
+ "pi-privacy is ON for this session. PII scanning, tool exfiltration guards, and privacy checks restored.",
158
+ "info",
159
+ );
160
+ }
161
+
162
+ if (verb === "status") {
163
+ const state = privacyDisabled() ? "OFF (disabled for this session)" : "ON (active)";
149
164
  const mine = piiAllowEntries();
150
- notify(
165
+ return notify(
151
166
  [
167
+ `pi-privacy status: ${state}`,
152
168
  mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
153
- "Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
154
- "Add one with /privacy allow <value> — an address (me@acme.com), a domain (@acme.com),",
155
- "an IPv4 block (10.0.0.0/8), or any exact/globbed value.",
169
+ "Reserved shapes (example.com, loopback, noreply@…) are allowed by default.",
170
+ "Commands: /privacy off | /privacy on | /privacy status | /privacy allow <value> | /privacy unallow <value>",
156
171
  ].join("\n"),
157
172
  "info",
158
173
  );
174
+ }
175
+
176
+ if (verb === "allow" || verb === "unallow") {
177
+ if (!value) return notify(`usage: /privacy ${verb} <value>`, "warning");
178
+ const r = verb === "allow" ? addPiiAllow(value) : removePiiAllow(value);
179
+ return notify(r.message, r.ok ? "info" : "warning");
180
+ }
181
+
182
+ if (verb && verb !== "help") {
183
+ return notify(`unknown option "${verb}" — usage: /privacy [off | on | status | allow <value> | unallow <value>]`, "warning");
184
+ }
185
+
186
+ const state = privacyDisabled() ? "OFF (disabled for this session)" : "ON (active)";
187
+ const mine = piiAllowEntries();
188
+ notify(
189
+ [
190
+ `pi-privacy status: ${state}`,
191
+ mine.length ? `PII allowlist (~/.privateer/config.json):\n ${mine.join("\n ")}` : "PII allowlist: empty",
192
+ "Reserved shapes (example.com, loopback, noreply@…) are allowed by default and not listed here.",
193
+ "Commands:",
194
+ " /privacy off — turn off pi-privacy completely for this session",
195
+ " /privacy on — restore pi-privacy protections",
196
+ " /privacy status — show current status and allowlist",
197
+ " /privacy allow <value> — add value to PII allowlist",
198
+ " /privacy unallow <value> — remove value from PII allowlist",
199
+ ].join("\n"),
200
+ "info",
201
+ );
202
+ };
203
+
204
+ const spec = {
205
+ description: "Manage privacy protections: /privacy [off | on | status | allow <value> | unallow <value>]",
206
+ handler,
207
+ getArgumentCompletions: (prefix: string) => {
208
+ const p = prefix.trim().toLowerCase();
209
+ const verbs = ["off", "on", "status", "allow", "unallow"];
210
+ return verbs.filter((v) => v.startsWith(p)).map((v) => ({ value: v, label: v }));
211
+ },
212
+ };
213
+
214
+ pi.registerCommand?.("privacy", spec);
215
+ pi.registerCommand?.("pi-privacy", {
216
+ ...spec,
217
+ description: "Alias for /privacy: /pi-privacy [off | on | status | allow <value> | unallow <value>]",
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Intercept pi-privacy's event registrations and commands so that when pi-privacy is completely
223
+ * disabled via `/privacy off` (or `PRIVATEER_PRIVACY_OFF=1` / `PI_PRIVACY_OFF=1`),
224
+ * all gating, prompt, and redaction hooks are cleanly bypassed.
225
+ */
226
+ function gatingPrivacyPi(pi: any): any {
227
+ const on = (event: string, handler: any) => {
228
+ if (typeof pi?.on !== "function") return;
229
+ if (typeof handler !== "function") return pi.on(event, handler);
230
+
231
+ if (
232
+ event === "before_provider_request" ||
233
+ event === "tool_call" ||
234
+ event === "user_bash" ||
235
+ event === "tool_result" ||
236
+ event === "model_select"
237
+ ) {
238
+ return pi.on(event, async (ev: any, ctx: any) => {
239
+ if (privacyDisabled()) return undefined;
240
+ return handler(ev, ctx);
241
+ });
242
+ }
243
+
244
+ return pi.on(event, handler);
245
+ };
246
+
247
+ const registerCommand = (name: string, spec: any) => {
248
+ if (typeof pi?.registerCommand !== "function") return;
249
+ if (name === "pii" && spec && typeof spec.handler === "function") {
250
+ const orig = spec.handler;
251
+ const wrappedHandler = async (args: string, ctx: any) => {
252
+ const raw = String(args ?? "").trim().toLowerCase();
253
+ const [verb] = raw.split(/\s+/);
254
+ if (verb === "off" || verb === "disable") {
255
+ setPrivacyDisabled(true);
256
+ await updatePostureBadge(ctx);
257
+ } else if (verb === "on" || verb === "enable" || verb === "restore") {
258
+ setPrivacyDisabled(false);
259
+ await updatePostureBadge(ctx);
260
+ }
261
+ return orig(args, ctx);
262
+ };
263
+ return pi.registerCommand(name, { ...spec, handler: wrappedHandler });
264
+ }
265
+ return pi.registerCommand(name, spec);
266
+ };
267
+
268
+ return new Proxy(pi, {
269
+ get(target, prop, receiver) {
270
+ if (prop === "on") return on;
271
+ if (prop === "registerCommand") return registerCommand;
272
+ const value = Reflect.get(target, prop, receiver);
273
+ return typeof value === "function" ? value.bind(target) : value;
159
274
  },
160
275
  });
161
276
  }
162
277
 
163
278
  /**
164
279
  * The privacy half of the moat as ONE factory: pi-privacy configured the Privateer way,
165
- * plus the `/privacy` command that maintains its allowlist. Both routes into pi-privacy
280
+ * plus the `/privacy` command that maintains its allowlist and toggles. Both routes into pi-privacy
166
281
  * (src/config/moat.ts and extensions/privateer-privacy.ts) use this, so neither can end
167
282
  * up with the gate but not its escape hatch.
168
283
  */
169
284
  export function privacyExtension() {
170
285
  const privacy = makePiPrivacyExtension(sharedPrivacyOptions());
171
286
  return function privateerPrivacyCore(pi: any): void {
172
- privacy(persistingModelPicks(pi));
287
+ privacy(gatingPrivacyPi(persistingModelPicks(pi)));
173
288
  registerPrivacyCommand(pi);
174
289
  };
175
290
  }
176
291
 
177
- /**
178
- * pi-privacy's `/models` picker switched the live session and stopped there, so the
179
- * pick lasted exactly as long as the terminal and the next one launched on whatever
180
- * settings.json still said. Pi persists a switch only for a caller that passes
181
- * `{ persist: true }`, and the extension api it reaches setModel through forwards no
182
- * options at all (see savedPiDefaultSpec in providers/defaultModel.ts).
183
- *
184
- * Pi's own selector splits the two — Enter switches for the session, ctrl+s makes it
185
- * the default. OUR picker has no second key to press and never advertised a
186
- * distinction, so a pick made in it means "this is my model": we persist it.
187
- *
188
- * Wrapped here rather than fixed inside pi-privacy so the rule sits with the rest of
189
- * what Privateer configures on that extension, and so it stays scoped to the picker.
190
- * The brand extension's sign-in switch shares the same api and deliberately does NOT
191
- * get this — writePiDefaultModel says why an automatic switch must not persist.
192
- *
193
- * A Proxy, not a spread: the api object is a plain literal today, but a spread of a
194
- * class instance would silently drop every method and take pi-privacy down with it.
195
- * Forwarding leaves that failure mode impossible.
196
- */
197
292
  function persistingModelPicks(pi: any): any {
198
293
  if (typeof pi?.setModel !== "function") return pi;
199
294
  const setModel = async (model: any) => {
@@ -0,0 +1,74 @@
1
+ // How many output tokens a turn may ask for, once the prompt has taken its share.
2
+ //
3
+ // Mirrors the patched clampMaxTokensToContext in pi-ai (api/simple-options.js) —
4
+ // that copy is what actually runs; this one is where the behaviour is specified and
5
+ // tested. See patches/@earendil-works+pi-ai+0.84.4.patch.
6
+ //
7
+ // ── The bug this exists to fix ───────────────────────────────────────────────
8
+ //
9
+ // Stock pi computes the answer budget as
10
+ //
11
+ // available = contextWindow − estimateContextTokens(context) − 4096
12
+ // maxTokens = min(asked, max(1, available))
13
+ //
14
+ // and that floor is literally `1`. So the moment the estimate reaches the declared
15
+ // window, every turn asks the provider for ONE token. The request still goes out,
16
+ // still sends the whole prompt, and is still billed in full — and comes back with a
17
+ // single token and `finish_reason: "length"`, which the TUI renders as "Response was
18
+ // truncated before completion." Compaction runs once, and if the estimate is still
19
+ // over (it usually is — see below) the session is stuck there: every turn burns a
20
+ // full-price prompt to produce nothing. That is the "it won't resume" failure, and
21
+ // it is reachable on any long session.
22
+ //
23
+ // Two things conspire to reach it early. `estimateContextTokens` is a chars/4
24
+ // approximation over the whole context — system prompt and tool schemas included —
25
+ // so it runs ahead of what the provider actually counts; and Privateer registers
26
+ // every account model with a flat `contextWindow: 128000` (providers/account.ts
27
+ // seedModel) because /api/models does not publish per-model windows. A model with a
28
+ // larger real window therefore hits this ceiling while it still has room.
29
+ //
30
+ // ── The fix ──────────────────────────────────────────────────────────────────
31
+ //
32
+ // Floor the budget at a usable answer instead of at 1. The floor only ever raises
33
+ // `available`; it never raises what the caller asked for, so a deliberately small
34
+ // request (a summariser, a title) keeps its own number — the `min` still wins.
35
+ //
36
+ // It is safe against the window because the floor is smaller than the 4096-token
37
+ // safety margin already subtracted above: when `available` lands between 0 and the
38
+ // floor, the tokens we hand back were inside that margin all along.
39
+ //
40
+ // When `available` is genuinely negative the context really does not fit, and the
41
+ // honest outcome is the provider saying so — a context-length error, which pi
42
+ // classifies via isContextOverflow and answers with compaction, the path designed
43
+ // for exactly this. That is strictly better than the silent one-token stub it
44
+ // replaces: same cost, but the agent recovers instead of looking hung.
45
+ export const CONTEXT_SAFETY_TOKENS = 4096;
46
+
47
+ // pi-ai's own MIN_ANSWER_TOKENS — the floor it already reserves when a thinking
48
+ // budget shares the response ceiling (clampThinkingBudgetToAnswerRoom). Below this a
49
+ // turn cannot produce a usable answer or even a complete tool call, so asking for
50
+ // less is never worth a request.
51
+ //
52
+ // The patch REFERENCES that constant rather than declaring its own: simple-options.js
53
+ // exports it from the same module scope, so a second top-level `const` of that name
54
+ // is a SyntaxError — and since every provider imports this module, that one would not
55
+ // fail quietly, it would stop the CLI from starting. This copy exists so the value is
56
+ // stated and tested here; tests/contextBudget.test.ts pins both halves together.
57
+ export const MIN_ANSWER_TOKENS = 1024;
58
+
59
+ export interface ContextBudgetModel {
60
+ contextWindow: number;
61
+ }
62
+
63
+ export function clampMaxTokensToContext(
64
+ model: ContextBudgetModel,
65
+ estimatedContextTokens: number,
66
+ maxTokens: number,
67
+ ): number {
68
+ // An unknown window (0 or absent) means "we cannot reason about room" — pass the
69
+ // ask through rather than inventing a ceiling. Stock behaviour, kept verbatim:
70
+ // the 1 here guards a zero/negative ask, it is not the floor this patch changes.
71
+ if (!(model.contextWindow > 0)) return Math.max(1, maxTokens);
72
+ const available = model.contextWindow - estimatedContextTokens - CONTEXT_SAFETY_TOKENS;
73
+ return Math.min(maxTokens, Math.max(MIN_ANSWER_TOKENS, available));
74
+ }
@@ -37,7 +37,7 @@ const HOST_LABELS: Record<string, string> = {
37
37
  // provider's machine code (which comes out of the response body) — these mean the
38
38
  // request never got a response at all.
39
39
  const NETWORK_ERRNO =
40
- /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/;
40
+ /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|EPIPE|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET|UND_ERR_BODY_TIMEOUT|UND_ERR_HEADERS_TIMEOUT)$/;
41
41
 
42
42
  // Pull structured fields off an unknown error without trusting any one shape.
43
43
  //
@@ -241,6 +241,35 @@ export function isHardHttpFailure(text: string | null | undefined): boolean {
241
241
  return status >= 400 && status < 500 && !TRANSIENT_CLIENT_STATUS.has(status);
242
242
  }
243
243
 
244
+ // ── Idle timeouts: a stalled stream, said plainly ────────────────────────────
245
+ //
246
+ // undici guards a connection that has gone quiet with `bodyTimeout` (the gap
247
+ // between response chunks) and `headersTimeout` (the wait for the first byte). Pi
248
+ // wires both from `httpIdleTimeoutMs` (default 5 min, see http-dispatcher.js). When
249
+ // either fires, undici throws BodyTimeoutError / HeadersTimeoutError with an exact,
250
+ // stable message and code — and NO HTTP status.
251
+ //
252
+ // That absence is the whole problem. `describeErrorText` only describes text that
253
+ // opens with a 3-digit status, so the bare string "Body Timeout Error" printed
254
+ // through untouched; and pi's retry regex matches the word "timeout", so the agent
255
+ // silently re-sent the entire turn across its whole retry budget — a single stalled
256
+ // stream became three 5-minute waits before the user saw any message at all. The
257
+ // transport timing out is a fact, not a guess from a body substring, so recognise
258
+ // it exactly (message OR code) and treat it as terminal: retrying re-bills the same
259
+ // request and, on a genuinely stalled provider, will stall again.
260
+ const IDLE_TIMEOUT_CODE = /^UND_ERR_(?:BODY|HEADERS)_TIMEOUT$/;
261
+ const IDLE_TIMEOUT_TEXT = /(?:^|\b)(?:Body|Headers) Timeout Error\b/i;
262
+
263
+ export function isIdleTimeoutError(text: string | null | undefined): boolean {
264
+ const s = typeof text === "string" ? text : "";
265
+ return IDLE_TIMEOUT_TEXT.test(s) || IDLE_TIMEOUT_CODE.test(s.trim());
266
+ }
267
+
268
+ const IDLE_TIMEOUT_DESCRIPTION: DescribedError = {
269
+ message: "The provider stopped responding — the connection went idle.",
270
+ hint: "No data arrived for the whole idle-timeout window, so the turn was cut off. Send it again, or run /model to switch providers — a slow model can be given longer under /settings → HTTP idle timeout.",
271
+ };
272
+
244
273
  function rawMessage(err: unknown): string {
245
274
  if (err instanceof Error) return err.message;
246
275
  if (typeof err === "string") return err;
@@ -318,6 +347,17 @@ export function describeError(err: unknown): DescribedError {
318
347
  hint: "Check the model id — run /model to switch.",
319
348
  });
320
349
  }
350
+ if (
351
+ status === 413 ||
352
+ facts.code === "PAYLOAD_TOO_LARGE" ||
353
+ facts.code === "REQUEST_TOO_LARGE" ||
354
+ /payload too large|request_too_large|request entity too large/i.test(text)
355
+ ) {
356
+ return out({
357
+ message: `Request payload too large${forProvider} (413).`,
358
+ hint: "The conversation history or attached files exceed the server limit. Start a new session (/new) or remove large attachments.",
359
+ });
360
+ }
321
361
  if (status === 429) {
322
362
  return out({
323
363
  message: `Rate limited${forProvider} (429).`,
@@ -332,6 +372,12 @@ export function describeError(err: unknown): DescribedError {
332
372
  retryable: true,
333
373
  });
334
374
  }
375
+ // A stream that stopped producing data. This carries no status, so without this
376
+ // branch it fell through to the generic "Network error" below, and pi re-sent the
377
+ // whole turn on the substring "timeout". Say what actually happened, and stop.
378
+ if (isIdleTimeoutError(text) || isIdleTimeoutError(facts.errno)) {
379
+ return out(IDLE_TIMEOUT_DESCRIPTION);
380
+ }
335
381
  if (
336
382
  facts.errno != null ||
337
383
  /fetch failed|cannot connect|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)
@@ -469,7 +515,18 @@ export function retryDelayMs(
469
515
  export function describeErrorText(text: string | null | undefined): DescribedError | null {
470
516
  const s = typeof text === "string" ? text : "";
471
517
  const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
472
- if (!Number.isFinite(status)) return null;
518
+ if (!Number.isFinite(status)) {
519
+ // No leading status: the one message we can still describe with certainty is a
520
+ // transport idle timeout, which undici names exactly. Everything else is printed
521
+ // unchanged, as before.
522
+ if (isIdleTimeoutError(s)) {
523
+ return {
524
+ message: redactText(IDLE_TIMEOUT_DESCRIPTION.message),
525
+ hint: IDLE_TIMEOUT_DESCRIPTION.hint,
526
+ };
527
+ }
528
+ return null;
529
+ }
473
530
 
474
531
  if (status === 429) {
475
532
  const stated = retryAfterMs(s);
@@ -493,6 +550,12 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
493
550
  hint: "Check the model id — run /model to switch.",
494
551
  };
495
552
  }
553
+ if (status === 413 || /413\b|payload too large|request_too_large|request entity too large/i.test(s)) {
554
+ return {
555
+ message: redactText(`Request payload too large (413).`),
556
+ hint: "The conversation history or attached files exceed the server limit. Start a new session (/new) or remove large attachments.",
557
+ };
558
+ }
496
559
  if (status >= 500) {
497
560
  return {
498
561
  message: redactText(compactProviderError(s)),
@@ -76,6 +76,29 @@ const DEFAULT_MODELS = [
76
76
  "openai/gpt-5.6-luna",
77
77
  ];
78
78
 
79
+ // What to assume when the server publishes no window for a model — an older server,
80
+ // a model the upstream catalog has no `context_length` for, or an admin-added row.
81
+ // 128000 was the flat value the whole catalog used to be registered with, so keeping
82
+ // it here means "no published window" behaves exactly as it always has, and only a
83
+ // model we have a real number for changes.
84
+ const DEFAULT_CONTEXT_WINDOW = 128000;
85
+
86
+ // A sane band for a published window. The catalog is remote input and the cache is a
87
+ // file on disk, so a garbled or hostile number must not reach pi's budget arithmetic:
88
+ // too small silently strangles every turn, too large defeats compaction by promising
89
+ // room that isn't there. Anything outside the band is treated as unpublished.
90
+ const MIN_PUBLISHED_CONTEXT_WINDOW = 8_192;
91
+ const MAX_PUBLISHED_CONTEXT_WINDOW = 10_000_000;
92
+
93
+ function validContextWindow(value: unknown): number | undefined {
94
+ return typeof value === "number" &&
95
+ Number.isFinite(value) &&
96
+ value >= MIN_PUBLISHED_CONTEXT_WINDOW &&
97
+ value <= MAX_PUBLISHED_CONTEXT_WINDOW
98
+ ? Math.floor(value)
99
+ : undefined;
100
+ }
101
+
79
102
  function seedModel(id: string) {
80
103
  return {
81
104
  id,
@@ -88,7 +111,13 @@ function seedModel(id: string) {
88
111
  // attaches when the user points at a screenshot. See providers/vision.ts.
89
112
  input: visionInput(id),
90
113
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
91
- contextWindow: 128000,
114
+ // The model's real window when the server published one, else the fallback.
115
+ // This is not cosmetic: pi sizes each turn's answer budget as
116
+ // (contextWindow − estimated prompt − 4096) and clamps max_tokens to it
117
+ // (pi-ai clampMaxTokensToContext, and see src/engine/contextBudget.ts). A window
118
+ // that under-states the model's real one therefore throttles the answer — and
119
+ // used to collapse it to a single token — while the model still has room.
120
+ contextWindow: accountContextWindow(id) ?? DEFAULT_CONTEXT_WINDOW,
92
121
  maxTokens: 16384,
93
122
  };
94
123
  }
@@ -146,25 +175,79 @@ const REASONING_EFFORT_THINKING: ThinkingProfile = {
146
175
  thinkingLevelMap: { off: "low", minimal: "low", xhigh: null },
147
176
  };
148
177
 
149
- // Only the TEE prefixes are annotated. Those are enclaves we drive directly and can
150
- // probe. The rest of the catalog is proxied to a third-party gateway whose thinking
151
- // shape we have NOT verified from here, and an unsupported parameter fails the whole
152
- // turn — decisively worse than a turn that thinks too much. They keep the old
153
- // behaviour exactly.
178
+ // The proxied catalog: everything that is NOT a TEE prefix reaches OpenRouter
179
+ // through the account relay, which forwards our body unchanged (treeview
180
+ // services/inferenceService.js proxyChatCompletion — "pass the client body through
181
+ // unchanged except for the fields we own"). OpenRouter normalises reasoning across
182
+ // vendors with a nested `reasoning` object, which is exactly Pi's "openrouter"
183
+ // thinkingFormat, and it derives a token budget from the effort — so the effort dial
184
+ // is also the budget cap.
185
+ //
186
+ // That cap is the point. `reasoning: false` does not mean "does not think": it means
187
+ // Pi sends NO thinking parameter at all, because every thinking branch in pi-ai
188
+ // buildParams is gated on model.reasoning. The model then reasons at the gateway's
189
+ // default — and reasoning shares `maxTokens` with the answer, so an unbounded
190
+ // thinking phase eats the response. Measured across 6,200 real turns: 58–76% of ALL
191
+ // output tokens on these models were reasoning, 158 turns spent ≥90% of the 16,384
192
+ // budget thinking, and 12 produced thousands of reasoning tokens with no answer and
193
+ // no tool call — a five-to-nine-minute spinner ending in "Response was truncated
194
+ // before completion." The annotated TEE models, on the same measurement, sat at 0%.
195
+ //
196
+ // "off" pins to the floor instead of sending `effort: "none"`, for the same reason
197
+ // harmony does above: an unsupported enum fails the whole turn, and this is the one
198
+ // path with no safety net — the app's generateTextStream retries without the hint on
199
+ // a 400 (inferenceService.js), proxyChatCompletion does not. "minimal" is only
200
+ // universal on the GPT-5 family, so it shares the floor. low/medium/high pass
201
+ // through verbatim; xhigh/max stay unmapped so getSupportedThinkingLevels drops them.
202
+ const OPENROUTER_THINKING: ThinkingProfile = {
203
+ reasoning: true,
204
+ thinkingLevelMap: { off: "low", minimal: "low" },
205
+ compat: { thinkingFormat: "openrouter" },
206
+ };
207
+
208
+ // Only the TEE prefixes are driven directly and can be probed from here. The rest is
209
+ // proxied to a third-party gateway, so an id is annotated only when it is
210
+ // unambiguously a thinking model — an allowlist, never a denylist. The live catalog
211
+ // carries 284 ids including roleplay finetunes (sao10k, thedrummer, gryphe) and
212
+ // code-apply models (morph, relace) that do not reason at all, and being wrong here
213
+ // costs the whole turn rather than just a slow one.
154
214
  //
215
+ // Every family below was measured burning its budget in the session logs. Families
216
+ // that reason but were NOT observed here — anthropic/*, qwen/*, moonshotai/*,
217
+ // minimax/* — are deliberately left alone until probed live: Anthropic in particular
218
+ // takes a thinking BUDGET through OpenRouter and constrains temperature alongside it,
219
+ // which is a second parameter we would be guessing at.
220
+ const PROXIED_THINKING_MODEL: RegExp[] = [
221
+ /^google\/gemini-/i, // gemini-2.5+ all take an effort; gemma-* deliberately excluded
222
+ /^z-ai\/glm-/i, // the whole GLM line reasons, vision variants included
223
+ /^deepseek\/deepseek-(r1|v3\.[12]|v4)/i, // NOT deepseek-chat*, which is the non-thinking split
224
+ /^x-ai\/grok-4/i, // grok-4.x; grok-build-0.1 is unprobed
225
+ /^openai\/gpt-(5|6|oss)/i, // gpt-4*/gpt-3.5* do not reason
226
+ ];
227
+
228
+ // Non-thinking variants inside an otherwise-thinking family. `-chat` is OpenAI's
229
+ // non-reasoning split (gpt-5.2-chat); `instruct` is the same idea everywhere.
230
+ const NON_THINKING_VARIANT = /(?:^|[-/])(?:chat|instruct)(?:[-.]|$)/i;
231
+
232
+ const TEE_MODEL = /^(tinfoil|phala|near)\//;
233
+
155
234
  // Two deliberate omissions inside the TEE set: `*-instruct` ids are the
156
235
  // non-thinking variants, and tinfoil/kimi-k2-6 reasons but ignored BOTH levers when
157
236
  // probed, so annotating it would hand the user a dial connected to nothing.
158
- const TEE_MODEL = /^(tinfoil|phala|near)\//;
237
+ function teeThinkingProfile(id: string): ThinkingProfile | null {
238
+ if (/instruct/i.test(id)) return null;
239
+ if (/gpt-oss/i.test(id)) return REASONING_EFFORT_THINKING;
240
+ if (/glm|qwen/i.test(id)) return CHAT_TEMPLATE_THINKING;
241
+ return null;
242
+ }
243
+
244
+ function proxiedThinkingProfile(id: string): ThinkingProfile | null {
245
+ if (NON_THINKING_VARIANT.test(id)) return null;
246
+ return PROXIED_THINKING_MODEL.some((re) => re.test(id)) ? OPENROUTER_THINKING : null;
247
+ }
159
248
 
160
249
  export function thinkingProfile(id: string): ThinkingProfile | null {
161
- if (!TEE_MODEL.test(id)) return null;
162
- if (/instruct/i.test(id)) return null;
163
- const profile = /gpt-oss/i.test(id)
164
- ? REASONING_EFFORT_THINKING
165
- : /glm|qwen/i.test(id)
166
- ? CHAT_TEMPLATE_THINKING
167
- : null;
250
+ const profile = TEE_MODEL.test(id) ? teeThinkingProfile(id) : proxiedThinkingProfile(id);
168
251
  // Hand out a COPY. These entries end up on hundreds of registered models, and a
169
252
  // shared nested object is one careless mutation away from retuning the whole catalog.
170
253
  return profile && { ...profile, thinkingLevelMap: { ...profile.thinkingLevelMap }, ...(profile.compat ? { compat: { ...profile.compat } } : {}) };
@@ -198,16 +281,69 @@ function catalogCachePath(): string {
198
281
 
199
282
  // Best effort in both directions: this cache is an optimization, and a launch must never
200
283
  // fail because it couldn't be read or written.
201
- function saveCachedCatalogIds(ids: string[]): void {
284
+ //
285
+ // `windows` joined `ids` here because the synchronous launch seed needs it. Registration
286
+ // builds every model entry before the live fetch can resolve, so a context window known
287
+ // only to the live catalog would always arrive one launch too late — the entries pi
288
+ // actually binds would already carry the fallback. A window is a capability, not a
289
+ // privacy claim, so unlike the tier it is safe to read back from disk: the worst a stale
290
+ // one does is size a budget against last week's number, and the live fetch corrects it
291
+ // moments later. (The v1 `ids` key is still written, so an older build reading this file
292
+ // sees exactly what it expects.)
293
+ function saveCachedCatalog(infos: AccountModelInfo[]): void {
202
294
  try {
203
295
  mkdirSync(globalDir(), { recursive: true });
204
- const payload = { v: 1, fetchedAt: new Date().toISOString(), ids: ids.slice(0, CATALOG_CACHE_MAX) };
296
+ const kept = infos.slice(0, CATALOG_CACHE_MAX);
297
+ const windows: Record<string, number> = {};
298
+ for (const info of kept) {
299
+ if (info.contextWindow !== undefined) windows[info.id] = info.contextWindow;
300
+ }
301
+ const payload = {
302
+ v: 2,
303
+ fetchedAt: new Date().toISOString(),
304
+ ids: kept.map((info) => info.id),
305
+ windows,
306
+ };
205
307
  writeFileSync(catalogCachePath(), JSON.stringify(payload) + "\n", "utf8");
308
+ forgetCachedContextWindows();
206
309
  } catch {
207
310
  /* unwritable home — we just seed from DEFAULT_MODELS next launch */
208
311
  }
209
312
  }
210
313
 
314
+ // Read the cache once per process. seedModel calls accountContextWindow for every id in
315
+ // the catalog on every registration, and the catalog re-registers whenever the shim
316
+ // state changes — re-reading and re-parsing a 284-entry file each time would turn a
317
+ // cheap lookup into hundreds of syscalls on the launch path.
318
+ let cachedWindows: Map<string, number> | null = null;
319
+
320
+ function cachedContextWindows(): Map<string, number> {
321
+ if (cachedWindows) return cachedWindows;
322
+ cachedWindows = new Map();
323
+ try {
324
+ const path = catalogCachePath();
325
+ if (existsSync(path)) {
326
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { windows?: unknown };
327
+ // A v1 file has no `windows` at all; every id then falls back, exactly as before.
328
+ if (parsed.windows && typeof parsed.windows === "object") {
329
+ for (const [id, value] of Object.entries(parsed.windows as Record<string, unknown>)) {
330
+ const window = validContextWindow(value);
331
+ if (window !== undefined && cachedWindows.size < CATALOG_CACHE_MAX) cachedWindows.set(id, window);
332
+ }
333
+ }
334
+ }
335
+ } catch {
336
+ /* absent, unreadable, or garbage — every model just uses the fallback window */
337
+ }
338
+ return cachedWindows;
339
+ }
340
+
341
+ // Drop the memo so the next read sees what was just written (and so a test can rewrite
342
+ // the cache between assertions).
343
+ function forgetCachedContextWindows(): void {
344
+ cachedWindows = null;
345
+ }
346
+
211
347
  export function loadCachedCatalogIds(): string[] {
212
348
  try {
213
349
  const path = catalogCachePath();
@@ -267,6 +403,10 @@ export function seedCatalogIds(): string[] {
267
403
  export interface AccountModelInfo {
268
404
  id: string;
269
405
  tier: PrivacyTier;
406
+ // The server's published context window, when it published one and it is credible.
407
+ // Absent means "unknown" — seedModel falls back to DEFAULT_CONTEXT_WINDOW rather
408
+ // than to a guess, so an older server behaves exactly as before.
409
+ contextWindow?: number;
270
410
  }
271
411
 
272
412
  // The set of tier strings pi-privacy defines (posture/tiers.ts). We only trust a
@@ -300,6 +440,19 @@ function normalizeTier(tier: string | undefined, modelId: string): PrivacyTier {
300
440
  // A live NEAR attestation (accountPosture) can still upgrade a row to tee-verified.
301
441
  const accountTierMap = new Map<string, PrivacyTier>();
302
442
 
443
+ // Published context windows, keyed by modelId. Unlike the tier map this one IS
444
+ // persisted (see the cache below): a window is a capability, not a privacy claim, and
445
+ // it is needed at LAUNCH — registration happens synchronously, long before the live
446
+ // fetch resolves, so a window learned only at fetch time would arrive after the model
447
+ // entries pi actually uses were built.
448
+ const accountWindowMap = new Map<string, number>();
449
+
450
+ // The server's published context window for a model, or undefined if we have not been
451
+ // told one. Live catalog first, then the on-disk cache from the last launch.
452
+ export function accountContextWindow(id: string): number | undefined {
453
+ return accountWindowMap.get(id) ?? cachedContextWindows().get(id);
454
+ }
455
+
303
456
  // The server-asserted baseline tier for an account model, or undefined if we haven't
304
457
  // seen it in a catalog fetch. Used by the /models picker (privateer-models.ts).
305
458
  export function accountBaselineTier(modelId: string): PrivacyTier | undefined {
@@ -329,23 +482,38 @@ export async function fetchAccountCatalog(): Promise<AccountModelInfo[]> {
329
482
  infos = fallback();
330
483
  } else {
331
484
  const data = (await res.json()) as {
332
- models?: { modelId?: string; privacy?: { tier?: string } }[];
485
+ models?: { modelId?: string; privacy?: { tier?: string }; contextLength?: unknown }[];
333
486
  };
334
487
  const parsed = (data.models ?? [])
335
- .map((m) => (m.modelId ? { id: m.modelId, tier: normalizeTier(m.privacy?.tier, m.modelId) } : null))
488
+ .map((m): AccountModelInfo | null =>
489
+ m.modelId
490
+ ? {
491
+ id: m.modelId,
492
+ tier: normalizeTier(m.privacy?.tier, m.modelId),
493
+ // `contextLength` is null for a model upstream has no window for, and
494
+ // absent entirely on a server older than the field — both mean
495
+ // "unknown", and validContextWindow collapses them to undefined.
496
+ contextWindow: validContextWindow(m.contextLength),
497
+ }
498
+ : null,
499
+ )
336
500
  .filter((x): x is AccountModelInfo => !!x);
337
501
  // Cache only a real LIVE listing — never the fallback, which would freeze the six
338
502
  // seed ids on disk and read back as though it were the catalog. Both the cache and
339
503
  // the returned list are the server's UNFILTERED offer; servability is decided at
340
504
  // registration (accountProviderConfig), which re-evaluates it every time.
341
- if (parsed.length) saveCachedCatalogIds(parsed.map((p) => p.id));
505
+ if (parsed.length) saveCachedCatalog(parsed);
342
506
  infos = parsed.length ? parsed : fallback();
343
507
  }
344
508
  } catch {
345
509
  infos = fallback();
346
510
  }
347
511
  accountTierMap.clear();
348
- for (const info of infos) accountTierMap.set(info.id, info.tier);
512
+ accountWindowMap.clear();
513
+ for (const info of infos) {
514
+ accountTierMap.set(info.id, info.tier);
515
+ if (info.contextWindow !== undefined) accountWindowMap.set(info.id, info.contextWindow);
516
+ }
349
517
  return infos;
350
518
  }
351
519
 
@@ -126,6 +126,13 @@ export interface RelayCallbacks {
126
126
  // bypass mode instead of relaying every action, so the agent runs to completion.
127
127
  // Dangerous/destructive actions still relay — they sit above bypass locally too.
128
128
  onNoQuarter?: (on: boolean) => void;
129
+ // The app toggled the privacy filter. `off` true = pi-privacy is disabled for this
130
+ // session, exactly as `/privacy off` does it: no outbound PII scan or prompt, no
131
+ // tool-result credential redaction, no exfiltration or downgrade guard. The app is
132
+ // the only surface that can ask for this without a keyboard on this machine, so the
133
+ // frame carries the SAME state the command writes (process.env, shared with
134
+ // subagent children) rather than a second flag beside it.
135
+ onPrivacy?: (off: boolean) => void;
129
136
  // A controller attached — push a transcript snapshot so it can catch up.
130
137
  onControllerAttached: () => void;
131
138
  // The last controller went away (app closed / socket reaped). The terminal keeps
@@ -696,6 +703,7 @@ export class RelayClient {
696
703
  seq?: number;
697
704
  data?: string;
698
705
  on?: boolean;
706
+ off?: boolean;
699
707
  value?: string;
700
708
  source?: string;
701
709
  description?: string;
@@ -763,6 +771,9 @@ export class RelayClient {
763
771
  case "no_quarter":
764
772
  this.cb.onNoQuarter?.(frame.on === true);
765
773
  break;
774
+ case "privacy":
775
+ this.cb.onPrivacy?.(frame.off === true);
776
+ break;
766
777
  case "controller_attached":
767
778
  this.cb.onControllerAttached();
768
779
  break;
@@ -1158,6 +1169,15 @@ export class RelayClient {
1158
1169
  this.rawSend({ type: "no_quarter", on });
1159
1170
  }
1160
1171
 
1172
+ // Echo the privacy-filter state up to the controller, same contract as
1173
+ // sendNoQuarter: an ack of the app's toggle, and a resync on controller attach.
1174
+ // Only ever sent while the filter is OFF — the app defaults its switch to "on"
1175
+ // (the safe reading), so a terminal too old to know this frame, or one running
1176
+ // with the filter intact, correctly shows protected.
1177
+ sendPrivacy(off: boolean): void {
1178
+ this.rawSend({ type: "privacy", off });
1179
+ }
1180
+
1161
1181
  // Push this terminal's live context (selected model, agent version) to a
1162
1182
  // controller so the app's session banner reflects reality instead of a stub.
1163
1183
  // Sent on controller attach — like the snapshot/no_quarter resync. NON-PII ONLY
@@ -12,6 +12,9 @@
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
14
  import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts";
15
+ // Pure env-var accessors (see config/privacyDisabled.ts) — no Pi, no node builtins,
16
+ // so this stays safe to import from the bridge, which boot-ordered code reaches.
17
+ import { onPrivacyDisabledChange, privacyDisabled, setPrivacyDisabled } from "../config/privacyDisabled.ts";
15
18
  import type { EngineEvent } from "../engine/events.ts";
16
19
  import type { PermissionRequest } from "../permissions/gate.ts";
17
20
  import type { AskOutcome } from "../permissions/modeGate.ts";
@@ -56,6 +59,10 @@ export interface RelayLike {
56
59
  // turns into a duplicate of something the app already displayed.
57
60
  hasController?(): boolean;
58
61
  sendNoQuarter(on: boolean): void;
62
+ // Echo the privacy-filter state (off = pi-privacy disabled). Optional so a
63
+ // transport built against an older shape still satisfies the interface; the
64
+ // bridge's callers all treat a missing echo as "filter on", which is the safe read.
65
+ sendPrivacy?(off: boolean): void;
59
66
  sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
60
67
  sendNotice(text: string): void;
61
68
  sendCommands(commands: { name: string; description?: string }[]): void;
@@ -190,7 +197,25 @@ export class RemoteBridge {
190
197
  private turnPrompt = "";
191
198
  private turnText = "";
192
199
 
193
- constructor(private readonly cfg: RemoteBridgeConfig) {}
200
+ // Unsubscribes the privacy watcher below. Held so a bridge that outlives its window
201
+ // (the desktop builds one per window, in one process) stops writing to a transport
202
+ // whose renderer is gone.
203
+ private readonly unwatchPrivacy: () => void;
204
+
205
+ constructor(private readonly cfg: RemoteBridgeConfig) {
206
+ // The privacy switch has a second driver: `/privacy off` typed into the composer
207
+ // (or into the CLI beside a driving app). Watch the flag itself rather than only
208
+ // the frame, so the app's shield follows the terminal however it was moved — and
209
+ // on the desktop, where the flag is process-wide, so does every OTHER window's,
210
+ // which is the truth: they share the env var this writes.
211
+ this.unwatchPrivacy = onPrivacyDisabledChange((off) => this.relay?.sendPrivacy?.(off));
212
+ }
213
+
214
+ /** Release what outlives a socket. Safe to call twice. */
215
+ dispose(): void {
216
+ this.unwatchPrivacy();
217
+ this.relay = undefined;
218
+ }
194
219
 
195
220
  // Wire the outbound relay once it's constructed (RelayClient needs `callbacks`
196
221
  // at construction, so the relay is attached right after).
@@ -286,10 +311,29 @@ export class RemoteBridge {
286
311
  setNoQuarter(on);
287
312
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
288
313
  },
314
+ // The app's privacy switch. It writes the SAME state `/privacy off` writes — the
315
+ // env var every module copy and subagent child reads — so a session toggled from
316
+ // the app and one toggled from the keyboard are the same session, and the CLI's
317
+ // own `/privacy status` tells the truth about what the app did. Echoed back so
318
+ // the app's switch reflects what this terminal is actually doing rather than what
319
+ // it was asked to do.
320
+ onPrivacy: (off) => {
321
+ const changed = privacyDisabled() !== off;
322
+ setPrivacyDisabled(off);
323
+ // A real change is echoed by the watcher in the constructor (which also covers
324
+ // `/privacy` typed at a composer). Ack a no-op ask here so an app whose switch
325
+ // was already out of step still gets an answer rather than silence.
326
+ if (!changed) this.relay?.sendPrivacy?.(off);
327
+ },
289
328
  onControllerAttached: () => {
290
329
  if (this.noQuarter || noQuarterActive()) {
291
330
  this.relay?.sendNoQuarter(true);
292
331
  }
332
+ // Resync the privacy switch the same way, and for a sharper reason: the filter
333
+ // may have been turned off from the keyboard, or by a launch flag, or by an
334
+ // earlier session of this app — an attaching controller must not paint a shield
335
+ // over a terminal that has none. Sent only when OFF, so silence means protected.
336
+ if (privacyDisabled()) this.relay?.sendPrivacy?.(true);
293
337
  this.cfg.onControllerAttached?.();
294
338
  },
295
339
  // The app left while we're still running. Same posture as a dropped socket: stop
@@ -319,6 +363,9 @@ export class RemoteBridge {
319
363
  getRemote = (): boolean => this.remote;
320
364
  getNoQuarter = (): boolean => this.noQuarter || noQuarterActive();
321
365
 
366
+ /** True while the privacy filter is disabled for this session (`/privacy off`). */
367
+ getPrivacyDisabled = (): boolean => privacyDisabled();
368
+
322
369
  // The gate's remote approver: relay the request to the app and await its
323
370
  // allow/deny. Fail closed if no controller, on abort, or on disconnect. (The gate
324
371
  // also wraps this in its own timeout, so a silent app can't wedge the turn.)