privateer-agent 0.11.0 → 0.12.1
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 +9 -0
- package/bin/privateer-launch.mjs +211 -75
- package/bin/privateer-subagent.mjs +35 -22
- package/extensions/privateer-brand.ts +14 -1
- package/extensions/privateer-gate.ts +20 -28
- package/extensions/privateer-media.ts +20 -0
- package/extensions/privateer-privacy.ts +3 -3
- package/package.json +16 -7
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +876 -5
- package/src/acp/run.ts +16 -19
- package/src/channels/run.ts +18 -27
- package/src/cli/chat.ts +61 -18
- package/src/config/hosted.ts +34 -0
- package/src/config/moat.ts +236 -0
- package/src/config/moatManifest.json +26 -0
- package/src/config/moatManifest.ts +71 -0
- package/src/harbor/index.ts +49 -113
- package/src/outbox/cloudOutbox.ts +95 -0
- package/src/permissions/classify.ts +113 -0
- package/src/providers/account.ts +88 -4
- package/src/providers/defaultModel.ts +66 -11
- package/src/remote/extensionsControl.ts +12 -24
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +39 -0
- package/src/remote/remoteBridge.ts +65 -0
- package/src/tools/media.ts +554 -0
- package/src/tools/relayFileTools.ts +10 -11
- package/src/tools/videoCompose.ts +579 -0
- package/src/tools/web.ts +12 -8
- package/src/config/harborDaemon.ts +0 -31
- package/src/config/inlineMoat.ts +0 -67
package/README.md
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
<a href="https://www.npmjs.com/package/privateer-agent">
|
|
13
13
|
<img src="https://img.shields.io/npm/v/privateer-agent" alt="npm" />
|
|
14
14
|
</a>
|
|
15
|
+
<a href="https://www.npmjs.com/package/privateer-agent">
|
|
16
|
+
<img src="https://img.shields.io/npm/dm/privateer-agent" alt="npm downloads" />
|
|
17
|
+
</a>
|
|
15
18
|
<a href="https://github.com/privateer-agent/privateer-agent/releases">
|
|
16
19
|
<img src="https://img.shields.io/badge/changelog-what's%20new-5b8def" alt="Changelog" />
|
|
17
20
|
</a>
|
|
@@ -116,6 +119,12 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
116
119
|
routing between them, and `human_gate` steps that pause for your approval and resume.
|
|
117
120
|
- **Chat-app channels.** Bridge the agent into Telegram, Slack, Discord, or WhatsApp with
|
|
118
121
|
role-based approval — admins can approve actions, members are read-only.
|
|
122
|
+
- **Make images and video.** Signed in, the agent can generate images, video clips, narration
|
|
123
|
+
and music on your account — and stitch them together locally with ffmpeg. It plans the whole
|
|
124
|
+
piece: render the stills, animate them, carry the last frame of one clip into the next so the
|
|
125
|
+
shots stay continuous, cut them together, then score and narrate the result. Generated media
|
|
126
|
+
is handed straight back as files on your machine; none of it is stored in our cloud. See
|
|
127
|
+
[docs/media-generation.md](docs/media-generation.md).
|
|
119
128
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
120
129
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
121
130
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
// platform (macOS, Linux, Windows). `bin/privateer-tui` (unix) and the Windows
|
|
4
4
|
// `privateer.cmd` are thin shims that just pick a Node and run THIS file.
|
|
5
5
|
//
|
|
6
|
-
// It boots Pi's full interactive TUI with the Privateer moat + tool packs
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// It boots Pi's full interactive TUI with the Privateer moat + tool packs, passed as
|
|
7
|
+
// explicit `-e` extension args (the same way --skill passes our bundled skills).
|
|
8
|
+
//
|
|
9
|
+
// We USED to install the moat as re-export shims in the agent dir's extensions/ and let
|
|
10
|
+
// Pi discover them. That directory is shared with every other Privateer process — the
|
|
11
|
+
// harbor daemon, the channels runner, ACP, the REPL — and Pi discovers it into every
|
|
12
|
+
// session built against that agent dir, so those processes each loaded a second copy of
|
|
13
|
+
// the moat on top of the one they build in code: a gate wired to a RemoteBridge nothing
|
|
14
|
+
// had attached a relay to, tool registrations that shadowed the session's own, and
|
|
15
|
+
// module-level state shared across concurrent sessions. Each entry point defended itself
|
|
16
|
+
// with a different env marker, and the ones nobody remembered to defend (media, MCP, web)
|
|
17
|
+
// were never covered at all. Passing `-e` instead means the agent dir's extensions/ holds
|
|
18
|
+
// ONLY the user's own extensions, so there is nothing of ours left to collide with, and
|
|
19
|
+
// each process loads exactly the moat it asked for. See src/config/moat.ts.
|
|
20
|
+
//
|
|
21
|
+
// Runs in the current directory; model via PRIVATEER_MODEL=provider/id.
|
|
12
22
|
//
|
|
13
23
|
// Ported from the original bash launcher; behaviour is intended to match exactly.
|
|
14
24
|
|
|
@@ -16,7 +26,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
16
26
|
import fs from "node:fs";
|
|
17
27
|
import os from "node:os";
|
|
18
28
|
import path from "node:path";
|
|
19
|
-
import { fileURLToPath
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
20
30
|
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
21
31
|
|
|
22
32
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
@@ -25,6 +35,32 @@ const isWin = process.platform === "win32";
|
|
|
25
35
|
|
|
26
36
|
const PRIVATEER_HOME = process.env.PRIVATEER_HOME || path.join(os.homedir(), ".privateer");
|
|
27
37
|
const ENV_FILE = path.join(REPO, ".env"); // dev-only; a real install has none
|
|
38
|
+
const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
|
|
39
|
+
const EXT_DIR = path.join(AGENT_DIR, "extensions");
|
|
40
|
+
|
|
41
|
+
// WHAT to load comes from src/config/moatManifest.json — the same file the TS side derives
|
|
42
|
+
// extensionsControl's RESERVED set and the per-profile factory lists from, so adding an
|
|
43
|
+
// extension is one edit rather than four. JSON because this file runs before the patches,
|
|
44
|
+
// with no tsx and no dependencies. See src/config/moatManifest.ts.
|
|
45
|
+
const MANIFEST = JSON.parse(fs.readFileSync(path.join(REPO, "src", "config", "moatManifest.json"), "utf8"));
|
|
46
|
+
|
|
47
|
+
// Releases up to 0.11 installed the moat as shim files here. They are no longer written
|
|
48
|
+
// (we pass `-e` instead), so any that survive an upgrade are stale — and a stale shim is
|
|
49
|
+
// worse than a missing one: Pi would discover it into every session sharing this agent
|
|
50
|
+
// dir, loading a second moat next to the one that process builds in code. Sweep on EVERY
|
|
51
|
+
// launch, not just the TUI's: a machine that only ever runs `privateer harbor` upgrades
|
|
52
|
+
// too, and its sessions are exactly the ones the duplicate hurt most.
|
|
53
|
+
function sweepLegacyShims() {
|
|
54
|
+
if (!fs.existsSync(EXT_DIR)) return;
|
|
55
|
+
for (const name of [...MANIFEST.shims.map((s) => s.name), ...MANIFEST.retired]) {
|
|
56
|
+
try {
|
|
57
|
+
fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
|
|
58
|
+
} catch {
|
|
59
|
+
/* a root-owned agent dir just means the stale shim stays; the in-process filter
|
|
60
|
+
(src/config/moat.ts) still keeps it out of any session we build. */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
28
64
|
|
|
29
65
|
// --- bundle detection ------------------------------------------------------
|
|
30
66
|
// A self-contained bundle ships its own pinned Node at "$REPO/node[.exe]" plus a
|
|
@@ -43,6 +79,20 @@ process.env.PATH = path.dirname(NODE_BIN) + path.delimiter + (process.env.PATH |
|
|
|
43
79
|
|
|
44
80
|
const args = process.argv.slice(2);
|
|
45
81
|
|
|
82
|
+
// The command name to hand back to the user in copy-pasteable hints. Pi builds those
|
|
83
|
+
// from its own APP_NAME ("pi"), which is installed nowhere on a Privateer machine — so
|
|
84
|
+
// its "To resume this session: pi --session <id>" line pasted straight into
|
|
85
|
+
// `bash: pi: command not found`. npm's bin symlink keeps its own name in argv[1], so
|
|
86
|
+
// the invocation tells us the truth; internal entrypoints (privateer-launch.mjs, the
|
|
87
|
+
// privateer-tui shim) are not on anyone's PATH, so those fall back to the published
|
|
88
|
+
// bin name. Children inherit this via env; the patched formatResumeCommand reads it.
|
|
89
|
+
process.env.PRIVATEER_CMD ??= invokedCommandName();
|
|
90
|
+
|
|
91
|
+
function invokedCommandName() {
|
|
92
|
+
const name = path.basename(process.argv[1] || "").replace(/\.(mjs|cjs|js|cmd|bat|exe)$/i, "");
|
|
93
|
+
return !name || name.startsWith("privateer-") ? "privateer" : name;
|
|
94
|
+
}
|
|
95
|
+
|
|
46
96
|
// `--no-quarter` — total permission bypass ("take no prisoners"). Strip it from the
|
|
47
97
|
// args BEFORE anything else so it never reaches Pi's cli.js (which doesn't know it)
|
|
48
98
|
// and so `sub`/`args.slice(1)` see only real subcommands. When present we export
|
|
@@ -93,31 +143,92 @@ function runToCompletion(cmd, cmdArgs, opts = {}) {
|
|
|
93
143
|
});
|
|
94
144
|
}
|
|
95
145
|
|
|
146
|
+
// npm gives no usable progress, so on a TTY show a braille spinner while it runs
|
|
147
|
+
// and keep its output buffered — shown only if the install fails. Non-TTY (CI,
|
|
148
|
+
// piped) keeps the old passthrough behaviour. The global package is replaced in
|
|
149
|
+
// place, so re-reading our own package.json afterwards yields the NEW version.
|
|
150
|
+
function updateNpmPackage() {
|
|
151
|
+
const cmd = isWin ? "npm.cmd" : "npm";
|
|
152
|
+
const npmArgs = ["install", "-g", "privateer-agent@latest", "--no-fund", "--no-audit"];
|
|
153
|
+
if (!process.stdout.isTTY) {
|
|
154
|
+
console.log("Updating privateer-agent to the latest release…");
|
|
155
|
+
// npm is npm.cmd on Windows; Node >=18.20 needs a shell to spawn a .cmd (EINVAL otherwise).
|
|
156
|
+
runToCompletion(cmd, npmArgs, { shell: isWin });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Ask npm for the globally installed version — REPO/package.json would lie when
|
|
160
|
+
// this copy runs from somewhere other than the global root (e.g. an npx cache).
|
|
161
|
+
const globalVer = () => {
|
|
162
|
+
try {
|
|
163
|
+
const r = spawnSync(cmd, ["ls", "-g", "privateer-agent", "--depth=0", "--json"], { shell: isWin, encoding: "utf8" });
|
|
164
|
+
return JSON.parse(r.stdout).dependencies?.["privateer-agent"]?.version ?? null;
|
|
165
|
+
} catch { return null; }
|
|
166
|
+
};
|
|
167
|
+
const before = globalVer();
|
|
168
|
+
console.log(`\x1b[1m⚓ Updating Privateer\x1b[0m${before ? ` \x1b[2m(currently ${before})\x1b[0m` : ""}`);
|
|
169
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
170
|
+
let captured = "";
|
|
171
|
+
const child = spawn(cmd, [...npmArgs, "--loglevel=error"], { shell: isWin, env: process.env });
|
|
172
|
+
child.stdout.on("data", (d) => (captured += d));
|
|
173
|
+
child.stderr.on("data", (d) => (captured += d));
|
|
174
|
+
process.stdout.write("\x1b[?25l");
|
|
175
|
+
const t0 = Date.now();
|
|
176
|
+
let i = 0;
|
|
177
|
+
const timer = setInterval(() => {
|
|
178
|
+
const s = Math.round((Date.now() - t0) / 1000);
|
|
179
|
+
process.stdout.write(`\r \x1b[36m${frames[i++ % frames.length]}\x1b[0m updating privateer-agent@latest \x1b[2m${s}s\x1b[0m\x1b[K`);
|
|
180
|
+
}, 80);
|
|
181
|
+
const restore = () => { clearInterval(timer); process.stdout.write("\r\x1b[K\x1b[?25h"); };
|
|
182
|
+
child.on("exit", (code, signal) => {
|
|
183
|
+
restore();
|
|
184
|
+
if (code === 0) {
|
|
185
|
+
const after = globalVer();
|
|
186
|
+
if (before && after && before === after) {
|
|
187
|
+
console.log(`\x1b[32m✓\x1b[0m Already ship-shape — privateer-agent ${after} is the latest release.`);
|
|
188
|
+
} else {
|
|
189
|
+
console.log(`\x1b[32m✓\x1b[0m Updated privateer-agent${before ? ` ${before} →` : ""}${after ? ` ${after}` : ""}`);
|
|
190
|
+
console.log(`\nRun \x1b[1mprivateer\x1b[0m to set sail on the new release.`);
|
|
191
|
+
}
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
194
|
+
if (captured.trim()) process.stderr.write(captured);
|
|
195
|
+
if (signal) process.kill(process.pid, signal);
|
|
196
|
+
else process.exit(code ?? 1);
|
|
197
|
+
});
|
|
198
|
+
child.on("error", (e) => {
|
|
199
|
+
restore();
|
|
200
|
+
console.error(`privateer: failed to launch npm — ${e.message}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
96
205
|
// --- `privateer update` ----------------------------------------------------
|
|
97
206
|
// Fetch the latest release and exit. Bundle installs re-run the download+extract
|
|
98
207
|
// installer; npm installs update the global package.
|
|
99
208
|
if (sub === "update") {
|
|
100
209
|
if (BUNDLED) {
|
|
101
|
-
|
|
210
|
+
// PRIVATEER_UPDATE=1 flips the installer into update mode: weigh-anchor banner,
|
|
211
|
+
// "X → Y" version reporting, and an early exit (no download) when already current.
|
|
212
|
+
// ?update=1 tells the server this fetch is an update, not a fresh install.
|
|
213
|
+
const env = { ...process.env, PRIVATEER_UPDATE: "1" };
|
|
102
214
|
if (isWin) {
|
|
103
|
-
runToCompletion("powershell", ["-NoProfile", "-Command", "irm https://privateer.pro/install.ps1 | iex"]);
|
|
215
|
+
runToCompletion("powershell", ["-NoProfile", "-Command", "irm 'https://privateer.pro/install.ps1?update=1' | iex"], { env });
|
|
104
216
|
} else {
|
|
105
|
-
runToCompletion("sh", ["-c", "curl -fsSL https://privateer.pro/install.sh | sh"]);
|
|
217
|
+
runToCompletion("sh", ["-c", "curl -fsSL 'https://privateer.pro/install.sh?update=1' | sh"], { env });
|
|
106
218
|
}
|
|
107
219
|
} else {
|
|
108
|
-
|
|
109
|
-
// npm is npm.cmd on Windows; Node >=18.20 needs a shell to spawn a .cmd (EINVAL otherwise).
|
|
110
|
-
runToCompletion(isWin ? "npm.cmd" : "npm", ["install", "-g", "privateer-agent@latest"], { shell: isWin });
|
|
220
|
+
updateNpmPackage();
|
|
111
221
|
}
|
|
112
|
-
//
|
|
222
|
+
// both paths exit via their child's exit handler.
|
|
113
223
|
}
|
|
114
224
|
|
|
115
225
|
// --- `privateer harbor [run|install|uninstall|status]` ---------------------
|
|
116
226
|
// The resident background harbor (routines + app-driven headless task spawns). Boots
|
|
117
|
-
// straight into src/harbor via bin/privateer-harbor.mjs —
|
|
118
|
-
//
|
|
227
|
+
// straight into src/harbor via bin/privateer-harbor.mjs — the harbor loads the moat as
|
|
228
|
+
// in-code factories, so it needs no `-e` args of its own.
|
|
119
229
|
// `daemon` is a hidden back-compat alias for the pre-rename command name.
|
|
120
230
|
else if (sub === "harbor" || sub === "daemon") {
|
|
231
|
+
sweepLegacyShims(); // a harbor-only machine upgrades too — see the function's note
|
|
121
232
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
122
233
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
|
|
123
234
|
}
|
|
@@ -126,16 +237,16 @@ else if (sub === "harbor" || sub === "daemon") {
|
|
|
126
237
|
// Privateer as an Agent Client Protocol server, spawned by an ACP host (Buzz's
|
|
127
238
|
// `buzz-acp`, Zed, …) and driven over newline-delimited JSON-RPC on stdio.
|
|
128
239
|
//
|
|
129
|
-
// ⚠️ STDOUT IS THE PROTOCOL here, so this branch must stay silent: no banner, no
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// line breaks the JSON-RPC stream and the host disconnects.
|
|
240
|
+
// ⚠️ STDOUT IS THE PROTOCOL here, so this branch must stay silent: no banner, no patch
|
|
241
|
+
// chatter (like `harbor`, the ACP entry loads the moat as in-code factories). A single
|
|
242
|
+
// stray stdout line breaks the JSON-RPC stream and the host disconnects.
|
|
133
243
|
else if (sub === "acp") {
|
|
244
|
+
sweepLegacyShims(); // silent: only ever removes files
|
|
134
245
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
135
246
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)]);
|
|
136
247
|
}
|
|
137
248
|
|
|
138
|
-
// --- normal launch:
|
|
249
|
+
// --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
|
|
139
250
|
else {
|
|
140
251
|
// Windows has no bash out of the box, but Privateer's command tool needs one. If a
|
|
141
252
|
// real bash isn't reachable, stop here with a clear, actionable message — otherwise
|
|
@@ -150,50 +261,22 @@ else {
|
|
|
150
261
|
// stock Pi behaviour, not a broken boot. Bundles ship pre-patched and no-op here.
|
|
151
262
|
ensurePatches();
|
|
152
263
|
|
|
153
|
-
|
|
154
|
-
|
|
264
|
+
// The agent dir's extensions/ is now the USER's alone — we create it so there's a place
|
|
265
|
+
// to drop one, and clear out any shim an older release left behind.
|
|
155
266
|
fs.mkdirSync(EXT_DIR, { recursive: true });
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"privateer-connect",
|
|
165
|
-
"pi-privacy", "pi-web-access", "rpiv-web-tools", "rpiv-ask-user-question",
|
|
166
|
-
"pi-mcp-adapter", "pi-hypa", "pi-subagents",
|
|
167
|
-
];
|
|
168
|
-
for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
|
|
169
|
-
|
|
170
|
-
const ext = (...p) => path.join(REPO, "extensions", ...p);
|
|
171
|
-
// Resolve dependencies by walking the node_modules chain, NOT as REPO/node_modules.
|
|
172
|
-
// npm only nests deps under us for a global install; `npx privateer-agent` and
|
|
173
|
-
// `npm i privateer-agent` HOIST them to a sibling/parent node_modules, where the
|
|
174
|
-
// hardcoded path resolves to nothing and every shim below points at a missing file.
|
|
267
|
+
sweepLegacyShims();
|
|
268
|
+
|
|
269
|
+
// Resolve every moat entry point to an absolute path, to be passed to Pi as `-e`.
|
|
270
|
+
// Dependencies resolve by walking the node_modules chain, NOT as REPO/node_modules: npm
|
|
271
|
+
// only nests deps under us for a global install; `npx privateer-agent` and `npm i
|
|
272
|
+
// privateer-agent` HOIST them to a sibling/parent node_modules, where a hardcoded path
|
|
273
|
+
// resolves to nothing. A target that doesn't exist means that optional tool pack isn't
|
|
274
|
+
// installed — drop it rather than passing a path Pi will fail to load.
|
|
175
275
|
const dep = (name, ...rest) => resolveDep(REPO, name, ...rest);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
fs.writeFileSync(path.join(EXT_DIR, `${name}.ts`), `export { default } from ${JSON.stringify(pathToFileURL(target).href)};\n`);
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
shim("privateer-brand", ext("privateer-brand.ts")); // banner, ⚓ badge, /signin /signout
|
|
184
|
-
shim("privateer-context", ext("privateer-context.ts")); // PRIVATEER.md context + /init
|
|
185
|
-
shim("privateer-gate", ext("privateer-gate.ts")); // the permission gate (moat)
|
|
186
|
-
shim("privateer-account", ext("privateer-account.ts"));
|
|
187
|
-
shim("privateer-models", ext("privateer-models.ts")); // /models picker w/ privacy shields
|
|
188
|
-
shim("privateer-posture", ext("privateer-posture.ts"));
|
|
189
|
-
shim("privateer-tools", ext("privateer-tools.ts"));
|
|
190
|
-
shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
|
|
191
|
-
shim("privateer-connect", ext("privateer-connect.ts")); // /connect — MCP connector manager
|
|
192
|
-
shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
|
|
193
|
-
shim("rpiv-ask-user-question", dep("@juicesharp/rpiv-ask-user-question", "index.ts")); // ask_user_question
|
|
194
|
-
shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
|
|
195
|
-
shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
|
|
196
|
-
shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
|
|
276
|
+
const MOAT_PATHS = MANIFEST.shims
|
|
277
|
+
.map((s) => (s.entry ? path.join(REPO, s.entry) : dep(...s.dep)))
|
|
278
|
+
.filter((p) => p && fs.existsSync(p));
|
|
279
|
+
const extArgs = MOAT_PATHS.flatMap((p) => ["-e", p]);
|
|
197
280
|
|
|
198
281
|
// Unlike the tool packs above, Pi's CLI is not optional — it IS the agent. If it
|
|
199
282
|
// didn't resolve, the install is broken; say so instead of spawning `undefined`.
|
|
@@ -206,10 +289,18 @@ else {
|
|
|
206
289
|
process.exit(1);
|
|
207
290
|
}
|
|
208
291
|
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
209
|
-
// The binary pi-subagents spawns for each child
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
292
|
+
// The binary pi-subagents spawns for each child, and the moat that child must load.
|
|
293
|
+
//
|
|
294
|
+
// ⚠️ SECURITY-LOAD-BEARING. A child used to inherit the moat by DISCOVERING the shims
|
|
295
|
+
// from the shared agent dir — so pointing PI_SUBAGENT_PI_BINARY straight at cli.js was
|
|
296
|
+
// enough. With the shims gone there is nothing to discover, and a child spawned that way
|
|
297
|
+
// would run COMPLETELY UNGATED. So route children through our wrapper, which injects the
|
|
298
|
+
// moat explicitly, and hand it the exact set this TUI is loading: a child gets its
|
|
299
|
+
// parent's moat, not a hardcoded subset that drifts from it.
|
|
300
|
+
if (!process.env.PI_SUBAGENT_PI_BINARY) {
|
|
301
|
+
process.env.PI_SUBAGENT_PI_BINARY = path.join(REPO, "bin", "privateer-subagent.mjs");
|
|
302
|
+
}
|
|
303
|
+
process.env.PRIVATEER_CHILD_EXTENSIONS = MOAT_PATHS.join(path.delimiter);
|
|
213
304
|
// Suppress Pi's upstream update banner (our banner is the startup surface). Disables
|
|
214
305
|
// ONLY the version fetch — fd/rg can still download on first run.
|
|
215
306
|
if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
|
|
@@ -233,10 +324,11 @@ else {
|
|
|
233
324
|
// auto-install. Fire-and-forget: the event loop stays alive while the TUI child runs.
|
|
234
325
|
refreshUpdateCache();
|
|
235
326
|
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
327
|
+
// Computed default model — used ONLY when the user has no saved pick and no
|
|
328
|
+
// override (see modelArgs below). Mirrors src/providers/defaultModel.ts
|
|
329
|
+
// resolveDefaultModel() — keep the two in step. Tinfoil's default is the same
|
|
330
|
+
// either way: direct when the user has a Tinfoil key (pi-privacy can client-attest
|
|
331
|
+
// the enclave), over the Privateer subscription otherwise.
|
|
240
332
|
//
|
|
241
333
|
// The last branch is the important one. A signed-out, keyless terminal used to launch
|
|
242
334
|
// on `openrouter/openai/gpt-4o-mini`, which it had no key for — so the first prompt
|
|
@@ -247,11 +339,13 @@ else {
|
|
|
247
339
|
// Privateer and points at /login.
|
|
248
340
|
const CRED = path.join(PRIVATEER_HOME, "credentials.json");
|
|
249
341
|
const signedIn = fs.existsSync(CRED);
|
|
250
|
-
|
|
342
|
+
// Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
|
|
343
|
+
// file carries the measurements behind the choice.
|
|
344
|
+
const ACCOUNT_MODEL = "privateer/tinfoil/kimi-k2-6";
|
|
251
345
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
252
346
|
? process.env.PRIVATEER_MODEL
|
|
253
347
|
: haveTinfoilKey()
|
|
254
|
-
? "tinfoil/
|
|
348
|
+
? "tinfoil/kimi-k2-6"
|
|
255
349
|
: signedIn
|
|
256
350
|
? ACCOUNT_MODEL
|
|
257
351
|
: haveKey("ANTHROPIC_API_KEY")
|
|
@@ -280,9 +374,35 @@ else {
|
|
|
280
374
|
.filter((dir) => fs.existsSync(dir));
|
|
281
375
|
const skillArgs = SKILL_DIRS.flatMap((dir) => ["--skill", dir]);
|
|
282
376
|
|
|
377
|
+
// Honor the user's own persisted pick. Pi writes defaultProvider + defaultModel to
|
|
378
|
+
// AGENT_DIR/settings.json on EVERY interactive switch (AgentSession.setModel — the
|
|
379
|
+
// built-in selector and pi-privacy's /models picker both land there), but consults
|
|
380
|
+
// it ONLY when no --model flag is passed (sdk.js: `let model = options.model` short-
|
|
381
|
+
// circuits the settings default). Passing --model unconditionally was therefore the
|
|
382
|
+
// "model switch doesn't persist" bug: every pick saved faithfully, then stomped at
|
|
383
|
+
// the next launch. Precedence (mirrors resolveDefaultModel — keep in step):
|
|
384
|
+
// 1. a --model the user typed on the privateer command line (already in `args`)
|
|
385
|
+
// 2. PRIVATEER_MODEL — deliberate override, folded into MODEL above
|
|
386
|
+
// 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
|
|
387
|
+
// falls back sanely if that model has vanished from the registry)
|
|
388
|
+
// 4. nothing saved (first run / fresh home) → the computed MODEL above
|
|
389
|
+
const userPassedModel = args.includes("--model");
|
|
390
|
+
let savedDefault = null;
|
|
391
|
+
try {
|
|
392
|
+
const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
|
|
393
|
+
if (
|
|
394
|
+
typeof s.defaultProvider === "string" && s.defaultProvider.trim() &&
|
|
395
|
+
typeof s.defaultModel === "string" && s.defaultModel.trim()
|
|
396
|
+
) {
|
|
397
|
+
savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
|
|
398
|
+
}
|
|
399
|
+
} catch { /* absent/unreadable → no saved pick */ }
|
|
400
|
+
const modelArgs =
|
|
401
|
+
userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
|
|
402
|
+
|
|
283
403
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
284
404
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
285
|
-
runToCompletion(NODE_BIN, [...nodeArgs, CLI,
|
|
405
|
+
runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
|
|
286
406
|
}
|
|
287
407
|
|
|
288
408
|
// --- helpers ---------------------------------------------------------------
|
|
@@ -346,11 +466,27 @@ function findWindowsBash() {
|
|
|
346
466
|
// tell the user why in a way they can act on. "current"/"applied"/"skipped" are silent.
|
|
347
467
|
function ensurePatches() {
|
|
348
468
|
if (applyPatchesIfNeeded(REPO, NODE_BIN) !== "failed") return;
|
|
469
|
+
|
|
470
|
+
// Most of the patch set is UX polish that degrades to stock Pi. The project config
|
|
471
|
+
// dir is NOT: without the patch, `<cwd>/.privateer/` is a directory Pi has never
|
|
472
|
+
// heard of, so a project's settings, packages, skills and extensions are ignored
|
|
473
|
+
// outright. Say so — and say it louder when the current project actually has one,
|
|
474
|
+
// because that user is about to run with a config they believe is loaded.
|
|
475
|
+
const projectDirIgnored = fs.existsSync(path.join(process.cwd(), ".privateer"));
|
|
349
476
|
process.stderr.write(
|
|
350
477
|
[
|
|
351
478
|
"",
|
|
352
479
|
" ⚓ Couldn't apply Privateer's bundled patches to node_modules — continuing without them.",
|
|
353
|
-
"
|
|
480
|
+
" Upstream fixes (retry-loop guard, /model → /models redirect) stay off, and this",
|
|
481
|
+
" project's `.privateer/` config directory is NOT read — only `.pi/` is.",
|
|
482
|
+
...(projectDirIgnored
|
|
483
|
+
? [
|
|
484
|
+
"",
|
|
485
|
+
` THIS PROJECT HAS ONE: ${path.join(process.cwd(), ".privateer")}`,
|
|
486
|
+
" Its settings, packages, skills and extensions are being ignored right now.",
|
|
487
|
+
]
|
|
488
|
+
: []),
|
|
489
|
+
"",
|
|
354
490
|
` Usually a permissions issue: ${path.join(REPO, "node_modules")} isn't writable`,
|
|
355
491
|
" by this user (a `sudo npm install -g` install). Re-run once with sudo, or",
|
|
356
492
|
" install without sudo (nvm, or an npm prefix you own) to fix it for good.",
|
|
@@ -1,35 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The binary pi-subagents spawns for
|
|
3
|
-
// when the PARENT loaded privateer's moat as IN-CODE extension factories (the lean
|
|
4
|
-
// REPL, the harbor, live task sessions) rather than agent-dir discovery.
|
|
2
|
+
// The binary pi-subagents spawns for EVERY subagent child (via PI_SUBAGENT_PI_BINARY).
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// `--no-extensions` to turn agent-dir discovery
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
4
|
+
// ⚠️ THIS IS WHAT KEEPS A SUBAGENT GATED. A child is a fresh `pi` subprocess that cannot
|
|
5
|
+
// inherit its parent's in-code extension factories, and — since the moat stopped being
|
|
6
|
+
// installed as discovery shims in the shared agent dir — there is nothing for it to
|
|
7
|
+
// discover either. So the moat reaches a child through exactly one route: the `-e` args
|
|
8
|
+
// injected here. A child spawned around this wrapper runs UNGATED.
|
|
9
|
+
//
|
|
10
|
+
// It also passes `--no-extensions` to turn agent-dir discovery off. That is now about the
|
|
11
|
+
// child's blast radius rather than double-loading: an unattended, headless worker should
|
|
12
|
+
// load the set its parent chose and nothing else — not whatever extensions the user
|
|
13
|
+
// happens to have configured for their own interactive sessions. (`--no-extensions`
|
|
14
|
+
// disables DISCOVERY only, so pi-subagents' own `--extension` args still load, as do the
|
|
15
|
+
// `-e` paths below.)
|
|
17
16
|
//
|
|
18
17
|
// Exported helpers are pure and unit-tested (tests/subagentWrapper.test.ts); the
|
|
19
18
|
// spawn only runs when this file is invoked as a binary.
|
|
20
19
|
|
|
21
20
|
import { spawn } from "node:child_process";
|
|
22
21
|
import { fileURLToPath } from "node:url";
|
|
23
|
-
import { dirname, resolve, join } from "node:path";
|
|
22
|
+
import { dirname, resolve, join, delimiter } from "node:path";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
24
|
|
|
25
25
|
const HERE = dirname(fileURLToPath(import.meta.url)); // bin/
|
|
26
26
|
const REPO = resolve(HERE, ".."); // repo root
|
|
27
27
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
28
|
+
// The moat a child loads: whatever its PARENT loaded, handed down through the environment.
|
|
29
|
+
//
|
|
30
|
+
// bin/privateer-launch.mjs sets PRIVATEER_CHILD_EXTENSIONS to the same list it passes the
|
|
31
|
+
// TUI, so a child of a terminal gets the terminal's moat — including the tool packs —
|
|
32
|
+
// rather than a hardcoded subset that silently drifts from it as the manifest changes.
|
|
33
|
+
//
|
|
34
|
+
// The fallback covers the in-code parents (the lean REPL, the harbor, live task sessions),
|
|
35
|
+
// which build their moat from factories and have no `-e` list to hand down. Those get the
|
|
36
|
+
// floor and nothing else: gate = the permission moat (fail-closed, forwards the child's
|
|
37
|
+
// approvals to the parent); privacy = ZDR/TEE posture + attestation dispatcher; account =
|
|
38
|
+
// the privateer/* provider, so a child can run account models. Deliberately narrower than
|
|
39
|
+
// a terminal's — an unattended run's children have no human to approve a tool that spends.
|
|
40
|
+
export function moatExtensionPaths(repoRoot = REPO, env = process.env) {
|
|
41
|
+
const inherited = (env.PRIVATEER_CHILD_EXTENSIONS ?? "")
|
|
42
|
+
.split(delimiter)
|
|
43
|
+
.map((p) => p.trim())
|
|
44
|
+
.filter((p) => p && existsSync(p));
|
|
45
|
+
if (inherited.length > 0) return inherited;
|
|
33
46
|
return [
|
|
34
47
|
join(repoRoot, "extensions", "privateer-gate.ts"),
|
|
35
48
|
join(repoRoot, "extensions", "privateer-privacy.ts"),
|
|
@@ -45,9 +58,9 @@ export function piCliPath(repoRoot = REPO) {
|
|
|
45
58
|
// Given the args pi-subagents built for the child, return the args to run the bundled
|
|
46
59
|
// cli.js with: `--no-extensions` + one `-e <path>` per moat extension, THEN the
|
|
47
60
|
// original args (so the injected flags precede the positional `Task:` prompt).
|
|
48
|
-
export function buildChildArgs(originalArgs, repoRoot = REPO) {
|
|
61
|
+
export function buildChildArgs(originalArgs, repoRoot = REPO, env = process.env) {
|
|
49
62
|
const inject = ["--no-extensions"];
|
|
50
|
-
for (const p of moatExtensionPaths(repoRoot)) inject.push("-e", p);
|
|
63
|
+
for (const p of moatExtensionPaths(repoRoot, env)) inject.push("-e", p);
|
|
51
64
|
return [...inject, ...originalArgs];
|
|
52
65
|
}
|
|
53
66
|
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
makeAccountProvider,
|
|
35
35
|
verificationLink,
|
|
36
36
|
} from "../src/providers/account.ts";
|
|
37
|
-
import { resolveSignedInModel } from "../src/providers/defaultModel.ts";
|
|
37
|
+
import { resolveSignedInModel, savedPiDefaultSpec } from "../src/providers/defaultModel.ts";
|
|
38
38
|
import { discoverContextFiles, onContextChanged } from "../src/context.ts";
|
|
39
39
|
import { type Palette, paletteFor } from "../src/ui/palette.ts";
|
|
40
40
|
|
|
@@ -501,6 +501,19 @@ export default function privateerBrand(pi: any): void {
|
|
|
501
501
|
return;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
// A saved default the user picked themselves (Pi persists every interactive
|
|
505
|
+
// switch, and the launcher now boots on it) is a standing instruction. When it
|
|
506
|
+
// points anywhere other than the sign-in target, arming the channel — done
|
|
507
|
+
// above — is all sign-in may do: auto-switching would stomp the pick the
|
|
508
|
+
// launcher just honored, one login at a time.
|
|
509
|
+
const saved = savedPiDefaultSpec();
|
|
510
|
+
if (saved && saved !== spec) {
|
|
511
|
+
dbg(`activateSignedInModel: saved default ${saved} is a deliberate pick — not switching`);
|
|
512
|
+
refresh(ctx);
|
|
513
|
+
ctx?.ui?.notify?.("Signed in — account channel armed. Staying on your saved model.", "info");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
504
517
|
const reg = ctx?.modelRegistry;
|
|
505
518
|
if (!reg?.find || typeof pi.setModel !== "function") return;
|
|
506
519
|
const model = reg.find(provider, id);
|
|
@@ -26,8 +26,6 @@ import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentSt
|
|
|
26
26
|
import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
|
|
27
27
|
import { makeSkillsControl } from "../src/remote/skillsControl.ts";
|
|
28
28
|
import { agentDir } from "../src/config/paths.ts";
|
|
29
|
-
import { inHarborDaemon } from "../src/config/harborDaemon.ts";
|
|
30
|
-
import { discoveredGateApplies } from "../src/config/inlineMoat.ts";
|
|
31
29
|
import { agentVersion } from "../src/config/version.ts";
|
|
32
30
|
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
33
31
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
@@ -435,22 +433,18 @@ const gate = makePermissionGate({
|
|
|
435
433
|
|
|
436
434
|
export default function privateerControl(pi: any): void {
|
|
437
435
|
piRef = pi;
|
|
438
|
-
// The moat — tool_call (block/allow) + tool_result (redact)
|
|
439
|
-
// session doesn't already carry one. A process that builds its sessions with
|
|
440
|
-
// makePermissionGate() as an inline factory (the harbor and its live task spawns,
|
|
441
|
-
// the channels runner) still auto-discovers this shim from the shared agent dir,
|
|
442
|
-
// and Pi runs EVERY tool_call handler — stopping early only on a block. Installing
|
|
443
|
-
// here too would mean two gates on one call: a second, redundant approval dialog
|
|
444
|
-
// where a UI is bound (a live task spawn), and a fail-closed local deny where one
|
|
445
|
-
// isn't (this file's bridge has no relay outside `/remote-access`). The session's
|
|
446
|
-
// own gate is the right one — it knows that session's cwd and its approver. See
|
|
447
|
-
// config/inlineMoat.ts for the full failure shape.
|
|
436
|
+
// The moat — tool_call (block/allow) + tool_result (redact).
|
|
448
437
|
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
438
|
+
// Unconditional, because this file now only loads where it is wanted: the interactive
|
|
439
|
+
// TUI and subagent children get it as an explicit `-e` argument (bin/privateer-launch.mjs,
|
|
440
|
+
// bin/privateer-subagent.mjs), and the processes that build their own gate from
|
|
441
|
+
// makePermissionGate() never load it at all. It used to be conditional — this extension
|
|
442
|
+
// was installed as a shim in the shared agent dir, so Pi discovered it into the harbor,
|
|
443
|
+
// the channels runner and the REPL as well, and a second gate there meant either a
|
|
444
|
+
// duplicate approval dialog or a fail-closed deny before the session's real approver was
|
|
445
|
+
// consulted. The fix moved to the host: nothing of ours is discoverable any more, so the
|
|
446
|
+
// gate no longer has to ask whose process it woke up in. See src/config/moat.ts.
|
|
447
|
+
gate(pi);
|
|
454
448
|
|
|
455
449
|
// Top-level session: watch the subagent approval channel and relay each child's
|
|
456
450
|
// gated action to the app over this session's bridge. The bridge fails closed while
|
|
@@ -464,17 +458,15 @@ export default function privateerControl(pi: any): void {
|
|
|
464
458
|
// save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
|
|
465
459
|
// live here because they share the RemoteBridge / its attachment stream.
|
|
466
460
|
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
477
|
-
}
|
|
461
|
+
// Also unconditional now, and for the same reason. These used to stand down inside the
|
|
462
|
+
// harbor daemon: this extension was discovered into every session the daemon ran, where
|
|
463
|
+
// `bridge` never gets a relay (only THIS file's /remote-access attaches one), so the pair
|
|
464
|
+
// registered here would shadow — Pi resolves duplicate tool names first-registration-wins
|
|
465
|
+
// — the session-scoped pair a live task spawn binds to its own connected relay, and
|
|
466
|
+
// send_file_to_client would answer "remote access is off" while the app sat attached and
|
|
467
|
+
// driving. The daemon no longer loads this file, so there is nothing to shadow.
|
|
468
|
+
pi.registerTool?.(makeSendFileTool(bridge));
|
|
469
|
+
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
478
470
|
|
|
479
471
|
// Subagents (and print/rpc) run as headless child `pi` processes with no UI. There
|
|
480
472
|
// no one can approve, so a "default" gate would fail-closed on every tool and the
|