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 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.
@@ -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
@@ -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
- console.log("Updating Privateer to the latest release…");
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
- console.log("Updating privateer-agent to the latest release…");
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
- // runToCompletion exits via the child's exit handler.
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 — no moat-shim install (the
118
- // harbor loads the moat as in-code factories, not interactive extensions).
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
- // 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.
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: install the moat, then exec Pi's TUI -------------------
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
- const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
154
- const EXT_DIR = path.join(AGENT_DIR, "extensions");
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
- // 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", "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
- // A missing target means that optional tool pack isn't installed — skip its shim
177
- // rather than writing one that points at nothing (which fails at extension load).
178
- const shim = (name, target) => {
179
- if (!target || !fs.existsSync(target)) return;
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. Point it at OUR cli.js so the child
210
- // reads this same PI_CODING_AGENT_DIR and DISCOVERS the moat shims (gated + private,
211
- // no -e injection). Set only when unset so a power user can override.
212
- if (!process.env.PI_SUBAGENT_PI_BINARY) process.env.PI_SUBAGENT_PI_BINARY = CLI;
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
- // Default model. Mirrors src/providers/defaultModel.ts resolveDefaultModel() keep
237
- // the two in step. Tinfoil's GLM 5.2 is the default either way: direct when the user
238
- // has a Tinfoil key (pi-privacy can client-attest the enclave), over the Privateer
239
- // subscription otherwise.
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
- const ACCOUNT_MODEL = "privateer/tinfoil/glm-5-2";
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/glm-5-2"
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, "--model", MODEL, ...skillArgs, ...args]);
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
- " Two upstream fixes (retry-loop guard, /model → /models redirect) stay off.",
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 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