privateer-agent 0.12.47 → 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 CHANGED
@@ -507,6 +507,30 @@ Honest caveat for Buzz: **Buzz currently auto-approves permission prompts**, so
507
507
  the tool ceiling *is* the control — which is exactly why the default is read-only. Full
508
508
  setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
509
509
 
510
+ Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
511
+ every approval arrives as a `session/request_permission` call it answers. The wire format,
512
+ option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
513
+
514
+ ### One-shot runs (`-p`) and spending
515
+
516
+ A `privateer -p "…"` run has no screen, so anything that needs approval is **denied**. That
517
+ includes every billed media tool, and the run says so when it starts. Two flags change it,
518
+ and both apply to that one invocation only. Nothing is saved, and an exported environment
519
+ variable can't turn either on.
520
+
521
+ ```bash
522
+ # pre-approve named billed tools, capped by calls and/or estimated dollars
523
+ privateer -p --allow-spend generate_video --max-calls 1 --max-spend 1.00 "make a 6s intro clip"
524
+
525
+ # or ask on your phone: approvals go to the Privateer app, denied if unanswered in time
526
+ privateer -p --approve-in-app --approval-timeout 300 "make a 6s intro clip"
527
+ ```
528
+
529
+ `--max-spend` is checked before each call against the server's own estimate for that exact
530
+ call. `media_capabilities` shows the same prices. A call that can't be priced is refused
531
+ under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
532
+ open or running in the background.
533
+
510
534
  ## Connectors — MCP
511
535
 
512
536
  Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
@@ -672,7 +696,10 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
672
696
 
673
697
  Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
