harnery 0.42.0 → 0.44.0

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 (343) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.d.ts +16 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +18 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +95 -52
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +38 -11
  9. package/dist/commands/diagnostics.d.ts.map +1 -1
  10. package/dist/commands/diagnostics.js +10 -0
  11. package/dist/commands/doctor.d.ts +12 -0
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/doctor.js +22 -7
  14. package/dist/commands/governor.js +22 -10
  15. package/dist/commands/inbox.d.ts +1 -1
  16. package/dist/commands/inbox.d.ts.map +1 -1
  17. package/dist/commands/inbox.js +55 -2
  18. package/dist/commands/rm.d.ts.map +1 -1
  19. package/dist/commands/rm.js +28 -4
  20. package/dist/commands/servers.d.ts.map +1 -1
  21. package/dist/commands/servers.js +16 -1
  22. package/dist/commands/supervisor.d.ts.map +1 -1
  23. package/dist/commands/supervisor.js +7 -2
  24. package/dist/commands/toc.d.ts +14 -0
  25. package/dist/commands/toc.d.ts.map +1 -1
  26. package/dist/commands/toc.js +7 -4
  27. package/dist/commands/tunnel.d.ts +65 -0
  28. package/dist/commands/tunnel.d.ts.map +1 -1
  29. package/dist/commands/tunnel.js +290 -101
  30. package/dist/commands/web-fetch.d.ts +3 -2
  31. package/dist/commands/web-fetch.d.ts.map +1 -1
  32. package/dist/commands/web-fetch.js +25 -19
  33. package/dist/commands/web.d.ts +7 -1
  34. package/dist/commands/web.d.ts.map +1 -1
  35. package/dist/commands/web.js +9 -3
  36. package/dist/commands/workflow.js +24 -30
  37. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  38. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  39. package/dist/core/agents/live-authority-v3.js +11 -0
  40. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  41. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  42. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  43. package/dist/core/agents/mailbox.d.ts +9 -8
  44. package/dist/core/agents/mailbox.d.ts.map +1 -1
  45. package/dist/core/agents/mailbox.js +24 -17
  46. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  47. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  48. package/dist/core/agents/render/prompt-context.js +19 -17
  49. package/dist/core/agents/render/session-context.d.ts +2 -8
  50. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/session-context.js +64 -43
  52. package/dist/core/agents/rules/stop-hook.js +3 -3
  53. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  54. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  55. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  57. package/dist/core/agents/state/names.d.ts +43 -3
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +87 -3
  60. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  61. package/dist/core/agents/state/pidmap.js +5 -0
  62. package/dist/core/agents/state/proc-start.d.ts +19 -0
  63. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  64. package/dist/core/agents/state/proc-start.js +41 -0
  65. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  66. package/dist/core/agents/state/stale-sweep.js +180 -70
  67. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  68. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  69. package/dist/core/agents/state/unchanged-file.js +390 -0
  70. package/dist/core/context/index.d.ts +22 -10
  71. package/dist/core/context/index.d.ts.map +1 -1
  72. package/dist/core/context/index.js +381 -119
  73. package/dist/core/context/observation.d.ts +26 -0
  74. package/dist/core/context/observation.d.ts.map +1 -0
  75. package/dist/core/context/observation.js +11 -0
  76. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  77. package/dist/core/diagnostics/bundle.js +25 -14
  78. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  79. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  80. package/dist/core/diagnostics/path-identity.js +12 -0
  81. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  82. package/dist/core/diagnostics/sanitize.js +4 -3
  83. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  84. package/dist/core/events/v3/authority-outbox.js +29 -0
  85. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  86. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  87. package/dist/core/events/v3/generation-custody.js +50 -0
  88. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  89. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  90. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  91. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  92. package/dist/core/events/v3/producers/recorder.js +77 -64
  93. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  94. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  95. package/dist/core/governor/planning.d.ts.map +1 -1
  96. package/dist/core/governor/planning.js +14 -1
  97. package/dist/core/governor/service-read.d.ts +10 -2
  98. package/dist/core/governor/service-read.d.ts.map +1 -1
  99. package/dist/core/governor/service-read.js +105 -22
  100. package/dist/core/governor/service.d.ts +20 -2
  101. package/dist/core/governor/service.d.ts.map +1 -1
  102. package/dist/core/governor/service.js +72 -47
  103. package/dist/core/hooks/cli.js +120 -23
  104. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  105. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  106. package/dist/core/hooks/fork-lineage.js +62 -0
  107. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  108. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  109. package/dist/core/hooks/resolve/transcript.js +84 -0
  110. package/dist/core/hooks/shell-rm.d.ts +2 -0
  111. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  112. package/dist/core/hooks/shell-rm.js +105 -0
  113. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  114. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  115. package/dist/core/hooks/shell-tokens.js +97 -0
  116. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  117. package/dist/core/hooks/shell-waiter.js +3 -93
  118. package/dist/core/inbox/contract.d.ts +2 -1
  119. package/dist/core/inbox/contract.d.ts.map +1 -1
  120. package/dist/core/inbox/index.d.ts +2 -0
  121. package/dist/core/inbox/index.d.ts.map +1 -1
  122. package/dist/core/inbox/index.js +2 -0
  123. package/dist/core/inbox/peer-messages.d.ts +37 -0
  124. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  125. package/dist/core/inbox/peer-messages.js +72 -0
  126. package/dist/core/inbox/service.d.ts +8 -1
  127. package/dist/core/inbox/service.d.ts.map +1 -1
  128. package/dist/core/inbox/service.js +67 -21
  129. package/dist/core/inbox/targets.d.ts +22 -0
  130. package/dist/core/inbox/targets.d.ts.map +1 -0
  131. package/dist/core/inbox/targets.js +108 -0
  132. package/dist/core/inbox/watcher.js +7 -3
  133. package/dist/core/journal/index.d.ts.map +1 -1
  134. package/dist/core/journal/index.js +17 -2
  135. package/dist/core/resources/contract.d.ts +3 -0
  136. package/dist/core/resources/contract.d.ts.map +1 -1
  137. package/dist/core/resources/service-status.d.ts +14 -1
  138. package/dist/core/resources/service-status.d.ts.map +1 -1
  139. package/dist/core/resources/service-status.js +23 -13
  140. package/dist/core/resources/service.d.ts +9 -1
  141. package/dist/core/resources/service.d.ts.map +1 -1
  142. package/dist/core/resources/service.js +47 -27
  143. package/dist/core/resources/status.d.ts +2 -5
  144. package/dist/core/resources/status.d.ts.map +1 -1
  145. package/dist/core/resources/status.js +36 -7
  146. package/dist/core/semantic/contract.d.ts +5 -5
  147. package/dist/core/servers/index.d.ts +24 -13
  148. package/dist/core/servers/index.d.ts.map +1 -1
  149. package/dist/core/servers/index.js +132 -92
  150. package/dist/core/servers/tunnels.d.ts +3 -5
  151. package/dist/core/servers/tunnels.d.ts.map +1 -1
  152. package/dist/core/servers/tunnels.js +3 -9
  153. package/dist/core/storage/process-log.d.ts +12 -0
  154. package/dist/core/storage/process-log.d.ts.map +1 -1
  155. package/dist/core/storage/process-log.js +10 -2
  156. package/dist/core/supervisor/contract.d.ts +12 -0
  157. package/dist/core/supervisor/contract.d.ts.map +1 -1
  158. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  159. package/dist/core/supervisor/explanations.js +2 -1
  160. package/dist/core/supervisor/findings.d.ts.map +1 -1
  161. package/dist/core/supervisor/findings.js +54 -9
  162. package/dist/core/supervisor/service.d.ts +9 -1
  163. package/dist/core/supervisor/service.d.ts.map +1 -1
  164. package/dist/core/supervisor/service.js +59 -29
  165. package/dist/core/supervisor/services.d.ts.map +1 -1
  166. package/dist/core/supervisor/services.js +22 -1
  167. package/dist/core/supervisor/status.d.ts +17 -2
  168. package/dist/core/supervisor/status.d.ts.map +1 -1
  169. package/dist/core/supervisor/status.js +70 -12
  170. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  171. package/dist/core/supervisor/timeline.js +2 -1
  172. package/dist/core/work/state.js +7 -7
  173. package/dist/core/workflow/engine.d.ts.map +1 -1
  174. package/dist/core/workflow/engine.js +105 -32
  175. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  176. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  177. package/dist/core/workflow/failure-evidence.js +114 -0
  178. package/dist/core/workflow/index.d.ts +1 -1
  179. package/dist/core/workflow/index.d.ts.map +1 -1
  180. package/dist/core/workflow/index.js +1 -1
  181. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  182. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  183. package/dist/core/workflow/live-session-v3.js +126 -5
  184. package/dist/core/workflow/proof.d.ts +5 -5
  185. package/dist/core/workflow/proof.d.ts.map +1 -1
  186. package/dist/core/workflow/proof.js +115 -20
  187. package/dist/core/workflow/run-state.d.ts +3 -1
  188. package/dist/core/workflow/run-state.d.ts.map +1 -1
  189. package/dist/core/workflow/run-state.js +31 -27
  190. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-claude.js +19 -0
  192. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  193. package/dist/core/workflow/spawn-codex.js +13 -0
  194. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  195. package/dist/core/workflow/spawn-cursor.js +19 -0
  196. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  197. package/dist/core/workflow/spawn-opencode.js +14 -0
  198. package/dist/core/workflow/types.d.ts +64 -17
  199. package/dist/core/workflow/types.d.ts.map +1 -1
  200. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  201. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/index.js +0 -1
  203. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  204. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  205. package/dist/core/workflow/workspaces/leases.js +30 -16
  206. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  207. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/local-git.js +270 -6
  209. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  210. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  211. package/dist/core/workflow/workspaces/ownership.js +194 -0
  212. package/dist/core/workflow/workspaces/reclaim.d.ts +35 -0
  213. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  215. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  216. package/dist/core/workflow/workspaces/recovery.js +21 -13
  217. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  218. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  219. package/dist/core/workflow/workspaces/state.js +21 -1
  220. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  221. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  222. package/dist/lib/browser/windows-chrome.js +262 -73
  223. package/dist/lib/docs-links.d.ts.map +1 -1
  224. package/dist/lib/docs-links.js +45 -2
  225. package/dist/lib/guarded-remove.d.ts +7 -0
  226. package/dist/lib/guarded-remove.d.ts.map +1 -1
  227. package/dist/lib/guarded-remove.js +106 -12
  228. package/dist/lib/identities/assume.d.ts.map +1 -1
  229. package/dist/lib/identities/assume.js +4 -0
  230. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  231. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  232. package/dist/lib/tunnel/allowlist.js +204 -0
  233. package/dist/lib/tunnel/current-address.d.ts +41 -0
  234. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  235. package/dist/lib/tunnel/current-address.js +123 -0
  236. package/dist/lib/tunnel/gate.js +13 -6
  237. package/dist/lib/tunnel/processes.d.ts +39 -0
  238. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  239. package/dist/lib/tunnel/processes.js +121 -0
  240. package/dist/lib/tunnel/state.d.ts +28 -3
  241. package/dist/lib/tunnel/state.d.ts.map +1 -1
  242. package/dist/lib/tunnel/state.js +98 -27
  243. package/dist/lib/zip/index.d.ts +41 -0
  244. package/dist/lib/zip/index.d.ts.map +1 -0
  245. package/dist/lib/zip/index.js +398 -0
  246. package/package.json +8 -1
  247. package/src/commander.ts +32 -0
  248. package/src/commands/agents.ts +151 -74
  249. package/src/commands/browse.ts +50 -14
  250. package/src/commands/diagnostics.ts +10 -0
  251. package/src/commands/doctor.ts +26 -7
  252. package/src/commands/governor.ts +23 -10
  253. package/src/commands/inbox.ts +52 -2
  254. package/src/commands/rm.ts +43 -4
  255. package/src/commands/servers.ts +16 -4
  256. package/src/commands/supervisor.ts +7 -2
  257. package/src/commands/toc.ts +14 -6
  258. package/src/commands/tunnel.ts +394 -116
  259. package/src/commands/web-fetch.ts +30 -19
  260. package/src/commands/web.ts +9 -3
  261. package/src/commands/workflow.ts +30 -30
  262. package/src/core/agents/live-authority-v3.ts +17 -0
  263. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  264. package/src/core/agents/mailbox.ts +26 -17
  265. package/src/core/agents/render/prompt-context.ts +35 -20
  266. package/src/core/agents/render/session-context.ts +92 -48
  267. package/src/core/agents/rules/stop-hook.ts +3 -3
  268. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  269. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  270. package/src/core/agents/state/names.ts +117 -6
  271. package/src/core/agents/state/pidmap.ts +13 -0
  272. package/src/core/agents/state/proc-start.ts +57 -0
  273. package/src/core/agents/state/stale-sweep.ts +247 -82
  274. package/src/core/agents/state/unchanged-file.ts +456 -0
  275. package/src/core/context/index.ts +446 -136
  276. package/src/core/context/observation.ts +29 -0
  277. package/src/core/diagnostics/bundle.ts +23 -18
  278. package/src/core/diagnostics/path-identity.ts +15 -0
  279. package/src/core/diagnostics/sanitize.ts +4 -3
  280. package/src/core/events/v3/authority-outbox.ts +56 -0
  281. package/src/core/events/v3/generation-custody.ts +59 -0
  282. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  283. package/src/core/events/v3/producers/recorder.ts +94 -68
  284. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  285. package/src/core/governor/planning.ts +20 -8
  286. package/src/core/governor/service-read.ts +128 -20
  287. package/src/core/governor/service.ts +119 -42
  288. package/src/core/hooks/cli.ts +139 -22
  289. package/src/core/hooks/fork-lineage.ts +82 -0
  290. package/src/core/hooks/resolve/transcript.ts +88 -0
  291. package/src/core/hooks/shell-rm.ts +103 -0
  292. package/src/core/hooks/shell-tokens.ts +96 -0
  293. package/src/core/hooks/shell-waiter.ts +4 -91
  294. package/src/core/inbox/contract.ts +2 -1
  295. package/src/core/inbox/index.ts +2 -0
  296. package/src/core/inbox/peer-messages.ts +94 -0
  297. package/src/core/inbox/service.ts +88 -22
  298. package/src/core/inbox/targets.ts +148 -0
  299. package/src/core/inbox/watcher.ts +8 -9
  300. package/src/core/journal/index.ts +19 -2
  301. package/src/core/resources/contract.ts +4 -0
  302. package/src/core/resources/service-status.ts +35 -12
  303. package/src/core/resources/service.ts +71 -24
  304. package/src/core/resources/status.ts +57 -8
  305. package/src/core/servers/index.ts +177 -91
  306. package/src/core/servers/tunnels.ts +3 -11
  307. package/src/core/storage/process-log.ts +17 -2
  308. package/src/core/supervisor/contract.ts +13 -0
  309. package/src/core/supervisor/explanations.ts +4 -1
  310. package/src/core/supervisor/findings.ts +68 -10
  311. package/src/core/supervisor/service.ts +88 -26
  312. package/src/core/supervisor/services.ts +26 -8
  313. package/src/core/supervisor/status.ts +89 -12
  314. package/src/core/supervisor/timeline.ts +2 -1
  315. package/src/core/work/state.ts +7 -7
  316. package/src/core/workflow/engine.ts +131 -49
  317. package/src/core/workflow/failure-evidence.ts +141 -0
  318. package/src/core/workflow/index.ts +0 -1
  319. package/src/core/workflow/live-session-v3.ts +146 -5
  320. package/src/core/workflow/proof.ts +134 -26
  321. package/src/core/workflow/run-state.ts +31 -29
  322. package/src/core/workflow/spawn-claude.ts +18 -0
  323. package/src/core/workflow/spawn-codex.ts +13 -0
  324. package/src/core/workflow/spawn-cursor.ts +18 -0
  325. package/src/core/workflow/spawn-opencode.ts +14 -0
  326. package/src/core/workflow/types.ts +51 -17
  327. package/src/core/workflow/workspaces/index.ts +0 -1
  328. package/src/core/workflow/workspaces/leases.ts +48 -17
  329. package/src/core/workflow/workspaces/local-git.ts +315 -5
  330. package/src/core/workflow/workspaces/ownership.ts +232 -0
  331. package/src/core/workflow/workspaces/reclaim.ts +58 -0
  332. package/src/core/workflow/workspaces/recovery.ts +25 -13
  333. package/src/core/workflow/workspaces/state.ts +20 -0
  334. package/src/lib/browser/windows-chrome.ts +346 -86
  335. package/src/lib/docs-links.ts +49 -2
  336. package/src/lib/guarded-remove.ts +132 -14
  337. package/src/lib/identities/assume.ts +4 -0
  338. package/src/lib/tunnel/allowlist.ts +208 -0
  339. package/src/lib/tunnel/current-address.ts +158 -0
  340. package/src/lib/tunnel/gate.ts +16 -8
  341. package/src/lib/tunnel/processes.ts +149 -0
  342. package/src/lib/tunnel/state.ts +123 -27
  343. package/src/lib/zip/index.ts +483 -0
