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 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
 
@@ -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. The moat
7
- // is installed as re-export SHIMS in the agent dir's extensions/, so BOTH this TUI
8
- // and any subagents it spawns (child processes reading the same agent dir) load the
9
- // identical set including our permission gate. One source of truth via discovery
10
- // (no `-e`, which would double-load vs discovery). Runs in the current directory;
11
- // model via PRIVATEER_MODEL=provider/id.
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, pathToFileURL } from "node:url";
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 — no moat-shim install (the
118
- // harbor loads the moat as in-code factories, not interactive extensions).
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
- // patch chatter, no moat-shim install (like `harbor`, the ACP entry loads the moat
131
- // as in-code factories rather than discovered extensions). A single stray stdout
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: install the moat, then exec Pi's TUI -------------------
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
- const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
154
- const EXT_DIR = path.join(AGENT_DIR, "extensions");
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
- // Install/refresh the moat + tool-pack shims. Each shim re-exports its target by
158
- // ABSOLUTE path (as a file:// URL, portable across OSes) so the target's own
159
- // relative imports resolve from the repo. We remove any shim we previously managed
160
- // first, so a dropped package can't linger and reload.
161
- const MANAGED = [
162
- "privateer-brand", "privateer-context", "privateer-gate", "privateer-account",
163
- "privateer-models", "privateer-posture", "privateer-tools", "privateer-privacy",
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
- // A missing target means that optional tool pack isn't installed — skip its shim
176
- // rather than writing one that points at nothing (which fails at extension load).
177
- const shim = (name, target) => {
178
- if (!target || !fs.existsSync(target)) return;
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. Point it at OUR cli.js so the child
208
- // reads this same PI_CODING_AGENT_DIR and DISCOVERS the moat shims (gated + private,
209
- // no -e injection). Set only when unset so a power user can override.
210
- if (!process.env.PI_SUBAGENT_PI_BINARY) process.env.PI_SUBAGENT_PI_BINARY = CLI;
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
- // Default model. Mirrors src/providers/defaultModel.ts resolveDefaultModel() keep
235
- // the two in step. Tinfoil's GLM 5.2 is the default either way: direct when the user
236
- // has a Tinfoil key (pi-privacy can client-attest the enclave), over the Privateer
237
- // subscription otherwise.
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
- const ACCOUNT_MODEL = "privateer/tinfoil/glm-5-2";
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/glm-5-2"
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, "--model", MODEL, ...skillArgs, ...args]);
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
- " Two upstream fixes (retry-loop guard, /model → /models redirect) stay off.",
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 each subagent child (via PI_SUBAGENT_PI_BINARY)
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
- // Why a wrapper here (vs the plain cli.js the TUI uses): a subagent child is a fresh
7
- // `pi` subprocess that can't inherit the parent's in-code factories. It CAN auto-
8
- // discover agent-dir extensions but if the parent ALSO loaded those same shims as
9
- // factories, Pi loads both (resource-loader merges discovered + inline) and the moat
10
- // double-loads (two gates, two provider registrations). So instead of relying on
11
- // discovery, this wrapper injects the moat EXPLICITLY as `-e` extensions and passes
12
- // `--no-extensions` to turn agent-dir discovery OFF. The child then loads exactly:
13
- // pi-subagents' own runtime extensions (already present in the argv it built), and
14
- // privateer's gate + privacy + account (the three `-e` below),
15
- // with no discovery, hence no double-load while pi-subagents' explicit `--extension`
16
- // args still load (‑‑no‑extensions only disables DISCOVERY, not explicit `-e`).
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
- // Absolute paths to privateer's moat extension entry files (the same modules the TUI
29
- // installs as discovery shims). gate = the permission moat (fail-closed / forwards
30
- // child approvals to the parent); privacy = ZDR/TEE posture + attestation dispatcher;
31
- // account = the privateer/* provider so a child can run account models.
32
- export function moatExtensionPaths(repoRoot = REPO) {
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) — but ONLY when this
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
- // A subagent child is the exception: it loads this file EXPLICITLY (`-e`, see
450
- // bin/privateer-subagent.mjs) as its only gate, and inherits the parent's env so
451
- // it must keep installing regardless of the inherited marker, or children of a
452
- // harbor task would run entirely ungated.
453
- if (discoveredGateApplies(isSubagentChild())) gate(pi);
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
- // NOT inside the harbor daemon. This extension is auto-discovered from the shared
468
- // ~/.privateer/agent/extensions into every session the daemon runs, but `bridge` only
469
- // ever gets a relay from THIS file's /remote-access command which the daemon never
470
- // runs. Registering there would shadow (Pi: first registration per name wins, and
471
- // discovered extensions load before inline factories) the session-scoped pair a live
472
- // task spawn registers against its own connected relay, so send_file_to_client would
473
- // always answer "remote access is off" while the app was attached and driving.
474
- if (!inHarborDaemon()) {
475
- pi.registerTool?.(makeSendFileTool(bridge));
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 `tinfoil/glm-5-2` — resolves as a "custom model id" with a startup warning
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), glm-5-2 first — the
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",