privateer-agent 0.12.34 → 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 +7 -0
- package/bin/apply-patches.mjs +29 -14
- package/bin/privateer-launch.mjs +224 -5
- package/bin/privateer-splash.mjs +2 -4
- package/bin/privateer-subagent.mjs +2 -0
- package/bin/startup-cache.d.mts +1 -0
- package/bin/startup-cache.mjs +19 -0
- package/extensions/privateer-models.ts +36 -9
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.84.4.patch +17 -1
- package/patches/pi-background-tasks+2.4.2.patch +110 -0
- package/src/boot.ts +7 -0
- package/src/bridge/engineAdapter.ts +47 -49
- package/src/engine/errors.ts +24 -0
- package/src/engine/events.ts +3 -1
- package/src/providers/account.ts +17 -7
- package/src/providers/defaultModel.ts +4 -2
- package/src/remote/relayClient.ts +2 -0
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
|
package/bin/apply-patches.mjs
CHANGED
|
@@ -67,13 +67,16 @@ export function resolveDep(from, name, ...rest) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
function resolvePatchRoots(repo, patchFiles) {
|
|
70
|
-
const roots = new
|
|
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)
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -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
|
|
45
|
-
|
|
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 {
|
package/bin/privateer-splash.mjs
CHANGED
|
@@ -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
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
376
|
+
available = (await registry?.getAvailable?.()) as ModelLike[] ?? [];
|
|
380
377
|
} catch (e) {
|
|
381
|
-
ui
|
|
378
|
+
ui?.notify?.(`Couldn't list models: ${(e as Error).message}`, "error");
|
|
382
379
|
return;
|
|
383
380
|
}
|
|
384
381
|
if (!available.length) {
|
|
385
|
-
ui
|
|
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.
|
|
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.
|
|
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
|
-
@@ -
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
return
|
|
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
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
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/engine/errors.ts
CHANGED
|
@@ -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
|
package/src/engine/events.ts
CHANGED
|
@@ -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" }
|
|
@@ -42,7 +44,7 @@ export type EngineEvent =
|
|
|
42
44
|
// engine — the engine has no clock and no view of the turn as a whole.
|
|
43
45
|
//
|
|
44
46
|
// It exists because silence is ambiguous and the app cannot resolve it: a tool
|
|
45
|
-
// call
|
|
47
|
+
// call may emit at its start and its end with nothing in between, so a fifteen-minute
|
|
46
48
|
// build, a stalled model socket and a dead agent all look identical from the far
|
|
47
49
|
// side of the relay. This is the frame that says which. `tool`/`toolMs` name what
|
|
48
50
|
// is being waited on when something is.
|
package/src/providers/account.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
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?:
|
|
719
|
+
const msg = (e as { message?: AssistantMessage })?.message;
|
|
719
720
|
if (msg?.role !== "assistant" || msg.stopReason !== "error" || !msg.errorMessage) return;
|
|
720
|
-
if (
|
|
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
|
|
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":
|