@@ -16,9 +16,11 @@ import type { EmitContext } from "../commander.ts";
16
16
  * harnery repo at the *matching version tag* into a cache dir and install the
17
17
  * web app's deps. Subsequent runs reuse the cache.
18
18
  *
19
- * The only thing `web/` pulls from `src/` is `harnery/core/journal` (a single,
20
- * dependency-free module), so the install is `web/` deps only: no root install,
21
- * no Playwright browser download.
19
+ * `web/` imports readers from `src/`, and `web/next.config.ts` resolves the
20
+ * root `playwright` package, so the install covers the repository root and
21
+ * then `web/`. Installing the `playwright` package downloads no browsers.
22
+ * Installing `web/` alone left `harn web up` failing with
23
+ * `Cannot find module 'playwright/package.json'`.
22
24
  */
23
25
 
24
26
  const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
@@ -53,8 +55,8 @@ function webCacheRoot(ref: string): string {
53
55
  }
54
56
 
55
57
  /** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
56
- export function webRunner(): "bun" | "npm" {
57
- const r = spawnSync("bun", ["--version"], { stdio: "ignore" });
58
+ export function webRunner(spawn: typeof spawnSync = spawnSync): "bun" | "npm" {
59
+ const r = spawn("bun", ["--version"], { stdio: "ignore" });
58
60
  return r.status === 0 ? "bun" : "npm";
59
61
  }
60
62
 
@@ -63,14 +65,20 @@ export function webRunner(): "bun" | "npm" {
63
65
  * its path, fetching (clone + install) on first use. Returns null on failure
64
66
  * (after emitting an actionable error).
65
67
  */
