privateer-agent 0.12.34 → 0.12.36

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
@@ -171,6 +171,13 @@ for how you installed) or check your version with `privateer --version`.
171
171
  > `~/.privateer/agent/settings.json`. Linux arm64 and Windows arm64 bundles aren't built
172
172
  > yet — arm64 Windows runs the x64 bundle under emulation.
173
173
 
174
+ Background-task storage is created only when a task starts, not when you open Privateer.
175
+ Tasks normally write logs to `.pi/tasks` in your working directory. If that directory
176
+ denies writes (for example, Git Bash opened in `C:\Program Files\Git`), Privateer
177
+ uses `~/.privateer/agent/tasks/<project-id>/<run-id>` instead, respecting
178
+ `PRIVATEER_HOME`. The task's output path shows the actual location. This does **not**
179
+ change the working directory for commands or grant permission to edit protected files.
180
+
174
181
  ### Verifying what you're about to run
175
182
 
176
183
  Privateer is a coding agent — it runs shell commands and edits files, so "should I trust
@@ -67,13 +67,16 @@ export function resolveDep(from, name, ...rest) {
67
67
  }
68
68
 
69
69
  function resolvePatchRoots(repo, patchFiles) {
70
- const roots = new Set();
70
+ const roots = new Map();
71
71
  for (const file of patchFiles) {
72
72
  // "@earendil-works+pi-coding-agent+0.80.3.patch" -> "@earendil-works/pi-coding-agent"
73
73
  const parts = path.basename(file, ".patch").split("+");
74
74
  const name = parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
75
75
  const root = findDepRoot(repo, name);
76
- if (root) roots.add(root);
76
+ if (root) {
77
+ if (!roots.has(root)) roots.set(root, []);
78
+ roots.get(root).push(file);
79
+ }
77
80
  }
78
81
  return [...roots];
79
82
  }
