privateer-agent 0.12.33 → 0.12.35

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");
@@ -348,6 +353,134 @@ if (sub === "update") {
348
353
  else updateSelf();
349
354
  }
350
355
 
356
+ // --- `privateer init [directory]` ------------------------------------------
357
+ // Scaffold a starter PRIVATEER.md project-context file. Runs synchronously and exits
358
+ // immediately — no Pi boot, no splash screen, no API keys needed.
359
+ else if (sub === "init") {
360
+ const rest = args.slice(1);
361
+ const help = rest.includes("--help") || rest.includes("-h");
362
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
363
+ if (help) {
364
+ console.log(
365
+ [
366
+ `${cmd} init — scaffold a starter PRIVATEER.md project-context file.`,
367
+ "",
368
+ `Usage: ${cmd} init [directory]`,
369
+ "",
370
+ "Options:",
371
+ " -h, --help Show this help message",
372
+ "",
373
+ "Creates a starter PRIVATEER.md in the current directory (or the specified directory).",
374
+ "Privateer loads this file automatically at startup to give the agent project context.",
375
+ ].join("\n"),
376
+ );
377
+ process.exit(0);
378
+ }
379
+
380
+ const unknownOpt = rest.find((a) => a.startsWith("-"));
381
+ if (unknownOpt) {
382
+ console.error(`privateer init: unknown option '${unknownOpt}'`);
383
+ console.error(`Run '${cmd} init --help' for usage.`);
384
+ process.exit(1);
385
+ }
386
+
387
+ const positionals = rest.filter((a) => !a.startsWith("-"));
388
+ if (positionals.length > 1) {
389
+ console.error(`privateer init: unexpected argument '${positionals[1]}'`);
390
+ console.error(`Run '${cmd} init --help' for usage.`);
391
+ process.exit(1);
392
+ }
393
+
394
+ const targetDir = positionals[0] ? path.resolve(positionals[0]) : process.cwd();
395
+ if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
396
+ console.error(`privateer init: directory not found '${targetDir}'`);
397
+ process.exit(1);
398
+ }
399
+
400
+ const targetFile = path.join(targetDir, "PRIVATEER.md");
401
+ if (fs.existsSync(targetFile)) {
402
+ console.log(`PRIVATEER.md already exists at ${targetFile} — left untouched.`);
403
+ process.exit(0);
404
+ }
405
+
406
+ const template = [
407
+ "# PRIVATEER.md",
408
+ "",
409
+ "Project context for the Privateer agent. Privateer loads this file automatically at",
410
+ "startup (the same way it loads AGENTS.md / CLAUDE.md) and prepends it to the model's",
411
+ "system prompt — so put anything the agent should always know about THIS project here.",
412
+ "",
413
+ "## Project",
414
+ "",
415
+ "<One or two lines: what this project is and what it does.>",
416
+ "",
417
+ "## Conventions",
418
+ "",
419
+ "- <Coding style, patterns, and idioms to follow.>",
420
+ "- <Things to avoid.>",
421
+ "",
422
+ "## Commands",
423
+ "",
424
+ "- build: <command>",
425
+ "- test: <command>",
426
+ "- run: <command>",
427
+ "",
428
+ "## Notes for the agent",
429
+ "",
430
+ "- <Domain context, gotchas, or constraints worth stating once.>",
431
+ "",
432
+ ].join("\n");
433
+
434
+ try {
435
+ fs.writeFileSync(targetFile, template, "utf8");
436
+ console.log(`\x1b[32m✓\x1b[0m Created ${targetFile}`);
437
+ console.log(" Edit it with your project's context — it loads automatically on every turn.");
438
+ process.exit(0);
439
+ } catch (err) {
440
+ console.error(`privateer init: failed to write ${targetFile} — ${err.message}`);
441
+ process.exit(1);
442
+ }
443
+ }
444
+
445
+ // --- `privateer help [command]` / `privateer --help` ------------------------
446
+ else if (sub === "help" || (args.length === 1 && (args[0] === "--help" || args[0] === "-h"))) {
447
+ const target = sub === "help" ? args[1] : undefined;
448
+ const cmd = process.env.PRIVATEER_CMD || "privateer";
449
+ if (target === "init") {
450
+ console.log(
451
+ [
452
+ `${cmd} init — scaffold a starter PRIVATEER.md project-context file.`,
453
+ "",
454
+ `Usage: ${cmd} init [directory]`,
455
+ "",
456
+ "Options:",
457
+ " -h, --help Show this help message",
458
+ "",
459
+ "Creates a starter PRIVATEER.md in the current directory (or the specified directory).",
460
+ "Privateer loads this file automatically at startup to give the agent project context.",
461
+ ].join("\n"),
462
+ );
463
+ process.exit(0);
464
+ }
465
+ if (target === "update") {
466
+ console.log(
467
+ [
468
+ `${cmd} update — fetch the latest release, or newer tool packs.`,
469
+ "",
470
+ ` ${cmd} update update the Privateer CLI itself`,
471
+ ` ${cmd} update --extensions update every installed tool pack`,
472
+ ` ${cmd} update <pack> update one pack (npm name or git URL)`,
473
+ ` ${cmd} update --all tool packs, then the CLI`,
474
+ "",
475
+ "Inside a running terminal, /update fetches tool packs in place — no restart.",
476
+ ].join("\n"),
477
+ );
478
+ process.exit(0);
479
+ }
480
+ printPrivateerHelp(cmd);
481
+ process.exit(0);
482
+ }
483
+
351
484
  // --- `privateer harbor [run|install|uninstall|status]` ---------------------
