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.
- package/README.md +32 -1
- package/bin/fresh-supervisor.d.mts +35 -0
- package/bin/fresh-supervisor.mjs +419 -0
- package/bin/headless-flags.d.mts +25 -0
- package/bin/headless-flags.mjs +166 -0
- package/bin/privateer-launch.mjs +179 -17
- package/extensions/privateer-fresh.ts +59 -0
- package/extensions/privateer-gate.ts +192 -5
- package/extensions/privateer-speak.ts +12 -1
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.86.0.patch +24 -11
- package/src/acp/run.ts +3 -1
- package/src/bridge/engineAdapter.ts +1 -1
- package/src/cli/chat.ts +6 -2
- 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 +57 -3
- 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/remote/headlessApproval.ts +146 -0
- package/src/tools/media.ts +151 -12
|
@@ -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
|
+
}
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|