66
- export function lazyFetchWebRoot(emit: EmitContext): string | null {
68
+ export function lazyFetchWebRoot(
69
+ emit: EmitContext,
70
+ spawn: typeof spawnSync = spawnSync,
71
+ ): string | null {
67
72
  const { version, repoUrl } = readPkg();
68
73
  const ref = resolveRef(version);
69
74
  const cacheRoot = webCacheRoot(ref);
70
75
  const webDir = path.join(cacheRoot, "web");
71
76
 
72
- // node_modules in the cached web/ is the "fetched + installed" marker.
73
- if (existsSync(path.join(webDir, "node_modules"))) {
77
+ // node_modules in both the cached root and web/ is the "fetched + installed" marker.
78
+ if (
79
+ existsSync(path.join(cacheRoot, "node_modules")) &&
80
+ existsSync(path.join(webDir, "node_modules"))
81
+ ) {
74
82
  emit.log(`harn web · using cached dashboard (${ref})`, "info");
75
83
  return webDir;
76
84
  }
@@ -85,7 +93,7 @@ export function lazyFetchWebRoot(emit: EmitContext): string | null {
85
93
  if (existsSync(cacheRoot)) rmSync(cacheRoot, { recursive: true, force: true });
86
94
  mkdirSync(path.dirname(cacheRoot), { recursive: true });
87
95
 
88
- const clone = spawnSync("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
96
+ const clone = spawn("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
89
97
  stdio: "inherit",
90
98
  });
91
99
  if (clone.status !== 0) {
@@ -100,16 +108,19 @@ export function lazyFetchWebRoot(emit: EmitContext): string | null {
100
108
  return null;
101
109
  }
102
110
 
103
- const runner = webRunner();
104
- emit.log(`harn web · installing dashboard deps (${runner} install in web/)`, "info");
105
- const install = spawnSync(runner, ["install"], { cwd: webDir, stdio: "inherit" });
106
- if (install.status !== 0) {
107
- emit.error({
108
- code: "web_fetch_failed",
109
- message: `${runner} install in ${webDir} failed (status ${install.status})`,
110
- hint: `Try manually: cd ${webDir} && ${runner} install`,
111
- });
112
- return null;
111
+ const runner = webRunner(spawn);
112
+ for (const dir of [cacheRoot, webDir]) {
113
+ const where = dir === webDir ? "web/" : "the harnery root";
114
+ emit.log(`harn web · installing dashboard deps (${runner} install in ${where})`, "info");
115
+ const install = spawn(runner, ["install"], { cwd: dir, stdio: "inherit" });
116
+ if (install.status !== 0) {
117
+ emit.error({
118
+ code: "web_fetch_failed",
119
+ message: `${runner} install in ${dir} failed (status ${install.status})`,
120
+ hint: `Try manually: cd ${dir} && ${runner} install`,
121
+ });
122
+ return null;
123
+ }
113
124
  }
114
125
 
115
126
  emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
@@ -82,11 +82,17 @@ export function nodeOptionsWithHeapCap(mb: number): string | undefined {
82
82
  return `${inherited} --max-old-space-size=${mb}`.trim();
83
83
  }
84
84
 
85
- /** Preload the request diagnostics hook without replacing Next's HTTP server. */
85
+ /**
86
+ * Preload the dashboard's server hooks (keep-alive and request diagnostics)
87
+ * without replacing Next's HTTP server. NODE_OPTIONS reaches the child that
88
+ * `next dev` forks, but Next re-parses every `--import` into one value, so
89
+ * this names the single combined preload; a second, different `--import`
90
+ * would collapse into an unloadable path (web/server-preload.mjs).
91
+ */
86
92
  export function nodeOptionsWithWebDiagnostics(webDir: string, mb: number): string {
87
93
  const inherited = nodeOptionsWithHeapCap(mb) ?? "";
88
- if (inherited.includes("server-performance.mjs")) return inherited;
89
- const preload = pathToFileURL(path.join(webDir, "server-performance.mjs")).href;
94
+ if (inherited.includes("server-preload.mjs")) return inherited;
95
+ const preload = pathToFileURL(path.join(webDir, "server-preload.mjs")).href;
90
96
  return `${inherited} --import=${preload}`.trim();
91
97
  }
92
98
 
@@ -618,11 +618,11 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
618
618
  workflow
619
619
  .command("reclaim <run-id>")
620
620
  .description(
621
- "Resolve a workspace preserved because it was dirty: salvage the work to its branch, then release.",
621
+ "Preview reclaim of an inactive owned workspace, or confirm salvage or discard and release.",
622
622
  )
623
623
  .option("--yes", "Confirm that this commits or discards uncommitted work, then releases")
624
624
  .option("--discard", "Throw the uncommitted work away instead of salvaging it")
625
- .option("--json", "Emit the reclaim preparation and cleanup result as JSON")
625
+ .option("--json", "Emit the reclaim preview or confirmed result as JSON")
626
626
  .action(async (runId: string, opts: WorkflowReclaimOpts) => {
627
627
  const coordRoot = resolveCoordRoot();
628
628
  if (!coordRoot) {
@@ -632,53 +632,53 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
632
632
  });
633
633
  process.exit(1);
634
634
  }
635
- if (!opts.yes) {
636
- emit.error({
637
- code: "reclaim_confirmation_required",
638
- message: opts.discard
639
- ? "reclaim --discard permanently destroys uncommitted work; pass --yes to confirm"
640
- : "workspace reclaim commits uncommitted work to its branch and then releases the worktree; pass --yes to confirm",
641
- });
642
- process.exit(1);
643
- }
644
635
  try {
645
- const { inspectWorkflowWorkspace, prepareReclaim, cleanupWorkspace } = await import(
636
+ const { createLocalGitWorktreeProvider, readWorkflowRunManifest } = await import(
646
637
  "../core/workflow/index.ts"
647
638
  );
648
- const inspection = inspectWorkflowWorkspace(coordRoot, runId);
649
- if (!inspection.ok) {
650
- emit.error({ code: "workflow_reclaim_failed", message: inspection.error });
651
- process.exit(1);
652
- }
653
- const allocation = inspection.value.allocation;
654
- if (!allocation) {
639
+ const binding = readWorkflowRunManifest(coordRoot, runId).execution.workspace_binding;
640
+ if (binding?.provider.id !== "local-git-worktree") {
655
641
  emit.error({
656
642
  code: "workflow_reclaim_unavailable",
657
643
  message: `run ${runId} has no provider-owned Git workspace to reclaim`,
658
644
  });
659
645
  process.exit(1);
660
646
  }
661
- const preparation = prepareReclaim({
662
- worktreePath: allocation.active_root,
647
+ const provider = createLocalGitWorktreeProvider({ coordRoot });
648
+ const result = await provider.reclaim(binding, {
663
649
  mode: opts.discard ? "discard" : "salvage",
664
- runId,
650
+ yes: opts.yes,
665
651
  });
666
- // Cleanup still owns removal. Reclaim only changes whether the tree it
667
- // finds is dirty, so there is one audited release path, not two.
668
- const provider = await builtInProviderForRun(coordRoot, runId);
669
- const cleanup = await cleanupWorkspace({ coordRoot, runId, provider });
670
652
  if (opts.json) {
671
653
  emit.config({ format: "json" });
672
- emit.data({ preparation, cleanup });
654
+ emit.data(result);
655
+ return;
656
+ }
657
+ if (result.action === "preview") {
658
+ const holders = result.lease_holder.map(
659
+ (holder) =>
660
+ `${holder.scope}: ${holder.owner_id ?? "unknown"}${holder.pid ? ` (pid ${holder.pid})` : ""}${holder.detail ? `; ${holder.detail}` : ""}`,
661
+ );
662
+ emit.text(
663
+ `reclaim preview: ${result.run_id}\n` +
664
+ `mode: ${result.mode}\nliveness: ${result.liveness}\nownership: ${result.ownership}\n` +
665
+ `lease holder: ${holders.length > 0 ? holders.join(", ") : "none observed"}\n` +
666
+ `dirty paths: ${result.dirty_paths === null ? "unknown" : result.dirty_paths.length > 0 ? result.dirty_paths.join(", ") : "none"}\n` +
667
+ (result.ownership_detail ? `ownership detail: ${result.ownership_detail}\n` : "") +
668
+ `Pass --yes to confirm ${result.mode} and release after all checks pass.\n`,
669
+ );
673
670
  return;
674
671
  }
675
672
  emit.text(
676
- `reclaim ${preparation.action}: ${preparation.detail}\n` +
677
- `workspace cleanup ${cleanup.status}: ${cleanup.binding_id}\n`,
673
+ `reclaim ${result.preparation.action}: ${result.preparation.detail}\n` +
674
+ `workspace cleanup ${result.cleanup.status}: ${result.cleanup.binding_id}\n`,
678
675
  );
679
676
  } catch (error) {
680
677
  emit.error({
681
- code: "workflow_reclaim_failed",
678
+ code:
679
+ error instanceof Error && "code" in error && typeof error.code === "string"
680
+ ? error.code
681
+ : "workflow_reclaim_failed",
682
682
  message: error instanceof Error ? error.message : String(error),
683
683
  });
684
684
  process.exit(1);
@@ -88,6 +88,7 @@ interface LiveAuthorityBaseV3 {
88
88
  nativeSessionId: string;
89
89
  adapter: Adapter;
90
90
  observationId?: string;
91
+ expectedGenerationId?: `gen_${string}`;
91
92
  }
92
93
 
93
94
  /**
@@ -648,6 +649,13 @@ function recordLiveAuthority<S extends CoordinationAuthoritySignalV3>(
648
649
  platform: livePlatformV3(),
649
650
  expected_prior_state_digest: coordinationAuthorityStateDigestV3(expected),
650
651
  desired_state_digest: coordinationAuthorityStateDigestV3(desired),
652
+ expected_generation_id:
653
+ input.expectedGenerationId ??
654
+ ((subject === input.owner
655
+ ? expected.v3_generation_id
656
+ : readLiveCoordinationRow(input.coordRoot, input.owner)?.v3_generation_id) as
657
+ | `gen_${string}`
658
+ | undefined),
651
659
  reconciler: {
652
660
  readStateDigest: () => {
653
661
  const heartbeat = readHeartbeat(input.coordRoot, subject);
@@ -681,6 +689,12 @@ export function liveCoordinationWriteModeV3(coordRoot: string): EventV3WriteMode
681
689
  }
682
690
 
683
691
  function requireHeartbeat(input: LiveAuthorityBaseV3, owner: string): Heartbeat {
692
+ if (
693
+ input.expectedGenerationId &&
694
+ readLiveCoordinationRow(input.coordRoot, owner)?.v3_generation_id !== input.expectedGenerationId
695
+ ) {
696
+ throw new LiveCoordinationAuthorityV3Error("expected_generation_changed");
697
+ }
684
698
  const heartbeat = ensureLiveCoordinationHeartbeat(
685
699
  input.coordRoot,
686
700
  owner,
@@ -688,6 +702,9 @@ function requireHeartbeat(input: LiveAuthorityBaseV3, owner: string): Heartbeat
688
702
  input.adapter,
689
703
  );
690
704
  if (!heartbeat) throw new LiveCoordinationAuthorityV3Error(`heartbeat_missing:${owner}`);
705
+ if (input.expectedGenerationId && heartbeat.v3_generation_id !== input.expectedGenerationId) {
706
+ throw new LiveCoordinationAuthorityV3Error("expected_generation_changed");
707
+ }
691
708
  return heartbeat;
692
709
  }
693
710
 
@@ -5,6 +5,7 @@ import { normalizeNativeIdV3 } from "../events/v3/canonical.ts";
5
5
  import type { EventV3 } from "../events/v3/contract.ts";
6
6
  import { readEventV3ControlState } from "../events/v3/control.ts";
7
7
  import { fingerprintContextV3 } from "../events/v3/fingerprint-keys.ts";
8
+ import { withGenerationCustodyV3 } from "../events/v3/generation-custody.ts";
8
9
  import {
9
10
  liveInstanceIdV3,
10
11
  livePlatformV3,
@@ -25,17 +26,34 @@ interface LiveLifecycleObservationBaseV3 {
25
26
  observedAt?: string;
26
27
  }
27
28
 
29
+ interface LiveSweepObservationV3Input extends LiveLifecycleObservationBaseV3 {
30
+ expectedGenerationId: `gen_${string}` | null;
31
+ observation: "stale_heartbeat" | "killed" | "unparseable_heartbeat" | "missing_timestamp";
32
+ ageMs: number;
33
+ }
34
+
28
35
  export function recordLiveSweepObservationV3(
29
- input: LiveLifecycleObservationBaseV3 & {
30
- observation: "stale_heartbeat" | "killed" | "unparseable_heartbeat" | "missing_timestamp";
31
- ageMs: number;
32
- },
36
+ input: LiveSweepObservationV3Input,
33
37
  ): LiveLifecycleObservationV3Result {
34
- return recordObservation(input, "lifecycle.sweep_observed", {
35
- subject_instance_id: liveInstanceIdV3(input.owner),
36
- observation: input.observation,
37
- provisional: true,
38
- age_ms: Math.max(0, Math.floor(input.ageMs)),
38
+ input = { ...input };
39
+ // Reject stale observations before preparing durable custody state. The
40
+ // in-custody checks cover replacement while acquiring custody and reentry.
41
+ requireSweepHookState(input);
42
+ return withGenerationCustodyV3(input.coordRoot, [liveInstanceIdV3(input.owner)], () => {
43
+ requireSweepHookState(input);
44
+ return recordObservation(
45
+ input,
46
+ "lifecycle.sweep_observed",
47
+ {
48
+ subject_instance_id: liveInstanceIdV3(input.owner),
49
+ observation: input.observation,
50
+ provisional: true,
51
+ age_ms: Math.max(0, Math.floor(input.ageMs)),
52
+ },
53
+ () => {
54
+ requireSweepHookState(input);
55
+ },
56
+ );
39
57
  });
40
58
  }
41
59
 
@@ -44,11 +62,16 @@ export function recordLiveResumeObservationV3(
44
62
  ): LiveLifecycleObservationV3Result {
45
63
  const normalized = withAttestedAdapter(input);
46
64
  const hook = requireHookState(normalized);
47
- return recordObservation(normalized, "session.resumed", {
48
- prior_generation_id: hook.generation_id,
49
- continuity: "native",
50
- evidence_reference: "heartbeat_recreated",
51
- });
65
+ return recordObservation(
66
+ normalized,
67
+ "session.resumed",
68
+ {
69
+ prior_generation_id: hook.generation_id,
70
+ continuity: "native",
71
+ evidence_reference: "heartbeat_recreated",
72
+ },
73
+ () => {},
74
+ );
52
75
  }
53
76
 
54
77
  function recordObservation<T extends "lifecycle.sweep_observed" | "session.resumed">(
@@ -66,6 +89,7 @@ function recordObservation<T extends "lifecycle.sweep_observed" | "session.resum
66
89
  continuity: "native";
67
90
  evidence_reference: string;
68
91
  },
92
+ beforeWrite: () => void,
69
93
  ): LiveLifecycleObservationV3Result {
70
94
  const route = resolveLiveEventLedgerRouteV3(input.coordRoot);
71
95
  if (route.state === "blocked") throw new LiveCoordinationAuthorityV3Error(route.reason);
@@ -121,6 +145,7 @@ function recordObservation<T extends "lifecycle.sweep_observed" | "session.resum
121
145
  monotonic_ns: process.hrtime.bigint().toString(),
122
146
  };
123
147
  const event = buildEventV3(eventType, { ...common, payload } as never) as EventV3;
148
+ beforeWrite();
124
149
  const durability = writeEventV3(input.coordRoot, event, {
125
150
  expectedGenesisId: control.genesis.event.payload.genesis_id as `gex_${string}`,
126
151
  });
@@ -130,6 +155,19 @@ function recordObservation<T extends "lifecycle.sweep_observed" | "session.resum
130
155
  return { state: "recorded", event };
131
156
  }
132
157
 
158
+ function requireSweepHookState(input: LiveSweepObservationV3Input): void {
159
+ if (
160
+ typeof input.expectedGenerationId !== "string" ||
161
+ !/^gen_[a-zA-Z0-9._-]+$/.test(input.expectedGenerationId)
162
+ ) {
163
+ throw new LiveCoordinationAuthorityV3Error("hook_generation_not_joinable");
164
+ }
165
+ const hook = requireHookState(withAttestedAdapter(input));
166
+ if (hook.generation_id !== input.expectedGenerationId) {
167
+ throw new LiveCoordinationAuthorityV3Error("expected_generation_changed");
168
+ }
169
+ }
170
+
133
171
  function withAttestedAdapter<T extends LiveLifecycleObservationBaseV3>(input: T): T {
134
172
  const adapter = liveCoordinationAdapterV3(input.coordRoot, input.owner);
135
173
  if (!adapter) return input;
@@ -9,13 +9,14 @@
9
9
  * shown to them, because nothing surfaces another session's journal.
10
10
  *
11
11
  * This module addresses the durable persona name instead of the instance, so a
12
- * message can be queued before its recipient exists. Delivery happens on the
13
- * recipient's side: their next SessionStart or prompt drains the queue, shows
14
- * the messages, and records them in their journal.
12
+ * message can be queued before its recipient exists. The recipient's next
13
+ * SessionStart or prompt can drain the queue for injection. A drain does not
14
+ * confirm that the adapter emitted the context or that anyone read it.
15
15
  *
16
16
  * Storage: `.harnery/mailbox/<slug>.jsonl`, one JSON record per line, appended
17
- * by senders and removed by an atomic claim on the recipient's side. Delivered
18
- * records are appended to `.harnery/mailbox/delivered/<slug>.jsonl` for audit.
17
+ * by senders and removed by an atomic claim on the recipient's side. Drained
18
+ * records are appended to `.harnery/mailbox/delivered/<slug>.jsonl` for audit,
19
+ * with injection explicitly unconfirmed.
19
20
  *
20
21
  * Related but distinct: `core/inbox` is an instance-keyed transport with a
21
22
  * storage-framework service behind it. This mailbox is name-keyed, file-only,
@@ -48,8 +49,8 @@ export const MAILBOX_MESSAGE_SCHEMA = "harnery.mailbox-message/v1" as const;
48
49
  export const MAX_BODY_BYTES = 4000;
49
50
  /** Pending messages held for one name before senders are refused. */
50
51
  export const MAX_PENDING = 25;
51
- /** Delivered-archive ceiling; the file is truncated from the head past this. */
52
- const MAX_DELIVERED_BYTES = 256 * 1024;
52
+ /** Drain-archive ceiling; the file is truncated from the head past this. */
53
+ const MAX_ARCHIVE_BYTES = 256 * 1024;
53
54
 
54
55
  export interface MailboxMessageV1 {
55
56
  schema: typeof MAILBOX_MESSAGE_SCHEMA;
@@ -104,7 +105,7 @@ export function mailboxPath(coordRoot: string, name: string): string {
104
105
  return join(mailboxDir(coordRoot), `${slugFor(name)}.jsonl`);
105
106
  }
106
107
 
107
- function deliveredPath(coordRoot: string, name: string): string {
108
+ function archivePath(coordRoot: string, name: string): string {
108
109
  return join(mailboxDir(coordRoot), "delivered", `${slugFor(name)}.jsonl`);
109
110
  }
110
111
 
@@ -243,10 +244,10 @@ export function peekMailbox(coordRoot: string, name: string): MailboxMessageV1[]
243
244
  /**
244
245
  * Claim and return every pending message for a name.
245
246
  *
246
- * The claim is an atomic rename, so two sessions sharing a name cannot deliver
247
+ * The claim is an atomic rename, so two sessions sharing a name cannot claim
247
248
  * the same message twice: whichever rename wins owns the batch. Claimed records
248
- * are appended to the delivered archive before the claim file is removed, so a
249
- * crash between the two leaves an auditable copy rather than a silent loss.
249
+ * are archived before the claim file is removed. The archive records only the
250
+ * drain; output, recipient read, and turn completion remain unconfirmed.
250
251
  */
251
252
  export function drainMailbox(coordRoot: string, name: string): MailboxMessageV1[] {
252
253
  let path: string;
@@ -279,12 +280,20 @@ export function drainMailbox(coordRoot: string, name: string): MailboxMessageV1[
279
280
 
280
281
  if (messages.length > 0) {
281
282
  try {
282
- const archive = deliveredPath(coordRoot, name);
283
+ const archive = archivePath(coordRoot, name);
283
284
  mkdirSync(join(mailboxDir(coordRoot), "delivered"), { recursive: true });
284
- const deliveredAt = new Date().toISOString();
285
+ const drainedAt = new Date().toISOString();
285
286
  appendFileSync(
286
287
  archive,
287
- `${messages.map((m) => JSON.stringify({ ...m, delivered_at: deliveredAt })).join("\n")}\n`,
288
+ `${messages
289
+ .map((m) =>
290
+ JSON.stringify({
291
+ ...m,
292
+ drained_at: drainedAt,
293
+ delivery_status: "injection-unconfirmed",
294
+ }),
295
+ )
296
+ .join("\n")}\n`,
288
297
  "utf8",
289
298
  );
290
299
  trimArchive(archive);
@@ -336,7 +345,7 @@ function sweepStaleClaims(coordRoot: string): void {
336
345
 
337
346
  function trimArchive(path: string): void {
338
347
  try {
339
- if (statSync(path).size <= MAX_DELIVERED_BYTES) return;
348
+ if (statSync(path).size <= MAX_ARCHIVE_BYTES) return;
340
349
  const lines = readFileSync(path, "utf8").split("\n").filter(Boolean);
341
350
  const keep = lines.slice(Math.floor(lines.length / 2));
342
351
  writeFileSync(path, `${keep.join("\n")}\n`, "utf8");
@@ -354,8 +363,8 @@ export function formatMailboxDelivery(
354
363
  const bin = resolveBinName(coordRoot);
355
364
  const header =
356
365
  messages.length === 1
357
- ? `Message from a peer agent (delivered now, recorded in your journal):`
358
- : `${messages.length} messages from peer agents (delivered now, recorded in your journal):`;
366
+ ? `Message from a peer agent (prepared for injection, recorded in your journal):`
367
+ : `${messages.length} messages from peer agents (prepared for injection, recorded in your journal):`;
359
368
  const body = messages
360
369
  .map((m) => {
361
370
  const when = m.created_at.replace("T", " ").replace(/\.\d+Z$/, "Z");
@@ -48,17 +48,18 @@ import { join } from "node:path";
48
48
  import { coordEnv } from "../../../lib/env.ts";
49
49
  import { endOfTurnStatusCommand, hostPromptReminder, resolveBinName } from "../../config.ts";
50
50
  import type { PressureWorkloadClass } from "../../diagnostics/contract.ts";
51
+ import { formatPeerInbox, peerInbox } from "../../inbox/peer-messages.ts";
51
52
  import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
52
53
  import { readResourceStatus } from "../../resources/status.ts";
53
- import { drainMailbox, formatMailboxDelivery } from "../mailbox.ts";
54
54
  import { sessionNameDisplayBlock, sessionNameDisplayPending } from "../session-name-display.ts";
55
55
  import { heartbeatPath } from "../state/heartbeat-reader.ts";
56
56
  import {
57
57
  readLiveCoordinationRow,
58
58
  readLiveCoordinationRows,
59
59
  } from "../state/live-coordination-view.ts";
60
+ import { forkRelationLabels } from "../state/names.ts";
60
61
  import type { AgentActivity, TaskState } from "../state/session-state.ts";
61
- import { formatPendingCouncils, recordDeliveredMessages } from "./session-context.ts";
62
+ import { formatPendingCouncils } from "./session-context.ts";
62
63
 
63
64
  interface HeartbeatRow {
64
65
  instance_id?: string;
@@ -102,6 +103,8 @@ export interface PromptContextOpts {
102
103
  * being bounced into a retry. Not hash-deduped, same rationale as the
103
104
  * status footer. */
104
105
  turnRitualNudge?: string;
106
+ /** The emitter acknowledges exactly these IDs only after output succeeds. */
107
+ onInboxPrepared?: (messageIds: readonly string[]) => void;
105
108
  }
106
109
 
107
110
  /**
@@ -131,16 +134,12 @@ export function renderPromptContext(opts: PromptContextOpts): string {
131
134
  // skipped without it.
132
135
  const resolvedName = agentName ?? readLiveCoordinationRow(coordRoot, instanceId)?.name;
133
136
 
134
- // 1. Peer messages. Drained first and never deduped: a message is delivered
135
- // exactly once, and it is the one section here that carries new information
136
- // from another agent rather than a reminder.
137
- //
138
- if (resolvedName) {
139
- const delivered = drainMailbox(coordRoot, resolvedName);
140
- if (delivered.length > 0) {
141
- recordDeliveredMessages(instanceId, delivered);
142
- sections.push(formatMailboxDelivery(coordRoot, delivered));
143
- }
137
+ // 1. Instance-bound messages. Rendering is read-only; failed output leaves
138
+ // this same batch pending rather than losing it before a prompt sees it.
139
+ const delivered = peerInbox(coordRoot).prepareSurface(instanceId);
140
+ if (delivered.length > 0) {
141
+ opts.onInboxPrepared?.(delivered.map(({ message_id }) => message_id));
142
+ sections.push(formatPeerInbox(coordRoot, delivered));
144
143
  }
145
144
 
146
145
  // 2. Peer table with hash dedup.
@@ -441,7 +440,11 @@ function computePeerTableIfChanged(
441
440
  if (oldHash && oldHash === newHash) return "";
442
441
 
443
442
  // Render peer table via the same formatter used at SessionStart.
444
- const local = formatPeerTable(peers, mySessionId);
443
+ const local = formatPeerTable(
444
+ peers,
445
+ mySessionId,
446
+ forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))),
447
+ );
445
448
  const remoteTable = formatRemoteTable(remote);
446
449
  const table = [local, remoteTable].filter(Boolean).join("\n\n");
447
450
  if (!table) return "";
@@ -580,7 +583,11 @@ function writeHashFile(path: string, value: string): void {
580
583
  * (no cross-file imports of formatting internals). If both renderers diverge,
581
584
  * pull the shared bits into a small util module. */
582
585
 
583
- function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
586
+ function formatPeerTable(
587
+ peers: HeartbeatRow[],
588
+ mySessionId: string,
589
+ relations: Map<string, string> = new Map(),
590
+ ): string {
584
591
  if (peers.length === 0) return "";
585
592
  const nowSec = Math.floor(Date.now() / 1000);
586
593
 
@@ -592,16 +599,20 @@ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
592
599
  }
593
600
  }
594
601
 
595
- type RowExt = HeartbeatRow & { display_files: string[] };
602
+ type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
596
603
  const rows: RowExt[] = peers
597
604
  .filter((p) => (p.kind ?? "unknown") !== "transient")
598
605
  .map((p) => {
599
606
  const folded = fold[p.instance_id ?? ""] ?? [];
600
607
  const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
601
- return { ...p, display_files: display };
608
+ const relation = relations.get(p.instance_id ?? "");
609
+ return { ...p, display_files: display, ...(relation ? { relation } : {}) };
602
610
  });
603
611
 
604
- const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt);
612
+ // Related sessions sort first so a fork parent never hides in "+N more".
613
+ const blocking = rows
614
+ .filter((p) => p.session_id !== mySessionId)
615
+ .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
605
616
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
606
617
 
607
618
  const out: string[] = [];
@@ -625,7 +636,7 @@ function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
625
636
  }
626
637
 
627
638
  function renderSubtable(
628
- rows: Array<HeartbeatRow & { display_files: string[] }>,
639
+ rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
629
640
  header: string,
630
641
  nowSec: number,
631
642
  ): string {
@@ -635,7 +646,10 @@ function renderSubtable(
635
646
  return `${header}\n${first.join("\n")}${overflow}`;
636
647
  }
637
648
 
638
- function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string {
649
+ function formatRow(
650
+ r: HeartbeatRow & { display_files: string[]; relation?: string },
651
+ nowSec: number,
652
+ ): string {
639
653
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
640
654
  const statePart = formatState(r);
641
655
  // Fall back started_at → last_heartbeat; if neither is a valid timestamp,
@@ -646,7 +660,8 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
646
660
  // Prefer a short instance_id over a bare "unknown" so an incomplete row is
647
661
  // still identifiable.
648
662
  const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
649
- return ` - agent-${label}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
663
+ const relation = r.relation ? ` [${r.relation}]` : "";
664
+ return ` - agent-${label}${relation}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
650
665
  }
651
666
 
652
667
  function formatState(r: HeartbeatRow): string {