352
485
  // The resident background harbor (routines + app-driven headless task spawns). Boots
353
486
  // straight into src/harbor via bin/privateer-harbor.mjs — the harbor loads the moat as
@@ -390,6 +523,10 @@ else if (sub === "acp") {
390
523
 
391
524
  // --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
392
525
  else {
526
+ // Node 22 can reuse compiled code across launches instead of reparsing Pi and every
527
+ // provider SDK. Set the child env before any Node spawn; children inherit it too.
528
+ configureCompileCache(PRIVATEER_HOME);
529
+
393
530
  // Windows has no bash out of the box, but Privateer's command tool needs one. If a
394
531
  // real bash isn't reachable, stop here with a clear, actionable message — otherwise
395
532
  // the user boots fine and only hits a cryptic `'bash' is not recognized` the first
@@ -447,6 +584,7 @@ else {
447
584
  // Suppress Pi's upstream update banner (our banner is the startup surface). Disables
448
585
  // ONLY the version fetch — fd/rg can still download on first run.
449
586
  if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
587
+ if (!process.env.PI_BG_DISABLE_UPDATE_CHECK) process.env.PI_BG_DISABLE_UPDATE_CHECK = "1";
450
588
 
451
589
  // Quiet Pi's built-in startup chatter so our banner is the only greeting. Each key is
452
590
  // set only when unset, so a user's own settings.json toggle still wins.
@@ -551,8 +689,21 @@ else {
551
689
  // TUI branch only: harbor/acp/subagent children have no terminal to animate on (and
552
690
  // acp's stdout is a JSON-RPC stream). pathToFileURL, not the bare path — a Windows
553
691
  // absolute path reads as the URL scheme "d:"; see bin/privateer.mjs for the same trap.
692
+ const isNonInteractive = args.some(
693
+ (a) =>
694
+ a === "-p" ||
695
+ a === "--print" ||
696
+ a === "--mode" ||
697
+ a === "--export" ||
698
+ a === "--list-models" ||
699
+ a === "--help" ||
700
+ a === "-h" ||
701
+ a === "-v" ||
702
+ a === "-V" ||
703
+ a === "--version",
704
+ );
554
705
  const splash = path.join(HERE, "privateer-splash.mjs");
555
- if (fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
706
+ if (!isNonInteractive && fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
556
707
 
557
708
  runToCompletion(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
558
709
  }
@@ -701,6 +852,74 @@ function warnKeylessLaunch() {
701
852
  process.stderr.write(lines.join("\n") + "\n");
702
853
  }
703
854
 
855
+ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
856
+ console.log(
857
+ [
858
+ "privateer - Terminal & TUI coding agent with verified privacy and local control",
859
+ "",
860
+ "Usage:",
861
+ ` ${cmd} [options] [--] [@files...] [messages...]`,
862
+ "",
863
+ "Commands:",
864
+ ` ${cmd} init [directory] Create a starter PRIVATEER.md project-context file`,
865
+ ` ${cmd} update [--all] Fetch the latest release or newer tool packs`,
866
+ ` ${cmd} harbor <command> Manage the resident background Harbor daemon (run/install/uninstall/status)`,
867
+ ` ${cmd} verify Check local installation integrity and patch state`,
868
+ ` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
869
+ ` ${cmd} install <source> Install extension source and add to settings`,
870
+ ` ${cmd} remove <source> Remove extension source from settings`,
871
+ ` ${cmd} uninstall <source> Alias for remove`,
872
+ ` ${cmd} list List installed extensions from settings`,
873
+ ` ${cmd} config Open TUI to enable/disable package resources`,
874
+ ` ${cmd} auth <command> Print credentials or check provider readiness`,
875
+ ` ${cmd} <command> --help Show help for a specific command`,
876
+ "",
877
+ "Options:",
878
+ " --provider <name> Provider name",
879
+ ' --model <pattern> Model pattern or ID (supports "provider/id" and optional ":<thinking>")',
880
+ " --api-key <key> API key (defaults to env vars)",
881
+ " --system-prompt <text> System prompt (default: coding assistant prompt)",
882
+ " --append-system-prompt <text> Append text or file contents to the system prompt",
883
+ " --mode <mode> Output mode: text (default), json, or rpc",
884
+ " --print, -p Non-interactive mode: process prompt and exit",
885
+ " --continue, -c Continue previous session",
886
+ " --resume, -r Select a session to resume",
887
+ " --session <path|id> Use specific session file or partial UUID",
888
+ " --session-id <id> Use exact project session ID, creating it if missing",
889
+ " --fork <path|id> Fork specific session file or partial UUID into a new session",
890
+ " --session-dir <dir> Directory for session storage and lookup",
891
+ " --no-session Don't save session (ephemeral)",
892
+ " --name, -n <name> Set session display name",
893
+ " --models <patterns> Comma-separated model patterns for Ctrl+P cycling",
894
+ " --no-tools, -nt Disable all tools by default (built-in and extension)",
895
+ " --no-builtin-tools, -nbt Disable built-in tools by default",
896
+ " --tools, -t <tools> Comma-separated allowlist of tool names to enable",
897
+ " --exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable",
898
+ " --thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh, max",
899
+ " --extension, -e <path> Load an extension file (can be used multiple times)",
900
+ " --no-extensions, -ne Disable extension discovery (explicit -e paths still work)",
901
+ " --skill <path> Load a skill file or directory (can be used multiple times)",
902
+ " --no-skills, -ns Disable skills discovery and loading",
903
+ " --prompt-template <path> Load a prompt template file or directory",
904
+ " --no-prompt-templates, -np Disable prompt template discovery and loading",
905
+ " --theme <path> Load a theme file or directory",
906
+ " --use-theme <name[/name]> Set the initial interactive theme for this run",
907
+ " --no-themes Disable theme discovery and loading",
908
+ " --no-context-files, -nc Disable PRIVATEER.md, AGENTS.md and CLAUDE.md discovery",
909
+ " --export <file> Export session file to HTML and exit",
910
+ " --list-models [search] List available models (with optional fuzzy search)",
911
+ " --verbose Force verbose startup",
912
+ " --tui-mode <mode> TUI mode: regular (default) or fullscreen",
913
+ " --approve, -a Trust project-local files for this run",
914
+ " --no-approve, -na Ignore project-local files for this run",
915
+ " --offline Disable startup network operations",
916
+ " -- End option parsing; treat remaining arguments as messages/files",
917
+ " --help, -h Show this help",
918
+ " --version, -v, -V Show version number",
919
+ ].join("\n"),
920
+ );
921
+ }
922
+
704
923
  function refreshUpdateCache() {
705
924
  const cache = path.join(PRIVATEER_HOME, "update-check.json");
706
925
  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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.33",
3
+ "version": "0.12.35",
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",
@@ -387,7 +387,23 @@ index bb022d4..382ff2b 100644
387
387
  // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts,
388
388
  // avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent.
389
389
  import * as _bundledPiCodingAgent from "../../index.js";
390
- @@ -621,9 +621,23 @@ export async function discoverAndLoadExtensions(configuredPaths, cwd, agentDir =
390
+ @@ -419,11 +419,14 @@ async function loadExtensionModule(extensionPath, cacheToken) {
391
+ // Compiled binaries and the bundled Node distribution use embedded modules.
392
+ // Source TypeScript reuses host modules and root tsconfig paths. Unbundled
393
+ // Node builds use dist aliases.
394
+ + // Privateer: reuse the host's Pi/Typebox namespaces in Node builds too.
395
+ + // Otherwise each extension can reload that graph through Jiti. Aliases
396
+ + // remain a fallback for non-virtual imports; extension reload is unchanged.
397
+ ...(isBunBinary || isNodeSeaBinary || isBundledNode
398
+ ? { virtualModules: VIRTUAL_MODULES, tryNative: false }
399
+ : isTypeScriptSourceRuntime
400
+ ? { virtualModules: VIRTUAL_MODULES, tsconfigPaths: true }
401
+ - : { alias: getAliases() }),
402
+ + : { virtualModules: VIRTUAL_MODULES, alias: getAliases() }),
403
+ });
404
+ const module = await jiti.import(extensionPath, { default: true });
405
+ const factory = module;
406
+ @@ -621,9 +624,23 @@ export async function discoverAndLoadExtensions(configuredPaths, cwd, agentDir =
391
407
  }
392
408
  }
393
409
  };
@@ -0,0 +1,110 @@
1
+ diff --git a/node_modules/pi-background-tasks/src/core/registry.ts b/node_modules/pi-background-tasks/src/core/registry.ts
2
+ index cfbf133..c632390 100644
3
+ --- a/node_modules/pi-background-tasks/src/core/registry.ts
4
+ +++ b/node_modules/pi-background-tasks/src/core/registry.ts
5
+ @@ -1,11 +1,11 @@
6
+ import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
7
+ -import { randomBytes } from 'node:crypto';
8
+ +import { createHash, randomBytes } from 'node:crypto';
9
+ import { createWriteStream, existsSync } from 'node:fs';
10
+ import { mkdir, realpath, writeFile } from 'node:fs/promises';
11
+ -import { join } from 'node:path';
12
+ +import { join, resolve } from 'node:path';
13
+ import type { Api, Model } from '@earendil-works/pi-ai';
14
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
15
+ -import { formatSize } from '@earendil-works/pi-coding-agent';
16
+ +import { formatSize, getAgentDir } from '@earendil-works/pi-coding-agent';
17
+ import {
18
+ boundedRead,
19
+ deriveTaskNameFromCommand,
20
+ @@ -781,9 +781,29 @@ export class BackgroundTaskRegistry {
21
+ if (this.runtimeDir) return this.runtimeDir;
22
+ const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
23
+ const runId = `${sessionId}-${String(process.pid)}`;
24
+ - const runtimeDirAbs = join(ctx.cwd, '.pi', 'tasks', runId);
25
+ - const runtimeDirDisplay = join('.pi', 'tasks', runId);
26
+ - await mkdir(runtimeDirAbs, { recursive: true });
27
+ + let runtimeDirAbs = join(ctx.cwd, '.pi', 'tasks', runId);
28
+ + let runtimeDirDisplay = join('.pi', 'tasks', runId);
29
+ + try {
30
+ + await mkdir(runtimeDirAbs, { recursive: true });
31
+ + } catch (error) {
32
+ + // Privateer: Git Bash can open in Program Files. Keep the command cwd,
33
+ + // but store task artifacts in the user's agent dir when cwd is protected.
34
+ + const code = (error as NodeJS.ErrnoException).code;
35
+ + if (code !== 'EACCES' && code !== 'EPERM' && code !== 'EROFS') throw error;
36
+ + const projectDir = runtimeDirAbs;
37
+ + const projectId = createHash('sha256').update(resolve(ctx.cwd)).digest('hex').slice(0, 16);
38
+ + runtimeDirAbs = resolve(getAgentDir(), 'tasks', projectId, runId);
39
+ + // Never describe an external artifact as project-relative.
40
+ + runtimeDirDisplay = runtimeDirAbs;
41
+ + try {
42
+ + await mkdir(runtimeDirAbs, { recursive: true, mode: 0o700 });
43
+ + } catch (fallbackError) {
44
+ + throw new Error(
45
+ + `Cannot create background task storage at ${projectDir} (${code}) or ${runtimeDirAbs}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`,
46
+ + { cause: fallbackError },
47
+ + );
48
+ + }
49
+ + }
50
+ this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
51
+ return this.runtimeDir;
52
+ }
53
+ diff --git a/node_modules/pi-background-tasks/src/extension.ts b/node_modules/pi-background-tasks/src/extension.ts
54
+ index 3afbd8e..d003a57 100644
55
+ --- a/node_modules/pi-background-tasks/src/extension.ts
56
+ +++ b/node_modules/pi-background-tasks/src/extension.ts
57
+ @@ -473,14 +473,15 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
58
+ pi.on('session_start', async (_event, ctx) => {
59
+ registry.setShuttingDown(false);
60
+ currentCtx = ctx;
61
+ - await registry.ensureRuntimeDir(ctx);
62
+ + // Privateer: no project writes just to open a session. Each task-start path
63
+ + // creates its runtime directory on demand, with a per-user fallback.
64
+ updateUi(ctx);
65
+ if (statusInterval) clearInterval(statusInterval);
66
+ statusInterval = setInterval(() => {
67
+ updateUi();
68
+ }, STATUS_INTERVAL_MS);
69
+ - // One-shot, non-blocking: never awaited on the session-start path or the status tick.
70
+ - void scheduleUpdateCheck(ctx);
71
+ + // Privateer: update check disabled. Privateer manages updates at the distribution
72
+ + // level via `privateer update`; individual tool packs shouldn't check npm or prompt /bg-update.
73
+ });
74
+
75
+ pi.on('session_shutdown', async (_event, ctx) => {
76
+ @@ -565,22 +566,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
77
+ });
78
+
79
+ pi.registerCommand('bg-update', {
80
+ - description: 'Show how to update pi-background-tasks to the latest published version',
81
+ + description: 'Show update guidance for background tasks in Privateer',
82
+ handler: (_args, ctx) => {
83
+ - const current = PACKAGE_VERSION ?? 'unknown';
84
+ - const latest = latestKnownVersion;
85
+ - const pinnedNpm = latest ? `${PACKAGE_NAME}@${latest}` : `${PACKAGE_NAME}@<version>`;
86
+ const lines = [
87
+ - latest
88
+ - ? `pi-background-tasks ${current} is installed; ${latest} is the latest published version.`
89
+ - : `pi-background-tasks ${current} is installed.`,
90
+ - 'Update from npm:',
91
+ - ` pi install npm:${PACKAGE_NAME}@latest`,
92
+ - ` pi install npm:${pinnedNpm}`,
93
+ - 'Git releases are independent of npm versions; use main only when you want current repository state:',
94
+ - ` pi install ${GIT_INSTALL_TARGET}@main`,
95
+ - `For a pinned git release, first verify the tag exists, then use ${GIT_INSTALL_TARGET}@<existing-tag>.`,
96
+ - 'This command only prints update instructions; it does not install or self-update.',
97
+ + 'pi-background-tasks is built into Privateer.',
98
+ + 'To update Privateer and bundled tools, run: privateer update',
99
+ ];
100
+ ctx.ui.notify(lines.join('\n'), 'info');
101
+ return Promise.resolve();
102
+ @@ -682,7 +672,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
103
+ pi.registerTool<typeof BgRunParams, BgRunDetails>({
104
+ name: 'bg_run',
105
+ label: 'Background Run',
106
+ - description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. By default, completed, failed, or killed terminal state is delivered automatically as <background-task-notification> and starts a follow-up agent turn; do not sleep or poll merely to wait. Output is written to .pi/tasks and model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
107
+ + description: `Start a named long-running shell command in the background and return immediately with a task ID and output path. By default, completed, failed, or killed terminal state is delivered automatically as <background-task-notification> and starts a follow-up agent turn; do not sleep or poll merely to wait. Output is written to .pi/tasks (or per-user agent storage when the project denies writes); use the returned output path. Model-visible logs are bounded to ${formatSize(MAX_LOG_BYTES)}.`,
108
+ promptSnippet:
109
+ 'Start a named long-running shell command; default terminal notification wakes a follow-up turn, so yield instead of polling',
110
+ promptGuidelines: [
package/src/boot.ts CHANGED
@@ -38,6 +38,13 @@ process.env[PI_AGENT_DIR_ENV] ??= agentDir();
38
38
  // (2) Install the attestation dispatcher (idempotent).
39
39
  installAttestationDispatcher();
40
40
 
41
+ // (3) Suppress upstream update checks: Pi's CLI update banner and pi-background-tasks'
42
+ // update check / footer notice ("bg ⬆ v... /bg-update"). Privateer manages updates
43
+ // at the distribution level via `privateer update` and its own banner; individual
44
+ // bundled tool packs must not query npm or display `/bg-update`.
45
+ process.env.PI_SKIP_VERSION_CHECK ??= "1";
46
+ process.env.PI_BG_DISABLE_UPDATE_CHECK ??= "1";
47
+
41
48
  // Marker other modules can assert on to catch an accidental "imported Pi before
42
49
  // boot" regression during development.
43
50
  export const BOOTED = true;
@@ -10,9 +10,9 @@
10
10
  // - `finish` carries usage + finishReason,
11
11
  // - compaction / auto-retry / agent-end-error mapped through.
12
12
  //
13
- // Field names for the compaction/retry/error events are best-effort against Pi
14
- // 0.80 and marked TODO(verify) — Phase 1's test (`tests/engine.test.ts`, ported
15
- // from tree-cli) asserts the mapping against a real event stream and pins them.
13
+ // Pi's turn_end is ONE model response plus tools, not the user's whole run.
14
+ // agent_end can still be followed by retries/compaction. Only agent_settled
15
+ // (Pi 0.84) is terminal; keeping this distinction preserves the app's busy pill.
16
16
 
17
17
  import {
18
18
  addUsage,
@@ -55,7 +55,7 @@ function textOf(result: unknown): string {
55
55
  const parts = (result as any)?.content;
56
56
  if (Array.isArray(parts)) return parts.map((p: any) => p.text ?? `[${p.type}]`).join("");
57
57
  try {
58
- return JSON.stringify(result);
58
+ return JSON.stringify(result) ?? "";
59
59
  } catch {
60
60
  return String(result);
61
61
  }
@@ -70,9 +70,16 @@ function num(v: unknown, fallback = 0): number {
70
70
  // each Pi event; feed each through in `session.subscribe`.
71
71
  export function createEngineEventAdapter() {
72
72
  let sessionTotal: UsageTotals = emptyUsage();
73
+ let turnTotal: UsageTotals = emptyUsage();
74
+ let lastMessage: any;
75
+ let terminalSent = false;
73
76
 
74
77
  function toEngineEvents(ev: PiSessionEvent): EngineEvent[] {
75
78
  switch (ev.type) {
79
+ case "agent_start":
80
+ terminalSent = false;
81
+ return [];
82
+
76
83
  case "message_update": {
77
84
  const a = ev.assistantMessageEvent as any;
78
85
  if (a?.type === "text_delta") return [{ type: "text", text: a.delta }];
@@ -90,6 +97,16 @@ export function createEngineEventAdapter() {
90
97
  },
91
98
  ];
92
99
 
100
+ // Pi partial results are snapshots, NOT deltas. Transports bound these;
101
+ // cloud redaction must see the full text BEFORE any tail truncation.
102
+ case "tool_execution_update":
103
+ return [{
104
+ type: "tool-progress",
105
+ id: ev.toolCallId as string,
106
+ name: ev.toolName as string,
107
+ output: textOf(ev.partialResult),
108
+ }];
109
+
93
110
  case "tool_execution_end":
94
111
  return ev.isError
95
112
  ? [
@@ -113,43 +130,35 @@ export function createEngineEventAdapter() {
113
130
  const msg = ev.message as any;
114
131
  const turn = normUsage(msg?.usage);
115
132
  sessionTotal = addUsage(sessionTotal, turn);
116
- const finishReason =
117
- (ev.finishReason as string) ?? (msg?.stopReason as string) ?? "stop";
118
- const out: EngineEvent[] = [{ type: "usage", usage: sessionTotal, turn }];
119
- // A FAILED turn ends here, not by throwing — and this is the only place the
120
- // app can learn it failed.
121
- //
122
- // Pi reports a dead model call as an assistant message with
123
- // `stopReason: "error"` and an `errorMessage`; `prompt()` then resolves
124
- // NORMALLY, so the desktop's runTurn catch never fires. Our own pi patch
125
- // widened that path deliberately (a hard 4xx, and a 429 that outlived the
126
- // retry budget, both end the turn instead of re-entering the agent loop),
127
- // which is right for the CLI — its TUI reads the assistant message and
128
- // prints describeErrorText. The app reads EngineEvents, and this adapter
129
- // used to drop `errorMessage` on the floor: the turn arrived as a bare
130
- // `finish`, which RemoteDriveContext closes as a green ✓ "done". A signed-in
131
- // user whose first turn 401'd or hit their cap saw a tick and no words.
132
- //
133
- // So say it. Same wording the terminal gets, ahead of the finish so the feed
134
- // reads in order, and redacted either way because an unrecognised body goes
135
- // out verbatim.
133
+ turnTotal = addUsage(turnTotal, turn);
134
+ lastMessage = msg;
135
+ return [{ type: "usage", usage: sessionTotal, turn: turnTotal }, { type: "step-finish" }];
136
+ }
137
+
138
+ case "agent_settled": {
139
+ if (terminalSent) return [];
140
+ terminalSent = true;
141
+ const msg = lastMessage;
142
+ lastMessage = undefined;
143
+ turnTotal = emptyUsage();
144
+ // Failed model calls often resolve prompt() normally. Delay the error
145
+ // until retries/overflow recovery are exhausted, but never hide it.
136
146
  if (msg?.stopReason === "error") {
137
147
  const raw = typeof msg.errorMessage === "string" ? msg.errorMessage : "";
138
148
  const described = describeErrorText(raw);
139
- out.push({
149
+ return [{
140
150
  type: "error",
141
151
  error: described?.message ?? redactText(raw || "The model call failed."),
142
152
  ...(described?.hint ? { hint: described.hint } : {}),
143
153
  ...(described?.retryable != null ? { retryable: described.retryable } : {}),
144
- });
154
+ }];
145
155
  }
146
- out.push({ type: "finish", usage: sessionTotal, finishReason });
147
- return out;
156
+ if (msg?.stopReason === "aborted") return [{ type: "aborted" }];
157
+ return [{ type: "finish", usage: sessionTotal, finishReason: msg?.stopReason ?? "stop" }];
148
158
  }
149
159
 
150
- // Compaction: Pi collapses history to free context. TODO(verify) field
151
- // names for before/after token counts against a real compaction event.
152
160
  case "compaction_start":
161
+ return [{ type: "step-finish" }];
153
162
  case "compaction_end":
154
163
  return [
155
164
  {
@@ -173,28 +182,17 @@ export function createEngineEventAdapter() {
173
182
  },
174
183
  ];
175
184
 
176
- // Terminal error surfaced at the end of an agent run.
177
- //
178
- // ⚠️ Pi 0.84's `agent_end` is `{ messages, willRetry }` — there is NO `error`
179
- // field, so this branch has been returning [] for every run since the 0.84
180
- // refactor. It is kept (harmless, and other Pi versions have carried one)
181
- // but it is NOT the error path: a failed model call arrives on `turn_end`
182
- // above, which is where the real mapping lives. Do not "restore" error
183
- // reporting here and delete it there.
184
- case "agent_end": {
185
- const err = (ev as any).error;
186
- if (!err) return [];
187
- return [
188
- {
189
- type: "error",
190
- error: typeof err === "string" ? err : (err.message ?? String(err)),
191
- retryable: Boolean((ev as any).retryable),
192
- },
193
- ];
194
- }
185
+ // agent_end is only a low-level loop boundary. willRetry:false still
186
+ // permits overflow compaction and queued continuation; never finish here.
187
+ case "agent_end":
188
+ return [];
195
189
 
196
190
  case "abort":
197
191
  case "aborted":
192
+ if (terminalSent) return [];
193
+ terminalSent = true;
194
+ lastMessage = undefined;
195
+ turnTotal = emptyUsage();
198
196
  return [{ type: "aborted" }];
199
197
 
200
198
  default:
package/src/cli/chat.ts CHANGED
@@ -329,6 +329,15 @@ async function main() {
329
329
  async function runTurn(text: string, remote: boolean, echo = true): Promise<void> {
330
330
  if (turnActive) {
331
331
  console.log(`\n${DIM}(busy — a turn is already running)${RESET}`);
332
+ // Say it to the DRIVER too, not just to this terminal's own console. An
333
+ // app-driven prompt refused here used to vanish: the app had already echoed
334
+ // the message into its feed and lit the thinking pill (submit() in
335
+ // RemoteDriveContext does both before transmitting), and the only trace of
336
+ // the refusal was a dim line on a terminal nobody was looking at. The driver
337
+ // saw their own message sit under a spinner forever. A notice does not close
338
+ // their turn, and must not — a turn IS running, and it is the one they are
339
+ // watching — but it tells them this message was not added to it.
340
+ if (remote) relay?.sendNotice("Not sent — this terminal is already running a turn. Wait for it to finish, or press Stop.");
332
341
  return;
333
342
  }
334
343
  turnActive = true;
@@ -117,6 +117,30 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
117
117
  return typeof code === "string" && CAP_CODE.test(code);
118
118
  }
119
119
 
120
+ /**
121
+ * Balance guidance for the signed-in Privateer account channel ONLY. A bare 429
122
+ * is not evidence of an empty balance; neither is a daily cap or provider quota.
123
+ * The SDK may retain the JSON body (flat or OpenAI-shaped), or just its message.
124
+ */
125
+ export function describeAccountBalanceError(text: string): DescribedError | null {
126
+ if (!/^\s*(?:402|429)\b/.test(text) || /<!doctype html|<html[\s>]/i.test(text)) return null;
127
+ const bodyStart = text.indexOf("{");
128
+ const facts = bodyStart < 0 ? {} : extract({ responseBody: text.slice(bodyStart) });
129
+ const code = facts.code;
130
+ const message = facts.providerMessage ?? text;
131
+ const balanceCode = /^(?:INSUFFICIENT_(?:BALANCE|FUNDS|CREDITS?)|(?:BALANCE|CREDITS?)_EXHAUSTED)$/i;
132
+ const balanceText = /\b(?:insufficient (?:credit(?:s| balance)?|balance|funds)|out of credits?|(?:credit balance|balance|credits?) (?:is |are )?exhausted)\b/i;
133
+ // If a machine code exists, it decides — don't turn a rate limit whose help
134
+ // text mentions credits into a billing failure.
135
+ if (code ? !balanceCode.test(code) : !balanceText.test(message)) return null;
136
+ return {
137
+ message: "Your Privateer account has insufficient balance.",
138
+ // Use our known destination, never a URL copied from an untrusted error body.
139
+ hint: "Top up at https://privateer.pro/top-up, then try again.",
140
+ retryable: false,
141
+ };
142
+ }
143
+
120
144
  // ── Oversized / non-API error bodies ─────────────────────────────────────────
121
145
  //
122
146
  // An inference endpoint does not always answer as an API. Put a WAF, a proxy or a
@@ -18,6 +18,8 @@ export type EngineEvent =
18
18
  | { type: "text"; text: string }
19
19
  | { type: "reasoning"; text: string }
20
20
  | { type: "tool-call"; id: string; name: string; input: unknown }
21
+ // Latest output snapshot; transports bound it, without settling the running card.
22
+ | { type: "tool-progress"; id: string; name: string; output: string }
21
23
  | { type: "tool-result"; id: string; name: string; output: unknown }
22
24
  | { type: "tool-error"; id: string; name: string; error: string }
23
25
  | { type: "step-finish" }
@@ -36,7 +38,20 @@ export type EngineEvent =
36
38
  | { type: "retrying"; attempt: number; max: number; delayMs: number; reason: string }
37
39
  // `error` is the short user-facing message; `hint` is an optional actionable
38
40
  // next step rendered dim beneath it. Both are already secret-redacted.
39
- | { type: "error"; error: string; hint?: string; retryable?: boolean };
41
+ | { type: "error"; error: string; hint?: string; retryable?: boolean }
42
+ // The turn is still running and has produced nothing for `sinceMs`. Emitted by a
43
+ // HOST that supervises turns (today: the desktop's turnSupervisor), never by the
44
+ // engine — the engine has no clock and no view of the turn as a whole.
45
+ //
46
+ // It exists because silence is ambiguous and the app cannot resolve it: a tool
47
+ // call may emit at its start and its end with nothing in between, so a fifteen-minute
48
+ // build, a stalled model socket and a dead agent all look identical from the far
49
+ // side of the relay. This is the frame that says which. `tool`/`toolMs` name what
50
+ // is being waited on when something is.
51
+ //
52
+ // It is NOT turn activity: it must not reset a "silent for 4m" clock, or it would
53
+ // erase the very thing it was sent to report.
54
+ | { type: "still-working"; sinceMs: number; tool?: string; toolMs?: number };
40
55
 
41
56
  export const emptyUsage = (): UsageTotals => ({
42
57
  inputTokens: 0,
@@ -27,6 +27,8 @@ import { join } from "node:path";
27
27
  import { globalDir } from "../config/paths.ts";
28
28
  import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts";
29
29
  import { installGzipRequestBodies } from "../util/gzipRequestBody.ts";
30
+ import { describeAccountBalanceError } from "../engine/errors.ts";
31
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
30
32
  import { interpretReport, teePosture, tierFromTeePosture, type PrivacyTier } from "pi-privacy";
31
33
  import { ACCOUNT_DEFAULT_MODEL_ID, ACCOUNT_NEAR_MODEL_ID, ensurePiDefaultModel } from "./defaultModel.ts";
32
34
  import { visionInput } from "./vision.ts";
@@ -650,7 +652,7 @@ export function registerAccountModels(pi: {
650
652
  export function makeAccountProvider() {
651
653
  return (pi: {
652
654
  registerProvider?: (name: string, config: unknown) => void;
653
- on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
655
+ on?: (event: string, handler: (e: unknown, ctx: unknown) => unknown) => void;
654
656
  }): void => {
655
657
  if (typeof pi.registerProvider !== "function") return;
656
658
  // Compress this channel's inference bodies before anything can send one. The edge
@@ -710,14 +712,22 @@ export function makeAccountProvider() {
710
712
  }
711
713
  });
712
714
 
713
- // message_end: an assistant turn that ended in an auth error on the account channel
714
- // means our session token is dead server-side. Pi has no reactive-401 refresh, so
715
- // replace the session now instead of failing every prompt until `expires`.
716
- // See recoverAccountSession.
715
+ // Replace confirmed balance failures before Pi renders/persists the message.
716
+ // showError alone misses first-attempt failures (the assistant component renders
717
+ // those directly). This also covers print mode and the remote event adapter.
717
718
  pi.on?.("message_end", (e, ctx) => {
718
- const msg = (e as { message?: { role?: string; stopReason?: string; errorMessage?: string } })?.message;
719
+ const msg = (e as { message?: AssistantMessage })?.message;
719
720
  if (msg?.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;
720
- if ((ctx as SeedContext)?.model?.provider !== "privateer") return;
721
+ if (msg.provider !== "privateer") return;
722
+ const balance = hasCredentials() ? describeAccountBalanceError(msg.errorMessage) : null;
723
+ if (balance) {
724
+ // Normalize the known billing failure to Payment Required, just as
725
+ // authedFetch does for cap responses. Keeping a hard status first stops
726
+ // Pi retrying/compacting into the same exhausted account.
727
+ return { message: { ...msg, errorMessage: `402 ${balance.message}\n${balance.hint}` } };
728
+ }
729
+ // An auth failure, unlike a balance failure, needs a fresh child session.
730
+ // Pi has no reactive-401 refresh; see recoverAccountSession.
721
731
  void recoverAccountSession(ctx, msg.errorMessage);
722
732
  });
723
733
  };
@@ -10,7 +10,7 @@
10
10
  // never nominated a model. resolveDefaultModel() makes the account channel the default
11
11
  // the moment credentials exist, and keeps the legacy BYO behaviour otherwise.
12
12
 
13
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { hasCredentials } from "../auth/privateer.ts";
16
16
  import { agentDir } from "../config/paths.ts";
@@ -282,8 +282,10 @@ export function writePiDefaultModel(spec: string): string | null {
282
282
  function writeSettingsDefaultModel(spec: string, onlyIfUnset: boolean): string | null {
283
283
  const parts = splitSpec(spec);
284
284
  if (!parts) return null;
285
- const settingsPath = join(agentDir(), "settings.json");
285
+ const dir = agentDir();
286
+ const settingsPath = join(dir, "settings.json");
286
287
  try {
288
+ mkdirSync(dir, { recursive: true });
287
289
  let settings: Record<string, unknown> = {};
288
290
  if (existsSync(settingsPath)) {
289
291
  const raw = readFileSync(settingsPath, "utf8").trim();
@@ -353,6 +353,8 @@ function projectEvent(ev: EngineEvent): Record<string, unknown> {
353
353
  switch (ev.type) {
354
354
  case "tool-call":
355
355
  return { type: "tool-call", id: ev.id, name: ev.name, input: safe(asText(ev.input), 2000) };
356
+ case "tool-progress":
357
+ return { type: "tool-progress", id: ev.id, name: ev.name, output: redactSecrets(ev.output).slice(-4000) };
356
358
  case "tool-result":
357
359
  return { type: "tool-result", id: ev.id, name: ev.name, output: safe(asText(ev.output), 4000) };
358
360
  case "tool-error":
@@ -373,6 +375,11 @@ function projectEvent(ev: EngineEvent): Record<string, unknown> {
373
375
  return { type: "aborted" };
374
376
  case "step-finish":
375
377
  return { type: "step-finish" };
378
+ // Fields, not just the tag: the default below would drop sinceMs/tool and the
379
+ // frame would arrive saying "something is happening" without saying what or
380
+ // for how long, which is the whole of its content.
381
+ case "still-working":
382
+ return { type: "still-working", sinceMs: ev.sinceMs, tool: ev.tool, toolMs: ev.toolMs };
376
383
  default:
377
384
  // text/reasoning are coalesced in sendEvent and never reach here.
378
385
  return { type: ev.type };