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
@@ -22,7 +22,7 @@ index 2a4d905..3a689f5 100644
22
22
  type HostForwardingStream = AssistantMessageEventStreamLike & HostAssistantMessageEventStream;
23
23
 
24
24
  diff --git a/node_modules/pi-background-tasks/src/core/registry.ts b/node_modules/pi-background-tasks/src/core/registry.ts
25
- index df6e6f4..cbab9f2 100644
25
+ index df6e6f4..8cbbec2 100644
26
26
  --- a/node_modules/pi-background-tasks/src/core/registry.ts
27
27
  +++ b/node_modules/pi-background-tasks/src/core/registry.ts
28
28
  @@ -1,11 +1,11 @@
@@ -32,7 +32,7 @@ index df6e6f4..cbab9f2 100644
32
32
  import { createWriteStream, existsSync } from 'node:fs';
33
33
  import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
34
34
  -import { join } from 'node:path';
35
- +import { join, resolve } from 'node:path';
35
+ +import { delimiter, join, resolve } from 'node:path';
36
36
  import type { Api, Model } from '@earendil-works/pi-ai';
37
37
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
38
38
  -import { formatSize } from '@earendil-works/pi-coding-agent';
@@ -40,7 +40,23 @@ index df6e6f4..cbab9f2 100644
40
40
  import {
41
41
  boundedRead,
42
42
  deriveTaskNameFromCommand,
43
- @@ -1263,9 +1263,29 @@ export class BackgroundTaskRegistry {
43
+ @@ -791,6 +791,15 @@ function writeDelegateStdin(
44
+ });
45
+ }
46
+
47
+ +// Privateer: a background shell must see the machine's PATH, not Privateer's private Node.
48
+ +function withoutPrivateRuntime(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
49
+ + const hidden = env['PRIVATEER_PRIVATE_RUNTIME_DIR'];
50
+ + if (!hidden) return env;
51
+ + const key = Object.keys(env).find((name) => name.toLowerCase() === 'path') ?? 'PATH';
52
+ + const visible = (env[key] ?? '').split(delimiter).filter((entry) => entry !== hidden);
53
+ + return { ...env, [key]: visible.join(delimiter) };
54
+ +}
55
+ +
56
+ export class BackgroundTaskRegistry {
57
+ private readonly tasks = new Map<string, BgTask>();
58
+ private runtimeDir: RuntimeDir | undefined;
59
+ @@ -1263,9 +1272,29 @@ export class BackgroundTaskRegistry {
44
60
  if (this.runtimeDir) return this.runtimeDir;
45
61
  const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
46
62
  const runId = `${sessionId}-${String(process.pid)}`;
@@ -73,11 +89,50 @@ index df6e6f4..cbab9f2 100644
73
89
  this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
74
90
  return this.runtimeDir;
75
91
  }
92
+ @@ -1514,7 +1543,7 @@ export class BackgroundTaskRegistry {
93
+ killProcess: this.killProcess,
94
+ killTree: this.killTree,
95
+ platform: this.platform,
96
+ - env: this.env,
97
+ + env: withoutPrivateRuntime(this.env),
98
+ maxOutputBytes: this.maxOutputBytes,
99
+ killGraceMs: this.killGraceMs,
100
+ stopWaitMs: this.stopWaitMs,
101
+ @@ -1739,7 +1768,7 @@ export class BackgroundTaskRegistry {
102
+ cwd: ctx.cwd,
103
+ detached: this.platform !== 'win32',
104
+ stdio: ['ignore', 'pipe', 'pipe'],
105
+ - env: this.env,
106
+ + env: withoutPrivateRuntime(this.env),
107
+ windowsHide: true,
108
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
109
+ });
76
110
  diff --git a/node_modules/pi-background-tasks/src/extension.ts b/node_modules/pi-background-tasks/src/extension.ts
77
- index c966df5..b073a15 100644
111
+ index c966df5..10b38c4 100644
78
112
  --- a/node_modules/pi-background-tasks/src/extension.ts
79
113
  +++ b/node_modules/pi-background-tasks/src/extension.ts
80
- @@ -710,8 +710,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
114
+ @@ -223,6 +223,20 @@ function renderPlainResult(result: TextToolResult, options: ToolRenderResultOpti
115
+ return new Text(text, 0, 0);
116
+ }
117
+
118
+ +const MAX_ONE_SHOT_EXIT_NOTICES = 3;
119
+ +
120
+ +function oneShotExitNotice(tasks: string[]): string {
121
+ + return [
122
+ + 'You ended your turn, but this is a one-shot run. The process exits when you stop, and these background tasks will be killed with it:',
123
+ + ...tasks.map((task) => `- ${task}`),
124
+ + '',
125
+ + 'Handle each one before you finish:',
126
+ + '- If the work depends on its result, wait for it. Check with bg_status and bg_logs, using `sleep` in bash between checks, then finish and verify the work.',
127
+ + '- If it has to keep running after this run, such as a server or daemon, bg_kill it, start it detached with `setsid nohup <command> > <logfile> 2>&1 < /dev/null &`, and confirm it is still up.',
128
+ + '- If it is no longer needed, bg_kill it.',
129
+ + ].join('\n');
130
+ +}
131
+ +
132
+ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promise<void> {
133
+ const config = parseBackgroundTasksConfig();
134
+ const dockEntryHint = dockShortcutFooterHint(config.dockShortcut);
135
+ @@ -710,8 +724,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
81
136
  currentCtx = ctx;
82
137
  const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
83
138
  currentRegistryCtx = nextRegistryCtx;
@@ -88,7 +143,36 @@ index c966df5..b073a15 100644
88
143
  updateUi(ctx);
89
144
  hostService.markReady();
90
145
  if (disposed) return;
91
- @@ -830,22 +830,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
146
+ @@ -729,6 +743,28 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
147
+ if (!disposed) void scheduleUpdateCheck(ctx);
148
+ });
149
+
150
+ + // Privateer patch: a one-shot run must not quietly kill what it started.
151
+ + //
152
+ + // In the TUI a background task outlives the turn and reports back when it finishes. `-p`
153
+ + // exits when the turn ends and shutdown kills whatever is still running, so a model that
154
+ + // says "training continues in the background", or leaves a server up, has just lost it.
155
+ + // Say so while it can still act. Capped so a model that keeps stopping cannot loop.
156
+ + let oneShotExitNotices = 0;
157
+ + pi.on('agent_end', (_event, ctx) => {
158
+ + if (ctx.mode !== 'print' && ctx.mode !== 'json') return;
159
+ + const running = registry.allTasks().filter((task) => task.status === 'running');
160
+ + if (running.length === 0 || oneShotExitNotices >= MAX_ONE_SHOT_EXIT_NOTICES) return;
161
+ + oneShotExitNotices += 1;
162
+ + pi.sendMessage(
163
+ + {
164
+ + customType: 'bg-one-shot-exit',
165
+ + content: oneShotExitNotice(running.map((task) => `${task.id}: ${taskDisplayName(task)}`)),
166
+ + display: false,
167
+ + },
168
+ + { deliverAs: 'followUp', triggerTurn: true },
169
+ + );
170
+ + });
171
+ +
172
+ pi.on('session_shutdown', async (event, ctx) => {
173
+ beginSessionShutdown(event.reason);
174
+ if (shutdownCleanupStarted) return;
175
+ @@ -830,22 +866,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
92
176
  });