@@ -112,23 +115,35 @@ export function applyPatchesIfNeeded(repo, nodeBin = process.execPath) {
112
115
  if (!pp || !fs.existsSync(pp)) return "skipped";
113
116
 
114
117
  let did = false;
115
- for (const root of roots) {
118
+ for (const [root, files] of roots) {
116
119
  const stampFile = path.join(root, "node_modules", ".privateer-patches.json");
117
120
  try {
118
121
  if (JSON.parse(fs.readFileSync(stampFile, "utf8")).hash === want) continue; // current
119
122
  } catch { /* missing or unreadable stamp — (re)apply */ }
120
123
 
121
- // --patch-dir points at OUR patches even though cwd is wherever the deps landed.
122
- // It MUST be relative: patch-package resolves it against cwd, so an absolute path
123
- // is silently mangled into a non-existent one and every patch "fails" to apply.
124
- const relPatchDir = path.relative(root, patchDir);
125
- const r = spawnSync(nodeBin, [pp, "--error-on-fail", "--patch-dir", relPatchDir], {
126
- cwd: root,
127
- stdio: ["ignore", "ignore", "pipe"],
128
- timeout: 60_000,
129
- windowsHide: true,
130
- });
131
- if (r.status !== 0) return "failed";
124
+ // Different targets can be hoisted to different roots. Apply only the patches
125
+ // belonging to this root, or patch-package fails on the absent/wrong copy of
126
+ // another target. Keep the staging dir on the SAME drive on Windows: --patch-dir
127
+ // must be relative (an absolute path is mangled by patch-package).
128
+ let stagedDir;
129
+ try {
130
+ let sourceDir = patchDir;
131
+ if (files.length !== patchFiles.length) {
132
+ stagedDir = fs.mkdtempSync(path.join(root, "node_modules", ".privateer-patches-"));
133
+ for (const file of files) fs.copyFileSync(path.join(patchDir, file), path.join(stagedDir, file));
134
+ sourceDir = stagedDir;
135
+ }
136
+ const relPatchDir = path.relative(root, sourceDir);
137
+ const r = spawnSync(nodeBin, [pp, "--error-on-fail", "--patch-dir", relPatchDir], {
138
+ cwd: root,
139
+ stdio: ["ignore", "ignore", "pipe"],
140
+ timeout: 60_000,
141
+ windowsHide: true,
142
+ });
143
+ if (r.status !== 0) return "failed";
144
+ } finally {
145
+ if (stagedDir) fs.rmSync(stagedDir, { recursive: true, force: true });
146
+ }
132
147
  did = true;
133
148
 
134
149
  // Only stamp after a clean apply, so a partial/failed run retries next launch.
@@ -30,6 +30,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
31
31
  import { routeUpdate } from "./update-route.mjs";
32
32
  import { runToCompletion } from "./run-to-completion.mjs";
33
+ import { configureCompileCache } from "./startup-cache.mjs";
33
34
 
34
35
  const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
35
36
  const REPO = path.resolve(HERE, "..");
@@ -41,15 +42,19 @@ const isWin = process.platform === "win32";
41
42
  // Unicode wave blocks and emojis into 3-byte mojibake that wraps and floods the console.
42
43
  if (isWin) {
43
44
  try {
44
- const chcp = process.env.SystemRoot
45
- ? path.join(process.env.SystemRoot, "System32", "chcp.com")
46
- : "chcp.com";
47
- spawnSync(chcp, ["65001"], { stdio: "ignore", windowsHide: true });
45
+ const comspec = process.env.ComSpec || "cmd.exe";
46
+ spawnSync(comspec, ["/d", "/s", "/c", "chcp 65001 >nul"], { stdio: "inherit", windowsHide: true });
48
47
  } catch {
49
48
  /* best effort */
50
49
  }
51
50
  }
52
51
 
52
+ // Suppress upstream update checks: Pi's CLI update banner and pi-background-tasks'
53
+ // update check / footer notice ("bg ⬆ v... /bg-update"). Privateer manages updates
54
+ // at the distribution level via `privateer update` and its own banner.
55
+ if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
56
+ if (!process.env.PI_BG_DISABLE_UPDATE_CHECK) process.env.PI_BG_DISABLE_UPDATE_CHECK = "1";
57
+
53
58
  const PRIVATEER_HOME = process.env.PRIVATEER_HOME || path.join(os.homedir(), ".privateer");
54
59
  const ENV_FILE = path.join(REPO, ".env"); // dev-only; a real install has none
55
60
  const AGENT_DIR = path.join(PRIVATEER_HOME, "agent");
@@ -165,6 +170,26 @@ if (NO_QUARTER) {
165
170
  );
166
171
  }
167
172
 
173
+ // `--no-privacy` / `--privacy-off` — completely disable pi-privacy for this session.
174
+ // Stripped before Pi's cli.js sees it. Exports PRIVATEER_PRIVACY_OFF=1 and PI_PRIVACY_OFF=1;
175
+ // all PII scanning, prompts, and tool exfiltration/result checks are bypassed.
176
+ const NO_PRIVACY = args.some((a) => a === "--no-privacy" || a === "--privacy-off");
177
+ if (NO_PRIVACY) {
178
+ for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--no-privacy" || args[i] === "--privacy-off") args.splice(i, 1);
179
+ process.env.PRIVATEER_PRIVACY_OFF = "1";
180
+ process.env.PI_PRIVACY_OFF = "1";
181
+ process.stderr.write(
182
+ [
183
+ "",
184
+ " ⚓ \x1b[1;33mPrivacy off\x1b[0m — pi-privacy DISABLED for this session.",
185
+ " PII scanning, tool exfiltration guards, and result credential redaction are OFF.",
186
+ " Outbound requests and tool calls will NOT prompt or redact PII / IP addresses.",
187
+ " Run /privacy on in-session to restore privacy protections.",
188
+ "",
189
+ ].join("\n") + "\n",
190
+ );
191
+ }
192
+
168
193
  // `--allow-computer-control` — arm GUI control (screenshots, mouse, keyboard) for this
169
194
  // session. Stripped before Pi's cli.js sees it, exactly like --no-quarter above.
170
195
  //
@@ -348,6 +373,134 @@ if (sub === "update") {
348
373
  else updateSelf();
349
374
  }
350
375
 
376
+ // --- `privateer init [directory]` ------------------------------------------
377
+ // Scaffold a starter PRIVATEER.md project-context file. Runs synchronously and exits
378
+ // immediately — no Pi boot, no splash screen, no API keys needed.
379
+ else if (sub === "init") {
380
+ const rest = args.slice(1);
381
+ const help = rest.includes("--help") || rest.includes("-h");
382
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
383
+ if (help) {
384
+ console.log(
385
+ [
386
+ `${cmd} init — scaffold a starter PRIVATEER.md project-context file.`,
387
+ "",
388
+ `Usage: ${cmd} init [directory]`,
389
+ "",
390
+ "Options:",
391
+ " -h, --help Show this help message",
392
+ "",
393
+ "Creates a starter PRIVATEER.md in the current directory (or the specified directory).",
394
+ "Privateer loads this file automatically at startup to give the agent project context.",
395
+ ].join("\n"),
396
+ );
397
+ process.exit(0);
398
+ }
399
+
400
+ const unknownOpt = rest.find((a) => a.startsWith("-"));
401
+ if (unknownOpt) {
402
+ console.error(`privateer init: unknown option '${unknownOpt}'`);
403
+ console.error(`Run '${cmd} init --help' for usage.`);
404
+ process.exit(1);
405
+ }
406
+
407
+ const positionals = rest.filter((a) => !a.startsWith("-"));
408
+ if (positionals.length > 1) {
409
+ console.error(`privateer init: unexpected argument '${positionals[1]}'`);
410
+ console.error(`Run '${cmd} init --help' for usage.`);
411
+ process.exit(1);
412
+ }
413
+
414
+ const targetDir = positionals[0] ? path.resolve(positionals[0]) : process.cwd();
415
+ if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
416
+ console.error(`privateer init: directory not found '${targetDir}'`);
417
+ process.exit(1);
418
+ }
419
+
420
+ const targetFile = path.join(targetDir, "PRIVATEER.md");
421
+ if (fs.existsSync(targetFile)) {
422
+ console.log(`PRIVATEER.md already exists at ${targetFile} — left untouched.`);
423
+ process.exit(0);
424
+ }
425
+
426
+ const template = [
427
+ "# PRIVATEER.md",
428
+ "",
429
+ "Project context for the Privateer agent. Privateer loads this file automatically at",
430
+ "startup (the same way it loads AGENTS.md / CLAUDE.md) and prepends it to the model's",
431
+ "system prompt — so put anything the agent should always know about THIS project here.",
432
+ "",
433
+ "## Project",
434
+ "",
435
+ "<One or two lines: what this project is and what it does.>",
436
+ "",
437
+ "## Conventions",
438
+ "",
439
+ "- <Coding style, patterns, and idioms to follow.>",
440
+ "- <Things to avoid.>",
441
+ "",
442
+ "## Commands",
443
+ "",
444
+ "- build: <command>",
445
+ "- test: <command>",
446
+ "- run: <command>",
447
+ "",
448
+ "## Notes for the agent",
449
+ "",
450
+ "- <Domain context, gotchas, or constraints worth stating once.>",
451
+ "",
452
+ ].join("\n");
453
+
454
+ try {
455
+ fs.writeFileSync(targetFile, template, "utf8");
456
+ console.log(`\x1b[32m✓\x1b[0m Created ${targetFile}`);
457
+ console.log(" Edit it with your project's context — it loads automatically on every turn.");
458
+ process.exit(0);
459
+ } catch (err) {
460
+ console.error(`privateer init: failed to write ${targetFile} — ${err.message}`);
461
+ process.exit(1);
462
+ }
463
+ }
464
+
465
+ // --- `privateer help [command]` / `privateer --help` ------------------------
466
+ else if (sub === "help" || (args.length === 1 && (args[0] === "--help" || args[0] === "-h"))) {
467
+ const target = sub === "help" ? args[1] : undefined;
468
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
469
+ if (target === "init") {
470
+ console.log(
471
+ [
472
+ `${cmd} init — scaffold a starter PRIVATEER.md project-context file.`,
473
+ "",
474
+ `Usage: ${cmd} init [directory]`,
475
+ "",
476
+ "Options:",
477
+ " -h, --help Show this help message",
478
+ "",
479
+ "Creates a starter PRIVATEER.md in the current directory (or the specified directory).",
480
+ "Privateer loads this file automatically at startup to give the agent project context.",
481
+ ].join("\n"),
482
+ );
483
+ process.exit(0);
484
+ }
485
+ if (target === "update") {
486
+ console.log(
487
+ [
488
+ `${cmd} update — fetch the latest release, or newer tool packs.`,
489
+ "",
490
+ ` ${cmd} update update the Privateer CLI itself`,
491
+ ` ${cmd} update --extensions update every installed tool pack`,
492
+ ` ${cmd} update <pack> update one pack (npm name or git URL)`,
493
+ ` ${cmd} update --all tool packs, then the CLI`,
494
+ "",
495
+ "Inside a running terminal, /update fetches tool packs in place — no restart.",
496
+ ].join("\n"),
497
+ );
498
+ process.exit(0);
499
+ }
500
+ printPrivateerHelp(cmd);
501
+ process.exit(0);
502
+ }
503
+
351
504
  // --- `privateer harbor [run|install|uninstall|status]` ---------------------
352
505
  // The resident background harbor (routines + app-driven headless task spawns). Boots
353
506
  // straight into src/harbor via bin/privateer-harbor.mjs — the harbor loads the moat as
@@ -390,6 +543,10 @@ else if (sub === "acp") {
390
543
 
391
544
  // --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
392
545
  else {
546
+ // Node 22 can reuse compiled code across launches instead of reparsing Pi and every
547
+ // provider SDK. Set the child env before any Node spawn; children inherit it too.
548
+ configureCompileCache(PRIVATEER_HOME);
549
+
393
550
  // Windows has no bash out of the box, but Privateer's command tool needs one. If a
394
551
  // real bash isn't reachable, stop here with a clear, actionable message — otherwise
395
552
  // the user boots fine and only hits a cryptic `'bash' is not recognized` the first
@@ -447,6 +604,7 @@ else {
447
604
  // Suppress Pi's upstream update banner (our banner is the startup surface). Disables
448
605
  // ONLY the version fetch — fd/rg can still download on first run.
449
606
  if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
607
+ if (!process.env.PI_BG_DISABLE_UPDATE_CHECK) process.env.PI_BG_DISABLE_UPDATE_CHECK = "1";
450
608
 
451
609
  // Quiet Pi's built-in startup chatter so our banner is the only greeting. Each key is
452
610
  // set only when unset, so a user's own settings.json toggle still wins.
@@ -551,8 +709,21 @@ else {
551
709
  // TUI branch only: harbor/acp/subagent children have no terminal to animate on (and
552
710
  // acp's stdout is a JSON-RPC stream). pathToFileURL, not the bare path — a Windows
553
711
  // absolute path reads as the URL scheme "d:"; see bin/privateer.mjs for the same trap.
712
+ const isNonInteractive = args.some(
713
+ (a) =>
714
+ a === "-p" ||
715
+ a === "--print" ||
716
+ a === "--mode" ||
717
+ a === "--export" ||
718
+ a === "--list-models" ||
719
+ a === "--help" ||
720
+ a === "-h" ||
721
+ a === "-v" ||
722
+ a === "-V" ||
723
+ a === "--version",
724
+ );
554
725
  const splash = path.join(HERE, "privateer-splash.mjs");
555
- if (fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
726
+ if (!isNonInteractive && fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
556
727
 
557
728
  runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
558
729
  }
@@ -701,6 +872,74 @@ function warnKeylessLaunch() {
701
872
  process.stderr.write(lines.join("\n") + "\n");
702
873
  }
703
874
 
875
+ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
876
+ console.log(
877
+ [
878
+ "privateer - Terminal & TUI coding agent with verified privacy and local control",
879
+ "",
880
+ "Usage:",
881
+ ` ${cmd} [options] [--] [@files...] [messages...]`,
882
+ "",
883
+ "Commands:",
884
+ ` ${cmd} init [directory] Create a starter PRIVATEER.md project-context file`,
885
+ ` ${cmd} update [--all] Fetch the latest release or newer tool packs`,
886
+ ` ${cmd} harbor <command> Manage the resident background Harbor daemon (run/install/uninstall/status)`,
887
+ ` ${cmd} verify Check local installation integrity and patch state`,
888
+ ` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
889
+ ` ${cmd} install <source> Install extension source and add to settings`,
890
+ ` ${cmd} remove <source> Remove extension source from settings`,
891
+ ` ${cmd} uninstall <source> Alias for remove`,
892
+ ` ${cmd} list List installed extensions from settings`,
893
+ ` ${cmd} config Open TUI to enable/disable package resources`,
894
+ ` ${cmd} auth <command> Print credentials or check provider readiness`,
895
+ ` ${cmd} <command> --help Show help for a specific command`,
896
+ "",
897
+ "Options:",
898
+ " --provider <name> Provider name",
899
+ ' --model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")',
900
+ " --api-key <key> API key (defaults to env vars)",
901
+ " --system-prompt <text> System prompt (default: coding assistant prompt)",
902
+ " --append-system-prompt <text> Append text or file contents to the system prompt",
903
+ " --mode <mode> Output mode: text (default), json, or rpc",
904
+ " --print, -p Non-interactive mode: process prompt and exit",
905
+ " --continue, -c Continue previous session",
906
+ " --resume, -r Select a session to resume",
907
+ " --session <path|id> Use specific session file or partial UUID",
908
+ " --session-id <id> Use exact project session ID, creating it if missing",
909
+ " --fork <path|id> Fork specific session file or partial UUID into a new session",
910
+ " --session-dir <dir> Directory for session storage and lookup",
911
+ " --no-session Don't save session (ephemeral)",
912
+ " --name, -n <name> Set session display name",
913
+ " --models <patterns> Comma-separated model patterns for Ctrl+P cycling",
914
+ " --no-tools, -nt Disable all tools by default (built-in and extension)",
915
+ " --no-builtin-tools, -nbt Disable built-in tools by default",
916
+ " --tools, -t <tools> Comma-separated allowlist of tool names to enable",
917
+ " --exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable",
918
+ " --thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh, max",
919
+ " --extension, -e <path> Load an extension file (can be used multiple times)",
920
+ " --no-extensions, -ne Disable extension discovery (explicit -e paths still work)",
921
+ " --skill <path> Load a skill file or directory (can be used multiple times)",
922
+ " --no-skills, -ns Disable skills discovery and loading",
923
+ " --prompt-template <path> Load a prompt template file or directory",
924
+ " --no-prompt-templates, -np Disable prompt template discovery and loading",
925
+ " --theme <path> Load a theme file or directory",
926
+ " --use-theme <name[/name]> Set the initial interactive theme for this run",
927
+ " --no-themes Disable theme discovery and loading",
928
+ " --no-context-files, -nc Disable PRIVATEER.md, AGENTS.md and CLAUDE.md discovery",
929
+ " --export <file> Export session file to HTML and exit",
930
+ " --list-models [search] List available models (with optional fuzzy search)",
931
+ " --verbose Force verbose startup",
932
+ " --tui-mode <mode> TUI mode: regular (default) or fullscreen",
933
+ " --approve, -a Trust project-local files for this run",
934
+ " --no-approve, -na Ignore project-local files for this run",
935
+ " --offline Disable startup network operations",
936
+ " -- End option parsing; treat remaining arguments as messages/files",
937
+ " --help, -h Show this help",
938
+ " --version, -v, -V Show version number",
939
+ ].join("\n"),
940
+ );
941
+ }
942
+
704
943
  function refreshUpdateCache() {
705
944
  const cache = path.join(PRIVATEER_HOME, "update-check.json");
706
945
  try {
@@ -64,10 +64,8 @@ const enabled =
64
64
  // than privateer.cmd, the console might still be on OEM CP437.
65
65
  if (enabled && process.platform === "win32") {
66
66
  try {
67
- const chcp = process.env.SystemRoot
68
- ? path.join(process.env.SystemRoot, "System32", "chcp.com")
69
- : "chcp.com";
70
- spawnSync(chcp, ["65001"], { stdio: "ignore", windowsHide: true });
67
+ const comspec = process.env.ComSpec || "cmd.exe";
68
+ spawnSync(comspec, ["/d", "/s", "/c", "chcp 65001 >nul"], { stdio: "inherit", windowsHide: true });
71
69
  } catch {
72
70
  /* best effort */
73
71
  }
@@ -74,6 +74,8 @@ export function buildChildArgs(originalArgs, repoRoot = REPO, env = process.env)
74
74
 
75
75
  // Run only when invoked directly (not when imported by the test).
76
76
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
77
+ if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
78
+ if (!process.env.PI_BG_DISABLE_UPDATE_CHECK) process.env.PI_BG_DISABLE_UPDATE_CHECK = "1";
77
79
  const args = buildChildArgs(process.argv.slice(2));
78
80
  const child = spawn(process.execPath, [piCliPath(), ...args], { stdio: "inherit", env: process.env });
79
81
  // Propagate the child's exit faithfully so pi-subagents' parent reads the real
@@ -0,0 +1 @@
1
+ export function configureCompileCache(privateerHome: string, env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // Set this BEFORE spawning Node: enabling it after importing Pi misses the expensive
5
+ // module graph. Node keys bytecode by runtime version and source contents; /reload
6
+ // still evaluates fresh modules. This is not Jiti's in-memory module cache.
7
+ // Keep it outside the install/project (both may be read-only), under the same home
8
+ // as the rest of this Privateer instance. Only code is cached, not session state.
9
+ export function configureCompileCache(privateerHome, env = process.env) {
10
+ // Respect Node's own override/opt-out, including an explicitly empty cache path.
11
+ if (env.NODE_COMPILE_CACHE !== undefined || env.NODE_DISABLE_COMPILE_CACHE === "1") return;
12
+ try {
13
+ const dir = path.resolve(privateerHome, "cache", "node-compile");
14
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
15
+ env.NODE_COMPILE_CACHE = dir;
16
+ } catch {
17
+ // An unavailable cache must never prevent startup (or print on ACP stdout).
18
+ }
19
+ }
@@ -30,6 +30,7 @@ import {
30
30
  accountPosture,
31
31
  fetchAccountCatalog,
32
32
  } from "../src/providers/account.ts";
33
+ import { writePiDefaultModel } from "../src/providers/defaultModel.ts";
33
34
 
34
35
  // A minimal view of Pi's theme (passed to the ui.custom factory) — enough to color
35
36
  // text without pulling Pi's internal theme types into an extension.
@@ -353,11 +354,7 @@ export default function privateerModels(pi: {
353
354
  argumentHint: "[search]",
354
355
  handler: async (args: string, ctx: any): Promise<void> => {
355
356
  const ui = ctx?.ui;
356
- if (!ui?.custom) {
357
- ctx?.ui?.notify?.("The model picker needs an interactive terminal.", "warning");
358
- return;
359
- }
360
- const registry = ctx.modelRegistry;
357
+ const registry = ctx?.modelRegistry;
361
358
  // Refresh so a just-fetched account catalog / models.json edit is reflected.
362
359
  try {
363
360
  registry?.refresh?.();
@@ -376,13 +373,43 @@ export default function privateerModels(pi: {
376
373
 
377
374
  let available: ModelLike[] = [];
378
375
  try {
379
- available = (await registry.getAvailable()) as ModelLike[];
376
+ available = (await registry?.getAvailable?.()) as ModelLike[] ?? [];
380
377
  } catch (e) {
381
- ui.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
378
+ ui?.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
382
379
  return;
383
380
  }
384
381
  if (!available.length) {
385
- ui.notify?.("No models available. Use /login to add a provider.", "warning");
382
+ ui?.notify?.("No models available. Use /login to add a provider.", "warning");
383
+ return;
384
+ }
385
+
386
+ const initialQuery = String(args ?? "").trim();
387
+ if (!ui?.custom) {
388
+ if (initialQuery) {
389
+ const match = available.find(
390
+ (m) => `${m.provider}/${m.id}` === initialQuery || m.id === initialQuery,
391
+ );
392
+ if (match) {
393
+ try {
394
+ const ok = await pi.setModel!(match);
395
+ if (ok === false) {
396
+ ui?.notify?.(`No API key for ${match.provider}/${match.id}.`, "warning");
397
+ return;
398
+ }
399
+ writePiDefaultModel(`${match.provider}/${match.id}`);
400
+ const tier = baselineTier(match.provider, match.id);
401
+ ui?.notify?.(
402
+ `Model: ${match.provider}/${match.id} · ${TIERS[tier].label}`,
403
+ "info",
404
+ );
405
+ return;
406
+ } catch (e) {
407
+ ui?.notify?.(`Couldn't switch model: ${(e as Error).message}`, "error");
408
+ return;
409
+ }
410
+ }
411
+ }
412
+ ui?.notify?.("The model picker needs an interactive terminal.", "warning");
386
413
  return;
387
414
  }
388
415
 
@@ -394,7 +421,6 @@ export default function privateerModels(pi: {
394
421
  tier: baselineTier(m.provider, m.id),
395
422
  }));
396
423
 
397
- const initialQuery = String(args ?? "").trim();
398
424
  const chosen: Row | undefined = await ui.custom(
399
425
  (tui: TuiLike, theme: ThemeLike, _kb: unknown, close: (result?: Row) => void) => {
400
426
  const picker = new ModelsPicker({
@@ -417,6 +443,7 @@ export default function privateerModels(pi: {
417
443
  ui.notify?.(`No API key for ${chosen.provider}/${chosen.id}.`, "warning");
418
444
  return;
419
445
  }
446
+ writePiDefaultModel(`${chosen.provider}/${chosen.id}`);
420
447
  ui.notify?.(
421
448
  `Model: ${chosen.provider}/${chosen.id} · ${TIERS[chosen.tier].label}`,
422
449
  "info",
@@ -9,6 +9,7 @@
9
9
  import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
10
  import { accountPosture } from "../src/providers/account.ts";
11
11
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
12
+ import { privacyDisabled } from "../src/config/privacyDisabled.ts";
12
13
 
13
14
  const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
14
15
 
@@ -35,11 +36,39 @@ async function badgeFor(provider: string, modelId: string, p: Palette): Promise<
35
36
  return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
36
37
  }
37
38
 
39
+ export async function updatePostureBadge(ctx: any, provider?: string, modelId?: string): Promise<void> {
40
+ if (!ctx?.ui?.setStatus) return;
41
+ if (privacyDisabled()) {
42
+ ctx.ui.setStatus("privacy", "⚑ privacy off");
43
+ ctx.ui.setStatus("pi-privacy", undefined);
44
+ return;
45
+ }
46
+ const p = provider ?? ctx?.model?.provider;
47
+ const m = modelId ?? ctx?.model?.id;
48
+ if (!p || !m) {
49
+ ctx.ui.setStatus("privacy", undefined);
50
+ return;
51
+ }
52
+ try {
53
+ ctx.ui.setStatus("privacy", "⛉ …");
54
+ const badge = await badgeFor(p, m, paletteFor(ctx?.ui?.theme));
55
+ ctx.ui.setStatus("privacy", badge);
56
+ } catch {
57
+ ctx.ui.setStatus("privacy", undefined);
58
+ }
59
+ }
60
+
38
61
  export default function privateerPosture(pi: any): void {
39
62
  // "latest wins" so rapid model cycling (Ctrl+P) doesn't leave a stale badge.
40
63
  let seq = 0;
41
64
  const update = async (provider?: string, modelId?: string, ctx?: any) => {
42
- if (!provider || !modelId || !ctx?.ui?.setStatus) return;
65
+ if (!ctx?.ui?.setStatus) return;
66
+ if (privacyDisabled()) {
67
+ ctx.ui.setStatus("privacy", "⚑ privacy off");
68
+ ctx.ui.setStatus("pi-privacy", undefined);
69
+ return;
70
+ }
71
+ if (!provider || !modelId) return;
43
72
  const mine = ++seq;
44
73
  try {
45
74
  ctx.ui.setStatus("privacy", "⛉ …"); // immediate placeholder while attesting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.34",
3
+ "version": "0.12.36",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -90,7 +90,7 @@
90
90
  "privateer-workflow": "0.1.0",
91
91
  "tinfoil": "1.1.11",
92
92
  "tsx": "4.23.0",
93
- "typebox": "1.3.4",
93
+ "typebox": "1.3.7",
94
94
  "undici": "7.29.0",
95
95
  "ws": "8.21.0",
96
96
  "yaml": "2.9.0",
@@ -0,0 +1,46 @@
1
+ diff --git a/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js b/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js
2
+ index 216a45f..a1e3214 100644
3
+ --- a/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js
4
+ +++ b/node_modules/@earendil-works/pi-ai/dist/api/simple-options.js
5
+ @@ -1,11 +1,38 @@
6
+ import { estimateContextTokens } from "../utils/estimate.js";
7
+ const CONTEXT_SAFETY_TOKENS = 4096;
8
+ -const MIN_MAX_TOKENS = 1;
9
+ +// Privateer patch: floor the answer budget at a usable answer, not at one token.
10
+ +//
11
+ +// Stock pi floored this at 1, so the moment the (chars/4) context estimate reached
12
+ +// the declared window every turn asked the provider for ONE token: the whole prompt
13
+ +// still sent, still billed, one token back with finish_reason "length" — which the
14
+ +// TUI renders as "Response was truncated before completion." Compaction gets one
15
+ +// attempt, and when the estimate is still over afterwards the session stays there,
16
+ +// burning a full-price prompt per turn and producing nothing. Privateer reaches it
17
+ +// early because every account model is registered with a flat contextWindow of
18
+ +// 128000 (src/providers/account.ts seedModel) — /api/models publishes no per-model
19
+ +// window — so a larger-windowed model hits this ceiling with room to spare.
20
+ +//
21
+ +// The floor only raises `available`; the Math.min still honours a deliberately small
22
+ +// ask (a summariser, a title), and it is smaller than the CONTEXT_SAFETY_TOKENS
23
+ +// already subtracted above, so a floored request was inside that margin anyway. When
24
+ +// `available` is genuinely negative the context really does not fit and the provider
25
+ +// says so — a context-length error, which pi routes to compaction via
26
+ +// isContextOverflow. That is the designed recovery, and strictly better than a
27
+ +// silent one-token stub that costs the same.
28
+ +//
29
+ +// The floor is MIN_ANSWER_TOKENS, this module's OWN constant — declared below at its
30
+ +// original site, deliberately not redeclared here (a second top-level `const` of that
31
+ +// name is a SyntaxError that takes the whole app down). Reading it from below is safe:
32
+ +// clampMaxTokensToContext is only ever called at request time, long after module
33
+ +// evaluation, so the temporal dead zone has closed. Same value, same meaning, and it
34
+ +// stays in sync if upstream retunes it.
35
+ +//
36
+ +// Mirrors src/engine/contextBudget.ts, which is where this is specified and tested.
37
+ export function clampMaxTokensToContext(model, context, maxTokens) {
38
+ if (model.contextWindow <= 0)
39
+ - return Math.max(MIN_MAX_TOKENS, maxTokens);
40
+ + return Math.max(1, maxTokens);
41
+ const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
42
+ - return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
43
+ + return Math.min(maxTokens, Math.max(MIN_ANSWER_TOKENS, available));
44
+ }
45
+ export function buildBaseOptions(model, context, options, apiKey) {
46
+ const samplingParams = model.samplingParams || options?.samplingParams