privateer-agent 0.10.0 → 0.12.0
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 +13 -0
- package/bin/privateer-launch.mjs +144 -66
- 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 +17 -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 +70 -25
- 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 +122 -0
- package/src/providers/account.ts +141 -19
- package/src/providers/defaultModel.ts +66 -11
- package/src/providers/phala/aci-verifier/VENDORED.md +16 -0
- package/src/providers/phala/reportBinding.ts +193 -0
- package/src/providers/phalaSeal.ts +7 -3
- package/src/providers/sealedShim.ts +35 -6
- package/src/remote/extensionsControl.ts +12 -22
- package/src/remote/liveTaskSession.ts +15 -24
- package/src/remote/relayClient.ts +47 -7
- 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>
|
|
@@ -73,6 +76,7 @@ What Privateer adds is a *moat* of Pi extensions layered on top:
|
|
|
73
76
|
| `privateer-account` | `/signin` billed inference against a Privateer account (device flow) |
|
|
74
77
|
| `privateer-posture`, `privateer-tools` | live attestation shield + Privateer tool pack |
|
|
75
78
|
| `rpiv-web-tools` | private-by-default web search (self-hosted SearXNG, no WebView) |
|
|
79
|
+
| `rpiv-ask-user-question` | `ask_user_question` — a structured questionnaire the agent puts to you instead of guessing |
|
|
76
80
|
| `pi-mcp-adapter`, `pi-subagents` | MCP servers · bounded parallel sub-agents |
|
|
77
81
|
|
|
78
82
|
They're ordinary Pi extensions — inspect them, replace them, or build your own alongside.
|
|
@@ -115,6 +119,12 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
115
119
|
routing between them, and `human_gate` steps that pause for your approval and resume.
|
|
116
120
|
- **Chat-app channels.** Bridge the agent into Telegram, Slack, Discord, or WhatsApp with
|
|
117
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).
|
|
118
128
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
119
129
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
120
130
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
@@ -509,6 +519,9 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
509
519
|
[Workflows](#workflows).
|
|
510
520
|
- **Web tools** (`rpiv-web-tools`) — private-by-default web search/fetch with pluggable backends
|
|
511
521
|
(self-hosted SearXNG for fully private search).
|
|
522
|
+
- **Ask user question** (`rpiv-ask-user-question`) — when a request is underspecified the agent
|
|
523
|
+
raises a structured questionnaire (typed options, multi-select, markdown previews, or type your
|
|
524
|
+
own answer) instead of guessing. Ungated by design — it only asks you something.
|
|
512
525
|
|
|
513
526
|
## Command reference
|
|
514
527
|
|
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
|
|
@@ -114,10 +164,11 @@ if (sub === "update") {
|
|
|
114
164
|
|
|
115
165
|
// --- `privateer harbor [run|install|uninstall|status]` ---------------------
|
|
116
166
|
// The resident background harbor (routines + app-driven headless task spawns). Boots
|
|
117
|
-
// straight into src/harbor via bin/privateer-harbor.mjs —
|
|
118
|
-
//
|
|
167
|
+
// straight into src/harbor via bin/privateer-harbor.mjs — the harbor loads the moat as
|
|
168
|
+
// in-code factories, so it needs no `-e` args of its own.
|
|
119
169
|
// `daemon` is a hidden back-compat alias for the pre-rename command name.
|
|
120
170
|
else if (sub === "harbor" || sub === "daemon") {
|
|
171
|
+
sweepLegacyShims(); // a harbor-only machine upgrades too — see the function's note
|
|
121
172
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
122
173
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
|
|
123
174
|
}
|
|
@@ -126,16 +177,16 @@ else if (sub === "harbor" || sub === "daemon") {
|
|
|
126
177
|
// Privateer as an Agent Client Protocol server, spawned by an ACP host (Buzz's
|
|
127
178
|
// `buzz-acp`, Zed, …) and driven over newline-delimited JSON-RPC on stdio.
|
|
128
179
|
//
|
|
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.
|
|
180
|
+
// ⚠️ STDOUT IS THE PROTOCOL here, so this branch must stay silent: no banner, no patch
|
|
181
|
+
// chatter (like `harbor`, the ACP entry loads the moat as in-code factories). A single
|
|
182
|
+
// stray stdout line breaks the JSON-RPC stream and the host disconnects.
|
|
133
183
|
else if (sub === "acp") {
|
|
184
|
+
sweepLegacyShims(); // silent: only ever removes files
|
|
134
185
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
135
186
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)]);
|
|
136
187
|
}
|
|
137
188
|
|
|
138
|
-
// --- normal launch:
|
|
189
|
+
// --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
|
|
139
190
|
else {
|
|
140
191
|
// Windows has no bash out of the box, but Privateer's command tool needs one. If a
|
|
141
192
|
// real bash isn't reachable, stop here with a clear, actionable message — otherwise
|
|
@@ -150,48 +201,22 @@ else {
|
|
|
150
201
|
// stock Pi behaviour, not a broken boot. Bundles ship pre-patched and no-op here.
|
|
151
202
|
ensurePatches();
|
|
152
203
|
|
|
153
|
-
|
|
154
|
-
|
|
204
|
+
// The agent dir's extensions/ is now the USER's alone — we create it so there's a place
|
|
205
|
+
// to drop one, and clear out any shim an older release left behind.
|
|
155
206
|
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", "pi-mcp-adapter", "pi-hypa", "pi-subagents",
|
|
166
|
-
];
|
|
167
|
-
for (const name of MANAGED) fs.rmSync(path.join(EXT_DIR, `${name}.ts`), { force: true });
|
|
168
|
-
|
|
169
|
-
const ext = (...p) => path.join(REPO, "extensions", ...p);
|
|
170
|
-
// Resolve dependencies by walking the node_modules chain, NOT as REPO/node_modules.
|
|
171
|
-
// npm only nests deps under us for a global install; `npx privateer-agent` and
|
|
172
|
-
// `npm i privateer-agent` HOIST them to a sibling/parent node_modules, where the
|
|
173
|
-
// hardcoded path resolves to nothing and every shim below points at a missing file.
|
|
207
|
+
sweepLegacyShims();
|
|
208
|
+
|
|
209
|
+
// Resolve every moat entry point to an absolute path, to be passed to Pi as `-e`.
|
|
210
|
+
// Dependencies resolve by walking the node_modules chain, NOT as REPO/node_modules: npm
|
|
211
|
+
// only nests deps under us for a global install; `npx privateer-agent` and `npm i
|
|
212
|
+
// privateer-agent` HOIST them to a sibling/parent node_modules, where a hardcoded path
|
|
213
|
+
// resolves to nothing. A target that doesn't exist means that optional tool pack isn't
|
|
214
|
+
// installed — drop it rather than passing a path Pi will fail to load.
|
|
174
215
|
const dep = (name, ...rest) => resolveDep(REPO, name, ...rest);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
fs.writeFileSync(path.join(EXT_DIR, `${name}.ts`), `export { default } from ${JSON.stringify(pathToFileURL(target).href)};\n`);
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
shim("privateer-brand", ext("privateer-brand.ts")); // banner, ⚓ badge, /signin /signout
|
|
183
|
-
shim("privateer-context", ext("privateer-context.ts")); // PRIVATEER.md context + /init
|
|
184
|
-
shim("privateer-gate", ext("privateer-gate.ts")); // the permission gate (moat)
|
|
185
|
-
shim("privateer-account", ext("privateer-account.ts"));
|
|
186
|
-
shim("privateer-models", ext("privateer-models.ts")); // /models picker w/ privacy shields
|
|
187
|
-
shim("privateer-posture", ext("privateer-posture.ts"));
|
|
188
|
-
shim("privateer-tools", ext("privateer-tools.ts"));
|
|
189
|
-
shim("privateer-privacy", ext("privateer-privacy.ts")); // pi-privacy + account tier resolver
|
|
190
|
-
shim("privateer-connect", ext("privateer-connect.ts")); // /connect — MCP connector manager
|
|
191
|
-
shim("rpiv-web-tools", dep("@juicesharp/rpiv-web-tools", "index.ts")); // private web tools
|
|
192
|
-
shim("pi-mcp-adapter", dep("pi-mcp-adapter", "index.ts"));
|
|
193
|
-
shim("pi-hypa", dep("@hypabolic/pi-hypa", "extensions", "index.ts"));
|
|
194
|
-
shim("pi-subagents", dep("pi-subagents", "src", "extension", "index.ts"));
|
|
216
|
+
const MOAT_PATHS = MANIFEST.shims
|
|
217
|
+
.map((s) => (s.entry ? path.join(REPO, s.entry) : dep(...s.dep)))
|
|
218
|
+
.filter((p) => p && fs.existsSync(p));
|
|
219
|
+
const extArgs = MOAT_PATHS.flatMap((p) => ["-e", p]);
|
|
195
220
|
|
|
196
221
|
// Unlike the tool packs above, Pi's CLI is not optional — it IS the agent. If it
|
|
197
222
|
// didn't resolve, the install is broken; say so instead of spawning `undefined`.
|
|
@@ -204,10 +229,18 @@ else {
|
|
|
204
229
|
process.exit(1);
|
|
205
230
|
}
|
|
206
231
|
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
207
|
-
// The binary pi-subagents spawns for each child
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
232
|
+
// The binary pi-subagents spawns for each child, and the moat that child must load.
|
|
233
|
+
//
|
|
234
|
+
// ⚠️ SECURITY-LOAD-BEARING. A child used to inherit the moat by DISCOVERING the shims
|
|
235
|
+
// from the shared agent dir — so pointing PI_SUBAGENT_PI_BINARY straight at cli.js was
|
|
236
|
+
// enough. With the shims gone there is nothing to discover, and a child spawned that way
|
|
237
|
+
// would run COMPLETELY UNGATED. So route children through our wrapper, which injects the
|
|
238
|
+
// moat explicitly, and hand it the exact set this TUI is loading: a child gets its
|
|
239
|
+
// parent's moat, not a hardcoded subset that drifts from it.
|
|
240
|
+
if (!process.env.PI_SUBAGENT_PI_BINARY) {
|
|
241
|
+
process.env.PI_SUBAGENT_PI_BINARY = path.join(REPO, "bin", "privateer-subagent.mjs");
|
|
242
|
+
}
|
|
243
|
+
process.env.PRIVATEER_CHILD_EXTENSIONS = MOAT_PATHS.join(path.delimiter);
|
|
211
244
|
// Suppress Pi's upstream update banner (our banner is the startup surface). Disables
|
|
212
245
|
// ONLY the version fetch — fd/rg can still download on first run.
|
|
213
246
|
if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
|
|
@@ -231,10 +264,11 @@ else {
|
|
|
231
264
|
// auto-install. Fire-and-forget: the event loop stays alive while the TUI child runs.
|
|
232
265
|
refreshUpdateCache();
|
|
233
266
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
267
|
+
// Computed default model — used ONLY when the user has no saved pick and no
|
|
268
|
+
// override (see modelArgs below). Mirrors src/providers/defaultModel.ts
|
|
269
|
+
// resolveDefaultModel() — keep the two in step. Tinfoil's default is the same
|
|
270
|
+
// either way: direct when the user has a Tinfoil key (pi-privacy can client-attest
|
|
271
|
+
// the enclave), over the Privateer subscription otherwise.
|
|
238
272
|
//
|
|
239
273
|
// The last branch is the important one. A signed-out, keyless terminal used to launch
|
|
240
274
|
// on `openrouter/openai/gpt-4o-mini`, which it had no key for — so the first prompt
|
|
@@ -245,11 +279,13 @@ else {
|
|
|
245
279
|
// Privateer and points at /login.
|
|
246
280
|
const CRED = path.join(PRIVATEER_HOME, "credentials.json");
|
|
247
281
|
const signedIn = fs.existsSync(CRED);
|
|
248
|
-
|
|
282
|
+
// Mirrors TINFOIL_MODEL_ID in src/providers/defaultModel.ts — keep them in step; that
|
|
283
|
+
// file carries the measurements behind the choice.
|
|
284
|
+
const ACCOUNT_MODEL = "privateer/tinfoil/kimi-k2-6";
|
|
249
285
|
const MODEL = process.env.PRIVATEER_MODEL
|
|
250
286
|
? process.env.PRIVATEER_MODEL
|
|
251
287
|
: haveTinfoilKey()
|
|
252
|
-
? "tinfoil/
|
|
288
|
+
? "tinfoil/kimi-k2-6"
|
|
253
289
|
: signedIn
|
|
254
290
|
? ACCOUNT_MODEL
|
|
255
291
|
: haveKey("ANTHROPIC_API_KEY")
|
|
@@ -278,9 +314,35 @@ else {
|
|
|
278
314
|
.filter((dir) => fs.existsSync(dir));
|
|
279
315
|
const skillArgs = SKILL_DIRS.flatMap((dir) => ["--skill", dir]);
|
|
280
316
|
|
|
317
|
+
// Honor the user's own persisted pick. Pi writes defaultProvider + defaultModel to
|
|
318
|
+
// AGENT_DIR/settings.json on EVERY interactive switch (AgentSession.setModel — the
|
|
319
|
+
// built-in selector and pi-privacy's /models picker both land there), but consults
|
|
320
|
+
// it ONLY when no --model flag is passed (sdk.js: `let model = options.model` short-
|
|
321
|
+
// circuits the settings default). Passing --model unconditionally was therefore the
|
|
322
|
+
// "model switch doesn't persist" bug: every pick saved faithfully, then stomped at
|
|
323
|
+
// the next launch. Precedence (mirrors resolveDefaultModel — keep in step):
|
|
324
|
+
// 1. a --model the user typed on the privateer command line (already in `args`)
|
|
325
|
+
// 2. PRIVATEER_MODEL — deliberate override, folded into MODEL above
|
|
326
|
+
// 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
|
|
327
|
+
// falls back sanely if that model has vanished from the registry)
|
|
328
|
+
// 4. nothing saved (first run / fresh home) → the computed MODEL above
|
|
329
|
+
const userPassedModel = args.includes("--model");
|
|
330
|
+
let savedDefault = null;
|
|
331
|
+
try {
|
|
332
|
+
const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
|
|
333
|
+
if (
|
|
334
|
+
typeof s.defaultProvider === "string" && s.defaultProvider.trim() &&
|
|
335
|
+
typeof s.defaultModel === "string" && s.defaultModel.trim()
|
|
336
|
+
) {
|
|
337
|
+
savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
|
|
338
|
+
}
|
|
339
|
+
} catch { /* absent/unreadable → no saved pick */ }
|
|
340
|
+
const modelArgs =
|
|
341
|
+
userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
|
|
342
|
+
|
|
281
343
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
282
344
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
283
|
-
runToCompletion(NODE_BIN, [...nodeArgs, CLI,
|
|
345
|
+
runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
|
|
284
346
|
}
|
|
285
347
|
|
|
286
348
|
// --- helpers ---------------------------------------------------------------
|
|
@@ -344,11 +406,27 @@ function findWindowsBash() {
|
|
|
344
406
|
// tell the user why in a way they can act on. "current"/"applied"/"skipped" are silent.
|
|
345
407
|
function ensurePatches() {
|
|
346
408
|
if (applyPatchesIfNeeded(REPO, NODE_BIN) !== "failed") return;
|
|
409
|
+
|
|
410
|
+
// Most of the patch set is UX polish that degrades to stock Pi. The project config
|
|
411
|
+
// dir is NOT: without the patch, `<cwd>/.privateer/` is a directory Pi has never
|
|
412
|
+
// heard of, so a project's settings, packages, skills and extensions are ignored
|
|
413
|
+
// outright. Say so — and say it louder when the current project actually has one,
|
|
414
|
+
// because that user is about to run with a config they believe is loaded.
|
|
415
|
+
const projectDirIgnored = fs.existsSync(path.join(process.cwd(), ".privateer"));
|
|
347
416
|
process.stderr.write(
|
|
348
417
|
[
|
|
349
418
|
"",
|
|
350
419
|
" ⚓ Couldn't apply Privateer's bundled patches to node_modules — continuing without them.",
|
|
351
|
-
"
|
|
420
|
+
" Upstream fixes (retry-loop guard, /model → /models redirect) stay off, and this",
|
|
421
|
+
" project's `.privateer/` config directory is NOT read — only `.pi/` is.",
|
|
422
|
+
...(projectDirIgnored
|
|
423
|
+
? [
|
|
424
|
+
"",
|
|
425
|
+
` THIS PROJECT HAS ONE: ${path.join(process.cwd(), ".privateer")}`,
|
|
426
|
+
" Its settings, packages, skills and extensions are being ignored right now.",
|
|
427
|
+
]
|
|
428
|
+
: []),
|
|
429
|
+
"",
|
|
352
430
|
` Usually a permissions issue: ${path.join(REPO, "node_modules")} isn't writable`,
|
|
353
431
|
" by this user (a `sudo npm install -g` install). Re-run once with sudo, or",
|
|
354
432
|
" 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
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Media tools for Pi's TUI: generate images, video, speech and music through the
|
|
2
|
+
// signed-in Privateer account, and stitch the results together locally with ffmpeg.
|
|
3
|
+
//
|
|
4
|
+
// Generation is registered only when the account channel can actually serve it
|
|
5
|
+
// (mediaEnabled → signed in, and HARBOR_MEDIA not explicitly off). Omitting the
|
|
6
|
+
// factory rather than merely hiding the tools is deliberate: a tool that exists but
|
|
7
|
+
// 401s on every call teaches the model to keep retrying, whereas a tool that isn't
|
|
8
|
+
// there makes it say "you'd need to sign in" and move on.
|
|
9
|
+
//
|
|
10
|
+
// video_compose is registered UNCONDITIONALLY. It is local ffmpeg work on files
|
|
11
|
+
// already on disk — no account, no network, no spend — so it stays useful to a
|
|
12
|
+
// signed-out terminal editing media that came from anywhere.
|
|
13
|
+
import { makeMediaTools } from "../src/tools/media.ts";
|
|
14
|
+
import { makeComposeTools } from "../src/tools/videoCompose.ts";
|
|
15
|
+
import { mediaEnabled } from "../src/config/hosted.ts";
|
|
16
|
+
|
|
17
|
+
export default function privateerMedia(pi: any): void {
|
|
18
|
+
if (mediaEnabled()) makeMediaTools()(pi);
|
|
19
|
+
makeComposeTools()(pi);
|
|
20
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// of which replaces (not merges) whatever model list that provider already had:
|
|
10
10
|
//
|
|
11
11
|
// - `tinfoil` gets a single seed model, so any other Tinfoil model — notably our
|
|
12
|
-
// default
|
|
12
|
+
// default (TINFOIL_DEFAULT_SPEC) — resolves as a "custom model id" with a startup warning
|
|
13
13
|
// and never shows in the picker. We re-register it with the current chat catalog.
|
|
14
14
|
// - `privateer` gets pi-privacy's PUBLIC developer-key channel (api.privateer.pro/v1 +
|
|
15
15
|
// `${PRIVATEER_API_KEY}`, one seed model), which clobbers the ACCOUNT channel our own
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
26
26
|
import { accountPosture, registerAccountModels } from "../src/providers/account.ts";
|
|
27
27
|
|
|
28
|
-
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models),
|
|
28
|
+
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models), kimi-k2-6 first — the
|
|
29
29
|
// launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
30
30
|
// doc-upload) are intentionally omitted. Refresh from the live catalog if Tinfoil adds
|
|
31
31
|
// models; this static list just needs to cover what we default to and commonly pick.
|
|
32
32
|
const TINFOIL_MODELS = [
|
|
33
|
-
"glm-5-2",
|
|
34
33
|
"kimi-k2-6",
|
|
34
|
+
"glm-5-2",
|
|
35
35
|
"deepseek-v4-pro",
|
|
36
36
|
"gpt-oss-120b",
|
|
37
37
|
"gpt-oss-safeguard-120b",
|