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.
- package/bin/privateer-launch.mjs +20 -0
- package/extensions/privateer-posture.ts +47 -3
- package/package.json +1 -1
- package/patches/@earendil-works+pi-ai+0.84.4.patch +46 -0
- package/patches/@earendil-works+pi-coding-agent+0.84.4.patch +71 -20
- package/src/config/privacyDisabled.ts +66 -0
- package/src/config/privacyPolicy.ts +135 -40
- package/src/engine/contextBudget.ts +74 -0
- package/src/engine/errors.ts +65 -2
- package/src/providers/account.ts +188 -20
- package/src/remote/relayClient.ts +20 -0
- package/src/remote/remoteBridge.ts +48 -1
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
54
|
-
|
|
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.
|
|
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..
|
|
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,
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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..
|
|
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..
|
|
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,
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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
|
-
@@ -
|
|
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 +
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
154
|
-
"
|
|
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
|
+
}
|
package/src/engine/errors.ts
CHANGED
|
@@ -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))
|
|
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)),
|
package/src/providers/account.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.)
|