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.
Files changed (229) hide show
  1. package/README.md +11 -0
  2. package/bin/boot-bundle.d.mts +2 -0
  3. package/bin/boot-bundle.mjs +44 -0
  4. package/bin/fresh-supervisor.d.mts +5 -1
  5. package/bin/fresh-supervisor.mjs +47 -10
  6. package/bin/privateer-launch.mjs +27 -3
  7. package/bin/privateer-subagent.mjs +5 -2
  8. package/bin/privateer-verify.mjs +12 -0
  9. package/bin/session-recovery.d.mts +53 -0
  10. package/bin/session-recovery.mjs +240 -0
  11. package/bin/session-seal-preload.mjs +17 -0
  12. package/bin/session-seal.d.mts +25 -0
  13. package/bin/session-seal.mjs +242 -0
  14. package/dist-boot/boot.mjs +3 -0
  15. package/dist-boot/c-27JKSMWL.mjs +1460 -0
  16. package/dist-boot/c-2BMRFV24.mjs +269 -0
  17. package/dist-boot/c-2CTF6JA3.mjs +166 -0
  18. package/dist-boot/c-2G3BBHRJ.mjs +84 -0
  19. package/dist-boot/c-2IDLKXX6.mjs +250 -0
  20. package/dist-boot/c-2NJD5VYM.mjs +8 -0
  21. package/dist-boot/c-2S5EEQPW.mjs +1602 -0
  22. package/dist-boot/c-2WRDCUVI.mjs +25 -0
  23. package/dist-boot/c-2YN3M256.mjs +14 -0
  24. package/dist-boot/c-37GM3GMU.mjs +187 -0
  25. package/dist-boot/c-3STKRSVO.mjs +2480 -0
  26. package/dist-boot/c-3TPV6TQQ.mjs +2320 -0
  27. package/dist-boot/c-3UVHLQDJ.mjs +225 -0
  28. package/dist-boot/c-42PUUZYC.mjs +697 -0
  29. package/dist-boot/c-4D5UMWVW.mjs +683 -0
  30. package/dist-boot/c-4ETZRMTA.mjs +46 -0
  31. package/dist-boot/c-4QT25BD5.mjs +1482 -0
  32. package/dist-boot/c-4WVR32AP.mjs +2579 -0
  33. package/dist-boot/c-5EMM567G.mjs +152 -0
  34. package/dist-boot/c-5FA4CV63.mjs +145 -0
  35. package/dist-boot/c-5GV3X3RB.mjs +14 -0
  36. package/dist-boot/c-5JCEPRIY.mjs +52 -0
  37. package/dist-boot/c-5NDKIIWT.mjs +16 -0
  38. package/dist-boot/c-5QL3AR26.mjs +4665 -0
  39. package/dist-boot/c-5XY6MFKN.mjs +5142 -0
  40. package/dist-boot/c-65UZIZC7.mjs +2897 -0
  41. package/dist-boot/c-6FUXKHOK.mjs +1969 -0
  42. package/dist-boot/c-6JHWQE4V.mjs +18242 -0
  43. package/dist-boot/c-6LOBR4I6.mjs +5280 -0
  44. package/dist-boot/c-6QHOZXKQ.mjs +14394 -0
  45. package/dist-boot/c-6ZO2SADQ.mjs +178 -0
  46. package/dist-boot/c-6ZORVVZP.mjs +1449 -0
  47. package/dist-boot/c-76QH4UCP.mjs +2441 -0
  48. package/dist-boot/c-77GO5L5Y.mjs +20982 -0
  49. package/dist-boot/c-7M23XFTN.mjs +5052 -0
  50. package/dist-boot/c-7TC367E2.mjs +32 -0
  51. package/dist-boot/c-7UOG7POX.mjs +1524 -0
  52. package/dist-boot/c-7VHP3FLN.mjs +376 -0
  53. package/dist-boot/c-A73JHZP2.mjs +13207 -0
  54. package/dist-boot/c-ABXULAFL.mjs +71 -0
  55. package/dist-boot/c-ANDD2AQO.mjs +74 -0
  56. package/dist-boot/c-AOY77AZL.mjs +1430 -0
  57. package/dist-boot/c-AXEPZ22I.mjs +108 -0
  58. package/dist-boot/c-B2Y7VNHU.mjs +427 -0
  59. package/dist-boot/c-B7VJUH73.mjs +77 -0
  60. package/dist-boot/c-BFW6CDVL.mjs +24 -0
  61. package/dist-boot/c-BHNIF2AR.mjs +188 -0
  62. package/dist-boot/c-BKF2O6BL.mjs +23 -0
  63. package/dist-boot/c-BOZXFTZN.mjs +8 -0
  64. package/dist-boot/c-BRNYJWZL.mjs +28 -0
  65. package/dist-boot/c-C3N4Z2HO.mjs +93 -0
  66. package/dist-boot/c-C4IV7ZFG.mjs +717 -0
  67. package/dist-boot/c-C5NK6UYV.mjs +793 -0
  68. package/dist-boot/c-C67644MB.mjs +1659 -0
  69. package/dist-boot/c-C6S2YB53.mjs +620 -0
  70. package/dist-boot/c-CDBEJSBF.mjs +37 -0
  71. package/dist-boot/c-CG47HBKU.mjs +435 -0
  72. package/dist-boot/c-CKCTWCT5.mjs +51 -0
  73. package/dist-boot/c-CN77DCGF.mjs +60 -0
  74. package/dist-boot/c-CRBXNKFO.mjs +1795 -0
  75. package/dist-boot/c-CRIHASQ6.mjs +973 -0
  76. package/dist-boot/c-CU4NGAUO.mjs +3786 -0
  77. package/dist-boot/c-CWRFLGQU.mjs +1883 -0
  78. package/dist-boot/c-CYQB2USO.mjs +32 -0
  79. package/dist-boot/c-DEE67DJO.mjs +379 -0
  80. package/dist-boot/c-DIFDBYOP.mjs +1258 -0
  81. package/dist-boot/c-DOSGVXDZ.mjs +34879 -0
  82. package/dist-boot/c-DR7XQI32.mjs +381 -0
  83. package/dist-boot/c-DTGPSB46.mjs +14666 -0
  84. package/dist-boot/c-DTHDNA4P.mjs +320 -0
  85. package/dist-boot/c-E4Z3Y5B5.mjs +3864 -0
  86. package/dist-boot/c-E66Z2TI7.mjs +203 -0
  87. package/dist-boot/c-EBU6RLRV.mjs +1527 -0
  88. package/dist-boot/c-EBZZNGSF.mjs +432 -0
  89. package/dist-boot/c-EC3LJ2KF.mjs +1032 -0
  90. package/dist-boot/c-EQBKXWNX.mjs +15829 -0
  91. package/dist-boot/c-EUEPIW4S.mjs +574 -0
  92. package/dist-boot/c-EX4YVWAT.mjs +378 -0
  93. package/dist-boot/c-EYZGSAJU.mjs +98 -0
  94. package/dist-boot/c-F75VRKY4.mjs +172 -0
  95. package/dist-boot/c-FH4NXKHV.mjs +177 -0
  96. package/dist-boot/c-FHNUCTEM.mjs +140 -0
  97. package/dist-boot/c-FJ5T42DS.mjs +5349 -0
  98. package/dist-boot/c-FPCNRL7G.mjs +1728 -0
  99. package/dist-boot/c-G4DLN7Q4.mjs +8 -0
  100. package/dist-boot/c-G56GYQFZ.mjs +2507 -0
  101. package/dist-boot/c-GLIOERDQ.mjs +1200 -0
  102. package/dist-boot/c-GMJ5HUBM.mjs +14 -0
  103. package/dist-boot/c-H3HSGMAQ.mjs +461 -0
  104. package/dist-boot/c-HIKTO6P7.mjs +275 -0
  105. package/dist-boot/c-I2MCJ34O.mjs +464 -0
  106. package/dist-boot/c-I3C6LBW4.mjs +765 -0
  107. package/dist-boot/c-I54OKQHW.mjs +387 -0
  108. package/dist-boot/c-IFSP3R4D.mjs +80 -0
  109. package/dist-boot/c-IZK77VB7.mjs +171 -0
  110. package/dist-boot/c-J3Y25UGE.mjs +54 -0
  111. package/dist-boot/c-J5IESS7W.mjs +27750 -0
  112. package/dist-boot/c-JJWZBY7L.mjs +254 -0
  113. package/dist-boot/c-JKTFXTGF.mjs +463 -0
  114. package/dist-boot/c-JN57AXQ4.mjs +77 -0
  115. package/dist-boot/c-KFIO6LUW.mjs +1096 -0
  116. package/dist-boot/c-KIEMEFWW.mjs +478 -0
  117. package/dist-boot/c-KIVXWBYD.mjs +28 -0
  118. package/dist-boot/c-KP6CRIP4.mjs +580 -0
  119. package/dist-boot/c-L26VOJX4.mjs +11558 -0
  120. package/dist-boot/c-L5GHWG5G.mjs +5988 -0
  121. package/dist-boot/c-M7NSQAKK.mjs +353 -0
  122. package/dist-boot/c-MCEWOPBW.mjs +56 -0
  123. package/dist-boot/c-MGPIEG7I.mjs +20 -0
  124. package/dist-boot/c-NREXJV3M.mjs +393 -0
  125. package/dist-boot/c-O5PP6CTX.mjs +162 -0
  126. package/dist-boot/c-OBQDHSXM.mjs +1774 -0
  127. package/dist-boot/c-OLQHHDD4.mjs +21 -0
  128. package/dist-boot/c-OXW4JQQH.mjs +319 -0
  129. package/dist-boot/c-P3D7ZUWN.mjs +1288 -0
  130. package/dist-boot/c-P4PQZVV5.mjs +948 -0
  131. package/dist-boot/c-P63T3XL2.mjs +26373 -0
  132. package/dist-boot/c-P6N5DQGZ.mjs +194 -0
  133. package/dist-boot/c-P7MLKQFC.mjs +58 -0
  134. package/dist-boot/c-PB3PZJL5.mjs +1276 -0
  135. package/dist-boot/c-PTN4S6G2.mjs +1259 -0
  136. package/dist-boot/c-PYCE5J7U.mjs +1083 -0
  137. package/dist-boot/c-Q4HYCRLB.mjs +19410 -0
  138. package/dist-boot/c-QUXMETGG.mjs +68 -0
  139. package/dist-boot/c-RDMJZCOX.mjs +356 -0
  140. package/dist-boot/c-ROGJK76M.mjs +17 -0
  141. package/dist-boot/c-RQJJ26T4.mjs +708 -0
  142. package/dist-boot/c-RSKCUPJF.mjs +7559 -0
  143. package/dist-boot/c-RVJU4O6P.mjs +37 -0
  144. package/dist-boot/c-S2RVHR3M.mjs +165 -0
  145. package/dist-boot/c-SKMK3PEE.mjs +91120 -0
  146. package/dist-boot/c-TI7SM4EG.mjs +20 -0
  147. package/dist-boot/c-TILJ6GXD.mjs +1235 -0
  148. package/dist-boot/c-TT6BCUJD.mjs +114 -0
  149. package/dist-boot/c-U4ITOXAN.mjs +2107 -0
  150. package/dist-boot/c-UGERXVO2.mjs +7120 -0
  151. package/dist-boot/c-UPBH5AXY.mjs +1363 -0
  152. package/dist-boot/c-US2PUNZL.mjs +597 -0
  153. package/dist-boot/c-UU4WZPPE.mjs +20 -0
  154. package/dist-boot/c-UWVIYPQS.mjs +39 -0
  155. package/dist-boot/c-V4RHQBPQ.mjs +17683 -0
  156. package/dist-boot/c-V5MRVK47.mjs +351 -0
  157. package/dist-boot/c-V6CL266R.mjs +1755 -0
  158. package/dist-boot/c-VJTMFKYE.mjs +14975 -0
  159. package/dist-boot/c-VJTRUKIB.mjs +773 -0
  160. package/dist-boot/c-W4IFSHPW.mjs +43 -0
  161. package/dist-boot/c-WETGB2CF.mjs +113 -0
  162. package/dist-boot/c-WO3QSZUU.mjs +1337 -0
  163. package/dist-boot/c-WQLH73TY.mjs +242 -0
  164. package/dist-boot/c-XL735JW5.mjs +500 -0
  165. package/dist-boot/c-Y7HADL5B.mjs +1107 -0
  166. package/dist-boot/c-YGQHSVSK.mjs +41 -0
  167. package/dist-boot/c-YMP2AMEB.mjs +10350 -0
  168. package/dist-boot/c-YRDO2WEF.mjs +12012 -0
  169. package/dist-boot/c-YUSTRATJ.mjs +8 -0
  170. package/dist-boot/c-Z2262R22.mjs +2697 -0
  171. package/dist-boot/c-Z4ALQMJC.mjs +709 -0
  172. package/dist-boot/c-Z5ZUQVUY.mjs +62 -0
  173. package/dist-boot/c-ZDZUKXF7.mjs +874 -0
  174. package/dist-boot/c-ZJYLGIC3.mjs +32 -0
  175. package/dist-boot/c-ZNA5CDMI.mjs +77 -0
  176. package/dist-boot/c-ZSO6DI3M.mjs +204 -0
  177. package/dist-boot/c-ZWUVHKMK.mjs +100 -0
  178. package/dist-boot/c-ZY4MESEW.mjs +832 -0
  179. package/dist-boot/c-ZZ5REI4L.mjs +329 -0
  180. package/dist-boot/cli.mjs +51 -0
  181. package/dist-boot/ext-anthropic-attribution.mjs +207 -0
  182. package/dist-boot/ext-pi-background-tasks.mjs +7234 -0
  183. package/dist-boot/ext-pi-subagents.mjs +37 -0
  184. package/dist-boot/ext-privateer-account.mjs +4426 -0
  185. package/dist-boot/ext-privateer-brand.mjs +5274 -0
  186. package/dist-boot/ext-privateer-codemode.mjs +37 -0
  187. package/dist-boot/ext-privateer-computer.mjs +626 -0
  188. package/dist-boot/ext-privateer-connect.mjs +1862 -0
  189. package/dist-boot/ext-privateer-context.mjs +323 -0
  190. package/dist-boot/ext-privateer-desktop.mjs +92 -0
  191. package/dist-boot/ext-privateer-final-audit.mjs +48 -0
  192. package/dist-boot/ext-privateer-fresh.mjs +81 -0
  193. package/dist-boot/ext-privateer-gate.mjs +5959 -0
  194. package/dist-boot/ext-privateer-hints.mjs +236 -0
  195. package/dist-boot/ext-privateer-media.mjs +2626 -0
  196. package/dist-boot/ext-privateer-models.mjs +3397 -0
  197. package/dist-boot/ext-privateer-posture.mjs +3006 -0
  198. package/dist-boot/ext-privateer-privacy.mjs +4578 -0
  199. package/dist-boot/ext-privateer-recovery.mjs +287 -0
  200. package/dist-boot/ext-privateer-speak.mjs +4754 -0
  201. package/dist-boot/ext-privateer-terminals.mjs +725 -0
  202. package/dist-boot/ext-privateer-tools.mjs +528 -0
  203. package/dist-boot/ext-privateer-truncation.mjs +74 -0
  204. package/dist-boot/ext-privateer-update.mjs +210 -0
  205. package/dist-boot/ext-privateer-vision.mjs +148 -0
  206. package/dist-boot/ext-privateer-web.mjs +543 -0
  207. package/dist-boot/ext-rpiv-ask-user-question.mjs +710 -0
  208. package/dist-boot/manifest.json +9 -0
  209. package/extensions/privateer-brand.ts +3 -1
  210. package/extensions/privateer-context.ts +4 -2
  211. package/extensions/privateer-final-audit.ts +17 -0
  212. package/extensions/privateer-gate.ts +55 -9
  213. package/extensions/privateer-recovery.ts +175 -0
  214. package/extensions/privateer-web.ts +4 -4
  215. package/package.json +3 -1
  216. package/patches/@earendil-works+pi-coding-agent+1.3.0.patch +199 -2
  217. package/patches/pi-background-tasks+2.6.10.patch +91 -7
  218. package/src/cli/chat.ts +7 -1
  219. package/src/config/moatManifest.json +2 -0
  220. package/src/config/whatsNew.json +4 -4
  221. package/src/context.ts +33 -5
  222. package/src/engine/finalAudit.ts +38 -0
  223. package/src/engine/stallWatchdog.ts +8 -1
  224. package/src/providers/account.ts +34 -3
  225. package/src/remote/attentionPush.ts +40 -0
  226. package/src/remote/liveTaskSession.ts +7 -1
  227. package/src/remote/relayClient.ts +38 -4
  228. package/src/remote/remoteBridge.ts +17 -0
  229. 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,2 @@
1
+ export const BOOT_DIR: string;
2
+ export function bootCliPath(repo: string, env?: NodeJS.ProcessEnv | Record<string, string | undefined>): string | null;
@@ -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(cmd: string, buildArgs: (first: boolean) => string[], opts: { home: string }): void;
35
+ export function runSupervised(
36
+ cmd: string,
37
+ buildArgs: (first: boolean, extra?: string[]) => string[],
38
+ opts: { home: string; interactive?: boolean },
39
+ ): void;
@@ -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 each
309
- * launch — the first uses the user's args as typed, later ones go through
310
- * filterRespawnArgs — and is called fresh each time so a model the user picked in the old
311
- * session (saved to settings.json) is what the new one boots on.
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
- child = spawn(cmd, buildArgs(first), {
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.on("exit", (code, signal) => {
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`);
@@ -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
- process.env.PATH = path.dirname(NODE_BIN) + path.delimiter + (process.env.PATH || "");
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, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
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
- else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
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
- const child = spawn(process.execPath, [piCliPath(), ...args], { stdio: "inherit", env: process.env });
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) => {
@@ -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 }>;