privateer-agent 0.12.47 → 0.12.50

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.
@@ -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,7 +29,9 @@ 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";
34
+ import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
33
35
  import { configureCompileCache } from "./startup-cache.mjs";
34
36
 
35
37
  const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
@@ -158,11 +160,19 @@ const NO_QUARTER = args.some((a) => a === "--no-quarter");
158
160
  if (NO_QUARTER) {
159
161
  for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-quarter") args.splice(i, 1);
160
162
  process.env.PRIVATEER_NO_QUARTER = "1";
163
+ // No quarter is also `/privacy off` (src/permissions/noQuarter.ts). Marked, unless the
164
+ // filter was already off, so shift+tab back to the moat restores it.
165
+ if (process.env.PRIVATEER_PRIVACY_OFF !== "1" && process.env.PI_PRIVACY_OFF !== "1") {
166
+ process.env.PRIVATEER_PRIVACY_OFF = "1";
167
+ process.env.PI_PRIVACY_OFF = "1";
168
+ process.env.PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER = "1";
169
+ }
161
170
  process.stderr.write(
162
171
  [
163
172
  "",
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.",
173
+ " ⚓ \x1b[1;31mNo quarter\x1b[0m — permission gate AND privacy filter DISABLED for this session.",
174
+ " Every action (shell, edits, destructive tools, out-of-cwd) runs WITHOUT a prompt,",
175
+ " and outbound requests are not scanned for PII.",
166
176
  " Only use this in a directory and with a task you fully trust.",
167
177
  " shift+tab (or /no-quarter off) raises the moat again.",
168
178
  "",
@@ -178,6 +188,7 @@ if (NO_PRIVACY) {
178
188
  for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-privacy" || args[i] === "--privacy-off") args.splice(i, 1);
179
189
  process.env.PRIVATEER_PRIVACY_OFF = "1";
180
190
  process.env.PI_PRIVACY_OFF = "1";
191
+ delete process.env.PRIVATEER_PRIVACY_OFF_BY_NO_QUARTER; // asked for outright — raising the moat keeps it off
181
192
  process.stderr.write(
182
193
  [
183
194
  "",
@@ -219,6 +230,25 @@ if (ALLOW_COMPUTER) {
219
230
  );
220
231
  }
221
232
 
233
+ // Headless-run flags — spend pre-approval and app approvals for `-p` runs. Stripped
234
+ // before Pi sees them, exactly like the flags above; grammar in bin/headless-flags.mjs.
235
+ //
236
+ // THE ENV IS OURS, NEVER THE SHELL'S. The grant reaches the gate as an env var (the only
237
+ // channel into Pi's process), so an inherited value is deleted FIRST: a
238
+ // PRIVATEER_CLI_SPEND left exported in someone's shell must never pre-approve a run
239
+ // they didn't type the flag for. Only this invocation's argv can set it.
240
+ delete process.env[CLI_SPEND_ENV];
241
+ delete process.env[APPROVE_IN_APP_ENV];
242
+ {
243
+ const headless = extractHeadlessFlags(args);
244
+ if (headless.error) {
245
+ process.stderr.write(`privateer: ${headless.error}\n`);
246
+ process.exit(2);
247
+ }
248
+ if (headless.spend) process.env[CLI_SPEND_ENV] = JSON.stringify(headless.spend);
249
+ if (headless.approveInAppMs) process.env[APPROVE_IN_APP_ENV] = String(headless.approveInAppMs);
250
+ }
251
+
222
252
  const sub = args[0];
223
253
 
224
254
  // `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
@@ -541,6 +571,98 @@ else if (sub === "acp") {
541
571
  runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)], { forwardSignals: true });
542
572
  }
543
573
 
574
+ // --- `privateer fresh [n|pid] [--list]` --------------------------------------
575
+ // Swap a running terminal's agent for a brand-new process, reaping everything the old
576
+ // one started. This is the rescue path: an agent that has jammed can't run its own
577
+ // /fresh, so you run this from another terminal. See bin/fresh-supervisor.mjs.
578
+ else if (sub === "fresh") {
579
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
580
+ const rest = args.slice(1);
581
+ if (rest.includes("--help") || rest.includes("-h")) {
582
+ console.log(
583
+ [
584
+ `${cmd} fresh — replace a running terminal's agent with a new one.`,
585
+ "",
586
+ ` ${cmd} fresh the only running terminal`,
587
+ ` ${cmd} fresh <n|pid> one of several — pick from the list`,
588
+ ` ${cmd} fresh --list show running terminals`,
589
+ "",
590
+ "The old agent and every process it started are stopped; the new one starts in",
591
+ "the same terminal with a new session and no link to the old one. Inside a",
592
+ "terminal, /fresh does the same. The old session stays resumable with /resume.",
593
+ ].join("\n"),
594
+ );
595
+ process.exit(0);
596
+ }
597
+ const terms = listTerminals(PRIVATEER_HOME);
598
+ const describe = (t, i) =>
599
+ ` ${i + 1}. ${t.cwd} (pid ${t.pid}, up ${Math.max(1, Math.round((Date.now() - t.startedAt) / 60000))}m)`;
600
+ if (rest.includes("--list")) {
601
+ console.log(terms.length ? terms.map(describe).join("\n") : "No running Privateer terminals.");
602
+ process.exit(0);
603
+ }
604
+ const pick = rest.find((a) => !a.startsWith("-"));
605
+ let target;
606
+ if (pick) {
607
+ const n = Number(pick);
608
+ target = terms.find((t) => t.pid === n) ?? (Number.isInteger(n) && n >= 1 ? terms[n - 1] : undefined);
609
+ if (!target) {
610
+ console.error(`${cmd} fresh: no running terminal "${pick}" — ${cmd} fresh --list shows them.`);
611
+ process.exit(1);
612
+ }
613
+ } else if (terms.length === 1) {
614
+ target = terms[0];
615
+ } else if (terms.length === 0) {
616
+ console.error("No running Privateer terminals.");
617
+ process.exit(1);
618
+ } else {
619
+ console.error(`Several Privateer terminals are running — pick one:\n${terms.map(describe).join("\n")}\n\n ${cmd} fresh <n>`);
620
+ process.exit(2);
621
+ }
622
+ requestFresh(target.socket, target.token).then(
623
+ () => {
624
+ console.log(`Fresh agent starting in ${target.cwd}.`);
625
+ process.exit(0);
626
+ },
627
+ (e) => {
628
+ console.error(`${cmd} fresh: ${e.message}`);
629
+ process.exit(1);
630
+ },
631
+ );
632
+ }
633
+
634
+ // --- Pi's own subcommands: `auth`, and the package commands -------------------
635
+ // Pi matches these only as args[0]. The normal launch below prepends --model, -e and
636
+ // --skill, so every one of them used to fall through as a CHAT MESSAGE — `privateer
637
+ // auth list` was answered by the model, which listed the project folders in the
638
+ // user's home directory. Hand them over bare instead, and refuse an `auth` Pi doesn't
639
+ // know rather than let it become a prompt.
640
+ else if (PI_SUBCOMMANDS.includes(sub)) {
641
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
642
+ const problem = authProblem(args, cmd);
643
+ if (problem) {
644
+ process.stderr.write(problem + "\n");
645
+ process.exit(2);
646
+ }
647
+ if (sub === "auth" && args[1] === "status") {
648
+ printAuthStatus(cmd);
649
+ process.exit(0);
650
+ }
651
+ const CLI = resolveDep(REPO, "@earendil-works/pi-coding-agent", "dist", "cli.js");
652
+ if (!CLI || !fs.existsSync(CLI)) {
653
+ console.error(
654
+ "privateer: couldn't find pi-coding-agent — the install looks incomplete.\n" +
655
+ " Try reinstalling: npm install -g privateer-agent@latest",
656
+ );
657
+ process.exit(1);
658
+ }
659
+ configureCompileCache(PRIVATEER_HOME); // same Pi module graph as a launch — reuse its compiled code
660
+ ensurePatches(); // project `.privateer/` config dirs are a patch; -l scope needs them
661
+ process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
662
+ const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
663
+ runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...args]);
664
+ }
665
+
544
666
  // --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
