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
@@ -12,11 +12,10 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { resolveBinName, resolveHooksSetupHint } from "../../config.ts";
14
14
  import { harneryVersion, loadAdapterWiring } from "../../hooks/adapter/wiring.ts";
15
- import { appendEntry } from "../../journal/index.ts";
15
+ import { formatPeerInbox, peerInbox } from "../../inbox/peer-messages.ts";
16
16
  import { readRemoteMachines } from "../../presence/index.ts";
17
- import { drainMailbox, formatMailboxDelivery, type MailboxMessageV1 } from "../mailbox.ts";
18
17
  import { readLiveCoordinationRows } from "../state/live-coordination-view.ts";
19
- import { readForkParent } from "../state/names.ts";
18
+ import { forkRelationLabels, readForkState } from "../state/names.ts";
20
19
  import type { AgentActivity, TaskState } from "../state/session-state.ts";
21
20
 
22
21
  interface HeartbeatRow {
@@ -42,6 +41,8 @@ export interface RenderOpts {
42
41
  /** Adapter label rendered in parens after the self-name (e.g. "Cursor", "Codex").
43
42
  * Claude Code omits it. */
44
43
  platformLabel?: string;
44
+ /** The emitter acknowledges exactly these IDs only after output succeeds. */
45
+ onInboxPrepared?: (messageIds: readonly string[]) => void;
45
46
  }
46
47
 
47
48
  /**
@@ -54,7 +55,17 @@ export function renderSessionContext(opts: RenderOpts): string {
54
55
 
55
56
  // 1. Self-name line + peer table (folded if peers present)
56
57
  const peers = readActivePeers(coordRoot, instanceId);
57
- const localTable = formatPeerTable(peers, sessionId);
58
+ const forkState = readForkState(coordRoot, instanceId);
59
+ const forkParent = forkState?.parent ?? null;
60
+ const parentRow = forkParent
61
+ ? peers.find((peer) => peer.instance_id === forkParent.instance_id)
62
+ : undefined;
63
+ const relations = forkRelationLabels(
64
+ coordRoot,
65
+ instanceId,
66
+ new Set(peers.map((peer) => peer.instance_id ?? "")),
67
+ );
68
+ const localTable = formatPeerTable(peers, sessionId, relations);
58
69
  // Cross-machine presence (ADR 0016): sessions on other machines (advisory).
59
70
  const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
60
71
  if (agentName) {
@@ -64,13 +75,11 @@ export function renderSessionContext(opts: RenderOpts): string {
64
75
  // asserts the parent's name, claims, and task. When recorded fork lineage
65
76
  // (.name-history forked_from) names the parent, say so specifically — the
66
77
  // fork is about to read a transcript full of that exact name. Otherwise
67
- // fall back to the generic clause, which needs no fork detection.
68
- const forkParent = readForkParent(coordRoot, instanceId);
69
- const authority = forkParent?.name
70
- ? `This conversation was branched from agent-${forkParent.name}'s session: earlier context showing agent-${forkParent.name}'s name, task, or file claims belongs to the pre-fork session, not to you.`
71
- : forkParent
72
- ? `This conversation was branched from another session (${forkParent.instance_id}); any agent name in earlier context belongs to that session, not to you.`
73
- : "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
78
+ // fall back to the generic clause, which needs no fork detection. A live
79
+ // parent is the dangerous case (2026-10-07: a fork read its running
80
+ // parent as its own earlier self and offered to close it), so the notice
81
+ // names what the parent is doing and forbids ending it.
82
+ const authority = forkAuthorityClause(forkParent, forkState?.fork === true, parentRow);
74
83
  const selfLine = `You are agent-${agentName}${suffix}. ${authority}`;
75
84
  messages.push(peerTable ? `${selfLine}\n\n${peerTable}` : selfLine);
76
85
  } else if (peerTable) {
@@ -84,15 +93,12 @@ export function renderSessionContext(opts: RenderOpts): string {
84
93
  );
85
94
  }
86
95
 
87
- // 3. Peer messages queued for this name while no session held it. Draining
88
- // here is what makes `agents ping` safe to use on a dormant agent: the sender
89
- // never has to know whether the recipient is running.
90
- if (agentName) {
91
- const delivered = drainMailbox(coordRoot, agentName);
92
- if (delivered.length > 0) {
93
- recordDeliveredMessages(instanceId, delivered);
94
- messages.push(formatMailboxDelivery(coordRoot, delivered));
95
- }
96
+ // 3. Rendering never consumes a message. The native emitter acknowledges the
97
+ // prepared instance-bound batch only after it has flushed the output.
98
+ const delivered = peerInbox(coordRoot).prepareSurface(instanceId);
99
+ if (delivered.length > 0) {
100
+ opts.onInboxPrepared?.(delivered.map(({ message_id }) => message_id));
101
+ messages.push(formatPeerInbox(coordRoot, delivered));
96
102
  }
97
103
 
98
104
  // 4. Council invites
@@ -139,27 +145,6 @@ export function renderSessionContext(opts: RenderOpts): string {
139
145
  return messages.join("\n\n");
140
146
  }
141
147
 
142
- /**
143
- * Write delivered peer messages into the recipient's own journal, so they
144
- * survive context compaction the way any other coordination breadcrumb does.
145
- * A message the sender already journaled (the recipient was live when it was
146
- * sent) is skipped to avoid a duplicate entry.
147
- */
148
- export function recordDeliveredMessages(
149
- instanceId: string,
150
- messages: readonly MailboxMessageV1[],
151
- ): void {
152
- for (const m of messages) {
153
- if (m.journaled) continue;
154
- try {
155
- appendEntry(instanceId, "handoff", `from agent-${m.from_name}: ${m.body}`);
156
- } catch {
157
- // A journal write failure must never cost the delivery itself; the
158
- // message is already in the rendered context the model reads.
159
- }
160
- }
161
- }
162
-
163
148
  /** One-line-per-agent view of sessions on other machines (presence refs). */
164
149
  function formatRemoteMachines(coordRoot: string): string {
165
150
  try {
@@ -194,13 +179,64 @@ function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRo
194
179
  );
195
180
  }
196
181
 
182
+ const DO_NOT_END =
183
+ "Do not end, release, or finalize that session unless the operator explicitly tells you to.";
184
+
185
+ function forkAuthorityClause(
186
+ parent: { instance_id: string; name: string | null } | null,
187
+ fork: boolean,
188
+ parentRow: HeartbeatRow | undefined,
189
+ ): string {
190
+ if (parent?.name && parentRow) {
191
+ const files = parentRow.files_touched ?? [];
192
+ const task = parentRow.task ? ` on "${parentRow.task.slice(0, 80)}"` : "";
193
+ const holds =
194
+ files.length > 0
195
+ ? ` and holds ${files.length} file${files.length === 1 ? "" : "s"} (${fmtFileList(files)})`
196
+ : "";
197
+ const owned =
198
+ files.length > 0
199
+ ? parentRow.task
200
+ ? "That name, task, and those files"
201
+ : "That name and those files"
202
+ : parentRow.task
203
+ ? "That name and task"
204
+ : "That name";
205
+ return (
206
+ `This conversation was forked from agent-${parent.name}, which is still running as a separate session${task}${holds}. ` +
207
+ `${owned} belong${owned === "That name" ? "s" : ""} to agent-${parent.name}, not to you. ${DO_NOT_END}`
208
+ );
209
+ }
210
+ if (parent?.name) {
211
+ return `This conversation was branched from agent-${parent.name}'s session: earlier context showing agent-${parent.name}'s name, task, or file claims belongs to the pre-fork session, not to you.`;
212
+ }
213
+ if (parent) {
214
+ return `This conversation was branched from another session (${parent.instance_id}); any agent name in earlier context belongs to that session, not to you.`;
215
+ }
216
+ if (fork) {
217
+ return `This conversation was forked from another session, which may still be running. Any agent name, task, or file claim in earlier context belongs to that session, not to you. ${DO_NOT_END}`;
218
+ }
219
+ return "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
220
+ }
221
+
222
+ function fmtFileList(files: string[]): string {
223
+ const sorted = [...files].sort();
224
+ return sorted.length <= 3
225
+ ? sorted.join(", ")
226
+ : `${sorted.slice(0, 3).join(", ")}, +${sorted.length - 3} more`;
227
+ }
228
+
197
229
  /**
198
230
  * Renders the peer table as
199
231
  * two subsections: "Other agent groups active" (blocking) and "Your group"
200
232
  * (subagents/siblings, no mutual block). Folds transient subagents' files
201
233
  * into their parent session.
202
234
  */
203
- function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
235
+ function formatPeerTable(
236
+ peers: HeartbeatRow[],
237
+ mySessionId: string,
238
+ relations: Map<string, string> = new Map(),
239
+ ): string {
204
240
  if (peers.length === 0) return "";
205
241
  const nowSec = Math.floor(Date.now() / 1000);
206
242
 
@@ -214,16 +250,20 @@ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
214
250
  }
215
251
 
216
252
  // Build rows with display_files (own files + folded transient files).
217
- type RowExt = HeartbeatRow & { display_files: string[] };
253
+ type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
218
254
  const rows: RowExt[] = peers
219
255
  .filter((p) => (p.kind ?? "unknown") !== "transient")
220
256
  .map((p) => {
221
257
  const folded = fold[p.instance_id ?? ""] ?? [];
222
258
  const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
223
- return { ...p, display_files: display };
259
+ const relation = relations.get(p.instance_id ?? "");
260
+ return { ...p, display_files: display, ...(relation ? { relation } : {}) };
224
261
  });
225
262
 
226
- const blocking = rows.filter((p) => p.session_id !== mySessionId).sort(byStartedAt);
263
+ // Related sessions sort first so the parent never hides in "+N more".
264
+ const blocking = rows
265
+ .filter((p) => p.session_id !== mySessionId)
266
+ .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
227
267
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
228
268
 
229
269
  const sections: string[] = [];
@@ -248,7 +288,7 @@ function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
248
288
  }