93
177
 
94
178
  pi.registerCommand('bg-update', {
@@ -114,7 +198,7 @@ index c966df5..b073a15 100644
114
198
  ];
115
199
  ctx.ui.notify(lines.join('\n'), 'info');
116
200
  return Promise.resolve();
117
- @@ -958,7 +947,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
201
+ @@ -958,7 +983,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
118
202
  pi.registerTool<typeof BgRunParams, BgRunDetails>({
119
203
  name: 'bg_run',
120
204
  label: 'Background Run',
package/src/cli/chat.ts CHANGED
@@ -16,6 +16,7 @@ import { privacyDisabled } from "../config/privacyDisabled.ts"; // no Pi deps
16
16
  import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
17
17
  import { createUIContext } from "../ext/headlessUi.ts"; // no Pi deps → safe pre-boot
18
18
  import { canOpenBrowser, openInBrowser } from "../util/openBrowser.ts"; // node:child_process only → safe pre-boot
19
+ import { transcriptSnapshot, type SnapshotEntry } from "../remote/transcriptSnapshot.ts"; // no deps → safe pre-boot
19
20
 
20
21
  // This lean REPL has no Pi TUI (and so no Theme), so it detects the terminal background
21
22
  // itself (COLORFGBG) and picks a palette — on a light terminal the standard "\x1b[33m"
@@ -181,7 +182,12 @@ async function main() {
181
182
  // the driver learns which folder they're driving without the OS username
182
183
  // crossing the relay.
183
184
  onControllerAttached: () => {
184
- relay?.sendSnapshot([]);
185
+ // The session's transcript, so an app back from sleep catches up (transcriptSnapshot.ts).
186
+ {
187
+ let entries: SnapshotEntry[] = [];
188
+ try { entries = transcriptSnapshot(session?.sessionManager?.getBranch?.() ?? []); } catch { /* no session yet */ }
189
+ relay?.sendSnapshot(entries, { full: entries.length > 0, running: turnActive });
190
+ }
185
191
  relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() });
186
192
  relay?.sendCommands(availableCommands());
187
193
  },
@@ -25,7 +25,9 @@
25
25
  { "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
26
26
  { "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
27
27
  { "entry": "extensions/privateer-truncation.ts", "name": "privateer-truncation", "note": "picks the task back up when a reply is cut off at the output limit" },
28
+ { "entry": "extensions/privateer-final-audit.ts", "name": "privateer-final-audit", "note": "makes a -p run check its result, and say what it invented or could not verify, before it ends" },
28
29
  { "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
30
+ { "entry": "extensions/privateer-recovery.ts", "name": "privateer-recovery", "note": "crash recovery — the live marker the supervisor resumes from, the note that tells the model its last action may have half-run, and session-sealing warnings" },
29
31
  { "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
30
32
  { "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools); crypto_lookup — account market data, in both cases" },
31
33
  { "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.15.4",
3
+ "reviewedFor": "0.15.6",
4
4
  "items": [
5
+ { "text": "Privateer now starts about three times faster, and quitting no longer freezes the next launch for up to 30 seconds", "since": "0.15.6" },
6
+ { "text": "If the agent crashes mid-task it comes back into your session, and saved sessions are now encrypted on disk", "since": "0.15.5" },
5
7
  { "text": "Slow images, speech and music run as background jobs — a dropped connection no longer loses a paid result", "since": "0.15.4" },
6
- { "text": "Driving from the app: a message sent mid-turn steers it, and questions survive a dropped link", "since": "0.15.1" },
7
- { "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" },
8
- { "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" }
8
+ { "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" }
9
9
  ]
10
10
  }
package/src/context.ts CHANGED
@@ -191,14 +191,42 @@ export function discoverContextFiles(cwd: string = process.cwd()): ContextFile[]
191
191
  export const CONTEXT_BLOCK_MARKER = "<!-- privateer:PRIVATEER.md -->";
192
192
  export const RUNTIME_GUIDELINES_MARKER = "<!-- privateer:runtime-guidelines -->";
193
193
 
194
- export function runtimeGuidelinesBlock(): string {
194
+ // bin/privateer-launch.mjs sets PRIVATEER_NODE; a host that embeds Privateer without the
195
+ // launcher has no runtime to promise, so the model is told to check instead.
196
+ function nodeGuideline(): string {
197
+ if (!process.env.PRIVATEER_NODE) {
198
+ return "- Node.js: may not be installed here. Check with `command -v node` before relying on it.";
199
+ }
200
+ return `- Node.js: Privateer's own runtime is at \`$PRIVATEER_NODE\` (v22+). Use it for throwaway calculations or JSON processing when nothing else fits: \`"$PRIVATEER_NODE" -e "..."\`. It is not part of this system: plain \`node\` exists only if the system really has it, and nothing you deliver (scripts, shebangs, services, git hooks, build output) may depend on \`$PRIVATEER_NODE\`, because it does not exist outside Privateer.`;
201
+ }
202
+
203
+ // Only for a `-p` run with nobody attached. An interactive user reviews each step and pays
204
+ // per call, so they keep the stricter batching rule below; a run nobody is watching has no
205
+ // other chance to catch its own mistakes.
206
+ const UNATTENDED_RUN_GUIDELINES = `\n<unattended_run>
207
+ Nobody is watching this run: no one can answer a question or catch a mistake before your final message. Work through to the end, take the most plausible reading of anything ambiguous, and name that assumption in your final message.
208
+ - Prove the result before you stop. Exercise it the way the user will: from a fresh shell, through the requested entry point, with their exact commands if they gave any. Check what has to still exist after you exit, and build whatever throwaway checks that takes. Cleaning up must not undo the result.
209
+ - Deliver exactly what was asked: the requested paths, formats and text, with nothing added or reworded.
210
+ - Treat data you were given as irreplaceable. Copy it first and experiment on the copy, because merely opening a file with its usual tool can change or delete it.
211
+ - Keep long jobs visible. Anything that may take more than a few minutes runs in the background writing to a log; check its progress, and shrink or drop an approach that will not finish. Never sit blocked on one silent command.
212
+ - Open your final message with anything unfinished, unverified or lost.
213
+ </unattended_run>\n`;
214
+
215
+ export function runtimeGuidelinesBlock({ unattended = false }: { unattended?: boolean } = {}): string {
216
+ const whenToolMissing = unattended
217
+ ? "install it with the host package manager (e.g. `apt-get install -y`, `brew install`) and mention that in your final message"
218
+ : "explain clearly what is missing, and offer to install it using the host package manager (e.g. `brew install`, `xcode-select --install`, `winget install`, `apt install`) upon user approval";
219
+ const whatNotToBuild = unattended
220
+ ? "."
221
+ : "; do not build test harnesses, scratch scripts or regression runs that the user did not ask for.";
195
222
  return `\n\n${RUNTIME_GUIDELINES_MARKER}\n<environment_guidelines>
196
- - Node.js environment: Node.js (v22+) is guaranteed to be available in Privateer. Prefer \`node -e "..."\` or small Node.js scripts for quick scripting, calculations, or JSON processing instead of assuming \`python\` or \`python3\` is installed.
223
+ ${nodeGuideline()}
197
224
  - Search fallback: If \`rg\` (ripgrep) is missing or returns "command not found", fall back to standard POSIX \`grep -rn <pattern> <path>\` or \`find <path>\`.
198
- - Missing system dependencies: If an essential external tool (e.g. \`git\`, \`python\`, \`rg\`) is missing and needed, check its presence (\`command -v <tool>\`), explain clearly what is missing, and offer to install it using the host package manager (e.g. \`brew install\`, \`xcode-select --install\`, \`winget install\`, \`apt install\`) upon user approval.
225
+ - Missing system dependencies: If an essential external tool (e.g. \`git\`, \`python\`, \`rg\`) is missing and needed, check its presence (\`command -v <tool>\`), ${whenToolMissing}.
199
226
  - Shell calls share no state: every shell call runs in a fresh subshell rooted at the session's working directory, so a \`cd\` (or a variable, or an activated venv) in one call is gone by the next. Use absolute paths, or move and work in ONE call (\`cd <dir> && <command>\`). Never spend a call on \`cd\` alone.
200
- - Batch your steps: every tool call re-sends the whole conversation to the model, so a chain of one-line calls is far slower and more expensive than the same work combined into fewer calls. Group independent reads, searches and checks into one command; do not build test harnesses, scratch scripts or regression runs that the user did not ask for.
201
- </environment_guidelines>\n`;
227
+ - Batch your steps: every tool call re-sends the whole conversation to the model, so a chain of one-line calls is far slower and more expensive than the same work combined into fewer calls. Group independent reads, searches and checks into one command${whatNotToBuild}
228
+ - Report what really happened: never present invented, guessed or placeholder data as a real result. If something could not be obtained, or you lost or damaged it, say so plainly.
229
+ </environment_guidelines>\n${unattended ? UNATTENDED_RUN_GUIDELINES : ""}`;
202
230
  }
203
231
 
204
232
  // Format the discovered files into a system-prompt fragment using the same framing Pi
@@ -0,0 +1,38 @@
1
+ // One forced look back before an unattended run ends.
2
+ //
3
+ // A `-p` run has nobody to notice a wrong result, and telling the model up front to be
4
+ // honest did not hold. Measured on a data-recovery task (2026-11-09): having destroyed
5
+ // its input, the model wrote invented rows into the user's database, cited the row count
6
+ // as verification, and reported success with no caveat, with the honesty rule in its
7
+ // system prompt. The rule is one line among thousands of tokens read before the work
8
+ // starts; this asks the question after the work is done, with the evidence in context.
9
+ //
10
+ // So: when a top-level headless run that used tools ends normally, queue one follow-up
11
+ // asking the model to check its result against what actually happened, fix what it can,
12
+ // and open its final message with anything invented, unverified or lost. Pi continues a
13
+ // message queued from an agent_end handler inside the same run, so `-p` waits for it, and
14
+ // the audited reply is the one a script reads.
15
+ //
16
+ // Once per run. Runs that only answered have nothing to audit, and a turn that ended on
17
+ // an error or an abort is handled elsewhere. A subagent's parent reviews what it returns.
18
+ // PRIVATEER_FINAL_AUDIT=0 turns it off.
19
+ //
20
+ // IMPORT-SAFETY: no Pi imports — the shapes below are the few fields read.
21
+
22
+ import { lastAssistant } from "./truncationRecovery.ts";
23
+
24
+ export const FINAL_AUDIT_ENV = "PRIVATEER_FINAL_AUDIT";
25
+
26
+ export const FINAL_AUDIT_PROMPT = `Before this run ends, check your work against what actually happened in this session.
27
+
28
+ 1. Re-read the original request. Is every part of it done, and did you see it work the way the user will use it? If something can still be fixed, fix it now.
29
+ 2. Look for anything presented as real that you did not directly observe or derive from the inputs: invented, guessed or reconstructed values, checks you did not run, inputs you changed or lost. If you wrote such values into a deliverable or the user's files, take them out if you can.
30
+
31
+ Then write your final message to the user. Start with those items, stated plainly. If there are none, just give the result.`;
32
+
33
+ export function needsFinalAudit(messages: unknown, mode: unknown, env: NodeJS.ProcessEnv = process.env): boolean {
34
+ if (mode !== "print" && mode !== "json") return false;
35
+ if (env.PI_SUBAGENT_CHILD === "1" || env[FINAL_AUDIT_ENV] === "0") return false;
36
+ if (!Array.isArray(messages) || lastAssistant(messages)?.stopReason !== "stop") return false;
37
+ return messages.some((message) => message?.role === "toolResult");
38
+ }
@@ -9,7 +9,8 @@
9
9
  // So: armed when a request goes out, re-armed by every streamed event (text, thinking,
10
10
  // tool-call deltas all count, so a slow but live stream never trips it), disarmed when
11
11
  // the message ends. Between requests (tool execution) it is off — a long local command
12
- // is not a provider stall. If it fires, the caller aborts the turn and says why.
12
+ // is not a provider stall. If it fires, the caller aborts the turn and either resends it
13
+ // (reset() re-enables the watchdog) or gives up and says why.
13
14
  //
14
15
  // The limit: PRIVATEER_REPLY_TIMEOUT seconds, default 180, 0 turns it off. Silence, not
15
16
  // total time, is what is measured, so a long answer is never cut off for being long.
@@ -55,6 +56,12 @@ export class StallWatchdog {
55
56
  this.timer = undefined;
56
57
  }
57
58
 
59
+ /** The stalled request is being resent: watch the new one. */
60
+ reset(): void {
61
+ this.disarm();
62
+ this.fired = false;
63
+ }
64
+
58
65
  get stalled(): boolean {
59
66
  return this.fired;
60
67
  }
@@ -923,7 +923,17 @@ export function makeAccountProvider(opts: { armOnStart?: boolean } = {}) {
923
923
  // all, and the first prompt dead-ends on "No API key found for privateer." — even
924
924
  // though the banner says "connected". The REPL (cli/chat.ts) and the harbor
925
925
  // already spawn one at startup; this gives the TUI the same seed.
926
- if (opts.armOnStart !== false) pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
926
+ //
927
+ // Nobody types faster than this resolves, so the TUI starts without waiting. `-p`
928
+ // prompts as soon as extensions are bound, and Pi's auth precheck runs before
929
+ // before_agent_start can re-arm, so a headless launch has to wait here. Without it,
930
+ // a signed-in machine with no leftover auth.json entry is told it isn't signed in.
931
+ if (opts.armOnStart !== false) {
932
+ pi.on?.("session_start", async (_e, ctx) => {
933
+ const arming = armAccountCredential(ctx);
934
+ if (!(ctx as SeedContext)?.hasUI) await arming;
935
+ });
936
+ }
927
937
 
928
938
  // Two safety nets around the account channel, both OUTSIDE the request path —
929
939
  // they read Pi's in-memory auth map and its finished messages, and never touch the
@@ -1088,8 +1098,10 @@ export async function armAccountCredential(
1088
1098
  // If the credentials vanished during the call, the spawn hit a 401 and
1089
1099
  // onSessionExpired already announced the sign-out — a second line here would
1090
1100
  // just repeat it (we returned early above if we STARTED signed out).
1091
- if (opts.notify !== false && c?.hasUI && hasCredentials()) {
1092
- c.ui?.notify?.(`Privateer account channel unavailable — ${(e as Error).message}`, "error");
1101
+ if (opts.notify !== false && hasCredentials()) {
1102
+ const message = `Privateer account channel unavailable — ${(e as Error).message}`;
1103
+ if (c?.hasUI) c.ui?.notify?.(message, "error");
1104
+ else console.error(message); // headless: the real cause, not "isn't signed in"
1093
1105
  }
1094
1106
  return false;
1095
1107
  }
@@ -1153,6 +1165,25 @@ export async function dropPersistedAccountCredential(
1153
1165
  }
1154
1166
  }
1155
1167
 
1168
+ // The drop for a process that is about to exit. Pi calls process.exit as soon as the
1169
+ // session_shutdown handlers return, so a drop nobody waits for dies mid-write: the
1170
+ // revoked credential stays on disk, and the lock on auth.json is left behind. Every
1171
+ // launch in the next 30 seconds then waits for that lock to go stale before it can
1172
+ // paint, which is what a quick quit-and-relaunch looked like: a frozen terminal.
1173
+ //
1174
+ // Bounded, because another terminal can legitimately hold that lock and quitting must
1175
+ // never hang. Past the bound the server-side TTL is the fallback, as for any failed drop.
1176
+ export async function dropPersistedAccountCredentialBeforeExit(timeoutMs = 2_000): Promise<void> {
1177
+ let timer: ReturnType<typeof setTimeout> | undefined;
1178
+ await Promise.race([
1179
+ dropPersistedAccountCredential().catch(() => false),
1180
+ new Promise<void>((resolve) => {
1181
+ timer = setTimeout(resolve, timeoutMs);
1182
+ }),
1183
+ ]);
1184
+ clearTimeout(timer);
1185
+ }
1186
+
1156
1187
  // Make sure Pi still HAS an account credential before a turn goes out. Normally
1157
1188
  // session_start's arm is enough and this is a free in-memory lookup. It exists for the
1158
1189
  // case session_start can't cover: the persisted entry disappearing mid-session, which
@@ -0,0 +1,40 @@
1
+ // "Your agent is asking you something" — a push to the account's phones when a
2
+ // question (ask_user_question, a select, a text prompt) is put to an app that isn't
3
+ // there to see it.
4
+ //
5
+ // A question sent up the relay only reaches a LIVE socket. A phone that backgrounded
6
+ // a minute ago has none, so the question sat held on this machine (remoteBridge's
7
+ // holdOpenQuestions) with nothing telling anyone it was waiting — the user found it
8
+ // by chance, or after the hold ran out and the tool came back as an error.
9
+ //
10
+ // Content-free on purpose, like the Inbox nudge (server services/pushService.js): the
11
+ // question itself never leaves the relay, so the push says only that one is waiting.
12
+ // Fire-and-forget and throttled — a failed or skipped push must never touch the turn.
13
+
14
+ const COOLDOWN_MS = 30_000;
15
+ let lastAt = 0;
16
+ let sink: (() => void) | null = null;
17
+
18
+ export function requestAttentionPush(): void {
19
+ const now = Date.now();
20
+ if (now - lastAt < COOLDOWN_MS) return;
21
+ lastAt = now;
22
+ if (sink) return sink();
23
+ void (async () => {
24
+ try {
25
+ const { apiRequest, hasCredentials } = await import("../auth/privateer.ts");
26
+ if (!hasCredentials()) return;
27
+ await apiRequest("/api/push/attention", { method: "POST", signal: AbortSignal.timeout(10_000) });
28
+ } catch {
29
+ /* best effort — the question is still held and re-sent when the app comes back */
30
+ }
31
+ })();
32
+ }
33
+
34
+ /** Test seam: count pushes instead of sending them (null restores the real one), and
35
+ * forget the cooldown. Tests run on machines that are signed in — without this a
36
+ * bridge test would buzz the developer's phone. */
37
+ export function setAttentionPushSink(fn: (() => void) | null): void {
38
+ sink = fn;
39
+ lastAt = 0;
40
+ }
@@ -25,6 +25,7 @@ import { rememberAccountCredential, persistAccountCredential, dropPersistedAccou
25
25
  import { modelRegistryOf } from "../providers/piAuthStore.ts";
26
26
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
27
27
  import { RemoteBridge } from "./remoteBridge.ts";
28
+ import { transcriptSnapshot, type SnapshotEntry } from "./transcriptSnapshot.ts";
28
29
  import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
29
30
  import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
30
31
  import { createUIContext } from "../ext/headlessUi.ts";
@@ -190,7 +191,12 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
190
191
  onControllerAttached: () => {
191
192
  attached = true;
192
193
  if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
193
- relay?.sendSnapshot([]);
194
+ // The session's transcript, so an app back from sleep catches up (transcriptSnapshot.ts).
195
+ {
196
+ let entries: SnapshotEntry[] = [];
197
+ try { entries = transcriptSnapshot(session?.sessionManager?.getBranch?.() ?? []); } catch { /* no session yet */ }
198
+ relay?.sendSnapshot(entries, { full: entries.length > 0, running: turnActive });
199
+ }
194
200
  // cwd rides along here (and nowhere else — see RelayClient.sendContext): this
195
201
  // session's working directory was either named by the driver in the spawn form
196
202
  // or defaulted to the harbor's own, and they have no other way to see which.
@@ -24,6 +24,7 @@ import type { EngineEvent } from "../engine/events.ts";
24
24
  import type { PermissionRequest } from "../permissions/gate.ts";
25
25
  import { noQuarterActive } from "../permissions/noQuarter.ts";
26
26
  import { authorizeControl } from "./controlAuth.ts";
27
+ import type { SnapshotTool } from "./transcriptSnapshot.ts";
27
28
  import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
28
29
  import {
29
30
  LIBRARY_CHUNK_CHARS,
@@ -1175,10 +1176,43 @@ export class RelayClient {
1175
1176
 
1176
1177
  // Catch-up history sent to a controller on attach. Structured (not markdown) so
1177
1178
  // the app renders it with the same styling as the live feed. Bounded: last 80
1178
- // entries, each clipped.
1179
- sendSnapshot(entries: { kind: string; text: string }[]): void {
1180
- const trimmed = entries.slice(-80).map((e) => ({ kind: e.kind, text: safe(String(e.text ?? ""), 4000) }));
1181
- this.rawSend({ type: "snapshot", entries: trimmed });
1179
+ // entries, each clipped, and the whole frame kept well under the relay's 256 KB
1180
+ // frame cap by dropping the OLDEST entries first.
1181
+ //
1182
+ // `full` marks the entries as the terminal's whole current transcript (see
1183
+ // transcriptSnapshot.ts), so the app may REPLACE what it holds rather than only
1184
+ // adopting into an empty feed; `running` says whether a turn is still going, which
1185
+ // is how a reattaching app learns that the turn it last saw "Link dropped" on is
1186
+ // over. Both absent = the old notice-only snapshot, which the app keeps treating as
1187
+ // such.
1188
+ sendSnapshot(
1189
+ entries: { kind: string; text: string; tool?: SnapshotTool }[],
1190
+ meta?: { full?: boolean; running?: boolean },
1191
+ ): void {
1192
+ const out: Record<string, unknown>[] = [];
1193
+ let budget = 160 * 1024;
1194
+ for (const e of entries.slice(-80).reverse()) {
1195
+ const t = e.tool;
1196
+ const entry: Record<string, unknown> = t
1197
+ ? {
1198
+ kind: e.kind,
1199
+ text: "",
1200
+ tool: {
1201
+ id: t.id,
1202
+ name: t.name,
1203
+ input: safe(t.input, 500),
1204
+ ...(t.output !== undefined ? { output: safe(t.output, 1500) } : {}),
1205
+ ...(t.error !== undefined ? { error: safe(t.error, 1000) } : {}),
1206
+ ...(t.running ? { running: true } : {}),
1207
+ },
1208
+ }
1209
+ : { kind: e.kind, text: safe(String(e.text ?? ""), e.kind === "reasoning" ? 1500 : 4000) };
1210
+ budget -= JSON.stringify(entry).length;
1211
+ if (budget < 0) break;
1212
+ out.push(entry);
1213
+ }
1214
+ out.reverse();
1215
+ this.rawSend({ type: "snapshot", entries: out, ...(meta?.full ? { full: true, running: meta.running === true } : {}) });
1182
1216
  }
1183
1217
 
1184
1218
  // ── agent → app file transfer (chunked) ─────────────────────────────────────
@@ -20,6 +20,7 @@ import { redactText } from "../util/redact.ts";
20
20
  import type { PermissionRequest } from "../permissions/gate.ts";
21
21
  import type { AskOutcome } from "../permissions/modeGate.ts";
22
22
  import type { RelayCallbacks } from "./relayClient.ts";
23
+ import { requestAttentionPush } from "./attentionPush.ts";
23
24
  import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
24
25
  import type { LibrarySaveRequest, LibrarySaveResult } from "./librarySave.ts";
25
26
  import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
@@ -168,6 +169,10 @@ export interface RemoteBridgeConfig {
168
169
  onFilesSearch?: (id: string, query: string) => void;
169
170
  // A controller (re)attached — the owner should push a transcript snapshot.
170
171
  onControllerAttached?: () => void;
172
+ // A question is open and no app is attached to see it. Defaults to a content-free
173
+ // push to the account's phones (attentionPush.ts); a test or an owner with its own
174
+ // channel may replace it.
175
+ onQuestionUnwatched?: () => void;
171
176
  // The last controller went away. Informational for the owner (the bridge already
172
177
  // fails pending approvals closed); a driven turn in flight keeps running.
173
178
  onControllerDetached?: () => void;
@@ -380,6 +385,7 @@ export class RemoteBridge {
380
385
  this.remote = false;
381
386
  this.rejectAllPending();
382
387
  this.holdOpenQuestions();
388
+ if (this.hasOpenQuestion()) this.questionUnwatched();
383
389
  this.cfg.onControllerDetached?.();
384
390
  },
385
391
  onAttachment: (file) => {
@@ -472,6 +478,9 @@ export class RemoteBridge {
472
478
  signal.addEventListener("abort", onAbort, { once: true });
473
479
  }
474
480
  this.relay!.requestSelect(id, req);
481
+ // Sent into a socket with no app on the other end: it is held and re-sent on
482
+ // reattach, but nobody knows to come back unless they are told.
483
+ if (this.relay!.hasController?.() === false) this.questionUnwatched();
475
484
  });
476
485
  };
477
486
 
@@ -495,6 +504,9 @@ export class RemoteBridge {
495
504
  signal.addEventListener("abort", onAbort, { once: true });
496
505
  }
497
506
  this.relay!.requestInput(id, req);
507
+ // Sent into a socket with no app on the other end: it is held and re-sent on
508
+ // reattach, but nobody knows to come back unless they are told.
509
+ if (this.relay!.hasController?.() === false) this.questionUnwatched();
498
510
  });
499
511
  };
500
512
 
@@ -711,6 +723,11 @@ export class RemoteBridge {
711
723
  return true;
712
724
  }
713
725
 
726
+ private questionUnwatched(): void {
727
+ if (this.cfg.onQuestionUnwatched) return this.cfg.onQuestionUnwatched();
728
+ requestAttentionPush();
729
+ }
730
+
714
731
  private resendOpenQuestions(): void {
715
732
  this.releaseQuestionGrace();
716
733
  for (const [id, q] of this.pendingSelects) this.relay?.requestSelect(id, q.req);