545
667
  else {
546
668
  // Node 22 can reuse compiled code across launches instead of reparsing Pi and every
@@ -687,19 +809,24 @@ else {
687
809
  // 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
688
810
  // falls back sanely if that model has vanished from the registry)
689
811
  // 4. nothing saved (first run / fresh home) → the computed MODEL above
690
- const userPassedModel = args.includes("--model");
691
- let savedDefault = null;
692
- try {
693
- const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
694
- if (
695
- typeof s.defaultProvider === "string" && s.defaultProvider.trim() &&
696
- typeof s.defaultModel === "string" && s.defaultModel.trim()
697
- ) {
698
- savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
699
- }
700
- } catch { /* absent/unreadable → no saved pick */ }
701
- const modelArgs =
702
- userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
812
+ //
813
+ // A FUNCTION, called per launch: a fresh agent (bin/fresh-supervisor.mjs) re-reads it,
814
+ // so a model picked in the old session boots the new one instead of being stomped by
815
+ // a first-run --model computed before that pick existed.
816
+ const modelArgsFor = (launchArgs) => {
817
+ const userPassedModel = launchArgs.includes("--model");
818
+ let savedDefault = null;
819
+ try {
820
+ const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
821
+ if (
822
+ typeof s.defaultProvider === "string" && s.defaultProvider.trim() &&
823
+ typeof s.defaultModel === "string" && s.defaultModel.trim()
824
+ ) {
825
+ savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
826
+ }
827
+ } catch { /* absent/unreadable → no saved pick */ }
828
+ return userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
829
+ };
703
830
 
704
831
  // Dev convenience: load provider keys from the repo's .env if present.
705
832
  const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
@@ -725,7 +852,12 @@ else {
725
852
  const splash = path.join(HERE, "privateer-splash.mjs");
726
853
  if (!isNonInteractive && fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
727
854
 
728
- runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
855
+ // Supervised rather than runToCompletion: the launcher stays up so /fresh, a voice
856
+ // command, or `privateer fresh` from another terminal can swap this agent for a new
857
+ // process with the old tree reaped. Print/export/version runs have nothing to swap.
858
+ const tuiArgs = (launchArgs) => [...nodeArgs, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
859
+ if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args));
860
+ else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
729
861
  }
730
862
 
731
863
  // --- helpers ---------------------------------------------------------------
@@ -872,6 +1004,28 @@ function warnKeylessLaunch() {
872
1004
  process.stderr.write(lines.join("\n") + "\n");
873
1005
  }
874
1006
 
1007
+ // `privateer auth status` — the question people (and agents) actually ask, answered
1008
+ // from local state only: no network, nothing sent anywhere, and no secret printed.
1009
+ function printAuthStatus(cmd) {
1010
+ let creds = null;
1011
+ try {
1012
+ creds = JSON.parse(fs.readFileSync(path.join(PRIVATEER_HOME, "credentials.json"), "utf8"));
1013
+ } catch { /* absent or unreadable → signed out */ }
1014
+ const lines = [];
1015
+ if (creds && typeof creds.accessToken === "string") {
1016
+ const who = creds.user?.email || "your account";
1017
+ lines.push(`Privateer account: signed in as ${who}${creds.serverBaseUrl ? ` (${creds.serverBaseUrl})` : ""}`);
1018
+ lines.push(" This checks the local login only; a session revoked from the app shows up on the next request.");
1019
+ } else {
1020
+ lines.push("Privateer account: not signed in");
1021
+ lines.push(` Run \`${cmd}\` and type /login, then approve the code in the Privateer app.`);
1022
+ }
1023
+ const keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "TINFOIL_API_KEY", "PRIVATEER_API_KEY"].filter(haveKey);
1024
+ lines.push(`Provider keys in the environment: ${keys.length ? keys.join(", ") : "none"}`);
1025
+ lines.push(`To check one provider end to end: ${cmd} auth check --provider <name>`);
1026
+ console.log(lines.join("\n"));
1027
+ }
1028
+
875
1029
  function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
876
1030
  console.log(
877
1031
  [
@@ -886,12 +1040,14 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
886
1040
  ` ${cmd} harbor <command> Manage the resident background Harbor daemon (run/install/uninstall/status)`,
887
1041
  ` ${cmd} verify Check local installation integrity and patch state`,
888
1042
  ` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
1043
+ ` ${cmd} fresh [n] Replace a running terminal's agent with a new one (stops everything it started)`,
889
1044
  ` ${cmd} install <source> Install extension source and add to settings`,
890
1045
  ` ${cmd} remove <source> Remove extension source from settings`,
891
1046
  ` ${cmd} uninstall <source> Alias for remove`,
892
1047
  ` ${cmd} list List installed extensions from settings`,
893
1048
  ` ${cmd} config Open TUI to enable/disable package resources`,
894
- ` ${cmd} auth <command> Print credentials or check provider readiness`,
1049
+ ` ${cmd} auth status Show whether this machine is signed in`,
1050
+ ` ${cmd} auth <command> Check provider readiness or print credentials (check, print-api-key, print-bearer-token)`,
895
1051
  ` ${cmd} <command> --help Show help for a specific command`,
896
1052
  "",
897
1053
  "Options:",
@@ -902,6 +1058,12 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
902
1058
  " --append-system-prompt <text> Append text or file contents to the system prompt",
903
1059
  " --mode <mode> Output mode: text (default), json, or rpc",
904
1060
  " --print, -p Non-interactive mode: process prompt and exit",
1061
+ " --allow-spend <tools> -p only: pre-approve billed tools for this run (e.g. generate_video)",
1062
+ " --max-calls <n> …at most n billed calls (a cap is required)",
1063
+ " --max-spend <usd> …at most this much, estimated before each call",
1064
+ " --approve-in-app -p only: send approvals to the Privateer app instead of denying",
1065
+ " --approval-timeout <seconds> How long --approve-in-app waits (default 300)",
1066
+ " --no-quarter Run with NO approval prompts at all (trusted tasks only)",
905
1067
  " --continue, -c Continue previous session",
906
1068
  " --resume, -r Select a session to resume",
907
1069
  " --session <path|id> Use specific session file or partial UUID",
@@ -0,0 +1,59 @@
1
+ // /fresh — replace this agent with a brand-new one, in the same terminal.
2
+ //
3
+ // Not /new. Pi's /new starts a new session inside THIS process: whatever has wedged,
4
+ // leaked or been left running (a dev server, a watcher, a subagent) comes along. /fresh
5
+ // asks the launcher above us (bin/fresh-supervisor.mjs) to stop this process and every
6
+ // process it started, then boot a new agent with a new session and nothing linking it
7
+ // to this one. The session file stays on disk, so /resume can still bring it back.
8
+ //
9
+ // It runs immediately, with no confirmation, because nothing is lost that /resume
10
+ // can't bring back — except the processes it reaps, which is the point.
11
+ //
12
+ // Only a terminal started by `privateer` has a supervisor. The desktop app, the harbor,
13
+ // ACP and subagent children don't, and they say so instead of failing quietly. A jammed
14
+ // agent can't run this at all — its event loop is the thing that's stuck — which is
15
+ // what `privateer fresh` from another terminal is for.
16
+ //
17
+ // Voice: extensions/privateer-speak.ts maps a few spoken phrases ("fresh start", …)
18
+ // onto this command.
19
+
20
+ import { FRESH_SOCKET_ENV, FRESH_TOKEN_ENV, requestFresh } from "../bin/fresh-supervisor.mjs";
21
+
22
+ // Read once, then taken out of this process's environment: every bash command and
23
+ // subagent the agent runs inherits process.env, and none of them has any business
24
+ // holding the key that restarts this terminal (and kills whatever it's running).
25
+ // Kept on globalThis rather than in a module variable because /reload and a new
26
+ // session re-run this factory, possibly through a fresh module instance, and by then
27
+ // the environment no longer has it.
28
+ const KEY = Symbol.for("privateer.fresh.launcher");
29
+ function launcherKey(): { socket?: string; token?: string } {
30
+ const g = globalThis as any;
31
+ if (!g[KEY]) {
32
+ g[KEY] = { socket: process.env[FRESH_SOCKET_ENV], token: process.env[FRESH_TOKEN_ENV] };
33
+ delete process.env[FRESH_SOCKET_ENV];
34
+ delete process.env[FRESH_TOKEN_ENV];
35
+ }
36
+ return g[KEY];
37
+ }
38
+
39
+ export default function privateerFresh(pi: any): void {
40
+ const { socket, token } = launcherKey();
41
+
42
+ pi.registerCommand?.("fresh", {
43
+ description: "Replace this agent with a new one — stops everything it started, keeps the session for /resume",
44
+ handler: async (_args: string, ctx: any) => {
45
+ const notify = (m: string, kind: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(m, kind);
46
+ if (!socket || !token) {
47
+ notify("/fresh needs a terminal started with `privateer` — this session has no launcher to hand over to. /new starts a new session in place.", "warning");
48
+ return;
49
+ }
50
+ notify("Starting a fresh agent…");
51
+ try {
52
+ await requestFresh(socket, token);
53
+ // The launcher now stops this process; nothing more to do here.
54
+ } catch (e) {
55
+ notify(`Couldn't reach the launcher (${e instanceof Error ? e.message : String(e)}). /new starts a new session in place.`, "error");
56
+ }
57
+ },
58
+ });
59
+ }