privateer-agent 0.12.45 → 0.12.49
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/README.md +28 -1
- package/bin/headless-flags.d.mts +25 -0
- package/bin/headless-flags.mjs +166 -0
- package/bin/privateer-launch.mjs +93 -3
- package/bin/privateer-subagent.mjs +1 -0
- package/extensions/privateer-gate.ts +192 -5
- package/extensions/privateer-vision.ts +39 -0
- package/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.86.0.patch +162 -10
- package/src/acp/run.ts +3 -1
- package/src/bridge/engineAdapter.ts +1 -1
- package/src/cli/chat.ts +7 -3
- package/src/config/moat.ts +5 -0
- package/src/config/moatManifest.json +1 -0
- package/src/config/privacyDisabled.ts +6 -1
- package/src/config/privacyPolicy.ts +3 -1
- package/src/engine/errors.ts +61 -4
- package/src/ext/permissionGate.ts +23 -4
- package/src/permissions/cliSpend.ts +163 -0
- package/src/permissions/modeGate.ts +9 -3
- package/src/permissions/noQuarter.ts +21 -2
- package/src/providers/account.ts +89 -1
- package/src/providers/visionDelegate.ts +199 -0
- package/src/remote/headlessApproval.ts +146 -0
- package/src/tools/media.ts +151 -12
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Vision delegation: when the current model can't see images, a vision model on the
|
|
2
|
+
// same provider (same key) describes them, and the description is what gets sent.
|
|
3
|
+
// All the policy — which delegate, why never another provider, what gets rewritten —
|
|
4
|
+
// lives in src/providers/visionDelegate.ts; this file is only the Pi wiring.
|
|
5
|
+
//
|
|
6
|
+
// It hooks `context`, which runs before EVERY LLM call on a per-request copy of the
|
|
7
|
+
// messages, rather than `input`/`tool_result`. That catches images from every source
|
|
8
|
+
// (@file mentions, pasted images, `read`, media and computer tools, MCP results, and
|
|
9
|
+
// history from before a /model switch) while leaving the session's own copy intact.
|
|
10
|
+
// Descriptions are cached by image hash, so each image costs one delegate call per
|
|
11
|
+
// process, not one per turn.
|
|
12
|
+
|
|
13
|
+
import { describeImagesInMessages, pickVisionDelegate } from "../src/providers/visionDelegate.ts";
|
|
14
|
+
|
|
15
|
+
export default function privateerVision(pi: any): void {
|
|
16
|
+
const announced = new Set<string>();
|
|
17
|
+
|
|
18
|
+
pi.on("context", async (event: any, ctx: any) => {
|
|
19
|
+
const model = ctx?.model;
|
|
20
|
+
const registry = ctx?.modelRegistry;
|
|
21
|
+
if (!model || !registry) return;
|
|
22
|
+
const delegate = pickVisionDelegate(model, registry);
|
|
23
|
+
if (!delegate) return;
|
|
24
|
+
|
|
25
|
+
const currentSpec = `${model.provider}/${model.id}`;
|
|
26
|
+
const delegateSpec = `${delegate.provider}/${delegate.id}`;
|
|
27
|
+
const messages = await describeImagesInMessages(event.messages, delegate, registry, {
|
|
28
|
+
currentSpec,
|
|
29
|
+
signal: ctx.signal,
|
|
30
|
+
onDescribe: () => {
|
|
31
|
+
const key = `${currentSpec}→${delegateSpec}`;
|
|
32
|
+
if (announced.has(key) || !ctx.hasUI) return;
|
|
33
|
+
announced.add(key);
|
|
34
|
+
ctx.ui.notify(`${currentSpec} can't see images — ${delegateSpec} will describe them for it.`, "info");
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return messages ? { messages } : undefined;
|
|
38
|
+
});
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.49",
|
|
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",
|
|
@@ -1025,6 +1025,19 @@ index d101003..540b972 100644
|
|
|
1025
1025
|
return "project";
|
|
1026
1026
|
}
|
|
1027
1027
|
return "path";
|
|
1028
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js
|
|
1029
|
+
index 170a066..b535f72 100644
|
|
1030
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js
|
|
1031
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js
|
|
1032
|
+
@@ -8,7 +8,7 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|
1033
|
+
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
|
|
1034
|
+
{ name: "import", description: "Import and resume a session from a JSONL file" },
|
|
1035
|
+
{ name: "share", description: "Share session as a secret GitHub gist" },
|
|
1036
|
+
- { name: "bug", description: "Report a bug to the Pi developers", argumentHint: "<description>" },
|
|
1037
|
+
+ { name: "bug", description: "Export a bug report to email to Privateer support", argumentHint: "<description>" },
|
|
1038
|
+
{ name: "copy", description: "Copy last agent message to clipboard" },
|
|
1039
|
+
{ name: "name", description: "Set session display name" },
|
|
1040
|
+
{ name: "session", description: "Show session info and stats" },
|
|
1028
1041
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js
|
|
1029
1042
|
index 7241668..7b79305 100644
|
|
1030
1043
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/tools/output-accumulator.js
|
|
@@ -1292,6 +1305,123 @@ index c30d6b8..ad1654a 100644
|
|
|
1292
1305
|
];
|
|
1293
1306
|
return warnings;
|
|
1294
1307
|
}
|
|
1308
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts
|
|
1309
|
+
index 7eae594..b21fae3 100644
|
|
1310
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts
|
|
1311
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.d.ts
|
|
1312
|
+
@@ -8,7 +8,7 @@ interface BugReportContext {
|
|
1313
|
+
showStatus: (message: string) => void;
|
|
1314
|
+
showError: (message: string) => void;
|
|
1315
|
+
}
|
|
1316
|
+
-/** Run the `/bug` flow: consent, optional summary, then upload or export. */
|
|
1317
|
+
+/** Run the `/bug` flow: consent, optional summary, then export a zip to email to support. */
|
|
1318
|
+
export declare function reportBug(context: BugReportContext, initialHint?: string): Promise<void>;
|
|
1319
|
+
export {};
|
|
1320
|
+
//# sourceMappingURL=bug-report.d.ts.map
|
|
1321
|
+
|
|
1322
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js
|
|
1323
|
+
index 59ae447..68cf842 100644
|
|
1324
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js
|
|
1325
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/bug-report.js
|
|
1326
|
+
@@ -1,18 +1,21 @@
|
|
1327
|
+
import * as path from "node:path";
|
|
1328
|
+
-import { getAuthCredential } from "../../cli/auth-command.js";
|
|
1329
|
+
import { BUG_REPORT_CUSTOM_ENTRY_TYPE, bugReportArchiveFileName, collectBugReportDiagnostics, collectBugReportMetadata, writeBugReportArchive, } from "../../core/bug-report.js";
|
|
1330
|
+
-import { uploadBugReport } from "../../core/bug-report-upload.js";
|
|
1331
|
+
import { clearCrashLog, readCrashLog } from "../../core/crash-log.js";
|
|
1332
|
+
-import { getRadiusGatewayUrl, RADIUS_PROVIDER_ID } from "../../core/radius.js";
|
|
1333
|
+
import { serializeSessionBranch } from "../../core/session-export.js";
|
|
1334
|
+
import { BorderedLoader } from "./components/bordered-loader.js";
|
|
1335
|
+
import { ExtensionInputComponent } from "./components/extension-input.js";
|
|
1336
|
+
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
|
1337
|
+
import { createShareTrailingEntries } from "./session-share.js";
|
|
1338
|
+
import { theme } from "./theme/theme.js";
|
|
1339
|
+
-const DISCLAIMER = "This report goes to the Pi developers (Earendil) and is not shared publicly. It includes your pi version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session.";
|
|
1340
|
+
+// Privateer patch: stock Pi's `/bug` uploads this bundle straight to Earendil's Radius
|
|
1341
|
+
+// Gateway (session transcript, settings, environment) with no Privateer-controlled
|
|
1342
|
+
+// endpoint in between — not acceptable for a privacy-first tool. We keep the same
|
|
1343
|
+
+// consent/redaction flow but the only delivery is a local zip the user emails to us
|
|
1344
|
+
+// themselves, so nothing leaves the machine without them explicitly attaching it.
|
|
1345
|
+
+const SUPPORT_EMAIL = "support@privateer.pro";
|
|
1346
|
+
+const DISCLAIMER = `This report is written to a zip file on your machine only — nothing is uploaded automatically. It includes your Privateer version, operating system, the current model and provider configuration (without API keys), loaded extensions, settings, and provider error diagnostics from this session. Email the file to ${SUPPORT_EMAIL} to send it to us.`;
|
|
1347
|
+
const TRANSCRIPT_NOTE = "The transcript contains your messages, model output, tool calls and their results, including file contents and command output read during this session.";
|
|
1348
|
+
-/** Run the `/bug` flow: consent, optional summary, then upload or export. */
|
|
1349
|
+
+/** Run the `/bug` flow: consent, optional summary, then export a zip for the user to email us. */
|
|
1350
|
+
export async function reportBug(context, initialHint) {
|
|
1351
|
+
const options = await promptForOptions(context, initialHint);
|
|
1352
|
+
if (!options) {
|
|
1353
|
+
@@ -47,16 +50,6 @@ export async function reportBug(context, initialHint) {
|
|
1354
|
+
context.showError(`Failed to build bug report: ${errorMessage(error)}`);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
- if (options.delivery === "upload") {
|
|
1358
|
+
- const failure = await upload(context, bundle);
|
|
1359
|
+
- if (failure === undefined)
|
|
1360
|
+
- return;
|
|
1361
|
+
- const fallback = await choose(context, "Upload failed", ["Export as Zip", "Cancel"], `${failure}\n\nExport the report as a zip archive instead?`);
|
|
1362
|
+
- if (fallback !== "Export as Zip") {
|
|
1363
|
+
- context.showStatus("Bug report cancelled");
|
|
1364
|
+
- return;
|
|
1365
|
+
- }
|
|
1366
|
+
- }
|
|
1367
|
+
await exportZip(context, bundle);
|
|
1368
|
+
}
|
|
1369
|
+
async function promptForOptions(context, initialHint) {
|
|
1370
|
+
@@ -76,14 +69,13 @@ async function promptForOptions(context, initialHint) {
|
|
1371
|
+
includeSummary = summary !== "No";
|
|
1372
|
+
}
|
|
1373
|
+
const description = hint.trim();
|
|
1374
|
+
- const delivery = await choose(context, "Bug report", ["Upload Report", "Export as Zip", "Cancel"], `Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nUpload sends the report to ${new URL(getRadiusGatewayUrl()).host}. Export writes a zip archive to the current directory instead.`);
|
|
1375
|
+
- if (!delivery || delivery === "Cancel")
|
|
1376
|
+
+ const confirmed = await choose(context, "Bug report", ["Export Report", "Cancel"], `Description: ${description || "none"}\nTranscript: ${includeSession ? "included" : "not included"}\nSummary: ${includeSummary ? `written by ${context.session.model?.name ?? "the current model"}` : "none"}\n\nWrites a zip archive to the current directory. Email it to ${SUPPORT_EMAIL}.`);
|
|
1377
|
+
+ if (!confirmed || confirmed === "Cancel")
|
|
1378
|
+
return undefined;
|
|
1379
|
+
return {
|
|
1380
|
+
hint: description || undefined,
|
|
1381
|
+
includeSession,
|
|
1382
|
+
includeSummary,
|
|
1383
|
+
- delivery: delivery === "Upload Report" ? "upload" : "zip",
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
function buildBundle(session, options, summary) {
|
|
1387
|
+
@@ -111,28 +103,6 @@ function buildBundle(session, options, summary) {
|
|
1388
|
+
: undefined,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
-async function upload(context, bundle) {
|
|
1392
|
+
- const loader = showLoader(context, "Uploading bug report...");
|
|
1393
|
+
- try {
|
|
1394
|
+
- const provider = context.session.modelRuntime.getProvider(RADIUS_PROVIDER_ID);
|
|
1395
|
+
- const token = provider
|
|
1396
|
+
- ? getAuthCredential(await context.session.modelRuntime.getAuth(RADIUS_PROVIDER_ID, { minOAuthValidityMs: 5 * 60_000 }))
|
|
1397
|
+
- : undefined;
|
|
1398
|
+
- const result = await uploadBugReport(bundle, { token, signal: loader.signal });
|
|
1399
|
+
- restoreEditor(context, loader);
|
|
1400
|
+
- recordInSession(context.session, bundle, { delivery: "upload" });
|
|
1401
|
+
- context.showStatus(`Bug report uploaded. Report ID: ${result.id}`);
|
|
1402
|
+
- return undefined;
|
|
1403
|
+
- }
|
|
1404
|
+
- catch (error) {
|
|
1405
|
+
- restoreEditor(context, loader);
|
|
1406
|
+
- if (loader.signal.aborted) {
|
|
1407
|
+
- context.showStatus("Bug report cancelled");
|
|
1408
|
+
- return undefined;
|
|
1409
|
+
- }
|
|
1410
|
+
- return errorMessage(error);
|
|
1411
|
+
- }
|
|
1412
|
+
-}
|
|
1413
|
+
async function exportZip(context, bundle) {
|
|
1414
|
+
const archivePath = path.join(process.cwd(), bugReportArchiveFileName(bundle.metadata.id));
|
|
1415
|
+
try {
|
|
1416
|
+
@@ -143,7 +113,7 @@ async function exportZip(context, bundle) {
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
recordInSession(context.session, bundle, { delivery: "zip", path: archivePath });
|
|
1420
|
+
- context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}`);
|
|
1421
|
+
+ context.showStatus(`Bug report exported to: ${archivePath}\nReport ID: ${bundle.metadata.id}\n\nEmail this file to ${SUPPORT_EMAIL} to reach support.`);
|
|
1422
|
+
}
|
|
1423
|
+
function recordInSession(session, bundle, delivery) {
|
|
1424
|
+
session.sessionManager.appendCustomEntry(BUG_REPORT_CUSTOM_ENTRY_TYPE, {
|
|
1295
1425
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js
|
|
1296
1426
|
index 6f7ed6a..b0ed0ae 100644
|
|
1297
1427
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/config-selector.js
|
|
@@ -1694,7 +1824,7 @@ index 6d0356c..ca0aa98 100644
|
|
|
1694
1824
|
if (content) {
|
|
1695
1825
|
text += `\n\n${content}`;
|
|
1696
1826
|
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
|
|
1697
|
-
index d82182c..
|
|
1827
|
+
index d82182c..08bec2d 100644
|
|
1698
1828
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1699
1829
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1700
1830
|
@@ -10,7 +10,7 @@ import * as TuiLayouts from "@earendil-works/pi-tui";
|
|
@@ -1720,7 +1850,7 @@ index d82182c..41426bc 100644
|
|
|
1720
1850
|
if (!sessionManager.usesDefaultSessionDir()) {
|
|
1721
1851
|
args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir()));
|
|
1722
1852
|
}
|
|
1723
|
-
@@ -192,6 +197,
|
|
1853
|
+
@@ -192,6 +197,77 @@ function formatLoginProviderCompletionDescription(provider) {
|
|
1724
1854
|
const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");
|
|
1725
1855
|
return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
|
|
1726
1856
|
}
|
|
@@ -1763,6 +1893,13 @@ index d82182c..41426bc 100644
|
|
|
1763
1893
|
+ message: "The provider stopped responding — the connection went idle.",
|
|
1764
1894
|
+ 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).",
|
|
1765
1895
|
+ };
|
|
1896
|
+
+ // Privateer patch: the OpenAI SDK's whole message for a request that never got
|
|
1897
|
+
+ // a response. Mirrors isConnectionFailureText in src/engine/errors.ts.
|
|
1898
|
+
+ if (/^\s*(?:connection error\.?|fetch failed|other side closed|socket hang up|(?:read |connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b)/i.test(s))
|
|
1899
|
+
+ return {
|
|
1900
|
+
+ message: `Couldn't reach the model provider — the request got no response ("${s.trim().slice(0, 80)}").`,
|
|
1901
|
+
+ hint: "Check your connection and send it again. If every message fails while a fresh `privateer` works, start a new session with /new.",
|
|
1902
|
+
+ };
|
|
1766
1903
|
+ return null;
|
|
1767
1904
|
+ }
|
|
1768
1905
|
+ if (status === 429) {
|
|
@@ -1775,7 +1912,13 @@ index d82182c..41426bc 100644
|
|
|
1775
1912
|
+ };
|
|
1776
1913
|
+ }
|
|
1777
1914
|
+ if (status === 401 || status === 403)
|
|
1778
|
-
+ return {
|
|
1915
|
+
+ return {
|
|
1916
|
+
+ // "401 status code (no body)" names neither what was refused nor what to do.
|
|
1917
|
+
+ message: /\(no body\)\s*$/i.test(s)
|
|
1918
|
+
+ ? `The model provider ${status === 401 ? "rejected the credential" : "refused access"} this request was sent with (${status}).`
|
|
1919
|
+
+ : s,
|
|
1920
|
+
+ hint: "Check your credentials — run /login to re-authenticate.",
|
|
1921
|
+
+ };
|
|
1779
1922
|
+ if (status === 404)
|
|
1780
1923
|
+ return { message: s, hint: "Check the model id — run /model to switch." };
|
|
1781
1924
|
+ if (status >= 500)
|
|
@@ -1785,7 +1928,7 @@ index d82182c..41426bc 100644
|
|
|
1785
1928
|
export class InteractiveMode {
|
|
1786
1929
|
runtimeHost;
|
|
1787
1930
|
renderer;
|
|
1788
|
-
@@ -404,9 +
|
|
1931
|
+
@@ -404,9 +480,15 @@ export class InteractiveMode {
|
|
1789
1932
|
}
|
|
1790
1933
|
getBuiltInCommandConflictDiagnostics(extensionRunner) {
|
|
1791
1934
|
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
|
|
@@ -1802,7 +1945,7 @@ index d82182c..41426bc 100644
|
|
|
1802
1945
|
.map((command) => ({
|
|
1803
1946
|
type: "warning",
|
|
1804
1947
|
message: command.invocationName === command.name
|
|
1805
|
-
@@ -775,20 +
|
|
1948
|
+
@@ -775,20 +857,18 @@ export class InteractiveMode {
|
|
1806
1949
|
this.showNewVersionNotification(newRelease);
|
|
1807
1950
|
}
|
|
1808
1951
|
});
|
|
@@ -1835,7 +1978,16 @@ index d82182c..41426bc 100644
|
|
|
1835
1978
|
// Check tmux keyboard setup asynchronously
|
|
1836
1979
|
this.checkTmuxKeyboardSetup().then((warning) => {
|
|
1837
1980
|
if (warning) {
|
|
1838
|
-
@@ -
|
|
1981
|
+
@@ -1568,7 +1648,7 @@ export class InteractiveMode {
|
|
1982
|
+
if (this.bugReportHintShown)
|
|
1983
|
+
return;
|
|
1984
|
+
this.bugReportHintShown = true;
|
|
1985
|
+
- this.chatContainer.addChild(new Text(theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug sends a report to the developers.`), this.outputPad, 0));
|
|
1986
|
+
+ this.chatContainer.addChild(new Text(theme.fg("muted", `If this looks like a ${APP_NAME} bug, /bug exports a report you can email to support@privateer.pro.`), this.outputPad, 0));
|
|
1987
|
+
this.ui.requestRender();
|
|
1988
|
+
}
|
|
1989
|
+
renderCurrentSessionState() {
|
|
1990
|
+
@@ -2419,7 +2499,17 @@ export class InteractiveMode {
|
|
1839
1991
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
1840
1992
|
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
|
1841
1993
|
this.editor.setText("");
|
|
@@ -1854,7 +2006,7 @@ index d82182c..41426bc 100644
|
|
|
1854
2006
|
return;
|
|
1855
2007
|
}
|
|
1856
2008
|
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
|
1857
|
-
@@ -2497,12 +
|
|
2009
|
+
@@ -2497,12 +2587,42 @@ export class InteractiveMode {
|
|
1858
2010
|
if (text === "/login" || text.startsWith("/login ")) {
|
|
1859
2011
|
const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined;
|
|
1860
2012
|
this.editor.setText("");
|
|
@@ -1899,7 +2051,7 @@ index d82182c..41426bc 100644
|
|
|
1899
2051
|
return;
|
|
1900
2052
|
}
|
|
1901
2053
|
if (text === "/new") {
|
|
1902
|
-
@@ -2827,9 +
|
|
2054
|
+
@@ -2827,9 +2947,19 @@ export class InteractiveMode {
|
|
1903
2055
|
if (entries[0]?.type !== "compaction") {
|
|
1904
2056
|
throw new Error("Completed compaction is missing from the session context");
|
|
1905
2057
|
}
|
|
@@ -1922,7 +2074,7 @@ index d82182c..41426bc 100644
|
|
|
1922
2074
|
this.addMessageToChat(createCompactionSummaryMessage(event.result.summary, event.result.tokensBefore, new Date().toISOString()));
|
|
1923
2075
|
if (event.result.usage) {
|
|
1924
2076
|
this.addCompactionCostNotice({
|
|
1925
|
-
@@ -3253,7 +
|
|
2077
|
+
@@ -3253,7 +3383,7 @@ export class InteractiveMode {
|
|
1926
2078
|
if (this.chatContainer.children.length > 0) {
|
|
1927
2079
|
this.chatContainer.addChild(new Spacer(1));
|
|
1928
2080
|
}
|
|
@@ -1931,7 +2083,7 @@ index d82182c..41426bc 100644
|
|
|
1931
2083
|
}
|
|
1932
2084
|
async getUserInput() {
|
|
1933
2085
|
const queuedInput = this.pendingUserInputs.shift();
|
|
1934
|
-
@@ -3597,7 +
|
|
2086
|
+
@@ -3597,7 +3727,17 @@ export class InteractiveMode {
|
|
1935
2087
|
}
|
|
1936
2088
|
showError(errorMessage) {
|
|
1937
2089
|
this.chatContainer.addChild(new Spacer(1));
|
package/src/acp/run.ts
CHANGED
|
@@ -304,7 +304,9 @@ export async function runAcp(): Promise<void> {
|
|
|
304
304
|
session.subscribe((ev: any) => {
|
|
305
305
|
for (const ee of adapter.toEngineEvents(ev)) {
|
|
306
306
|
if (ee.type === "text") holder.events.onText(ee.text);
|
|
307
|
-
|
|
307
|
+
// With the hint: the host shows this string and nothing else, and "Connection
|
|
308
|
+
// error." alone says neither what failed nor what to do (engine/errors.ts).
|
|
309
|
+
else if (ee.type === "error") holder.error = (ee as { hint?: string }).hint ? `${ee.error} ${(ee as { hint?: string }).hint}` : ee.error;
|
|
308
310
|
// Tool activity goes BOTH to the host (rendered as live progress — without
|
|
309
311
|
// it a minute of tool work looks like a hang) and to stderr, where it is
|
|
310
312
|
// the only way to tell a gate denial apart from the model simply choosing
|
|
@@ -145,7 +145,7 @@ export function createEngineEventAdapter() {
|
|
|
145
145
|
// until retries/overflow recovery are exhausted, but never hide it.
|
|
146
146
|
if (msg?.stopReason === "error") {
|
|
147
147
|
const raw = typeof msg.errorMessage === "string" ? msg.errorMessage : "";
|
|
148
|
-
const described = describeErrorText(raw);
|
|
148
|
+
const described = describeErrorText(raw, { provider: typeof msg.provider === "string" ? msg.provider : undefined });
|
|
149
149
|
return [{
|
|
150
150
|
type: "error",
|
|
151
151
|
error: described?.message ?? redactText(raw || "The model call failed."),
|
package/src/cli/chat.ts
CHANGED
|
@@ -12,6 +12,7 @@ import "../boot.ts"; // env + attestation dispatcher, before any Pi import
|
|
|
12
12
|
import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
|
|
13
13
|
import { cliPalette } from "../ui/palette.ts"; // no Pi deps → safe pre-boot
|
|
14
14
|
import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts"; // no Pi deps → safe pre-boot
|
|
15
|
+
import { privacyDisabled } from "../config/privacyDisabled.ts"; // no Pi deps → safe pre-boot
|
|
15
16
|
import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
|
|
16
17
|
import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
|
|
17
18
|
import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts"; // node:child_process only → safe pre-boot
|
|
@@ -419,14 +420,17 @@ async function main() {
|
|
|
419
420
|
// same key; takes effect from the next gated action, so an approval already on
|
|
420
421
|
// screen still needs an answer.
|
|
421
422
|
function applyNoQuarter(on: boolean): void {
|
|
422
|
-
|
|
423
|
+
const privacyWasOff = privacyDisabled();
|
|
424
|
+
setNoQuarter(on); // also `/privacy off` on the way down (src/permissions/noQuarter.ts)
|
|
425
|
+
const privacyMoved = privacyDisabled() !== privacyWasOff;
|
|
423
426
|
flushOut(); // land streamed output above the notice
|
|
424
427
|
console.log(
|
|
425
428
|
on
|
|
426
429
|
? `\n${RED}⚑ No quarter — the permission gate is OFF for this session.${RESET}\n` +
|
|
427
430
|
`${DIM} Every action (shell, edits, destructive tools, out-of-cwd, protected files) runs without asking.\n` +
|
|
431
|
+
(privacyMoved ? ` The privacy filter is off too: outbound requests are not scanned for PII.\n` : "") +
|
|
428
432
|
` shift+tab (or /no-quarter off) raises the moat again.${RESET}`
|
|
429
|
-
: `\n${GREEN}⚓ Moat raised — the permission gate is back on.${RESET}`,
|
|
433
|
+
: `\n${GREEN}⚓ Moat raised — the permission gate is back on${privacyMoved ? ", and so is the privacy filter" : ""}.${RESET}`,
|
|
430
434
|
);
|
|
431
435
|
}
|
|
432
436
|
|
|
@@ -449,7 +453,7 @@ async function main() {
|
|
|
449
453
|
// Shown once per distinct non-vision spec (visionWarningAcknowledged), not on every
|
|
450
454
|
// launch — a user who keeps a deliberate non-vision pick already knows the tradeoff.
|
|
451
455
|
if (!acceptsImages(spec) && !visionWarningAcknowledged(spec)) {
|
|
452
|
-
console.log(`${YELLOW}⚠ ${provider}/${modelId} can't see images —
|
|
456
|
+
console.log(`${YELLOW}⚠ ${provider}/${modelId} can't see images — a vision model on the same key will describe them for it (none available → they're dropped). Run /model to switch.${RESET}`);
|
|
453
457
|
acknowledgeVisionWarning(spec);
|
|
454
458
|
}
|
|
455
459
|
if (noQuarterActive()) applyNoQuarter(true); // launched with --no-quarter: say so up front
|
package/src/config/moat.ts
CHANGED
|
@@ -284,6 +284,11 @@ export async function buildMoat(opts: MoatOptions): Promise<ExtensionFactory[]>
|
|
|
284
284
|
}
|
|
285
285
|
factories.push(makeAccountProvider()); // must follow pi-privacy — see header
|
|
286
286
|
|
|
287
|
+
// Every kind: a text-only model gets image descriptions from a vision model on the
|
|
288
|
+
// same key instead of silently losing the image. See providers/visionDelegate.ts.
|
|
289
|
+
const { default: privateerVision } = await import("../../extensions/privateer-vision.ts");
|
|
290
|
+
factories.push(privateerVision);
|
|
291
|
+
|
|
287
292
|
if (caps.context) {
|
|
288
293
|
const { default: privateerContext } = await import("../../extensions/privateer-context.ts");
|
|
289
294
|
factories.push(privateerContext);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
{ "entry": "extensions/privateer-tools.ts", "name": "privateer-tools", "note": "Privateer tool pack" },
|
|
17
17
|
{ "entry": "extensions/privateer-privacy.ts", "name": "privateer-privacy", "note": "pi-privacy + account tier resolver" },
|
|
18
18
|
{ "entry": "extensions/privateer-connect.ts", "name": "privateer-connect", "note": "/connect — MCP connector manager" },
|
|
19
|
+
{ "entry": "extensions/privateer-vision.ts", "name": "privateer-vision", "note": "a vision model on the same key describes images for a text-only model" },
|
|
19
20
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
20
21
|
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
21
22
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
const ENV = "PRIVATEER_PRIVACY_OFF";
|
|
20
20
|
const ALT_ENV = "PI_PRIVACY_OFF";
|
|
21
21
|
|
|
22
|
+
/** Set while the filter is off BECAUSE no quarter took it down (src/permissions/noQuarter.ts). */
|
|
23
|
+
export const NO_QUARTER_PRIVACY_MARK = "PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER";
|
|
24
|
+
|
|
22
25
|
/** True while pi-privacy is completely disabled for this session. Read live, never cached. */
|
|
23
26
|
export function privacyDisabled(): boolean {
|
|
24
27
|
return process.env[ENV] === "1" || process.env[ALT_ENV] === "1";
|
|
@@ -41,10 +44,12 @@ export function onPrivacyDisabledChange(fn: (disabled: boolean) => void): () =>
|
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Set the disabled state. Modifies process.env so every module copy and child process agrees.
|
|
44
|
-
* Returns the new state.
|
|
47
|
+
* Returns the new state. Clears the no-quarter mark: an explicit set is the operator's call,
|
|
48
|
+
* so raising the moat later won't flip it back (noQuarter.ts re-sets the mark after its own call).
|
|
45
49
|
*/
|
|
46
50
|
export function setPrivacyDisabled(disabled: boolean): boolean {
|
|
47
51
|
const before = privacyDisabled();
|
|
52
|
+
delete process.env[NO_QUARTER_PRIVACY_MARK];
|
|
48
53
|
if (disabled) {
|
|
49
54
|
process.env[ENV] = "1";
|
|
50
55
|
process.env[ALT_ENV] = "1";
|
|
@@ -89,7 +89,9 @@ export function sharedPrivacyOptions() {
|
|
|
89
89
|
// ZDR-channel models stay at their honest floor. The live verdict still comes from
|
|
90
90
|
// resolveTier above on select — this only lifts the label.
|
|
91
91
|
privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
92
|
-
// No quarter = unattended.
|
|
92
|
+
// No quarter = unattended. No quarter also takes the whole filter down (noQuarter.ts),
|
|
93
|
+
// so this only answers when the operator has run `/privacy on` with the moat down.
|
|
94
|
+
// The PII send-or-redact question would stall a session the
|
|
93
95
|
// operator explicitly stepped away from, so pi-privacy swallows it the SAFE way —
|
|
94
96
|
// redact, then send — and reports what it masked as output instead of asking. A live
|
|
95
97
|
// function, not a boolean: shift+tab / `/no-quarter` flips this mid-session and the
|
package/src/engine/errors.ts
CHANGED
|
@@ -359,8 +359,11 @@ export function describeError(err: unknown): DescribedError {
|
|
|
359
359
|
});
|
|
360
360
|
}
|
|
361
361
|
if (status === 429) {
|
|
362
|
+
// Never name the upstream provider here — OpenRouter is an implementation
|
|
363
|
+
// detail behind the Privateer account channel, and a plain "rate limited"
|
|
364
|
+
// is all a user needs to know regardless of who's actually throttling us.
|
|
362
365
|
return out({
|
|
363
|
-
message: `Rate limited
|
|
366
|
+
message: `Rate limited (429).`,
|
|
364
367
|
hint: "Wait a moment and try again.",
|
|
365
368
|
retryable: true,
|
|
366
369
|
});
|
|
@@ -502,6 +505,36 @@ export function retryDelayMs(
|
|
|
502
505
|
return Math.round(backoff * (1 - rand() * 0.25));
|
|
503
506
|
}
|
|
504
507
|
|
|
508
|
+
/** What the caller of describeErrorText knows about the failing request, if anything. */
|
|
509
|
+
export interface ErrorTextContext {
|
|
510
|
+
/** Pi's provider id for the model that failed (e.g. "privateer", "openrouter"). */
|
|
511
|
+
provider?: string;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const PROVIDER_LABELS: Record<string, string> = {
|
|
515
|
+
privateer: "Privateer",
|
|
516
|
+
anthropic: "Anthropic",
|
|
517
|
+
openai: "OpenAI",
|
|
518
|
+
openrouter: "OpenRouter",
|
|
519
|
+
google: "Google",
|
|
520
|
+
tinfoil: "Tinfoil",
|
|
521
|
+
ollama: "Ollama",
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
function providerLabel(provider: string | undefined): string {
|
|
525
|
+
if (!provider) return "the model provider";
|
|
526
|
+
return PROVIDER_LABELS[provider] ?? provider;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A request that never got an HTTP response, in the words the SDKs actually use:
|
|
530
|
+
// OpenAI's APIConnectionError is the bare "Connection error.", undici's is "fetch
|
|
531
|
+
// failed" / "other side closed", Node's are the errno names.
|
|
532
|
+
const CONNECTION_FAILURE = /^\s*(?:connection error\.?|fetch failed|other side closed|socket hang up|(?:read |connect )?(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b)/i;
|
|
533
|
+
|
|
534
|
+
export function isConnectionFailureText(text: string | null | undefined): boolean {
|
|
535
|
+
return CONNECTION_FAILURE.test(typeof text === "string" ? text : "");
|
|
536
|
+
}
|
|
537
|
+
|
|
505
538
|
/**
|
|
506
539
|
* Describe an error we only have the TEXT of, for the one place that has nothing else.
|
|
507
540
|
*
|
|
@@ -512,9 +545,14 @@ export function retryDelayMs(
|
|
|
512
545
|
* and say the same thing `describeError` would. Returns null when there is no leading
|
|
513
546
|
* status to read, so ordinary messages print unchanged.
|
|
514
547
|
*/
|
|
515
|
-
export function describeErrorText(
|
|
548
|
+
export function describeErrorText(
|
|
549
|
+
text: string | null | undefined,
|
|
550
|
+
context: ErrorTextContext = {},
|
|
551
|
+
): DescribedError | null {
|
|
516
552
|
const s = typeof text === "string" ? text : "";
|
|
517
553
|
const status = Number(/^\s*(\d{3})\b/.exec(s)?.[1] ?? NaN);
|
|
554
|
+
const account = context.provider === "privateer";
|
|
555
|
+
const who = providerLabel(context.provider);
|
|
518
556
|
if (!Number.isFinite(status)) {
|
|
519
557
|
// No leading status: the one message we can still describe with certainty is a
|
|
520
558
|
// transport idle timeout, which undici names exactly. Everything else is printed
|
|
@@ -525,6 +563,17 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
|
|
|
525
563
|
hint: IDLE_TIMEOUT_DESCRIPTION.hint,
|
|
526
564
|
};
|
|
527
565
|
}
|
|
566
|
+
// The OpenAI SDK's whole message for a request that never got a response. It
|
|
567
|
+
// says nothing about WHERE, so name the endpoint and the next step.
|
|
568
|
+
if (isConnectionFailureText(s)) {
|
|
569
|
+
return {
|
|
570
|
+
message: redactText(`Couldn't reach ${who} — the request got no response ("${s.trim().slice(0, 80)}").`),
|
|
571
|
+
hint: account
|
|
572
|
+
? "Check this machine's connection. If it started around a sign-in, the session reconnects on your next message; if every message fails while a fresh `privateer` works, start a new session with /new."
|
|
573
|
+
: "Check your connection and the provider's base URL, then send it again — or run /model to switch providers.",
|
|
574
|
+
retryable: true,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
528
577
|
return null;
|
|
529
578
|
}
|
|
530
579
|
|
|
@@ -539,9 +588,17 @@ export function describeErrorText(text: string | null | undefined): DescribedErr
|
|
|
539
588
|
};
|
|
540
589
|
}
|
|
541
590
|
if (status === 401 || status === 403) {
|
|
591
|
+
// "401 status code (no body)" names neither what was refused nor what to do. The
|
|
592
|
+
// status is enough to say both.
|
|
593
|
+
const bare = /\(no body\)\s*$/i.test(s) || s.trim() === String(status);
|
|
594
|
+
const refused = status === 401 ? "rejected the credential" : "refused access";
|
|
542
595
|
return {
|
|
543
|
-
message: redactText(
|
|
544
|
-
|
|
596
|
+
message: redactText(
|
|
597
|
+
bare ? `${who} ${refused} this request was sent with (${status}).` : compactProviderError(s),
|
|
598
|
+
),
|
|
599
|
+
hint: account
|
|
600
|
+
? "This terminal's Privateer session was refused. It is renewed automatically on your next message; if it keeps failing, run /login (or `privateer auth status` to see whether this machine is signed in)."
|
|
601
|
+
: `Check the API key for ${who} — run /login, or set the provider's API key env var.`,
|
|
545
602
|
};
|
|
546
603
|
}
|
|
547
604
|
if (status === 404) {
|
|
@@ -57,7 +57,13 @@ export interface GateController {
|
|
|
57
57
|
// session can spend what it was told it may spend instead of denying every media
|
|
58
58
|
// call for want of a human. Lifts `alwaysAsk` and nothing else — see
|
|
59
59
|
// ModeGate.isSpendPreauthorized for the guards.
|
|
60
|
-
|
|
60
|
+
// `input` and `signal` are the call's own, for a grant that prices the call before
|
|
61
|
+
// answering (the `--max-spend` ledger, permissions/cliSpend.ts).
|
|
62
|
+
isSpendPreauthorized?(req: PermissionRequest, input?: unknown, signal?: AbortSignal): boolean | Promise<boolean>;
|
|
63
|
+
// Extra words for the model when the gate denies `req` — WHY, and what would allow it
|
|
64
|
+
// (e.g. a headless run explaining --allow-spend). Appended to the denial; absent ⇒ the
|
|
65
|
+
// generic denial text alone.
|
|
66
|
+
explainDenial?(req: PermissionRequest): string | undefined;
|
|
61
67
|
// Block a tool outright while the turn is remote-driven (only consulted when
|
|
62
68
|
// getRemote() is true). For tools whose own prompts render on the host terminal
|
|
63
69
|
// rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
|
|
@@ -174,9 +180,22 @@ export async function decideToolCall(
|
|
|
174
180
|
getNoQuarter: ctrl.getNoQuarter,
|
|
175
181
|
getAutoApprove: ctrl.getAutoApprove,
|
|
176
182
|
getSkipAllPermissions: ctrl.getSkipAllPermissions,
|
|
177
|
-
isSpendPreauthorized: ctrl.isSpendPreauthorized
|
|
183
|
+
isSpendPreauthorized: ctrl.isSpendPreauthorized
|
|
184
|
+
? (r: PermissionRequest) => ctrl.isSpendPreauthorized!(r, input, ctx.signal)
|
|
185
|
+
: undefined,
|
|
178
186
|
});
|
|
179
187
|
|
|
188
|
+
// Why, when there is more to say than "denied" — read at denial time, so it reflects
|
|
189
|
+
// what the ask learned (a spend ledger's refusal reason, say).
|
|
190
|
+
const why = (): string => {
|
|
191
|
+
try {
|
|
192
|
+
const extra = ctrl.explainDenial?.(req);
|
|
193
|
+
return extra ? ` ${extra}` : "";
|
|
194
|
+
} catch {
|
|
195
|
+
return "";
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
180
199
|
let decision: "allow" | "deny";
|
|
181
200
|
try {
|
|
182
201
|
decision = await withTimeout(gate.request(req), ctrl.approvalTimeoutMs, ctx.signal);
|
|
@@ -186,13 +205,13 @@ export async function decideToolCall(
|
|
|
186
205
|
// tell the model to stop retrying and take a different path (or ask the user).
|
|
187
206
|
return {
|
|
188
207
|
block: true,
|
|
189
|
-
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it
|
|
208
|
+
reason: `Approval unavailable (${(err as Error)?.message ?? "error"}) — blocked by default. Do not retry the same command; it will be blocked again. Try a different approach or ask the user to run it.${why()}`,
|
|
190
209
|
};
|
|
191
210
|
}
|
|
192
211
|
if (decision === "deny") {
|
|
193
212
|
return {
|
|
194
213
|
block: true,
|
|
195
|
-
reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves
|
|
214
|
+
reason: `${req.title} was denied by the permission gate. Do not retry the same command; it will be denied again. Take a different approach or ask the user to run it themselves.${why()}`,
|
|
196
215
|
};
|
|
197
216
|
}
|
|
198
217
|
return undefined;
|