privateer-agent 0.15.4 → 0.15.6
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 +11 -0
- package/bin/boot-bundle.d.mts +2 -0
- package/bin/boot-bundle.mjs +44 -0
- package/bin/fresh-supervisor.d.mts +5 -1
- package/bin/fresh-supervisor.mjs +47 -10
- package/bin/privateer-launch.mjs +27 -3
- package/bin/privateer-subagent.mjs +5 -2
- package/bin/privateer-verify.mjs +12 -0
- package/bin/session-recovery.d.mts +53 -0
- package/bin/session-recovery.mjs +240 -0
- package/bin/session-seal-preload.mjs +17 -0
- package/bin/session-seal.d.mts +25 -0
- package/bin/session-seal.mjs +242 -0
- package/dist-boot/boot.mjs +3 -0
- package/dist-boot/c-27JKSMWL.mjs +1460 -0
- package/dist-boot/c-2BMRFV24.mjs +269 -0
- package/dist-boot/c-2CTF6JA3.mjs +166 -0
- package/dist-boot/c-2G3BBHRJ.mjs +84 -0
- package/dist-boot/c-2IDLKXX6.mjs +250 -0
- package/dist-boot/c-2NJD5VYM.mjs +8 -0
- package/dist-boot/c-2S5EEQPW.mjs +1602 -0
- package/dist-boot/c-2WRDCUVI.mjs +25 -0
- package/dist-boot/c-2YN3M256.mjs +14 -0
- package/dist-boot/c-37GM3GMU.mjs +187 -0
- package/dist-boot/c-3STKRSVO.mjs +2480 -0
- package/dist-boot/c-3TPV6TQQ.mjs +2320 -0
- package/dist-boot/c-3UVHLQDJ.mjs +225 -0
- package/dist-boot/c-42PUUZYC.mjs +697 -0
- package/dist-boot/c-4D5UMWVW.mjs +683 -0
- package/dist-boot/c-4ETZRMTA.mjs +46 -0
- package/dist-boot/c-4QT25BD5.mjs +1482 -0
- package/dist-boot/c-4WVR32AP.mjs +2579 -0
- package/dist-boot/c-5EMM567G.mjs +152 -0
- package/dist-boot/c-5FA4CV63.mjs +145 -0
- package/dist-boot/c-5GV3X3RB.mjs +14 -0
- package/dist-boot/c-5JCEPRIY.mjs +52 -0
- package/dist-boot/c-5NDKIIWT.mjs +16 -0
- package/dist-boot/c-5QL3AR26.mjs +4665 -0
- package/dist-boot/c-5XY6MFKN.mjs +5142 -0
- package/dist-boot/c-65UZIZC7.mjs +2897 -0
- package/dist-boot/c-6FUXKHOK.mjs +1969 -0
- package/dist-boot/c-6JHWQE4V.mjs +18242 -0
- package/dist-boot/c-6LOBR4I6.mjs +5280 -0
- package/dist-boot/c-6QHOZXKQ.mjs +14394 -0
- package/dist-boot/c-6ZO2SADQ.mjs +178 -0
- package/dist-boot/c-6ZORVVZP.mjs +1449 -0
- package/dist-boot/c-76QH4UCP.mjs +2441 -0
- package/dist-boot/c-77GO5L5Y.mjs +20982 -0
- package/dist-boot/c-7M23XFTN.mjs +5052 -0
- package/dist-boot/c-7TC367E2.mjs +32 -0
- package/dist-boot/c-7UOG7POX.mjs +1524 -0
- package/dist-boot/c-7VHP3FLN.mjs +376 -0
- package/dist-boot/c-A73JHZP2.mjs +13207 -0
- package/dist-boot/c-ABXULAFL.mjs +71 -0
- package/dist-boot/c-ANDD2AQO.mjs +74 -0
- package/dist-boot/c-AOY77AZL.mjs +1430 -0
- package/dist-boot/c-AXEPZ22I.mjs +108 -0
- package/dist-boot/c-B2Y7VNHU.mjs +427 -0
- package/dist-boot/c-B7VJUH73.mjs +77 -0
- package/dist-boot/c-BFW6CDVL.mjs +24 -0
- package/dist-boot/c-BHNIF2AR.mjs +188 -0
- package/dist-boot/c-BKF2O6BL.mjs +23 -0
- package/dist-boot/c-BOZXFTZN.mjs +8 -0
- package/dist-boot/c-BRNYJWZL.mjs +28 -0
- package/dist-boot/c-C3N4Z2HO.mjs +93 -0
- package/dist-boot/c-C4IV7ZFG.mjs +717 -0
- package/dist-boot/c-C5NK6UYV.mjs +793 -0
- package/dist-boot/c-C67644MB.mjs +1659 -0
- package/dist-boot/c-C6S2YB53.mjs +620 -0
- package/dist-boot/c-CDBEJSBF.mjs +37 -0
- package/dist-boot/c-CG47HBKU.mjs +435 -0
- package/dist-boot/c-CKCTWCT5.mjs +51 -0
- package/dist-boot/c-CN77DCGF.mjs +60 -0
- package/dist-boot/c-CRBXNKFO.mjs +1795 -0
- package/dist-boot/c-CRIHASQ6.mjs +973 -0
- package/dist-boot/c-CU4NGAUO.mjs +3786 -0
- package/dist-boot/c-CWRFLGQU.mjs +1883 -0
- package/dist-boot/c-CYQB2USO.mjs +32 -0
- package/dist-boot/c-DEE67DJO.mjs +379 -0
- package/dist-boot/c-DIFDBYOP.mjs +1258 -0
- package/dist-boot/c-DOSGVXDZ.mjs +34879 -0
- package/dist-boot/c-DR7XQI32.mjs +381 -0
- package/dist-boot/c-DTGPSB46.mjs +14666 -0
- package/dist-boot/c-DTHDNA4P.mjs +320 -0
- package/dist-boot/c-E4Z3Y5B5.mjs +3864 -0
- package/dist-boot/c-E66Z2TI7.mjs +203 -0
- package/dist-boot/c-EBU6RLRV.mjs +1527 -0
- package/dist-boot/c-EBZZNGSF.mjs +432 -0
- package/dist-boot/c-EC3LJ2KF.mjs +1032 -0
- package/dist-boot/c-EQBKXWNX.mjs +15829 -0
- package/dist-boot/c-EUEPIW4S.mjs +574 -0
- package/dist-boot/c-EX4YVWAT.mjs +378 -0
- package/dist-boot/c-EYZGSAJU.mjs +98 -0
- package/dist-boot/c-F75VRKY4.mjs +172 -0
- package/dist-boot/c-FH4NXKHV.mjs +177 -0
- package/dist-boot/c-FHNUCTEM.mjs +140 -0
- package/dist-boot/c-FJ5T42DS.mjs +5349 -0
- package/dist-boot/c-FPCNRL7G.mjs +1728 -0
- package/dist-boot/c-G4DLN7Q4.mjs +8 -0
- package/dist-boot/c-G56GYQFZ.mjs +2507 -0
- package/dist-boot/c-GLIOERDQ.mjs +1200 -0
- package/dist-boot/c-GMJ5HUBM.mjs +14 -0
- package/dist-boot/c-H3HSGMAQ.mjs +461 -0
- package/dist-boot/c-HIKTO6P7.mjs +275 -0
- package/dist-boot/c-I2MCJ34O.mjs +464 -0
- package/dist-boot/c-I3C6LBW4.mjs +765 -0
- package/dist-boot/c-I54OKQHW.mjs +387 -0
- package/dist-boot/c-IFSP3R4D.mjs +80 -0
- package/dist-boot/c-IZK77VB7.mjs +171 -0
- package/dist-boot/c-J3Y25UGE.mjs +54 -0
- package/dist-boot/c-J5IESS7W.mjs +27750 -0
- package/dist-boot/c-JJWZBY7L.mjs +254 -0
- package/dist-boot/c-JKTFXTGF.mjs +463 -0
- package/dist-boot/c-JN57AXQ4.mjs +77 -0
- package/dist-boot/c-KFIO6LUW.mjs +1096 -0
- package/dist-boot/c-KIEMEFWW.mjs +478 -0
- package/dist-boot/c-KIVXWBYD.mjs +28 -0
- package/dist-boot/c-KP6CRIP4.mjs +580 -0
- package/dist-boot/c-L26VOJX4.mjs +11558 -0
- package/dist-boot/c-L5GHWG5G.mjs +5988 -0
- package/dist-boot/c-M7NSQAKK.mjs +353 -0
- package/dist-boot/c-MCEWOPBW.mjs +56 -0
- package/dist-boot/c-MGPIEG7I.mjs +20 -0
- package/dist-boot/c-NREXJV3M.mjs +393 -0
- package/dist-boot/c-O5PP6CTX.mjs +162 -0
- package/dist-boot/c-OBQDHSXM.mjs +1774 -0
- package/dist-boot/c-OLQHHDD4.mjs +21 -0
- package/dist-boot/c-OXW4JQQH.mjs +319 -0
- package/dist-boot/c-P3D7ZUWN.mjs +1288 -0
- package/dist-boot/c-P4PQZVV5.mjs +948 -0
- package/dist-boot/c-P63T3XL2.mjs +26373 -0
- package/dist-boot/c-P6N5DQGZ.mjs +194 -0
- package/dist-boot/c-P7MLKQFC.mjs +58 -0
- package/dist-boot/c-PB3PZJL5.mjs +1276 -0
- package/dist-boot/c-PTN4S6G2.mjs +1259 -0
- package/dist-boot/c-PYCE5J7U.mjs +1083 -0
- package/dist-boot/c-Q4HYCRLB.mjs +19410 -0
- package/dist-boot/c-QUXMETGG.mjs +68 -0
- package/dist-boot/c-RDMJZCOX.mjs +356 -0
- package/dist-boot/c-ROGJK76M.mjs +17 -0
- package/dist-boot/c-RQJJ26T4.mjs +708 -0
- package/dist-boot/c-RSKCUPJF.mjs +7559 -0
- package/dist-boot/c-RVJU4O6P.mjs +37 -0
- package/dist-boot/c-S2RVHR3M.mjs +165 -0
- package/dist-boot/c-SKMK3PEE.mjs +91120 -0
- package/dist-boot/c-TI7SM4EG.mjs +20 -0
- package/dist-boot/c-TILJ6GXD.mjs +1235 -0
- package/dist-boot/c-TT6BCUJD.mjs +114 -0
- package/dist-boot/c-U4ITOXAN.mjs +2107 -0
- package/dist-boot/c-UGERXVO2.mjs +7120 -0
- package/dist-boot/c-UPBH5AXY.mjs +1363 -0
- package/dist-boot/c-US2PUNZL.mjs +597 -0
- package/dist-boot/c-UU4WZPPE.mjs +20 -0
- package/dist-boot/c-UWVIYPQS.mjs +39 -0
- package/dist-boot/c-V4RHQBPQ.mjs +17683 -0
- package/dist-boot/c-V5MRVK47.mjs +351 -0
- package/dist-boot/c-V6CL266R.mjs +1755 -0
- package/dist-boot/c-VJTMFKYE.mjs +14975 -0
- package/dist-boot/c-VJTRUKIB.mjs +773 -0
- package/dist-boot/c-W4IFSHPW.mjs +43 -0
- package/dist-boot/c-WETGB2CF.mjs +113 -0
- package/dist-boot/c-WO3QSZUU.mjs +1337 -0
- package/dist-boot/c-WQLH73TY.mjs +242 -0
- package/dist-boot/c-XL735JW5.mjs +500 -0
- package/dist-boot/c-Y7HADL5B.mjs +1107 -0
- package/dist-boot/c-YGQHSVSK.mjs +41 -0
- package/dist-boot/c-YMP2AMEB.mjs +10350 -0
- package/dist-boot/c-YRDO2WEF.mjs +12012 -0
- package/dist-boot/c-YUSTRATJ.mjs +8 -0
- package/dist-boot/c-Z2262R22.mjs +2697 -0
- package/dist-boot/c-Z4ALQMJC.mjs +709 -0
- package/dist-boot/c-Z5ZUQVUY.mjs +62 -0
- package/dist-boot/c-ZDZUKXF7.mjs +874 -0
- package/dist-boot/c-ZJYLGIC3.mjs +32 -0
- package/dist-boot/c-ZNA5CDMI.mjs +77 -0
- package/dist-boot/c-ZSO6DI3M.mjs +204 -0
- package/dist-boot/c-ZWUVHKMK.mjs +100 -0
- package/dist-boot/c-ZY4MESEW.mjs +832 -0
- package/dist-boot/c-ZZ5REI4L.mjs +329 -0
- package/dist-boot/cli.mjs +51 -0
- package/dist-boot/ext-anthropic-attribution.mjs +207 -0
- package/dist-boot/ext-pi-background-tasks.mjs +7234 -0
- package/dist-boot/ext-pi-subagents.mjs +37 -0
- package/dist-boot/ext-privateer-account.mjs +4426 -0
- package/dist-boot/ext-privateer-brand.mjs +5274 -0
- package/dist-boot/ext-privateer-codemode.mjs +37 -0
- package/dist-boot/ext-privateer-computer.mjs +626 -0
- package/dist-boot/ext-privateer-connect.mjs +1862 -0
- package/dist-boot/ext-privateer-context.mjs +323 -0
- package/dist-boot/ext-privateer-desktop.mjs +92 -0
- package/dist-boot/ext-privateer-final-audit.mjs +48 -0
- package/dist-boot/ext-privateer-fresh.mjs +81 -0
- package/dist-boot/ext-privateer-gate.mjs +5959 -0
- package/dist-boot/ext-privateer-hints.mjs +236 -0
- package/dist-boot/ext-privateer-media.mjs +2626 -0
- package/dist-boot/ext-privateer-models.mjs +3397 -0
- package/dist-boot/ext-privateer-posture.mjs +3006 -0
- package/dist-boot/ext-privateer-privacy.mjs +4578 -0
- package/dist-boot/ext-privateer-recovery.mjs +287 -0
- package/dist-boot/ext-privateer-speak.mjs +4754 -0
- package/dist-boot/ext-privateer-terminals.mjs +725 -0
- package/dist-boot/ext-privateer-tools.mjs +528 -0
- package/dist-boot/ext-privateer-truncation.mjs +74 -0
- package/dist-boot/ext-privateer-update.mjs +210 -0
- package/dist-boot/ext-privateer-vision.mjs +148 -0
- package/dist-boot/ext-privateer-web.mjs +543 -0
- package/dist-boot/ext-rpiv-ask-user-question.mjs +710 -0
- package/dist-boot/manifest.json +9 -0
- package/extensions/privateer-brand.ts +3 -1
- package/extensions/privateer-context.ts +4 -2
- package/extensions/privateer-final-audit.ts +17 -0
- package/extensions/privateer-gate.ts +55 -9
- package/extensions/privateer-recovery.ts +175 -0
- package/extensions/privateer-web.ts +4 -4
- package/package.json +3 -1
- package/patches/@earendil-works+pi-coding-agent+1.3.0.patch +199 -2
- package/patches/pi-background-tasks+2.6.10.patch +91 -7
- package/src/cli/chat.ts +7 -1
- package/src/config/moatManifest.json +2 -0
- package/src/config/whatsNew.json +4 -4
- package/src/context.ts +33 -5
- package/src/engine/finalAudit.ts +38 -0
- package/src/engine/stallWatchdog.ts +8 -1
- package/src/providers/account.ts +34 -3
- package/src/remote/attentionPush.ts +40 -0
- package/src/remote/liveTaskSession.ts +7 -1
- package/src/remote/relayClient.ts +38 -4
- package/src/remote/remoteBridge.ts +17 -0
- package/src/remote/transcriptSnapshot.ts +104 -0
package/README.md
CHANGED
|
@@ -505,6 +505,17 @@ privateer -p --approve-in-app --approval-timeout 300 "make a 6s intro clip"
|
|
|
505
505
|
`--max-spend` is checked before each call against the server's estimate for that call
|
|
506
506
|
(`media_capabilities` shows the same prices). A call that can't be priced is refused.
|
|
507
507
|
|
|
508
|
+
Piped input is waited for, however long the pipe stays silent
|
|
509
|
+
(`pytest | privateer -p "why did this fail?"`). If a program starts Privateer with stdin
|
|
510
|
+
open and never writes to it, a run that already has a prompt continues after 3 seconds.
|
|
511
|
+
Scripts with nothing to send can skip the wait with `< /dev/null`.
|
|
512
|
+
|
|
513
|
+
Before a one-shot run that used tools ends, Privateer asks the model to check its result
|
|
514
|
+
against what actually happened and to open its final message with anything it invented,
|
|
515
|
+
could not verify or lost. That is one extra model call; `PRIVATEER_FINAL_AUDIT=0` turns it
|
|
516
|
+
off. If the provider goes silent mid-run, the request is resent up to twice before the run
|
|
517
|
+
stops with an error.
|
|
518
|
+
|
|
508
519
|
## Hand off sensitive work
|
|
509
520
|
|
|
510
521
|
Your coding agent's context goes to its provider. When a task touches files that shouldn't
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Which Pi entry should this launch run: the compiled boot build, or the source tree?
|
|
2
|
+
// scripts/build-boot.mjs makes the build; docs/startup-performance.md explains why.
|
|
3
|
+
//
|
|
4
|
+
// The answer is the build only when it is certainly the same code as the source next
|
|
5
|
+
// to it. Every other case returns null and the launcher runs from source, which is
|
|
6
|
+
// slower and always correct, so nothing here is ever a reason a launch fails.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export const BOOT_DIR = "dist-boot";
|
|
12
|
+
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Absolute path of the build's entry script, or null to run from source. */
|
|
22
|
+
export function bootCliPath(repo, env = process.env) {
|
|
23
|
+
if (env.PRIVATEER_NO_BOOT_BUNDLE === "1") return null;
|
|
24
|
+
|
|
25
|
+
// A checkout is where source gets edited, and the build wouldn't see those edits.
|
|
26
|
+
if (fs.existsSync(path.join(repo, ".git")) && env.PRIVATEER_BOOT_BUNDLE !== "1") return null;
|
|
27
|
+
|
|
28
|
+
const dir = path.join(repo, BOOT_DIR);
|
|
29
|
+
const manifest = readJson(path.join(dir, "manifest.json"));
|
|
30
|
+
if (!manifest) return null;
|
|
31
|
+
|
|
32
|
+
// A build left behind by an upgrade is older code wearing the new version's name.
|
|
33
|
+
const pkg = readJson(path.join(repo, "package.json"));
|
|
34
|
+
if (!pkg || manifest.version !== pkg.version) return null;
|
|
35
|
+
|
|
36
|
+
// The build maps each file back to its place under <repo>/node_modules. A hoisted
|
|
37
|
+
// install (npx, or a dependency of another project) has no such place.
|
|
38
|
+
if (!fs.existsSync(path.join(repo, "node_modules", "@earendil-works", "pi-coding-agent", "package.json"))) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entry = path.join(dir, "boot.mjs");
|
|
43
|
+
return fs.existsSync(entry) && fs.existsSync(path.join(dir, "cli.mjs")) ? entry : null;
|
|
44
|
+
}
|
|
@@ -32,4 +32,8 @@ export function runDir(home: string): string;
|
|
|
32
32
|
export function listTerminals(home: string): TerminalEntry[];
|
|
33
33
|
export function requestFresh(socket: string, token: string, timeoutMs?: number): Promise<{ ok: true; pid: number }>;
|
|
34
34
|
/** Run the TUI under supervision; `buildArgs(true)` for the first launch, false after. */
|
|
35
|
-
export function runSupervised(
|
|
35
|
+
export function runSupervised(
|
|
36
|
+
cmd: string,
|
|
37
|
+
buildArgs: (first: boolean, extra?: string[]) => string[],
|
|
38
|
+
opts: { home: string; interactive?: boolean },
|
|
39
|
+
): void;
|
package/bin/fresh-supervisor.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import fs from "node:fs";
|
|
|
44
44
|
import net from "node:net";
|
|
45
45
|
import os from "node:os";
|
|
46
46
|
import path from "node:path";
|
|
47
|
+
import { RECOVERY_ENV, encodeRecovery, readMarker, removeMarker, shouldAutoResume } from "./session-recovery.mjs";
|
|
47
48
|
|
|
48
49
|
const isWin = process.platform === "win32";
|
|
49
50
|
|
|
@@ -254,9 +255,15 @@ export function requestFresh(socket, token, timeoutMs = 3000) {
|
|
|
254
255
|
// ── supervisor ──────────────────────────────────────────────────────────────
|
|
255
256
|
|
|
256
257
|
/** Put a terminal a killed TUI left in raw mode / hidden cursor / paste mode back. */
|
|
257
|
-
function resetTerminal() {
|
|
258
|
+
function resetTerminal({ clear = true } = {}) {
|
|
258
259
|
if (!process.stdout.isTTY) return;
|
|
259
260
|
if (!isWin) spawnSync("stty", ["sane"], { stdio: ["inherit", "ignore", "ignore"] });
|
|
261
|
+
// After a CRASH the screen is kept: the error the old agent printed is the only
|
|
262
|
+
// record of why it died, and the resumed agent redraws the conversation anyway.
|
|
263
|
+
if (!clear) {
|
|
264
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l\x1b[?2004l\x1b[<u\x1b[0m\n");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
260
267
|
// Show cursor, leave the alternate screen, bracketed paste off, pop the kitty keyboard
|
|
261
268
|
// protocol, reset attributes, then clear screen + scrollback so the new agent starts on
|
|
262
269
|
// a blank page rather than under the old conversation.
|
|
@@ -305,12 +312,20 @@ async function reap(child) {
|
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
/**
|
|
308
|
-
* Run the TUI under supervision. `buildArgs(first)` returns the node argv for
|
|
309
|
-
* launch — the first uses the user's args as typed, later ones go through
|
|
310
|
-
* filterRespawnArgs
|
|
311
|
-
*
|
|
315
|
+
* Run the TUI under supervision. `buildArgs(first, extra)` returns the node argv for
|
|
316
|
+
* each launch — the first uses the user's args as typed, later ones go through
|
|
317
|
+
* filterRespawnArgs, with `extra` appended (a crash resume adds --session <file>) — and
|
|
318
|
+
* is called fresh each time so a model the user picked in the old session (saved to
|
|
319
|
+
* settings.json) is what the new one boots on.
|
|
320
|
+
*
|
|
321
|
+
* Crash resume (bin/session-recovery.mjs): when the agent dies like a crash — a
|
|
322
|
+
* non-requested exit code or a crash signal — while its live marker says it was in a
|
|
323
|
+
* session, the supervisor relaunches straight back into that session and hands the
|
|
324
|
+
* marker over in $PRIVATEER_RECOVERY so the new agent can tell the model what was
|
|
325
|
+
* interrupted. Bounded: RESUME_BUDGET per RESUME_WINDOW_MS, and an agent that dies
|
|
326
|
+
* before its session loaded has no marker, so a session that kills it on load can't loop.
|
|
312
327
|
*/
|
|
313
|
-
export function runSupervised(cmd, buildArgs, { home }) {
|
|
328
|
+
export function runSupervised(cmd, buildArgs, { home, interactive } = {}) {
|
|
314
329
|
const token = crypto.randomBytes(24).toString("hex");
|
|
315
330
|
const dir = runDir(home);
|
|
316
331
|
const regFile = path.join(dir, `${process.pid}.json`);
|
|
@@ -318,6 +333,7 @@ export function runSupervised(cmd, buildArgs, { home }) {
|
|
|
318
333
|
let child = null;
|
|
319
334
|
let replacing = false;
|
|
320
335
|
let registered = false;
|
|
336
|
+
const restarts = [];
|
|
321
337
|
|
|
322
338
|
const register = () => {
|
|
323
339
|
try {
|
|
@@ -334,13 +350,32 @@ export function runSupervised(cmd, buildArgs, { home }) {
|
|
|
334
350
|
if (!isWin) fs.rmSync(sock, { force: true });
|
|
335
351
|
};
|
|
336
352
|
|
|
337
|
-
const launch = (first) => {
|
|
338
|
-
|
|
353
|
+
const launch = (first, extra = [], extraEnv = {}) => {
|
|
354
|
+
const me = spawn(cmd, buildArgs(first, extra), {
|
|
339
355
|
stdio: "inherit",
|
|
340
|
-
env: { ...process.env, [FRESH_SOCKET_ENV]: sock, [FRESH_TOKEN_ENV]: token },
|
|
356
|
+
env: { ...process.env, [FRESH_SOCKET_ENV]: sock, [FRESH_TOKEN_ENV]: token, ...extraEnv },
|
|
341
357
|
});
|
|
342
|
-
child
|
|
358
|
+
child = me;
|
|
359
|
+
me.on("exit", (code, signal) => {
|
|
343
360
|
if (replacing) return;
|
|
361
|
+
const marker = readMarker(home, me.pid);
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
if (shouldAutoResume({
|
|
364
|
+
code, signal, marker, restarts, now,
|
|
365
|
+
sessionExists: !!marker && fs.existsSync(marker.sessionFile),
|
|
366
|
+
interactive: interactive ?? !!process.stdin.isTTY,
|
|
367
|
+
})) {
|
|
368
|
+
restarts.push(now);
|
|
369
|
+
resetTerminal({ clear: false });
|
|
370
|
+
const why = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
371
|
+
process.stdout.write(`\x1b[2m⚓ The agent stopped unexpectedly (${why}). Resuming your session…\x1b[0m\n`);
|
|
372
|
+
// The marker stays on disk until the new agent consumes it, so a resume that
|
|
373
|
+
// itself fails still leaves the next launch something to point at.
|
|
374
|
+
launch(false, ["--session", marker.sessionFile], { [RECOVERY_ENV]: encodeRecovery(marker) });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// Asked to stop with nothing in flight: no reason to keep it.
|
|
378
|
+
if (marker && !marker.turnOpen && (code === 0 || signal === "SIGINT" || signal === "SIGTERM")) removeMarker(home, me.pid);
|
|
344
379
|
server.close();
|
|
345
380
|
unregister();
|
|
346
381
|
if (signal) process.kill(process.pid, signal);
|
|
@@ -361,6 +396,8 @@ export function runSupervised(cmd, buildArgs, { home }) {
|
|
|
361
396
|
const exited = new Promise((r) => (old.exitCode !== null || old.signalCode !== null ? r() : old.once("exit", r)));
|
|
362
397
|
const count = await reap(old);
|
|
363
398
|
await exited;
|
|
399
|
+
// /fresh is a requested stop, even mid-turn: never offer that turn back later.
|
|
400
|
+
if (old.pid) removeMarker(home, old.pid);
|
|
364
401
|
resetTerminal();
|
|
365
402
|
const extra = count > 1 ? ` and ${count - 1} process${count - 1 === 1 ? "" : "es"} it started` : "";
|
|
366
403
|
process.stdout.write(`\x1b[2m⚓ Fresh agent — the previous one${extra} stopped.\x1b[0m\n`);
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -34,6 +34,8 @@ import { runToCompletion } from "./run-to-completion.mjs";
|
|
|
34
34
|
import { modelArgs } from "./model-args.mjs";
|
|
35
35
|
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
36
36
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
37
|
+
import { bootCliPath } from "./boot-bundle.mjs";
|
|
38
|
+
import { sweepSessions } from "./session-seal.mjs";
|
|
37
39
|
|
|
38
40
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
39
41
|
const REPO = path.resolve(HERE, "..");
|
|
@@ -132,7 +134,16 @@ const BUNDLED = fs.existsSync(bundledNode) && fs.existsSync(path.join(REPO, "BUN
|
|
|
132
134
|
// very node already running this script (a suitable >=22, since we booted under it).
|
|
133
135
|
const NODE_BIN = BUNDLED ? bundledNode : process.execPath;
|
|
134
136
|
// Make sure the chosen node's directory is on PATH for shebang-spawned grandchildren.
|
|
135
|
-
|
|
137
|
+
// The agent's own shells get that entry filtered back out (getShellEnv and the background
|
|
138
|
+
// task registry, both patched): seeing a `node` the machine does not really have, it wrote
|
|
139
|
+
// `#!/usr/bin/env node` tools that die the moment Privateer exits. PRIVATEER_NODE is how a
|
|
140
|
+
// shell reaches this runtime on purpose. A directory the user already had on PATH stays.
|
|
141
|
+
const runtimeDir = path.dirname(NODE_BIN);
|
|
142
|
+
if (!(process.env.PATH || "").split(path.delimiter).includes(runtimeDir)) {
|
|
143
|
+
process.env.PRIVATEER_PRIVATE_RUNTIME_DIR = runtimeDir;
|
|
144
|
+
}
|
|
145
|
+
process.env.PRIVATEER_NODE = NODE_BIN;
|
|
146
|
+
process.env.PATH = runtimeDir + path.delimiter + (process.env.PATH || "");
|
|
136
147
|
|
|
137
148
|
const args = process.argv.slice(2);
|
|
138
149
|
|
|
@@ -753,6 +764,8 @@ else {
|
|
|
753
764
|
);
|
|
754
765
|
process.exit(1);
|
|
755
766
|
}
|
|
767
|
+
// The compiled build when it is valid for this install, else the source tree.
|
|
768
|
+
const ENTRY = bootCliPath(REPO) ?? CLI;
|
|
756
769
|
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
757
770
|
// The binary pi-subagents spawns for each child, and the moat that child must load.
|
|
758
771
|
//
|
|
@@ -895,6 +908,9 @@ else {
|
|
|
895
908
|
|
|
896
909
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
897
910
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
911
|
+
// Session sealing (bin/session-seal.mjs): the codec has to be in place before Pi's
|
|
912
|
+
// main() opens a session, so it rides in as a preload on EVERY run that can write one.
|
|
913
|
+
nodeArgs.push("--import", pathToFileURL(path.join(HERE, "session-seal-preload.mjs")).href);
|
|
898
914
|
|
|
899
915
|
// The boot splash. `--import` so it runs before Pi's entry module — most of the wait
|
|
900
916
|
// it covers IS that module graph loading, so a splash started any later would miss it.
|
|
@@ -920,11 +936,19 @@ else {
|
|
|
920
936
|
// Supervised rather than runToCompletion: the launcher stays up so /fresh, a voice
|
|
921
937
|
// command, or `privateer fresh` from another terminal can swap this agent for a new
|
|
922
938
|
// process with the old tree reaped. Print/export/version runs have nothing to swap.
|
|
923
|
-
const tuiArgs = (launchArgs) => [...nodeArgs,
|
|
939
|
+
const tuiArgs = (launchArgs) => [...nodeArgs, ENTRY, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
|
|
924
940
|
// forwardSignals: a `kill` reaches only this launcher, and a -p run left behind keeps
|
|
925
941
|
// working (and spending) with nobody waiting for its answer.
|
|
926
942
|
if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args), { forwardSignals: true });
|
|
927
|
-
|
|
943
|
+
// `extra` is how a crash resume re-enters the session (--session <file>).
|
|
944
|
+
else {
|
|
945
|
+
// Seal the sessions written before sealing existed — once, in this (the
|
|
946
|
+
// supervisor's) process, in the background, while the agent boots.
|
|
947
|
+
setTimeout(() => {
|
|
948
|
+
sweepSessions(PRIVATEER_HOME, path.join(AGENT_DIR, "sessions")).catch(() => {});
|
|
949
|
+
}, 3000).unref();
|
|
950
|
+
runSupervised(NODE_BIN, (first, extra = []) => tuiArgs([...(first ? args : filterRespawnArgs(args)), ...extra]), { home: PRIVATEER_HOME });
|
|
951
|
+
}
|
|
928
952
|
}
|
|
929
953
|
|
|
930
954
|
// --- helpers ---------------------------------------------------------------
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// spawn only runs when this file is invoked as a binary.
|
|
19
19
|
|
|
20
20
|
import { spawn } from "node:child_process";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
22
|
import { dirname, resolve, join, delimiter } from "node:path";
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
24
|
|
|
@@ -91,7 +91,10 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
|
|
|
91
91
|
if (!process.env.PI_SKIP_VERSION_CHECK) process.env.PI_SKIP_VERSION_CHECK = "1";
|
|
92
92
|
if (!process.env.PI_BG_DISABLE_UPDATE_CHECK) process.env.PI_BG_DISABLE_UPDATE_CHECK = "1";
|
|
93
93
|
const args = buildChildArgs(process.argv.slice(2));
|
|
94
|
-
|
|
94
|
+
// The session codec, as for the TUI (bin/session-seal-preload.mjs): a child writes
|
|
95
|
+
// its own session file, and it must be sealed like the parent's.
|
|
96
|
+
const preload = pathToFileURL(join(HERE, "session-seal-preload.mjs")).href;
|
|
97
|
+
const child = spawn(process.execPath, ["--import", preload, piCliPath(), ...args], { stdio: "inherit", env: process.env });
|
|
95
98
|
// Propagate the child's exit faithfully so pi-subagents' parent reads the real
|
|
96
99
|
// outcome (a signal re-raises; otherwise exit with the same code).
|
|
97
100
|
child.on("exit", (code, signal) => {
|
package/bin/privateer-verify.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import path from "node:path";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
|
|
23
23
|
import { findDepRoot } from "./apply-patches.mjs";
|
|
24
|
+
import { BOOT_DIR, bootCliPath } from "./boot-bundle.mjs";
|
|
24
25
|
|
|
25
26
|
const REGISTRY = process.env.npm_config_registry || "https://registry.npmjs.org";
|
|
26
27
|
const NET_TIMEOUT_MS = 8000;
|
|
@@ -228,6 +229,17 @@ export async function verify({ repo, offline = false } = {}) {
|
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
// Which code a launch actually runs: the compiled build, when valid, is the patched
|
|
233
|
+
// source above in one place, so "patched" is only half the answer without this.
|
|
234
|
+
heading("Boot build");
|
|
235
|
+
const bootBuild = readJson(path.join(repo, BOOT_DIR, "manifest.json"));
|
|
236
|
+
if (bootCliPath(repo)) {
|
|
237
|
+
pass("compiled entry in use", `${bootBuild.modules} modules · pi ${bootBuild.pi}`);
|
|
238
|
+
note("built from this release's source and patches; PRIVATEER_NO_BOOT_BUNDLE=1 runs the source tree");
|
|
239
|
+
} else {
|
|
240
|
+
note(bootBuild ? "present but not used here, so launches run from source" : "none, so launches run from source");
|
|
241
|
+
}
|
|
242
|
+
|
|
231
243
|
// --- 5. where config and credentials live -------------------------------
|
|
232
244
|
heading("Config");
|
|
233
245
|
const home = process.env.PRIVATEER_HOME || path.join(process.env.HOME || "", ".privateer");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Types for session recovery (bin/session-recovery.mjs). Same reason as the other
|
|
2
|
+
// bin/*.d.mts: bin/ runs under a bare `node`, but the recovery extension and its tests
|
|
3
|
+
// are TypeScript.
|
|
4
|
+
|
|
5
|
+
export const RECOVERY_ENV: "PRIVATEER_RECOVERY";
|
|
6
|
+
export const MARKER_MAX_AGE_MS: number;
|
|
7
|
+
export const RESUME_BUDGET: number;
|
|
8
|
+
export const RESUME_WINDOW_MS: number;
|
|
9
|
+
|
|
10
|
+
export interface MarkerTool {
|
|
11
|
+
name: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A live marker, as kept in PRIVATEER_HOME/run/live/<pid>.json. */
|
|
17
|
+
export interface LiveMarker {
|
|
18
|
+
v: 1;
|
|
19
|
+
pid: number;
|
|
20
|
+
sessionFile: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
turnOpen: boolean;
|
|
25
|
+
tool: MarkerTool | null;
|
|
26
|
+
hinted?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function liveDir(home: string): string;
|
|
30
|
+
export function markerPath(home: string, pid: number): string;
|
|
31
|
+
export function writeMarker(home: string, marker: Omit<LiveMarker, "v"> & { v: 1 } | LiveMarker): void;
|
|
32
|
+
export function readMarker(home: string, pid: number): LiveMarker | null;
|
|
33
|
+
export function removeMarker(home: string, pid: number): void;
|
|
34
|
+
export function parseMarker(text: string | null): LiveMarker | null;
|
|
35
|
+
export function isAlive(pid: number): boolean;
|
|
36
|
+
export function listStale(home: string, opts?: { cwd?: string; now?: number; alive?: (pid: number) => boolean }): LiveMarker[];
|
|
37
|
+
export function staleForSession(home: string, sessionFile: string, opts?: { now?: number; alive?: (pid: number) => boolean }): LiveMarker | null;
|
|
38
|
+
export function shouldAutoResume(input: {
|
|
39
|
+
code: number | null;
|
|
40
|
+
signal: string | null;
|
|
41
|
+
marker: LiveMarker | null;
|
|
42
|
+
restarts?: number[];
|
|
43
|
+
now?: number;
|
|
44
|
+
sessionExists?: boolean;
|
|
45
|
+
interactive?: boolean;
|
|
46
|
+
}): boolean;
|
|
47
|
+
export function summarizeToolCall(name: unknown, args: unknown): string;
|
|
48
|
+
export function recoveryNote(marker: LiveMarker | null, opts?: { reason?: string }): string | null;
|
|
49
|
+
export function recoveryNotice(marker: LiveMarker, opts?: { now?: number }): string;
|
|
50
|
+
export function sessionIdFromFile(file: string): string | null;
|
|
51
|
+
export function resumeHint(marker: LiveMarker, opts?: { cmd?: string; now?: number }): string;
|
|
52
|
+
export function encodeRecovery(marker: LiveMarker): string;
|
|
53
|
+
export function decodeRecovery(value: string | undefined | null): LiveMarker | null;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Session recovery — what lets a terminal survive its agent dying mid-task.
|
|
2
|
+
//
|
|
3
|
+
// Pi already writes the session to disk as it goes (~/.privateer/agent/sessions,
|
|
4
|
+
// one JSONL file per session, the user's prompt persisted BEFORE the first reply),
|
|
5
|
+
// and it already patches a tool call that never got a result ("No result
|
|
6
|
+
// provided"). What nothing did was notice the death and act on it:
|
|
7
|
+
// - an uncaught exception was logged (crashes.json) and the next launch said
|
|
8
|
+
// "Run /bug" — it never offered the session back;
|
|
9
|
+
// - SIGKILL, the OOM killer, a closed terminal or a power cut left no trace at all;
|
|
10
|
+
// - a resumed session gave the model no hint that its last tool may have half-run.
|
|
11
|
+
//
|
|
12
|
+
// So each agent process keeps a LIVE MARKER (run/live/<pid>.json, 0600): which
|
|
13
|
+
// session file it is in, whether a turn is open, and which tool is running. The
|
|
14
|
+
// agent writes it (extensions/privateer-recovery.ts); whoever outlives the agent
|
|
15
|
+
// reads it:
|
|
16
|
+
// - the supervisor (fresh-supervisor.mjs), when its child dies like a crash,
|
|
17
|
+
// relaunches into the same session at once;
|
|
18
|
+
// - the launcher, on the next `privateer` in that folder, prints how to resume a
|
|
19
|
+
// session that died with a turn open (it never resumes on its own there — the
|
|
20
|
+
// user may have come back to start something new);
|
|
21
|
+
// - the resumed agent turns the marker into ONE note for the model: the previous
|
|
22
|
+
// process stopped during `<tool>`, the work may be partly applied, check before
|
|
23
|
+
// continuing. Never an automatic re-run: replaying `git push` or a migration is
|
|
24
|
+
// worse than losing the turn.
|
|
25
|
+
//
|
|
26
|
+
// A marker is removed when the agent shuts down cleanly with NO turn open. A
|
|
27
|
+
// graceful shutdown with a turn open (the terminal closed, SIGTERM) leaves it: the
|
|
28
|
+
// work was interrupted all the same.
|
|
29
|
+
//
|
|
30
|
+
// Dependency-free on purpose: the launcher reads this before anything is patched,
|
|
31
|
+
// and the supervisor runs it with no TS loader.
|
|
32
|
+
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
export const RECOVERY_ENV = "PRIVATEER_RECOVERY";
|
|
37
|
+
|
|
38
|
+
/** Markers older than this are garbage: nobody is coming back for that turn. */
|
|
39
|
+
export const MARKER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
40
|
+
/** Auto-resume at most this many times… */
|
|
41
|
+
export const RESUME_BUDGET = 2;
|
|
42
|
+
/** …within this window, so a session that crashes on load can't loop forever. */
|
|
43
|
+
export const RESUME_WINDOW_MS = 3 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// Deaths that look like the process failing rather than being asked to stop.
|
|
46
|
+
// SIGKILL is in: it is what the OOM killer sends. SIGINT/SIGTERM/SIGHUP are not —
|
|
47
|
+
// those are a person, a terminal or a service manager saying stop.
|
|
48
|
+
const CRASH_SIGNALS = new Set(["SIGKILL", "SIGSEGV", "SIGABRT", "SIGBUS", "SIGILL", "SIGFPE", "SIGTRAP"]);
|
|
49
|
+
// Exit codes that mean "stopped on request" (128 + SIGINT / SIGTERM / SIGHUP).
|
|
50
|
+
const REQUESTED_CODES = new Set([0, 129, 130, 143]);
|
|
51
|
+
|
|
52
|
+
export function liveDir(home) {
|
|
53
|
+
return path.join(home, "run", "live");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function markerPath(home, pid) {
|
|
57
|
+
return path.join(liveDir(home), `${pid}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Write atomically (tmp + rename) and owner-only: it names files and commands. */
|
|
61
|
+
export function writeMarker(home, marker) {
|
|
62
|
+
const dir = liveDir(home);
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
64
|
+
const file = markerPath(home, marker.pid);
|
|
65
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
66
|
+
fs.writeFileSync(tmp, JSON.stringify(marker) + "\n", { mode: 0o600 });
|
|
67
|
+
fs.renameSync(tmp, file);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readMarker(home, pid) {
|
|
71
|
+
return parseMarker(safeRead(markerPath(home, pid)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeMarker(home, pid) {
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(markerPath(home, pid), { force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* best-effort */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeRead(file) {
|
|
83
|
+
try {
|
|
84
|
+
return fs.readFileSync(file, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shape-check a marker. Anything malformed reads as absent, never as a crash. */
|
|
91
|
+
export function parseMarker(text) {
|
|
92
|
+
if (typeof text !== "string") return null;
|
|
93
|
+
let m;
|
|
94
|
+
try {
|
|
95
|
+
m = JSON.parse(text);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!m || m.v !== 1 || !Number.isInteger(m.pid) || typeof m.sessionFile !== "string" || !m.sessionFile) return null;
|
|
100
|
+
return {
|
|
101
|
+
v: 1,
|
|
102
|
+
pid: m.pid,
|
|
103
|
+
sessionFile: m.sessionFile,
|
|
104
|
+
cwd: typeof m.cwd === "string" ? m.cwd : "",
|
|
105
|
+
startedAt: Number(m.startedAt) || 0,
|
|
106
|
+
updatedAt: Number(m.updatedAt) || 0,
|
|
107
|
+
turnOpen: m.turnOpen === true,
|
|
108
|
+
tool: m.tool && typeof m.tool.name === "string"
|
|
109
|
+
? { name: m.tool.name, summary: typeof m.tool.summary === "string" ? m.tool.summary : "", startedAt: Number(m.tool.startedAt) || 0 }
|
|
110
|
+
: null,
|
|
111
|
+
hinted: m.hinted === true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isAlive(pid) {
|
|
116
|
+
try {
|
|
117
|
+
process.kill(pid, 0);
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return e?.code === "EPERM";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Markers whose agent died MID-TURN, newest first. Prunes the rest of the dead: idle
|
|
126
|
+
* ones, ones past MARKER_MAX_AGE_MS, and ones whose session file is gone.
|
|
127
|
+
*/
|
|
128
|
+
export function listStale(home, { cwd, now = Date.now(), alive = isAlive } = {}) {
|
|
129
|
+
let names = [];
|
|
130
|
+
try {
|
|
131
|
+
names = fs.readdirSync(liveDir(home)).filter((n) => /^\d+\.json$/.test(n));
|
|
132
|
+
} catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const name of names) {
|
|
137
|
+
const file = path.join(liveDir(home), name);
|
|
138
|
+
const m = parseMarker(safeRead(file));
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
if (alive(m.pid)) continue;
|
|
141
|
+
// Dead while IDLE says nothing worth saying (a closed terminal can kill Pi on its
|
|
142
|
+
// dead-tty path, which skips session_shutdown, so these do happen). The
|
|
143
|
+
// supervisor's crash resume reads its child's marker directly, not through here.
|
|
144
|
+
if (!m.turnOpen || now - (m.updatedAt || m.startedAt) > MARKER_MAX_AGE_MS || !fs.existsSync(m.sessionFile)) {
|
|
145
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best-effort */ }
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (cwd && path.resolve(m.cwd) !== path.resolve(cwd)) continue;
|
|
149
|
+
out.push(m);
|
|
150
|
+
}
|
|
151
|
+
return out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The stale marker (if any) for one session file — what a resumed agent consumes. */
|
|
155
|
+
export function staleForSession(home, sessionFile, opts = {}) {
|
|
156
|
+
const want = path.resolve(sessionFile);
|
|
157
|
+
return listStale(home, opts).find((m) => path.resolve(m.sessionFile) === want) ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Should the supervisor relaunch into the session right now? Pure.
|
|
162
|
+
* `restarts` = timestamps of earlier auto-resumes by this supervisor.
|
|
163
|
+
*/
|
|
164
|
+
// There is deliberately no minimum-lifetime floor. The marker is written only once a
|
|
165
|
+
// session has LOADED (session_start), so an agent that crashes while booting or while
|
|
166
|
+
// loading the session has no marker and is never resumed — that, plus the budget, is
|
|
167
|
+
// the loop guard. A 5s floor was tried and wrongly refused a real kill -9 of a warm
|
|
168
|
+
// agent that had been up for 3s.
|
|
169
|
+
export function shouldAutoResume({ code, signal, marker, restarts = [], now = Date.now(), sessionExists = true, interactive = true }) {
|
|
170
|
+
if (!marker || !interactive || !sessionExists) return false;
|
|
171
|
+
const crashed = signal ? CRASH_SIGNALS.has(signal) : !REQUESTED_CODES.has(code ?? 0);
|
|
172
|
+
if (!crashed) return false;
|
|
173
|
+
return restarts.filter((t) => now - t < RESUME_WINDOW_MS).length < RESUME_BUDGET;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One line describing a tool call, safe to keep in a 0600 file and show the model. */
|
|
177
|
+
export function summarizeToolCall(name, args) {
|
|
178
|
+
const flat = (s) => String(s).replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
179
|
+
const clip = (s, n = 160) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
180
|
+
if (!args || typeof args !== "object") return clip(flat(name));
|
|
181
|
+
const pick = args.command ?? args.cmd ?? args.path ?? args.file_path ?? args.file ?? args.url ?? args.query;
|
|
182
|
+
if (typeof pick === "string" && pick.trim()) return clip(flat(pick));
|
|
183
|
+
return clip(Object.keys(args).slice(0, 6).join(", "));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ago = (ms) => {
|
|
187
|
+
const m = Math.round(ms / 60000);
|
|
188
|
+
if (m < 1) return "moments ago";
|
|
189
|
+
if (m < 60) return `${m} min ago`;
|
|
190
|
+
const h = Math.round(m / 60);
|
|
191
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** The note the resumed agent hands the model. Null when no turn was open. */
|
|
195
|
+
export function recoveryNote(marker, { reason = "stopped unexpectedly" } = {}) {
|
|
196
|
+
if (!marker?.turnOpen) return null;
|
|
197
|
+
const during = marker.tool
|
|
198
|
+
? `while it was running the \`${marker.tool.name}\` tool${marker.tool.summary ? ` (\`${marker.tool.summary}\`)` : ""}`
|
|
199
|
+
: "in the middle of a turn, between tool calls";
|
|
200
|
+
return [
|
|
201
|
+
`[Privateer] The previous agent process for this session ${reason} ${during}.`,
|
|
202
|
+
"Its last action may have run fully, partly, or not at all, and anything it started (a server, a build, a migration) may still be running or may have been killed.",
|
|
203
|
+
"Do not repeat that action blindly. Check the current state first (files, git status, running processes, the remote) and tell the user briefly what you found before carrying on.",
|
|
204
|
+
].join(" ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** What the user sees in the resumed terminal. */
|
|
208
|
+
export function recoveryNotice(marker, { now = Date.now() } = {}) {
|
|
209
|
+
const when = ago(now - (marker.updatedAt || marker.startedAt || now));
|
|
210
|
+
if (!marker.turnOpen) return `Resumed your session — the previous agent stopped ${when}.`;
|
|
211
|
+
const what = marker.tool ? ` during ${marker.tool.name}${marker.tool.summary ? `: ${marker.tool.summary}` : ""}` : "";
|
|
212
|
+
return `Resumed your session — the previous agent stopped ${when} mid-task${what}. It may have been partly done; the agent will check before continuing.`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Pi's session id from its file name (`<timestamp>_<id>.jsonl`). */
|
|
216
|
+
export function sessionIdFromFile(file) {
|
|
217
|
+
const m = path.basename(file).match(/_([0-9a-f-]{8,})\.jsonl$/i);
|
|
218
|
+
return m ? m[1] : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Copy-pasteable resume hint for the next launch in that folder. */
|
|
222
|
+
export function resumeHint(marker, { cmd = "privateer", now = Date.now() } = {}) {
|
|
223
|
+
const id = sessionIdFromFile(marker.sessionFile);
|
|
224
|
+
const target = id ?? marker.sessionFile;
|
|
225
|
+
const what = marker.tool ? ` (during ${marker.tool.name}${marker.tool.summary ? `: ${marker.tool.summary}` : ""})` : "";
|
|
226
|
+
return `⚓ Your last session in this folder stopped mid-task ${ago(now - (marker.updatedAt || now))}${what}.\n Resume it: ${cmd} --session ${target}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Encode a marker for the relaunched child's environment (and decode it there). */
|
|
230
|
+
export function encodeRecovery(marker) {
|
|
231
|
+
return Buffer.from(JSON.stringify(marker)).toString("base64");
|
|
232
|
+
}
|
|
233
|
+
export function decodeRecovery(value) {
|
|
234
|
+
if (typeof value !== "string" || !value) return null;
|
|
235
|
+
try {
|
|
236
|
+
return parseMarker(Buffer.from(value, "base64").toString("utf8"));
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `node --import` preload for every Pi process privateer starts (the TUI, -p runs,
|
|
2
|
+
// subagent children): installs the session codec (bin/session-seal.mjs) BEFORE
|
|
3
|
+
// Pi's main() opens a session, which happens before any extension loads — so an
|
|
4
|
+
// extension is too late for this, and a preload is the only place it fits.
|
|
5
|
+
//
|
|
6
|
+
// A key file that exists but can't be read is NOT papered over with a new key (that
|
|
7
|
+
// would orphan every sealed session). Sealing stays off for this run, the agent
|
|
8
|
+
// tells the user (extensions/privateer-recovery.ts reads the reason below), and Pi
|
|
9
|
+
// writes plaintext exactly as it did before sealing existed.
|
|
10
|
+
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { installCodec } from "./session-seal.mjs";
|
|
14
|
+
|
|
15
|
+
const home = process.env.PRIVATEER_HOME || path.join(os.homedir(), ".privateer");
|
|
16
|
+
const result = installCodec(home);
|
|
17
|
+
if (!result.ok) globalThis[Symbol.for("privateer.sessionCodecError")] = result.error;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Types for session sealing (bin/session-seal.mjs). Same reason as the other
|
|
2
|
+
// bin/*.d.mts: bin/ runs under a bare `node`, but its tests are TypeScript.
|
|
3
|
+
|
|
4
|
+
export const CODEC_KEY: unique symbol;
|
|
5
|
+
export const QUIET_MS: number;
|
|
6
|
+
|
|
7
|
+
export interface SessionCodec {
|
|
8
|
+
failures: number;
|
|
9
|
+
encode(entry: unknown): string;
|
|
10
|
+
decode(obj: unknown): any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function keyPath(home: string): string;
|
|
14
|
+
export function loadOrCreateKey(home: string): Buffer;
|
|
15
|
+
export function isSealed(obj: unknown): boolean;
|
|
16
|
+
export function encodeEntry(entry: unknown, key: Buffer): string;
|
|
17
|
+
export function decodeEntry(obj: unknown, key: Buffer): any;
|
|
18
|
+
export function installCodec(home: string): { ok: true } | { ok: false; error: string };
|
|
19
|
+
export function sealFile(file: string, key: Buffer): "sealed" | "unchanged" | "busy" | "failed";
|
|
20
|
+
export function listSessionFiles(sessionsDir: string): string[];
|
|
21
|
+
export function sweepSessions(
|
|
22
|
+
home: string,
|
|
23
|
+
sessionsDir: string,
|
|
24
|
+
opts?: { now?: number; quietMs?: number },
|
|
25
|
+
): Promise<{ skipped: true } | { error: true } | { sealed: number; unchanged: number; busy: number; failed: number; recent: number }>;
|