249
289
 
250
290
  function renderSubtable(
251
- rows: Array<HeartbeatRow & { display_files: string[] }>,
291
+ rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
252
292
  header: string,
253
293
  nowSec: number,
254
294
  ): string {
@@ -258,7 +298,10 @@ function renderSubtable(
258
298
  return `${header}\n${first.join("\n")}${overflow}`;
259
299
  }
260
300
 
261
- function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number): string {
301
+ function formatRow(
302
+ r: HeartbeatRow & { display_files: string[]; relation?: string },
303
+ nowSec: number,
304
+ ): string {
262
305
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
263
306
  const activity = r.activity ?? "unknown";
264
307
  const lifecycle = r.task_state ?? "active";
@@ -266,7 +309,8 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
266
309
  lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : "";
267
310
  const ageFrom = fmtAge(nowSec - parseIsoSec(r.started_at));
268
311
  const filesPart = fmtFiles(r.display_files);
269
- return ` - agent-${r.name ?? "unknown"}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
312
+ const relation = r.relation ? ` [${r.relation}]` : "";
313
+ return ` - agent-${r.name ?? "unknown"}${relation}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
270
314
  }
271
315
 
272
316
  function fmtFiles(files: string[]): string {
@@ -424,9 +424,9 @@ function sessionNameBlock(coordRoot: string, instanceId: string): VerdictResult
424
424
  } catch {
425
425
  // The ledger evidence still authorizes the block; the cache is UX only.
426
426
  }
427
- const instruction = name
428
- ? `Reproduce it by itself in a fenced code block: ${name}`
429
- : `Run \`${resolveBinName(coordRoot)} agents suggest-name\`, then reproduce its output by itself in a fenced code block.`;
427
+ // The first post-mint reply was already rejected. Repeating its title later
428
+ // cannot change that evidence; the JSON retry result opens a new window.
429
+ const instruction = `Run \`${resolveBinName(coordRoot)} agents suggest-name --json\` as your only tool call to create a fresh verification boundary, then display its suggested_session_name verbatim by itself in a fenced code block${name ? `: ${name}` : "."}`;
430
430
  return {
431
431
  allow: false,
432
432
  exit_code: 2,
@@ -24,6 +24,11 @@ import {
24
24
  writeFileSync,
25
25
  } from "node:fs";
26
26
  import { dirname, join } from "node:path";
27
+ import {
28
+ assertCurrentGenerationV3,
29
+ withGenerationCustodyV3,
30
+ } from "../../events/v3/generation-custody.ts";
31
+ import { liveInstanceIdV3 } from "../../events/v3/live-routing.ts";
27
32
  import { canonicalClaimPath } from "../claim-path.ts";
28
33
  import { type Heartbeat, heartbeatPath, readHeartbeat } from "./heartbeat-reader.ts";
29
34
  import type { TaskState } from "./session-state.ts";
@@ -63,12 +68,15 @@ function mutate(
63
68
  instanceId: string,
64
69
  fn: (hb: Heartbeat) => Heartbeat,
65
70
  ): Heartbeat | null {
66
- const hb = readHeartbeat(coordRoot, instanceId);
67
- if (!hb) return null;
68
- const next = fn(hb);
69
- next.last_heartbeat = nowIsoSeconds();
70
- atomicWrite(heartbeatPath(coordRoot, instanceId), JSON.stringify(next, null, 2));
71
- return next;
71
+ return withGenerationCustodyV3(coordRoot, [liveInstanceIdV3(instanceId)], () => {
72
+ const hb = readHeartbeat(coordRoot, instanceId);
73
+ if (!hb) return null;
74
+ const next = fn(hb);
75
+ next.last_heartbeat = nowIsoSeconds();
76
+ assertCurrentGenerationV3(coordRoot, liveInstanceIdV3(instanceId), hb.v3_generation_id);
77
+ atomicWrite(heartbeatPath(coordRoot, instanceId), JSON.stringify(next, null, 2));
78
+ return next;
79
+ });
72
80
  }
73
81
 
74
82
  export function setTask(coordRoot: string, instanceId: string, task: string): Heartbeat | null {
@@ -100,12 +108,15 @@ export function setIdentityCache(
100
108
  name: string,
101
109
  agentId: string,
102
110
  ): Heartbeat | null {
103
- const heartbeat = readHeartbeat(coordRoot, instanceId);
104
- if (!heartbeat) return null;
105
- heartbeat.name = name;
106
- heartbeat.agent_id = agentId;
107
- atomicWrite(heartbeatPath(coordRoot, instanceId), JSON.stringify(heartbeat, null, 2));
108
- return heartbeat;
111
+ return withGenerationCustodyV3(coordRoot, [liveInstanceIdV3(instanceId)], () => {
112
+ const heartbeat = readHeartbeat(coordRoot, instanceId);
113
+ if (!heartbeat) return null;
114
+ heartbeat.name = name;
115
+ heartbeat.agent_id = agentId;
116
+ assertCurrentGenerationV3(coordRoot, liveInstanceIdV3(instanceId), heartbeat.v3_generation_id);
117
+ atomicWrite(heartbeatPath(coordRoot, instanceId), JSON.stringify(heartbeat, null, 2));
118
+ return heartbeat;
119
+ });
109
120
  }
110
121
 
111
122
  /** Restore the pool-assigned display name on a generation that was
@@ -328,6 +339,12 @@ export function clearCoordinationCache(coordRoot: string, instanceId: string): b
328
339
  }
329
340
 
330
341
  export function healPidmap(coordRoot: string, instanceId: string, pid: number): void {
342
+ withGenerationCustodyV3(coordRoot, [liveInstanceIdV3(instanceId)], () =>
343
+ healPidmapUnderCustody(coordRoot, instanceId, pid),
344
+ );
345
+ }
346
+
347
+ function healPidmapUnderCustody(coordRoot: string, instanceId: string, pid: number): void {
331
348
  const dir = join(coordRoot, ".harnery", "pid-map");
332
349
  mkdirSync(dir, { recursive: true });
333
350
  const hb = readHeartbeat(coordRoot, instanceId);
@@ -5,6 +5,10 @@ import {
5
5
  readCoordinationViewV3,
6
6
  requireAuthoritySafeCoordinationViewV3,
7
7
  } from "../../events/v3/coordination-view.ts";
8
+ import {
9
+ assertCurrentGenerationV3,
10
+ withGenerationCustodyV3,
11
+ } from "../../events/v3/generation-custody.ts";
8
12
  import { liveInstanceIdV3, resolveLiveEventLedgerRouteV3 } from "../../events/v3/live-routing.ts";
9
13
  import { type Heartbeat, readHeartbeat } from "./heartbeat-reader.ts";
10
14
  import {
@@ -64,6 +68,26 @@ function materializeLiveCoordinationHeartbeat(
64
68
  _adapter: Adapter,
65
69
  model: string | undefined,
66
70
  force: boolean,
71
+ ): Heartbeat | null {
72
+ return withGenerationCustodyV3(coordRoot, [liveInstanceIdV3(nativeInstanceId)], () =>
73
+ materializeHeartbeatUnderCustody(
74
+ coordRoot,
75
+ nativeInstanceId,
76
+ nativeSessionId,
77
+ _adapter,
78
+ model,
79
+ force,
80
+ ),
81
+ );
82
+ }
83
+
84
+ function materializeHeartbeatUnderCustody(
85
+ coordRoot: string,
86
+ nativeInstanceId: string,
87
+ nativeSessionId: string,
88
+ _adapter: Adapter,
89
+ model: string | undefined,
90
+ force: boolean,
67
91
  ): Heartbeat | null {
68
92
  const route = resolveLiveEventLedgerRouteV3(coordRoot);
69
93
  if (route.state === "blocked") throw new Error(`event_v3_coordination_view:${route.reason}`);
@@ -133,6 +157,11 @@ function materializeLiveCoordinationHeartbeat(
133
157
  v3_projection_event_id: generation.last_event_id,
134
158
  v3_task_state: generation.task_state === "set" ? "set" : "cleared",
135
159
  };
160
+ assertCurrentGenerationV3(
161
+ coordRoot,
162
+ liveInstanceIdV3(nativeInstanceId),
163
+ generation.generation_id,
164
+ );
136
165
  writeHeartbeatCache(coordRoot, nativeInstanceId, materialized);
137
166
  return materialized;
138
167
  }
@@ -331,13 +331,20 @@ export interface NameHistoryRow {
331
331
  ts: string;
332
332
  /** Durable persona UUID. Present after `agents identity assume`. */
333
333
  agent_id?: string;
334
- /** Audit marker distinguishing an explicit role adoption from pool assignment. */
335
- source?: "pool" | "identity.assume";
334
+ /** Audit marker distinguishing an explicit role adoption from pool assignment,
335
+ * and a lineage row appended after the name was already assigned. */
336
+ source?: "pool" | "identity.assume" | "fork.detected";
336
337
  previous_name?: string;
337
338
  /** Instance this session was forked/branched from (recorded fork lineage).
338
339
  * Stamped only on the row that first assigns this instance, when the adapter
339
340
  * layer detected or supplied a parent conversation. */
340
341
  forked_from?: string;
342
+ /** The adapter declared or inherited-history proved this instance is a fork,
343
+ * even when its parent could not be resolved yet. */
344
+ fork?: true;
345
+ /** Fork detection has run against this instance's transcript; the tool-path
346
+ * heal skips instances carrying it so the scan runs once per instance. */
347
+ fork_checked?: true;
341
348
  }
342
349
 
343
350
  function atomicWrite(path: string, content: string): void {
@@ -516,7 +523,13 @@ export function assignName(
516
523
  coordRoot: string,
517
524
  instanceId: string,
518
525
  kind: NameKind,
519
- opts: { freshnessSecs: number; forkedFrom?: string; nowMs?: number },
526
+ opts: {
527
+ freshnessSecs: number;
528
+ forkedFrom?: string;
529
+ fork?: boolean;
530
+ forkChecked?: boolean;
531
+ nowMs?: number;
532
+ },
520
533
  ): string {
521
534
  // Check 1: existing history row → original name. A resume re-enters here,
522
535
  // which also makes fork stamping naturally idempotent: lineage lands only on
@@ -558,13 +571,111 @@ export function assignName(
558
571
  kind,
559
572
  source: "pool",
560
573
  ...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
574
+ ...(opts.fork || (forkedFrom && forkedFrom !== instanceId) ? { fork: true as const } : {}),
575
+ ...(opts.forkChecked ? { fork_checked: true as const } : {}),
561
576
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
562
577
  });
563
578
  return name;
564
579
  }
565
580
 
581
+ export interface ForkState {
582
+ /** True when the instance is a recorded fork, parent known or not. */
583
+ fork: boolean;
584
+ /** Detection already ran for this instance. */
585
+ checked: boolean;
586
+ parent: { instance_id: string; name: string | null } | null;
587
+ }
588
+
589
+ /** Recorded fork state for <instanceId>, or null when it has no history row. */
590
+ export function readForkState(coordRoot: string, instanceId: string): ForkState | null {
591
+ const history = readHistory(coordRoot);
592
+ let seen = false;
593
+ let fork = false;
594
+ let checked = false;
595
+ let parentId: string | undefined;
596
+ for (const row of history) {
597
+ if (row.instance_id !== instanceId) continue;
598
+ seen = true;
599
+ if (row.fork || row.forked_from) fork = true;
600
+ if (row.fork_checked) checked = true;
601
+ if (row.forked_from && row.forked_from !== instanceId) parentId = row.forked_from;
602
+ }
603
+ if (!seen) return null;
604
+ const parent = parentId
605
+ ? { instance_id: parentId, name: resolveName(coordRoot, parentId)?.name ?? null }
606
+ : null;
607
+ return { fork, checked, parent };
608
+ }
609
+
610
+ /**
611
+ * Record fork detection that ran after the instance was already named (the
612
+ * tool-path heal). Appends a copy of the latest identity row, so the name,
613
+ * kind, and persona are unchanged, plus `fork_checked` and any lineage found.
614
+ * A no-op for an instance with no history row or one already checked.
615
+ */
616
+ export function recordForkDetection(
617
+ coordRoot: string,
618
+ instanceId: string,
619
+ detection: { fork: boolean; forkedFrom?: string },
620
+ ): boolean {
621
+ const history = readHistory(coordRoot);
622
+ let latest: NameHistoryRow | undefined;
623
+ for (const row of history) {
624
+ if (row.instance_id !== instanceId) continue;
625
+ if (row.fork_checked) return false;
626
+ latest = row;
627
+ }
628
+ if (!latest) return false;
629
+ const forkedFrom =
630
+ detection.forkedFrom && detection.forkedFrom !== instanceId ? detection.forkedFrom : undefined;
631
+ appendHistory(coordRoot, {
632
+ instance_id: instanceId,
633
+ name: latest.name,
634
+ kind: latest.kind,
635
+ ...(latest.agent_id ? { agent_id: latest.agent_id } : {}),
636
+ source: "fork.detected",
637
+ ...(forkedFrom ? { forked_from: forkedFrom } : {}),
638
+ ...(detection.fork || forkedFrom || latest.fork ? { fork: true as const } : {}),
639
+ fork_checked: true,
640
+ ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
641
+ });
642
+ return true;
643
+ }
644
+
645
+ /**
646
+ * Peer labels for recorded fork relations, keyed by instance id: this
647
+ * session's fork parent and its direct forks. `liveIds` marks which of them
648
+ * are running, so a fork can tell its live parent from its own earlier self.
649
+ */
650
+ export function forkRelationLabels(
651
+ coordRoot: string,
652
+ instanceId: string,
653
+ liveIds: Set<string>,
654
+ ): Map<string, string> {
655
+ const out = new Map<string, string>();
656
+ const parent = readForkParent(coordRoot, instanceId);
657
+ if (parent) {
658
+ out.set(
659
+ parent.instance_id,
660
+ liveIds.has(parent.instance_id) ? "your fork parent, live" : "your fork parent",
661
+ );
662
+ }
663
+ for (const child of readForkChildren(coordRoot, instanceId)) out.set(child, "your fork");
664
+ return out;
665
+ }
666
+
667
+ /** Instance ids recorded as direct forks of <instanceId>. */
668
+ export function readForkChildren(coordRoot: string, instanceId: string): Set<string> {
669
+ const out = new Set<string>();
670
+ for (const row of readHistory(coordRoot)) {
671
+ if (row.forked_from === instanceId && row.instance_id !== instanceId) out.add(row.instance_id);
672
+ }
673
+ return out;
674
+ }
675
+
566
676
  /** One step of recorded fork lineage: the latest row for <instanceId> that
567
- * carries `forked_from` (latest-row-wins, matching resolveName). */
677
+ * carries `forked_from`. Later rows without lineage (an identity assumption)
678
+ * do not erase it: a session never stops being a fork. */
568
679
  export function readForkParent(
569
680
  coordRoot: string,
570
681
  instanceId: string,
@@ -572,8 +683,8 @@ export function readForkParent(
572
683
  const history = readHistory(coordRoot);
573
684
  for (let i = history.length - 1; i >= 0; i--) {
574
685
  const row = history[i]!;
575
- if (row.instance_id !== instanceId) continue;
576
- if (!row.forked_from) return null;
686
+ if (row.instance_id !== instanceId || !row.forked_from) continue;
687
+ if (row.forked_from === instanceId) return null;
577
688
  const parent = resolveName(coordRoot, row.forked_from);
578
689
  return { instance_id: row.forked_from, name: parent?.name ?? null };
579
690
  }
@@ -24,6 +24,8 @@ import {
24
24
  writeFileSync,
25
25
  } from "node:fs";
26
26
  import { dirname, join } from "node:path";
27
+ import { withGenerationCustodyV3 } from "../../events/v3/generation-custody.ts";
28
+ import { liveInstanceIdV3 } from "../../events/v3/live-routing.ts";
27
29
  import { checkPidToken, processStartToken } from "./proc-start.ts";
28
30
 
29
31
  /**
@@ -126,6 +128,17 @@ export function writePidmapRow(
126
128
  pid: number,
127
129
  instanceId: string,
128
130
  platform: string,
131
+ ): void {
132
+ withGenerationCustodyV3(coordRoot, [liveInstanceIdV3(instanceId)], () =>
133
+ writePidmapRowUnderCustody(coordRoot, pid, instanceId, platform),
134
+ );
135
+ }
136
+
137
+ function writePidmapRowUnderCustody(
138
+ coordRoot: string,
139
+ pid: number,
140
+ instanceId: string,
141
+ platform: string,
129
142
  ): void {
130
143
  const dir = join(coordRoot, ".harnery", "pid-map");
131
144
  const path = join(dir, String(pid));
@@ -160,6 +160,63 @@ export function processStartToken(pid: number): string | null {
160
160
  return startTokenProbe() === "procfs" ? procfsStartToken(pid) : psStartToken(pid);
161
161
  }
162
162
 
163
+ export type ProcessGenerationVerdict = "same" | "different" | "unknown";
164
+
165
+ type ReadProcessStartToken = (pid: number) => string | null;
166
+
167
+ /** A missing or unreadable token never establishes process identity. */
168
+ export function verifyProcessGeneration(
169
+ pid: number,
170
+ recordedToken: string | null | undefined,
171
+ readToken: ReadProcessStartToken = processStartToken,
172
+ ): ProcessGenerationVerdict {
173
+ if (
174
+ !Number.isInteger(pid) ||
175
+ pid <= 0 ||
176
+ typeof recordedToken !== "string" ||
177
+ !recordedToken.trim()
178
+ )
179
+ return "unknown";
180
+ // A bootless Linux token cannot identify a process across machine boots.
181
+ if (/^l\d+$/.test(recordedToken)) return "unknown";
182
+ try {
183
+ const currentToken = readToken(pid);
184
+ if (typeof currentToken !== "string" || !currentToken.trim()) return "unknown";
185
+ if (/^l\d+$/.test(currentToken)) return "unknown";
186
+ return currentToken === recordedToken ? "same" : "different";
187
+ } catch {
188
+ return "unknown";
189
+ }
190
+ }
191
+
192
+ export interface ProcessGenerationSignalDependencies {
193
+ readToken?: ReadProcessStartToken;
194
+ kill?: (pid: number, signal: NodeJS.Signals | number) => unknown;
195
+ }
196
+
197
+ /**
198
+ * Recheck the process generation immediately before sending the signal.
199
+ *
200
+ * The process can still exit and its PID can be reused between the token read
201
+ * and the signal. Eliminating that race requires a generation-bound OS handle.
202
+ */
203
+ export function signalIfSameGeneration(
204
+ pid: number,
205
+ recordedToken: string | null | undefined,
206
+ signal: NodeJS.Signals | number,
207
+ dependencies: ProcessGenerationSignalDependencies = {},
208
+ ): { sent: boolean; verdict: ProcessGenerationVerdict } {
209
+ const kill = dependencies.kill ?? process.kill;
210
+ const verdict = verifyProcessGeneration(pid, recordedToken, dependencies.readToken);
211
+ if (verdict !== "same") return { sent: false, verdict };
212
+ try {
213
+ kill(pid, signal);
214
+ return { sent: true, verdict };
215
+ } catch {
216
+ return { sent: false, verdict };
217
+ }
218
+ }
219
+
163
220
  /**
164
221
  * Do two tokens describe the same run?
165
222
  *