674
698
  `privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
675
- [`docs/acp.md`](docs/acp.md)), `privateer --no-quarter`, `privateer --version`.
699
+ [`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
700
+ `privateer -p … [--allow-spend …] [--approve-in-app]` (see
701
+ [one-shot runs](#one-shot-runs--p-and-spending)), `privateer --no-quarter`, `privateer --version`.
702
+ An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
676
703
 
677
704
  ## Develop
678
705
 
@@ -0,0 +1,25 @@
1
+ // Types for bin/headless-flags.mjs. Same reason as update-route.d.mts: the
2
+ // implementation is plain .mjs because bin/ runs under a bare `node`, but the tests
3
+ // that pin the grammar are TypeScript.
4
+
5
+ export const BILLED_TOOLS: string[];
6
+ export const CLI_SPEND_ENV: string;
7
+ export const APPROVE_IN_APP_ENV: string;
8
+ export const PI_SUBCOMMANDS: string[];
9
+
10
+ export interface CliSpendGrant {
11
+ tools: string[];
12
+ maxCalls?: number;
13
+ maxSpendUsd?: number;
14
+ }
15
+
16
+ export function isHeadlessRun(args: string[]): boolean;
17
+
18
+ /** Strips the headless flags out of `args` in place. */
19
+ export function extractHeadlessFlags(args: string[]): {
20
+ spend?: CliSpendGrant;
21
+ approveInAppMs?: number;
22
+ error?: string;
23
+ };
24
+
25
+ export function authProblem(args: string[], cmd?: string): string | null;
@@ -0,0 +1,166 @@
1
+ // The launcher's argv rules for HEADLESS runs and for Pi's own subcommands.
2
+ //
3
+ // Plain .mjs, like update-route.mjs, because bin/ runs under a bare `node` before any
4
+ // transpiler exists — and separate from the launcher because the launcher runs on
5
+ // import, so nothing inside it can be tested. See tests/headlessFlags.test.ts.
6
+
7
+ /** Every tool that bills the account — mirrors BILLED_MEDIA_TOOLS in src/permissions/classify.ts. */
8
+ export const BILLED_TOOLS = [
9
+ "generate_image",
10
+ "generate_video",
11
+ "generate_model",
12
+ "generate_sprite",
13
+ "generate_speech",
14
+ "generate_music",
15
+ "generate_sfx",
16
+ ];
17
+
18
+ /** The env var carrying a `--allow-spend` grant from the launcher to the gate. */
19
+ export const CLI_SPEND_ENV = "PRIVATEER_CLI_SPEND";
20
+ /** The env var carrying `--approve-in-app` (its value is the timeout in ms). */
21
+ export const APPROVE_IN_APP_ENV = "PRIVATEER_APPROVE_IN_APP";
22
+
23
+ const DEFAULT_APPROVAL_TIMEOUT_SEC = 300;
24
+
25
+ /** True when these args ask Pi for a one-shot, no-screen run. */
26
+ export function isHeadlessRun(args) {
27
+ for (let i = 0; i < args.length; i++) {
28
+ const a = args[i];
29
+ if (a === "--") break;
30
+ if (a === "-p" || a === "--print") return true;
31
+ if (a === "--mode" && args[i + 1] === "json") return true;
32
+ if (a === "--mode=json") return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function toolName(raw) {
38
+ const t = raw.trim();
39
+ if (!t) return null;
40
+ // `video` is accepted for `generate_video`: it is what a person types, and it is
41
+ // unambiguous. Anything else must be one of the billed tools, spelled out.
42
+ const full = t.startsWith("generate_") ? t : `generate_${t}`;
43
+ return BILLED_TOOLS.includes(full) ? full : null;
44
+ }
45
+
46
+ /**
47
+ * Pull the headless-run flags out of `args` (in place) and validate them together.
48
+ *
49
+ * --allow-spend <tool[,tool…]> pre-approve these billed tools for THIS run
50
+ * --max-calls <n> …at most n billed calls in total
51
+ * --max-spend <usd> …at most this much, estimated before each call
52
+ * --approve-in-app send approvals this run can't answer to the app
53
+ * --approval-timeout <seconds> how long to wait for the app (default 300)
54
+ *
55
+ * Returns `{ spend, approveInAppMs, error }`. `error` is a message for the user and
56
+ * means nothing else should be trusted. A spend grant must be capped (calls, dollars
57
+ * or both) and only exists for `-p` / `--mode json` runs — in the TUI a person
58
+ * approves each call, so there is nothing to pre-approve.
59
+ */
60
+ export function extractHeadlessFlags(args) {
61
+ const tools = [];
62
+ let maxCalls;
63
+ let maxSpendUsd;
64
+ let approveInApp = false;
65
+ let timeoutSec;
66
+ let spendFlagSeen = false;
67
+
68
+ const take = (i, name) => {
69
+ const v = args[i + 1];
70
+ if (v === undefined || v.startsWith("--")) return { error: `${name} needs a value` };
71
+ return { value: v };
72
+ };
73
+
74
+ for (let i = 0; i < args.length; i++) {
75
+ const a = args[i];
76
+ if (a === "--") break;
77
+ const [flag, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, undefined];
78
+ if (!["--allow-spend", "--max-calls", "--max-spend", "--approve-in-app", "--approval-timeout"].includes(flag)) continue;
79
+
80
+ let value = inline;
81
+ let consumed = 1;
82
+ if (flag !== "--approve-in-app" && value === undefined) {
83
+ const t = take(i, flag);
84
+ if (t.error) return { error: t.error };
85
+ value = t.value;
86
+ consumed = 2;
87
+ }
88
+ args.splice(i, consumed);
89
+ i--;
90
+
91
+ if (flag === "--allow-spend") {
92
+ spendFlagSeen = true;
93
+ for (const raw of value.split(",")) {
94
+ const name = toolName(raw);
95
+ if (!name) {
96
+ return { error: `--allow-spend: "${raw.trim()}" is not a billed tool. Use one of: ${BILLED_TOOLS.join(", ")}` };
97
+ }
98
+ if (!tools.includes(name)) tools.push(name);
99
+ }
100
+ } else if (flag === "--max-calls") {
101
+ spendFlagSeen = true;
102
+ const n = Number(value);
103
+ if (!Number.isInteger(n) || n < 1) return { error: `--max-calls must be a whole number of calls, 1 or more (got "${value}")` };
104
+ maxCalls = n;
105
+ } else if (flag === "--max-spend") {
106
+ spendFlagSeen = true;
107
+ const n = Number(String(value).replace(/^\$/, ""));
108
+ if (!Number.isFinite(n) || n <= 0) return { error: `--max-spend must be a dollar amount above 0, e.g. 1.00 (got "${value}")` };
109
+ maxSpendUsd = n;
110
+ } else if (flag === "--approve-in-app") {
111
+ approveInApp = true;
112
+ } else if (flag === "--approval-timeout") {
113
+ const n = Number(value);
114
+ if (!Number.isFinite(n) || n < 10 || n > 3600) return { error: `--approval-timeout must be 10-3600 seconds (got "${value}")` };
115
+ timeoutSec = n;
116
+ }
117
+ }
118
+
119
+ const headless = isHeadlessRun(args);
120
+ if (spendFlagSeen) {
121
+ if (tools.length === 0) return { error: "--max-calls / --max-spend cap a spend grant — name the tools with --allow-spend <tool>" };
122
+ if (maxCalls === undefined && maxSpendUsd === undefined) {
123
+ return { error: "--allow-spend needs a cap: add --max-calls <n>, --max-spend <usd>, or both" };
124
+ }
125
+ if (!headless) {
126
+ return { error: "--allow-spend only applies to a headless run (-p / --print, or --mode json). In the terminal UI you approve each call yourself." };
127
+ }
128
+ }
129
+ if (timeoutSec !== undefined && !approveInApp) return { error: "--approval-timeout only applies with --approve-in-app" };
130
+ if (approveInApp && !headless) {
131
+ return { error: "--approve-in-app only applies to a headless run (-p / --print, or --mode json). An interactive terminal can use /remote-access." };
132
+ }
133
+
134
+ return {
135
+ spend: tools.length ? { tools, ...(maxCalls !== undefined ? { maxCalls } : {}), ...(maxSpendUsd !== undefined ? { maxSpendUsd } : {}) } : undefined,
136
+ approveInAppMs: approveInApp ? Math.round((timeoutSec ?? DEFAULT_APPROVAL_TIMEOUT_SEC) * 1000) : undefined,
137
+ };
138
+ }
139
+
140
+ // Pi's own subcommands. Pi recognizes each only as args[0], so the launcher must hand
141
+ // them over with NOTHING in front — the normal launch prepends --model, -e and --skill,
142
+ // which is exactly how `privateer auth check` used to become a chat message.
143
+ export const PI_SUBCOMMANDS = ["auth", "install", "remove", "uninstall", "list", "config"];
144
+
145
+ const AUTH_SUBCOMMANDS = ["check", "print-api-key", "print-bearer-token"];
146
+
147
+ /**
148
+ * Is `args` an `auth` invocation Pi won't understand? Returns the message to print, or
149
+ * null. `status` is ours (answered by the launcher), `help`/--help are Pi's.
150
+ */
151
+ export function authProblem(args, cmd = "privateer") {
152
+ if (args[0] !== "auth") return null;
153
+ const sub = args[1];
154
+ if (sub === undefined || sub === "help" || sub === "status" || args.includes("--help") || args.includes("-h")) return null;
155
+ if (AUTH_SUBCOMMANDS.includes(sub)) return null;
156
+ return [
157
+ `${cmd} auth: unknown command "${sub}".`,
158
+ "",
159
+ ` ${cmd} auth status is this machine signed in to Privateer?`,
160
+ ` ${cmd} auth check --provider <name> are a provider's credentials ready?`,
161
+ ` ${cmd} auth print-api-key --provider <name> print a provider's API key`,
162
+ ` ${cmd} auth print-bearer-token --provider <name> print a provider's bearer token`,
163
+ "",
164
+ `Nothing was sent to a model. To sign in, run \`${cmd}\` and type /login.`,
165
+ ].join("\n");
166
+ }
@@ -29,6 +29,7 @@ import path from "node:path";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
31
  import { routeUpdate } from "./update-route.mjs";
32
+ import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, authProblem, extractHeadlessFlags } from "./headless-flags.mjs";
32
33
  import { runToCompletion } from "./run-to-completion.mjs";
33
34
  import { configureCompileCache } from "./startup-cache.mjs";
34
35
 
@@ -158,11 +159,19 @@ const NO_QUARTER = args.some((a) => a === "--no-quarter");
158
159
  if (NO_QUARTER) {
159
160
  for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-quarter") args.splice(i, 1);
160
161
  process.env.PRIVATEER_NO_QUARTER = "1";
162
+ // No quarter is also `/privacy off` (src/permissions/noQuarter.ts). Marked, unless the
163
+ // filter was already off, so shift+tab back to the moat restores it.
164
+ if (process.env.PRIVATEER_PRIVACY_OFF !== "1" && process.env.PI_PRIVACY_OFF !== "1") {
165
+ process.env.PRIVATEER_PRIVACY_OFF = "1";
166
+ process.env.PI_PRIVACY_OFF = "1";
167
+ process.env.PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER = "1";
168
+ }
161
169
  process.stderr.write(
162
170
  [
163
171
  "",
164
- " ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate DISABLED for this session.",
165
- " Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt.",
172
+ " ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate AND privacy filter DISABLED for this session.",
173
+ " Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt,",
174
+ " and outbound requests are not scanned for PII.",
166
175
  " Only use this in a directory and with a task you fully trust.",
167
176
  " shift+tab (or /no-quarter off) raises the moat again.",
168
177
  "",
@@ -178,6 +187,7 @@ if (NO_PRIVACY) {
178
187
  for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-privacy" || args[i] === "--privacy-off") args.splice(i, 1);
179
188
  process.env.PRIVATEER_PRIVACY_OFF = "1";
180
189
  process.env.PI_PRIVACY_OFF = "1";
190
+ delete process.env.PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER; // asked for outright — raising the moat keeps it off
181
191
  process.stderr.write(
182
192
  [
183
193
  "",
@@ -219,6 +229,25 @@ if (ALLOW_COMPUTER) {
219
229
  );
220
230
  }
221
231
 
232
+ // Headless-run flags — spend pre-approval and app approvals for `-p` runs. Stripped
233
+ // before Pi sees them, exactly like the flags above; grammar in bin/headless-flags.mjs.
234
+ //
235
+ // THE ENV IS OURS, NEVER THE SHELL'S. The grant reaches the gate as an env var (the only
236
+ // channel into Pi's process), so an inherited value is deleted FIRST: a
237
+ // PRIVATEER_CLI_SPEND left exported in someone's shell must never pre-approve a run
238
+ // they didn't type the flag for. Only this invocation's argv can set it.
239
+ delete process.env[CLI_SPEND_ENV];
240
+ delete process.env[APPROVE_IN_APP_ENV];
241
+ {
242
+ const headless = extractHeadlessFlags(args);
243
+ if (headless.error) {
244
+ process.stderr.write(`privateer: ${headless.error}\n`);
245
+ process.exit(2);
246
+ }
247
+ if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
248
+ if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
249
+ }
250
+
222
251
  const sub = args[0];
223
252
 
224
253
  // `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
@@ -541,6 +570,38 @@ else if (sub === "acp") {
541
570
  runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)], { forwardSignals: true });
542
571
  }
543
572
 
573
+ // --- Pi's own subcommands: `auth`, and the package commands -------------------
574
+ // Pi matches these only as args[0]. The normal launch below prepends --model, -e and
575
+ // --skill, so every one of them used to fall through as a CHAT MESSAGE — `privateer
576
+ // auth list` was answered by the model, which listed the project folders in the
577
+ // user's home directory. Hand them over bare instead, and refuse an `auth` Pi doesn't
578
+ // know rather than let it become a prompt.
579
+ else if (PI_SUBCOMMANDS.includes(sub)) {
580
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
581
+ const problem = authProblem(args, cmd);
582
+ if (problem) {
583
+ process.stderr.write(problem + "\n");
584
+ process.exit(2);
585
+ }
586
+ if (sub === "auth" && args[1] === "status") {
587
+ printAuthStatus(cmd);
588
+ process.exit(0);
589
+ }
590
+ const CLI = resolveDep(REPO, "@earendil-works/pi-coding-agent", "dist", "cli.js");
591
+ if (!CLI || !fs.existsSync(CLI)) {
592
+ console.error(
593
+ "privateer: couldn't find pi-coding-agent — the install looks incomplete.\n" +
594
+ " Try reinstalling: npm install -g privateer-agent@latest",
595
+ );
596
+ process.exit(1);
597
+ }
598
+ configureCompileCache(PRIVATEER_HOME); // same Pi module graph as a launch — reuse its compiled code
599
+ ensurePatches(); // project `.privateer/` config dirs are a patch; -l scope needs them
600
+ process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
601
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
602
+ runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...args]);
603
+ }
604
+
544
605
  // --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
545
606
  else {
546
607
  // Node 22 can reuse compiled code across launches instead of reparsing Pi and every
@@ -872,6 +933,28 @@ function warnKeylessLaunch() {
872
933
  process.stderr.write(lines.join("\n") + "\n");
873
934
  }
874
935
 
936
+ // `privateer auth status` — the question people (and agents) actually ask, answered
937
+ // from local state only: no network, nothing sent anywhere, and no secret printed.
938
+ function printAuthStatus(cmd) {
939
+ let creds = null;
940
+ try {
941
+ creds = JSON.parse(fs.readFileSync(path.join(PRIVATEER_HOME, "credentials.json"), "utf8"));
942
+ } catch { /* absent or unreadable → signed out */ }
943
+ const lines = [];
944
+ if (creds && typeof creds.accessToken === "string") {
945
+ const who = creds.user?.email || "your account";
946
+ lines.push(`Privateer account: signed in as ${who}${creds.serverBaseUrl ? ` (${creds.serverBaseUrl})` : ""}`);
947
+ lines.push(" This checks the local login only; a session revoked from the app shows up on the next request.");
948
+ } else {
949
+ lines.push("Privateer account: not signed in");
950
+ lines.push(` Run \`${cmd}\` and type /login, then approve the code in the Privateer app.`);
951
+ }
952
+ const keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "TINFOIL_API_KEY", "PRIVATEER_API_KEY"].filter(haveKey);
953
+ lines.push(`Provider keys in the environment: ${keys.length ? keys.join(", ") : "none"}`);
954
+ lines.push(`To check one provider end to end: ${cmd} auth check --provider <name>`);
955
+ console.log(lines.join("\n"));
956
+ }
957
+
875
958
  function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
876
959
  console.log(
877
960
  [
@@ -891,7 +974,8 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
891
974
  ` ${cmd} uninstall <source> Alias for remove`,
892
975
  ` ${cmd} list List installed extensions from settings`,
893
976
  ` ${cmd} config Open TUI to enable/disable package resources`,
894
- ` ${cmd} auth <command> Print credentials or check provider readiness`,
977
+ ` ${cmd} auth status Show whether this machine is signed in`,
978
+ ` ${cmd} auth <command> Check provider readiness or print credentials (check, print-api-key, print-bearer-token)`,
895
979
  ` ${cmd} <command> --help Show help for a specific command`,
896
980
  "",
897
981
  "Options:",
@@ -902,6 +986,12 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
902
986
  " --append-system-prompt <text> Append text or file contents to the system prompt",
903
987
  " --mode <mode> Output mode: text (default), json, or rpc",
904
988
  " --print, -p Non-interactive mode: process prompt and exit",
989
+ " --allow-spend <tools> -p only: pre-approve billed tools for this run (e.g. generate_video)",
990
+ " --max-calls <n> …at most n billed calls (a cap is required)",
991
+ " --max-spend <usd> …at most this much, estimated before each call",
992
+ " --approve-in-app -p only: send approvals to the Privateer app instead of denying",
993
+ " --approval-timeout <seconds> How long --approve-in-app waits (default 300)",
994
+ " --no-quarter Run with NO approval prompts at all (trusted tasks only)",
905
995
  " --continue, -c Continue previous session",
906
996
  " --resume, -r Select a session to resume",
907
997
  " --session <path|id> Use specific session file or partial UUID",
@@ -35,7 +35,14 @@ import { matchesKey } from "@earendil-works/pi-tui";
35
35
  import * as priv from "../src/auth/privateer.ts";
36
36
  import { paletteFor } from "../src/ui/palette.ts";
37
37
  import { noQuarterActive, setNoQuarter } from "../src/permissions/noQuarter.ts";
38
+ import { privacyDisabled } from "../src/config/privacyDisabled.ts";
39
+ import { updatePostureBadge } from "./privateer-posture.ts";
38
40
  import { childSpendAllows } from "../src/permissions/childSpend.ts";
41
+ import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
42
+ import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
43
+ import type { PermissionRequest } from "../src/permissions/gate.ts";
44
+ import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
45
+ import { describeErrorText } from "../src/engine/errors.ts";
39
46
  import type { PermissionMode } from "../src/config/permissionMode.ts";
40
47
 
41
48
  const MODES: PermissionMode[] = ["default", "acceptEdits", "bypass", "plan"];
@@ -245,6 +252,10 @@ function advertiseCommands(): { name: string; description?: string }[] {
245
252
  // own connect/disconnect callbacks can refresh the indicator, not just the command.
246
253
  const REMOTE_STATUS_KEY = "privateer:remote-access";
247
254
  let uiRef: any = null;
255
+ // The live model, for repainting the privacy badge when no quarter moves the filter —
256
+ // privateer-posture's own watcher sits on ITS copy of privacyDisabled.ts (see
257
+ // noQuarter.ts on per-extension module copies), so a flip from here never reaches it.
258
+ let modelRef: { provider?: string; id?: string } | undefined;
248
259
  // "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
249
260
  // "connected" → socket open, controller reachable (green).
250
261
  let remoteState: "off" | "connecting" | "connected" = "off";
@@ -299,12 +310,17 @@ function refreshNoQuarterStatus(): void {
299
310
  // Flip the state and tell the user, loudly on the way down. Takes effect from the
300
311
  // next gated action — an approval already on screen still needs an answer.
301
312
  function applyNoQuarter(on: boolean, ui: any): void {
302
- setNoQuarter(on);
313
+ const privacyWasOff = privacyDisabled();
314
+ setNoQuarter(on); // also `/privacy off` on the way down, and back on the way up if it did that
303
315
  refreshNoQuarterStatus();
316
+ const privacyMoved = privacyDisabled() !== privacyWasOff;
317
+ if (privacyMoved) void updatePostureBadge({ ui, model: modelRef });
304
318
  ui?.notify?.(
305
319
  on
306
- ? "⚑ No quarter — the permission gate is OFF for this session. Every action (shell, edits, destructive tools, out-of-cwd, protected files) now runs without asking. shift+tab to raise the moat again."
307
- : "⚓ Moat raised — the permission gate is back on.",
320
+ ? "⚑ No quarter — the permission gate is OFF for this session. Every action (shell, edits, destructive tools, out-of-cwd, protected files) now runs without asking" +
321
+ (privacyMoved ? ", and the privacy filter is off: outbound requests are not scanned for PII" : "") +
322
+ ". shift+tab to raise the moat again."
323
+ : "⚓ Moat raised — the permission gate is back on" + (privacyMoved ? ", and so is the privacy filter." : "."),
308
324
  on ? "warning" : "info",
309
325
  );
310
326
  }
@@ -455,7 +471,78 @@ const bridge = new RemoteBridge({
455
471
  // otherwise keep the fail-closed defaultLocalAsk (headless deny). A top-level TUI keeps
456
472
  // its own interactive/remote gate.
457
473
  const childChannel = isSubagentChild() ? inheritedChannelDir() : undefined;
458
- const localAsk = childChannel ? makeChildGateAsk(childChannel) : defaultLocalAsk;
474
+
475
+ // ── headless (`-p` / `--mode json`) runs ─────────────────────────────────────────
476
+ //
477
+ // A top-level headless run has no screen, so the default asker denies. Two launch flags
478
+ // change that, both typed per invocation and neither ever persisted (bin/headless-flags.mjs):
479
+ // --allow-spend … --max-calls/--max-spend pre-approves named billing tools, capped
480
+ // (src/permissions/cliSpend.ts)
481
+ // --approve-in-app puts what's left to the Privateer app
482
+ // (src/remote/headlessApproval.ts)
483
+ // Neither applies in a subagent child (its grant comes only from childSpend.ts) nor in
484
+ // the TUI; `headlessRun` is set from Pi's own mode at session_start.
485
+ let headlessRun = false;
486
+ const cliGrant = isSubagentChild() ? null : readCliSpendGrant();
487
+ const approveInAppMs = isSubagentChild() ? 0 : Number(process.env.PRIVATEER_APPROVE_IN_APP) || 0;
488
+
489
+ // Price a billed call from the server's capability report — the same reservation
490
+ // figure the server holds (tools/media.ts). Loaded on first use: most runs never price
491
+ // anything, and media.ts has no business in the TUI's startup graph.
492
+ async function quoteMediaCall(tool: string, input: unknown, signal?: AbortSignal): Promise<number | null> {
493
+ const { readMediaCapabilities, quoteMediaCallUsd } = await import("../src/tools/media.ts");
494
+ const a = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
495
+ const model = typeof a.model === "string" && a.model ? a.model : undefined;
496
+ const query = tool === "generate_video" || tool === "generate_sprite" ? { videoModel: model } : tool === "generate_model" ? { model } : {};
497
+ const caps = await readMediaCapabilities(query, signal);
498
+ return caps.ok ? quoteMediaCallUsd(tool, input, caps.data) : null;
499
+ }
500
+
501
+ const cliLedger = cliGrant ? new CliSpendLedger(cliGrant, quoteMediaCall) : null;
502
+
503
+ // Why a particular request was denied, for the model. Keyed by the request object the
504
+ // gate builds per call, so parallel calls never read each other's reason.
505
+ const denialNotes = new WeakMap<PermissionRequest, string>();
506
+ // The call's own arguments, seen by the spend check, so an app prompt can be priced
507
+ // for THIS call rather than the dearest one the model could make.
508
+ const requestInputs = new WeakMap<PermissionRequest, unknown>();
509
+
510
+ const appApprover = approveInAppMs
511
+ ? new HeadlessAppApprover({
512
+ timeoutMs: approveInAppMs,
513
+ log: (line) => process.stderr.write(`${line}\n`),
514
+ signedIn: () => priv.hasCredentials(),
515
+ label: "privateer -p",
516
+ makeRelay: (callbacks) => new RelayClient(callbacks as any, { label: "privateer -p" }) as any,
517
+ })
518
+ : null;
519
+
520
+ async function headlessAsk(req: PermissionRequest, ctx: any) {
521
+ if (appApprover) {
522
+ // Put the price on the question — "about $0.50" is what makes a phone approval an
523
+ // informed one. Best-effort and bounded: a slow quote must not eat the timeout.
524
+ let note: string | undefined;
525
+ if (isSpendRequest(req)) {
526
+ const usd = await Promise.race([
527
+ quoteMediaCall(req.tool, requestInputs.get(req), ctx?.signal).catch(() => null),
528
+ new Promise<null>((r) => setTimeout(() => r(null), 5_000)),
529
+ ]);
530
+ if (typeof usd === "number") note = `about $${usd.toFixed(2)}`;
531
+ }
532
+ const out = await appApprover.ask(req, ctx?.signal, note);
533
+ if (out === "deny" && !denialNotes.has(req)) {
534
+ denialNotes.set(req, "It was not approved in the Privateer app. Stop and report this rather than retrying.");
535
+ }
536
+ return out;
537
+ }
538
+ const out = await defaultLocalAsk(req, ctx);
539
+ if (out === "deny" && isSpendRequest(req) && !denialNotes.has(req)) denialNotes.set(req, headlessSpendGuidance(req.tool));
540
+ return out;
541
+ }
542
+
543
+ const localAsk = childChannel
544
+ ? makeChildGateAsk(childChannel)
545
+ : (req: PermissionRequest, ctx: any) => (headlessRun ? headlessAsk(req, ctx) : defaultLocalAsk(req, ctx));
459
546
 
460
547
  const gate = makePermissionGate({
461
548
  getMode: () => mode,
@@ -477,7 +564,28 @@ const gate = makePermissionGate({
477
564
  // when pi-subagents has marked us a child), so a terminal keeps asking its human. This
478
565
  // is what lets an unattended run delegate a shot to a subagent: without it the child's
479
566
  // gate denies every generate_* call, having no one to ask.
480
- isSpendPreauthorized: (req) => childSpendAllows(req.tool),
567
+ //
568
+ // Plus, in a top-level headless run only, the `--allow-spend` ledger: capped, per
569
+ // invocation, and priced before each call when there's a --max-spend.
570
+ isSpendPreauthorized: async (req, input, signal) => {
571
+ requestInputs.set(req, input);
572
+ if (childSpendAllows(req.tool)) return true;
573
+ if (!cliLedger || !headlessRun) return false;
574
+ const d = await cliLedger.authorize(req.tool, input, signal);
575
+ if (d.ok) {
576
+ process.stderr.write(
577
+ `⚓ --allow-spend: running ${req.tool}${d.usd !== null ? ` (about $${d.usd.toFixed(2)})` : ""} — ${cliLedger.summary()}\n`,
578
+ );
579
+ return true;
580
+ }
581
+ denialNotes.set(
582
+ req,
583
+ `Not covered by this run's spend pre-approval: ${d.reason}. ` +
584
+ (appApprover ? "" : "Stop and report this to the user rather than retrying."),
585
+ );
586
+ return false;
587
+ },
588
+ explainDenial: (req) => denialNotes.get(req),
481
589
  });
482
590
 
483
591
  export default function privateerControl(pi: any): void {
@@ -550,6 +658,7 @@ export default function privateerControl(pi: any): void {
550
658
  // machine's real catalog and the banner shows the current spec from the start.
551
659
  if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
552
660
  if (!currentSpec && ctx?.model) currentSpec = modelSpec(ctx.model);
661
+ if (ctx?.model) modelRef = ctx.model;
553
662
  refreshRemoteStatus();
554
663
  // shift+tab → no quarter, and the red footer indicator when it's already on (a
555
664
  // `--no-quarter` launch, or a session replacement mid-run). Interactive TUI only:
@@ -561,12 +670,90 @@ export default function privateerControl(pi: any): void {
561
670
  if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
562
671
  mode = "bypass";
563
672
  }
673
+ headlessRun = !isSubagentChild() && (ctx?.mode === "print" || ctx?.mode === "json");
674
+ if (headlessRun) announceHeadlessSpend();
675
+ });
676
+
677
+ // Say it UP FRONT. An agent driving `privateer -p` used to plan a whole job and only
678
+ // hit the billing wall at the last step, because nothing said a -p run can't approve a
679
+ // billed call. Tell the person (stderr, before any work) and the model (its system
680
+ // prompt, before it plans). Quiet when nothing billable is enabled, or when no quarter
681
+ // lifts the gate anyway.
682
+ function uncoveredBilledTools(): string[] {
683
+ if (noQuarterActive()) return [];
684
+ const active: string[] = (() => {
685
+ try {
686
+ return pi.getActiveTools?.() ?? [];
687
+ } catch {
688
+ return [];
689
+ }
690
+ })();
691
+ return active.filter((t) => BILLED_MEDIA_TOOLS.has(t) && !cliGrant?.tools.includes(t));
692
+ }
693
+ function announceHeadlessSpend(): void {
694
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
695
+ const out: string[] = [];
696
+ if (cliGrant) {
697
+ const caps = [
698
+ cliGrant.maxCalls !== undefined ? `at most ${cliGrant.maxCalls} call(s)` : "",
699
+ cliGrant.maxSpendUsd !== undefined ? `$${cliGrant.maxSpendUsd.toFixed(2)} estimated` : "",
700
+ ].filter(Boolean);
701
+ out.push(`⚓ Spend pre-approved for this run: ${cliGrant.tools.join(", ")} — ${caps.join(", ")}.`);
702
+ }
703
+ const uncovered = uncoveredBilledTools();
704
+ if (uncovered.length) {
705
+ out.push(
706
+ appApprover
707
+ ? `⚓ Billed tools (${uncovered.join(", ")}) will ask for approval in the Privateer app — up to ${Math.round(approveInAppMs / 1000)}s each.`
708
+ : `⚓ Heads-up: billed tools (${uncovered.join(", ")}) are enabled, but a -p run can't approve them — any call will be denied.\n` +
709
+ ` To allow them: --allow-spend <tool> --max-calls <n> [--max-spend <usd>], or --approve-in-app, or drive Privateer over ACP (\`${cmd} acp\`).`,
710
+ );
711
+ }
712
+ if (out.length) process.stderr.write(out.join("\n") + "\n");
713
+ }
714
+ pi.on("before_agent_start", (event: any) => {
715
+ if (!headlessRun) return;
716
+ const uncovered = uncoveredBilledTools();
717
+ const lines: string[] = [];
718
+ if (cliGrant) {
719
+ lines.push(
720
+ `This run may call ${cliGrant.tools.join(", ")} without further approval, within its caps` +
721
+ `${cliGrant.maxCalls !== undefined ? ` (at most ${cliGrant.maxCalls} call(s) in total)` : ""}` +
722
+ `${cliGrant.maxSpendUsd !== undefined ? ` (at most $${cliGrant.maxSpendUsd.toFixed(2)}, estimated before each call — check prices with media_capabilities)` : ""}.`,
723
+ );
724
+ }
725
+ if (uncovered.length) {
726
+ lines.push(
727
+ appApprover
728
+ ? `Calls to ${uncovered.join(", ")} bill the user's account and must be approved by them in the Privateer app; each waits for that approval.`
729
+ : `This is a non-interactive run with nobody to approve spending: ${uncovered.join(", ")} will be DENIED if called. ` +
730
+ "Do not plan work that depends on them. If the task needs one, say so up front and tell the user to re-run with " +
731
+ "--allow-spend <tool> --max-calls <n>, or --approve-in-app.",
732
+ );
733
+ }
734
+ if (!lines.length) return;
735
+ return { systemPrompt: `${event.systemPrompt}\n\n# Billing in this run\n${lines.join("\n")}` };
736
+ });
737
+ pi.on("session_shutdown", () => appApprover?.close());
738
+
739
+ // Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
740
+ // "Connection error." — and exits. Follow it with what that means and what to do,
741
+ // on stderr so stdout stays the answer (or the JSON stream).
742
+ pi.on("agent_end", (ev: any) => {
743
+ if (!headlessRun) return;
744
+ const msgs: any[] = Array.isArray(ev?.messages) ? ev.messages : [];
745
+ const last = [...msgs].reverse().find((m) => m?.role === "assistant");
746
+ if (last?.stopReason !== "error" || typeof last.errorMessage !== "string") return;
747
+ const described = describeErrorText(last.errorMessage, { provider: last.provider });
748
+ if (!described) return;
749
+ process.stderr.write(`privateer: ${described.message}${described.hint ? `\n ${described.hint}` : ""}\n`);
564
750
  });
565
751
 
566
752
  // Follow local model switches too (the user picking a model in the TUI): keep
567
753
  // currentSpec current and push context so a driving app's banner stays in sync.
568
754
  pi.on("model_select", (ev: any) => {
569
755
  if (ev?.model) {
756
+ modelRef = ev.model;
570
757
  currentSpec = modelSpec(ev.model);
571
758
  relay?.sendContext({ model: currentSpec, cwd: process.cwd(), version: agentVersion() });
572
759
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.47",
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",