harnery 0.43.0 → 0.45.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 (323) hide show
  1. package/README.md +3 -3
  2. package/dist/commander.js +1 -0
  3. package/dist/commands/agents.d.ts.map +1 -1
  4. package/dist/commands/agents.js +41 -48
  5. package/dist/commands/browse.d.ts.map +1 -1
  6. package/dist/commands/browse.js +38 -11
  7. package/dist/commands/diagnostics.d.ts.map +1 -1
  8. package/dist/commands/diagnostics.js +10 -0
  9. package/dist/commands/doctor.d.ts +12 -0
  10. package/dist/commands/doctor.d.ts.map +1 -1
  11. package/dist/commands/doctor.js +22 -7
  12. package/dist/commands/files.d.ts +2 -2
  13. package/dist/commands/files.d.ts.map +1 -1
  14. package/dist/commands/files.js +7 -4
  15. package/dist/commands/governor.js +22 -10
  16. package/dist/commands/inbox.d.ts +1 -1
  17. package/dist/commands/inbox.d.ts.map +1 -1
  18. package/dist/commands/inbox.js +55 -2
  19. package/dist/commands/rm.d.ts.map +1 -1
  20. package/dist/commands/rm.js +28 -4
  21. package/dist/commands/servers.d.ts.map +1 -1
  22. package/dist/commands/servers.js +26 -17
  23. package/dist/commands/supervisor.d.ts.map +1 -1
  24. package/dist/commands/supervisor.js +7 -2
  25. package/dist/commands/toc.d.ts +14 -0
  26. package/dist/commands/toc.d.ts.map +1 -1
  27. package/dist/commands/toc.js +7 -4
  28. package/dist/commands/tunnel.d.ts +15 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +453 -153
  31. package/dist/commands/turn-rollback.d.ts +5 -0
  32. package/dist/commands/turn-rollback.d.ts.map +1 -0
  33. package/dist/commands/turn-rollback.js +76 -0
  34. package/dist/commands/workflow.d.ts.map +1 -1
  35. package/dist/commands/workflow.js +68 -30
  36. package/dist/core/agents/coord-client.d.ts.map +1 -1
  37. package/dist/core/agents/coord-client.js +18 -0
  38. package/dist/core/agents/live-authority-v3.d.ts +1 -0
  39. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  40. package/dist/core/agents/live-authority-v3.js +11 -0
  41. package/dist/core/agents/live-lifecycle-v3.d.ts +4 -2
  42. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  43. package/dist/core/agents/live-lifecycle-v3.js +28 -7
  44. package/dist/core/agents/mailbox.d.ts +9 -8
  45. package/dist/core/agents/mailbox.d.ts.map +1 -1
  46. package/dist/core/agents/mailbox.js +24 -17
  47. package/dist/core/agents/render/prompt-context.d.ts +2 -0
  48. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/prompt-context.js +8 -12
  50. package/dist/core/agents/render/session-context.d.ts +2 -8
  51. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  52. package/dist/core/agents/render/session-context.js +7 -30
  53. package/dist/core/agents/rules/stop-hook.js +3 -3
  54. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  55. package/dist/core/agents/state/heartbeat-writer.js +25 -14
  56. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  57. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  58. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  59. package/dist/core/agents/state/pidmap.js +5 -0
  60. package/dist/core/agents/state/proc-start.d.ts +19 -1
  61. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  62. package/dist/core/agents/state/proc-start.js +46 -1
  63. package/dist/core/agents/state/process-handle.d.ts +26 -0
  64. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  65. package/dist/core/agents/state/process-handle.js +121 -0
  66. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  67. package/dist/core/agents/state/stale-sweep.js +180 -70
  68. package/dist/core/agents/state/unchanged-file.d.ts +44 -0
  69. package/dist/core/agents/state/unchanged-file.d.ts.map +1 -0
  70. package/dist/core/agents/state/unchanged-file.js +390 -0
  71. package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
  72. package/dist/core/artifacts/delivery-card.js +2 -1
  73. package/dist/core/context/index.d.ts +22 -10
  74. package/dist/core/context/index.d.ts.map +1 -1
  75. package/dist/core/context/index.js +381 -119
  76. package/dist/core/context/observation.d.ts +26 -0
  77. package/dist/core/context/observation.d.ts.map +1 -0
  78. package/dist/core/context/observation.js +11 -0
  79. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  80. package/dist/core/diagnostics/bundle.js +25 -14
  81. package/dist/core/diagnostics/path-identity.d.ts +3 -0
  82. package/dist/core/diagnostics/path-identity.d.ts.map +1 -0
  83. package/dist/core/diagnostics/path-identity.js +12 -0
  84. package/dist/core/diagnostics/sanitize.d.ts.map +1 -1
  85. package/dist/core/diagnostics/sanitize.js +4 -3
  86. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  87. package/dist/core/events/v3/authority-outbox.js +29 -0
  88. package/dist/core/events/v3/generation-custody.d.ts +5 -0
  89. package/dist/core/events/v3/generation-custody.d.ts.map +1 -0
  90. package/dist/core/events/v3/generation-custody.js +50 -0
  91. package/dist/core/events/v3/producers/coordination-recorder.d.ts +1 -0
  92. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  93. package/dist/core/events/v3/producers/coordination-recorder.js +7 -0
  94. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  95. package/dist/core/events/v3/producers/recorder.js +77 -64
  96. package/dist/core/governor/planning.d.ts.map +1 -1
  97. package/dist/core/governor/planning.js +14 -1
  98. package/dist/core/governor/service-read.d.ts +10 -2
  99. package/dist/core/governor/service-read.d.ts.map +1 -1
  100. package/dist/core/governor/service-read.js +105 -22
  101. package/dist/core/governor/service.d.ts +20 -2
  102. package/dist/core/governor/service.d.ts.map +1 -1
  103. package/dist/core/governor/service.js +72 -47
  104. package/dist/core/hooks/cli.js +33 -5
  105. package/dist/core/inbox/contract.d.ts +2 -1
  106. package/dist/core/inbox/contract.d.ts.map +1 -1
  107. package/dist/core/inbox/index.d.ts +2 -0
  108. package/dist/core/inbox/index.d.ts.map +1 -1
  109. package/dist/core/inbox/index.js +2 -0
  110. package/dist/core/inbox/peer-messages.d.ts +37 -0
  111. package/dist/core/inbox/peer-messages.d.ts.map +1 -0
  112. package/dist/core/inbox/peer-messages.js +72 -0
  113. package/dist/core/inbox/service.d.ts +8 -1
  114. package/dist/core/inbox/service.d.ts.map +1 -1
  115. package/dist/core/inbox/service.js +67 -21
  116. package/dist/core/inbox/targets.d.ts +22 -0
  117. package/dist/core/inbox/targets.d.ts.map +1 -0
  118. package/dist/core/inbox/targets.js +108 -0
  119. package/dist/core/inbox/watcher.js +7 -3
  120. package/dist/core/journal/index.d.ts.map +1 -1
  121. package/dist/core/journal/index.js +17 -2
  122. package/dist/core/resources/contract.d.ts +3 -0
  123. package/dist/core/resources/contract.d.ts.map +1 -1
  124. package/dist/core/resources/service-status.d.ts +14 -1
  125. package/dist/core/resources/service-status.d.ts.map +1 -1
  126. package/dist/core/resources/service-status.js +23 -13
  127. package/dist/core/resources/service.d.ts +7 -13
  128. package/dist/core/resources/service.d.ts.map +1 -1
  129. package/dist/core/resources/service.js +34 -91
  130. package/dist/core/resources/status.d.ts +2 -5
  131. package/dist/core/resources/status.d.ts.map +1 -1
  132. package/dist/core/resources/status.js +36 -7
  133. package/dist/core/semantic/service.d.ts.map +1 -1
  134. package/dist/core/semantic/service.js +2 -9
  135. package/dist/core/servers/index.d.ts +34 -22
  136. package/dist/core/servers/index.d.ts.map +1 -1
  137. package/dist/core/servers/index.js +297 -144
  138. package/dist/core/servers/tunnels.d.ts +7 -7
  139. package/dist/core/servers/tunnels.d.ts.map +1 -1
  140. package/dist/core/servers/tunnels.js +47 -10
  141. package/dist/core/supervisor/contract.d.ts +12 -0
  142. package/dist/core/supervisor/contract.d.ts.map +1 -1
  143. package/dist/core/supervisor/explanations.d.ts.map +1 -1
  144. package/dist/core/supervisor/explanations.js +2 -1
  145. package/dist/core/supervisor/findings.d.ts.map +1 -1
  146. package/dist/core/supervisor/findings.js +54 -9
  147. package/dist/core/supervisor/service.d.ts +7 -1
  148. package/dist/core/supervisor/service.d.ts.map +1 -1
  149. package/dist/core/supervisor/service.js +59 -29
  150. package/dist/core/supervisor/services.d.ts.map +1 -1
  151. package/dist/core/supervisor/services.js +22 -1
  152. package/dist/core/supervisor/status.d.ts +17 -2
  153. package/dist/core/supervisor/status.d.ts.map +1 -1
  154. package/dist/core/supervisor/status.js +70 -12
  155. package/dist/core/supervisor/timeline.d.ts.map +1 -1
  156. package/dist/core/supervisor/timeline.js +2 -1
  157. package/dist/core/turn-rollback/content.d.ts +53 -0
  158. package/dist/core/turn-rollback/content.d.ts.map +1 -0
  159. package/dist/core/turn-rollback/content.js +396 -0
  160. package/dist/core/turn-rollback/index.d.ts +72 -0
  161. package/dist/core/turn-rollback/index.d.ts.map +1 -0
  162. package/dist/core/turn-rollback/index.js +443 -0
  163. package/dist/core/work/state.js +7 -7
  164. package/dist/core/workflow/engine.d.ts.map +1 -1
  165. package/dist/core/workflow/engine.js +105 -32
  166. package/dist/core/workflow/failure-evidence.d.ts +16 -0
  167. package/dist/core/workflow/failure-evidence.d.ts.map +1 -0
  168. package/dist/core/workflow/failure-evidence.js +114 -0
  169. package/dist/core/workflow/index.d.ts +5 -3
  170. package/dist/core/workflow/index.d.ts.map +1 -1
  171. package/dist/core/workflow/index.js +3 -2
  172. package/dist/core/workflow/live-session-v3.d.ts +6 -0
  173. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  174. package/dist/core/workflow/live-session-v3.js +126 -5
  175. package/dist/core/workflow/proof.d.ts +5 -5
  176. package/dist/core/workflow/proof.d.ts.map +1 -1
  177. package/dist/core/workflow/proof.js +115 -20
  178. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  179. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  180. package/dist/core/workflow/recovery-audit.js +116 -0
  181. package/dist/core/workflow/run-state.d.ts +20 -1
  182. package/dist/core/workflow/run-state.d.ts.map +1 -1
  183. package/dist/core/workflow/run-state.js +141 -32
  184. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  185. package/dist/core/workflow/spawn-claude.js +19 -0
  186. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  187. package/dist/core/workflow/spawn-codex.js +13 -0
  188. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  189. package/dist/core/workflow/spawn-cursor.js +19 -0
  190. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  191. package/dist/core/workflow/spawn-opencode.js +14 -0
  192. package/dist/core/workflow/types.d.ts +64 -17
  193. package/dist/core/workflow/types.d.ts.map +1 -1
  194. package/dist/core/workflow/workspaces/index.d.ts +0 -1
  195. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  196. package/dist/core/workflow/workspaces/index.js +0 -1
  197. package/dist/core/workflow/workspaces/leases.d.ts +3 -1
  198. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  199. package/dist/core/workflow/workspaces/leases.js +30 -16
  200. package/dist/core/workflow/workspaces/local-git.d.ts +6 -2
  201. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  202. package/dist/core/workflow/workspaces/local-git.js +328 -8
  203. package/dist/core/workflow/workspaces/ownership.d.ts +11 -0
  204. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -0
  205. package/dist/core/workflow/workspaces/ownership.js +194 -0
  206. package/dist/core/workflow/workspaces/reclaim.d.ts +40 -0
  207. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  208. package/dist/core/workflow/workspaces/reclaim.js +14 -0
  209. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  210. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  211. package/dist/core/workflow/workspaces/recovery.js +42 -10
  212. package/dist/core/workflow/workspaces/state.d.ts +1 -0
  213. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  214. package/dist/core/workflow/workspaces/state.js +21 -1
  215. package/dist/lib/browser/windows-chrome.d.ts +100 -12
  216. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  217. package/dist/lib/browser/windows-chrome.js +262 -73
  218. package/dist/lib/docs-links.d.ts.map +1 -1
  219. package/dist/lib/docs-links.js +45 -2
  220. package/dist/lib/guarded-remove.d.ts +7 -0
  221. package/dist/lib/guarded-remove.d.ts.map +1 -1
  222. package/dist/lib/guarded-remove.js +106 -12
  223. package/dist/lib/identities/assume.d.ts.map +1 -1
  224. package/dist/lib/identities/assume.js +4 -0
  225. package/dist/lib/tunnel/state.d.ts +13 -6
  226. package/dist/lib/tunnel/state.d.ts.map +1 -1
  227. package/dist/lib/tunnel/state.js +58 -12
  228. package/dist/lib/zip/index.d.ts +41 -0
  229. package/dist/lib/zip/index.d.ts.map +1 -0
  230. package/dist/lib/zip/index.js +398 -0
  231. package/package.json +12 -1
  232. package/src/commander.ts +10 -0
  233. package/src/commands/agents.ts +54 -63
  234. package/src/commands/browse.ts +50 -14
  235. package/src/commands/diagnostics.ts +10 -0
  236. package/src/commands/doctor.ts +26 -7
  237. package/src/commands/files.ts +13 -5
  238. package/src/commands/governor.ts +23 -10
  239. package/src/commands/inbox.ts +52 -2
  240. package/src/commands/rm.ts +43 -4
  241. package/src/commands/servers.ts +27 -26
  242. package/src/commands/supervisor.ts +7 -2
  243. package/src/commands/toc.ts +14 -6
  244. package/src/commands/tunnel.ts +533 -166
  245. package/src/commands/turn-rollback.ts +111 -0
  246. package/src/commands/workflow.ts +89 -30
  247. package/src/core/agents/coord-client.ts +20 -0
  248. package/src/core/agents/live-authority-v3.ts +17 -0
  249. package/src/core/agents/live-lifecycle-v3.ts +52 -14
  250. package/src/core/agents/mailbox.ts +26 -17
  251. package/src/core/agents/render/prompt-context.ts +10 -12
  252. package/src/core/agents/render/session-context.ts +9 -32
  253. package/src/core/agents/rules/stop-hook.ts +3 -3
  254. package/src/core/agents/state/heartbeat-writer.ts +29 -12
  255. package/src/core/agents/state/live-coordination-writer.ts +29 -0
  256. package/src/core/agents/state/pidmap.ts +13 -0
  257. package/src/core/agents/state/proc-start.ts +62 -2
  258. package/src/core/agents/state/process-handle.ts +165 -0
  259. package/src/core/agents/state/stale-sweep.ts +247 -82
  260. package/src/core/agents/state/unchanged-file.ts +456 -0
  261. package/src/core/artifacts/delivery-card.ts +7 -1
  262. package/src/core/context/index.ts +446 -136
  263. package/src/core/context/observation.ts +29 -0
  264. package/src/core/diagnostics/bundle.ts +23 -18
  265. package/src/core/diagnostics/path-identity.ts +15 -0
  266. package/src/core/diagnostics/sanitize.ts +4 -3
  267. package/src/core/events/v3/authority-outbox.ts +56 -0
  268. package/src/core/events/v3/generation-custody.ts +59 -0
  269. package/src/core/events/v3/producers/coordination-recorder.ts +15 -0
  270. package/src/core/events/v3/producers/recorder.ts +94 -68
  271. package/src/core/governor/planning.ts +20 -8
  272. package/src/core/governor/service-read.ts +128 -20
  273. package/src/core/governor/service.ts +119 -42
  274. package/src/core/hooks/cli.ts +33 -5
  275. package/src/core/inbox/contract.ts +2 -1
  276. package/src/core/inbox/index.ts +2 -0
  277. package/src/core/inbox/peer-messages.ts +94 -0
  278. package/src/core/inbox/service.ts +88 -22
  279. package/src/core/inbox/targets.ts +148 -0
  280. package/src/core/inbox/watcher.ts +8 -9
  281. package/src/core/journal/index.ts +19 -2
  282. package/src/core/resources/contract.ts +4 -0
  283. package/src/core/resources/service-status.ts +35 -12
  284. package/src/core/resources/service.ts +57 -95
  285. package/src/core/resources/status.ts +57 -8
  286. package/src/core/semantic/service.ts +2 -8
  287. package/src/core/servers/index.ts +376 -141
  288. package/src/core/servers/tunnels.ts +57 -13
  289. package/src/core/supervisor/contract.ts +13 -0
  290. package/src/core/supervisor/explanations.ts +4 -1
  291. package/src/core/supervisor/findings.ts +68 -10
  292. package/src/core/supervisor/service.ts +87 -26
  293. package/src/core/supervisor/services.ts +26 -8
  294. package/src/core/supervisor/status.ts +89 -12
  295. package/src/core/supervisor/timeline.ts +2 -1
  296. package/src/core/turn-rollback/content.ts +499 -0
  297. package/src/core/turn-rollback/index.ts +580 -0
  298. package/src/core/work/state.ts +7 -7
  299. package/src/core/workflow/engine.ts +131 -49
  300. package/src/core/workflow/failure-evidence.ts +141 -0
  301. package/src/core/workflow/index.ts +12 -2
  302. package/src/core/workflow/live-session-v3.ts +146 -5
  303. package/src/core/workflow/proof.ts +134 -26
  304. package/src/core/workflow/recovery-audit.ts +181 -0
  305. package/src/core/workflow/run-state.ts +165 -34
  306. package/src/core/workflow/spawn-claude.ts +18 -0
  307. package/src/core/workflow/spawn-codex.ts +13 -0
  308. package/src/core/workflow/spawn-cursor.ts +18 -0
  309. package/src/core/workflow/spawn-opencode.ts +14 -0
  310. package/src/core/workflow/types.ts +51 -17
  311. package/src/core/workflow/workspaces/index.ts +0 -1
  312. package/src/core/workflow/workspaces/leases.ts +48 -17
  313. package/src/core/workflow/workspaces/local-git.ts +388 -7
  314. package/src/core/workflow/workspaces/ownership.ts +232 -0
  315. package/src/core/workflow/workspaces/reclaim.ts +62 -0
  316. package/src/core/workflow/workspaces/recovery.ts +45 -10
  317. package/src/core/workflow/workspaces/state.ts +20 -0
  318. package/src/lib/browser/windows-chrome.ts +346 -86
  319. package/src/lib/docs-links.ts +49 -2
  320. package/src/lib/guarded-remove.ts +132 -14
  321. package/src/lib/identities/assume.ts +4 -0
  322. package/src/lib/tunnel/state.ts +70 -15
  323. package/src/lib/zip/index.ts +483 -0
@@ -14,21 +14,25 @@
14
14
  * for `gc` once its starting agent has ended or been unobserved for a long
15
15
  * time AND nobody has used the server for the idle window.
16
16
  *
17
- * Fail-closed rules: a record whose pid was recycled is treated as dead, never
18
- * signalled; an unreadable coordination view makes every owner `unknown`,
17
+ * Fail-closed rules: a record whose pid was recycled is never signalled;
18
+ * an unreadable start token or coordination view is `unknown`,
19
19
  * which `gc` never stops; a record without an owner is never stopped
20
20
  * automatically.
21
21
  */
22
22
 
23
- import { spawnSync } from "node:child_process";
24
- import { createHash } from "node:crypto";
23
+ import { createHash, randomUUID } from "node:crypto";
25
24
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
26
25
  import { hostname } from "node:os";
27
26
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
28
- import { listStates } from "../../lib/tunnel/state.ts";
29
27
  import { resolveCoordRoot } from "../agents/coord-client.ts";
30
28
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
31
- import { checkPidToken, processStartToken } from "../agents/state/proc-start.ts";
29
+ import {
30
+ type ProcessGenerationSignalDependencies,
31
+ type ProcessGenerationVerdict,
32
+ processStartToken,
33
+ signalIfSameGeneration,
34
+ verifyProcessGeneration,
35
+ } from "../agents/state/proc-start.ts";
32
36
  import { serverPortRange } from "../config.ts";
33
37
  import {
34
38
  readCoordinationViewV3,
@@ -37,6 +41,7 @@ import {
37
41
  import { liveInstanceIdV3 } from "../events/v3/live-route-observer.ts";
38
42
  import { resolveOwner } from "../hooks/resolve/owner.ts";
39
43
  import { writePrivateJsonAtomic } from "../storage/atomic-json.ts";
44
+ import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
40
45
  import {
41
46
  ancestry,
42
47
  establishedConnectionCounts,
@@ -47,8 +52,13 @@ import {
47
52
  type ScanSupport,
48
53
  scanSupport,
49
54
  } from "./net.ts";
50
- import { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
51
55
 
56
+ export {
57
+ processStartToken,
58
+ signalIfSameGeneration,
59
+ verifyProcessGeneration,
60
+ } from "../agents/state/proc-start.ts";
61
+ export { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
52
62
  export {
53
63
  ancestry,
54
64
  establishedConnectionCounts,
@@ -64,9 +74,9 @@ export {
64
74
  readProcessInfo,
65
75
  scanSupport,
66
76
  } from "./net.ts";
67
- export { tunnelServerId, tunnelServerInput } from "./tunnels.ts";
77
+ export { createTunnelReadinessVerifier, tunnelServerId, tunnelServerInput } from "./tunnels.ts";
68
78
 
69
- export const SERVER_RECORD_SCHEMA_VERSION = 1;
79
+ export const SERVER_RECORD_SCHEMA_VERSION = 2;
70
80
  export const SERVER_DEFAULT_IDLE_HOURS = 2;
71
81
  export const SERVER_DEFAULT_OWNER_STALE_HOURS = 24;
72
82
  const TOUCH_INTERVAL_MS = 60_000;
@@ -76,6 +86,7 @@ export type ServerKind = "service" | "session";
76
86
  export interface ServerRecord {
77
87
  schema_version: typeof SERVER_RECORD_SCHEMA_VERSION;
78
88
  id: string;
89
+ registration_id: string;
79
90
  kind: ServerKind;
80
91
  /** Free-form family name: `preview`, `tunnel`, `dashboard`, ... */
81
92
  type: string;
@@ -87,13 +98,13 @@ export interface ServerRecord {
87
98
  start_token?: string;
88
99
  /** Further processes that belong to this server (helpers, children). */
89
100
  pids?: number[];
101
+ /** Generation witnesses captured when the further processes registered. */
102
+ pids_start_tokens?: Record<string, string>;
90
103
  host: string;
91
104
  /** What the server serves, usually a directory. */
92
105
  scope?: string;
93
106
  cwd?: string;
94
107
  log?: string;
95
- /** Graceful stop command; signals are the fallback. */
96
- stop_argv?: string[];
97
108
  owner?: { instance_id: string; name?: string };
98
109
  started_at: string;
99
110
  registered_at: string;
@@ -108,11 +119,12 @@ export interface RegisterServerInput {
108
119
  url?: string;
109
120
  port?: number;
110
121
  pid?: number;
122
+ /** Preserve an observed witness when adopting, rather than probing a new generation. */
123
+ start_token?: string;
111
124
  pids?: number[];
112
125
  scope?: string;
113
126
  cwd?: string;
114
127
  log?: string;
115
- stop_argv?: string[];
116
128
  started_at?: string;
117
129
  /** `null` records no owner; omitted resolves the calling agent session. */
118
130
  owner?: { instance_id: string; name?: string } | null;
@@ -122,7 +134,7 @@ export interface ServerOptions {
122
134
  coordRoot?: string | null;
123
135
  }
124
136
 
125
- export type ServerState = "running" | "dead" | "other-host";
137
+ export type ServerState = "running" | "dead" | "other-host" | "unknown";
126
138
  export type OwnerState = "live" | "abandoned" | "ended" | "unknown" | "none";
127
139
 
128
140
  export interface ServerView {
@@ -136,6 +148,8 @@ export interface ServerView {
136
148
 
137
149
  export interface UnregisteredListener {
138
150
  pid: number;
151
+ /** Captured during the scan, before a stop can be authorized. */
152
+ start_token?: string;
139
153
  port: number;
140
154
  address: string;
141
155
  command: string;
@@ -203,34 +217,57 @@ export function registerServer(
203
217
  )
204
218
  throw new Error(`invalid server port: ${input.port}`);
205
219
  const id = input.id ?? serverId(input.type, input.scope);
206
- const now = new Date().toISOString();
207
- const startToken = processStartToken(pid);
208
- const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
209
- const record: ServerRecord = {
210
- schema_version: SERVER_RECORD_SCHEMA_VERSION,
211
- id,
212
- kind: input.kind,
213
- type: input.type,
214
- label: input.label ?? id,
215
- ...(input.url ? { url: input.url } : {}),
216
- ...(input.port ? { port: input.port } : {}),
217
- pid,
218
- ...(startToken ? { start_token: startToken } : {}),
219
- ...(input.pids?.length
220
- ? { pids: input.pids.filter((p) => Number.isSafeInteger(p) && p > 0) }
221
- : {}),
222
- host: hostname(),
223
- ...(input.scope ? { scope: resolve(input.scope) } : {}),
224
- ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
225
- ...(input.log ? { log: resolve(input.log) } : {}),
226
- ...(input.stop_argv?.length ? { stop_argv: input.stop_argv } : {}),
227
- ...(owner ? { owner } : {}),
228
- started_at: input.started_at ?? now,
229
- registered_at: now,
230
- last_active_at: now,
231
- };
232
- writePrivateJsonAtomic(recordPath(coordRoot, id), record);
233
- return record;
220
+ return withServerMutation(coordRoot, id, () => {
221
+ const path = recordPath(coordRoot, id);
222
+ const now = new Date().toISOString();
223
+ const startToken = input.start_token ?? processStartToken(pid);
224
+ const pids = [...new Set(input.pids?.filter((p) => Number.isSafeInteger(p) && p > 0))];
225
+ const pidsStartTokens: Record<string, string> = {};
226
+ for (const auxiliaryPid of pids) {
227
+ const token = processStartToken(auxiliaryPid);
228
+ if (token) pidsStartTokens[auxiliaryPid] = token;
229
+ }
230
+ const owner = input.owner === undefined ? callingOwner(coordRoot) : (input.owner ?? undefined);
231
+ const record: ServerRecord = {
232
+ schema_version: SERVER_RECORD_SCHEMA_VERSION,
233
+ id,
234
+ registration_id: randomUUID(),
235
+ kind: input.kind,
236
+ type: input.type,
237
+ label: input.label ?? id,
238
+ ...(input.url ? { url: input.url } : {}),
239
+ ...(input.port ? { port: input.port } : {}),
240
+ pid,
241
+ ...(startToken ? { start_token: startToken } : {}),
242
+ ...(pids.length ? { pids, pids_start_tokens: pidsStartTokens } : {}),
243
+ host: hostname(),
244
+ ...(input.scope ? { scope: resolve(input.scope) } : {}),
245
+ ...(input.cwd ? { cwd: resolve(input.cwd) } : {}),
246
+ ...(input.log ? { log: resolve(input.log) } : {}),
247
+ ...(owner ? { owner } : {}),
248
+ started_at: input.started_at ?? now,
249
+ registered_at: now,
250
+ last_active_at: now,
251
+ };
252
+ if (
253
+ input.start_token !== undefined &&
254
+ verifyProcessGeneration(pid, input.start_token) !== "same"
255
+ )
256
+ throw new Error(`pid ${pid} generation could not be verified before registration`);
257
+ if (existsSync(path)) {
258
+ const existing = readRecord(path);
259
+ if (!existing)
260
+ throw new Error(
261
+ `registration ${id} custody is unknown: invalid or older server record retained`,
262
+ );
263
+ if (sameProcessCustody(existing, record) && serverState(existing) === "running")
264
+ return existing;
265
+ if (existing.host !== hostname() || !allProcessesAbsent(existing))
266
+ throw new Error(`registration ${id} custody is held by a live or unverifiable server`);
267
+ }
268
+ writePrivateJsonAtomic(path, record);
269
+ return record;
270
+ });
234
271
  }
235
272
 
236
273
  /** Best-effort registration that never throws (for server start paths). */
@@ -256,39 +293,132 @@ function callingOwner(coordRoot: string): ServerRecord["owner"] {
256
293
  }
257
294
  }
258
295
 
259
- /**
260
- * Remove a server's record. With `pid`, only a record still naming that pid
261
- * is removed, so a replacement that re-registered the same id survives.
262
- */
263
- export function unregisterServer(id: string, options?: ServerOptions & { pid?: number }): boolean {
296
+ /** Remove only the exact registration receipt observed by the caller. */
297
+ export function unregisterServer(record: ServerRecord, options?: ServerOptions): boolean {
264
298
  const coordRoot = rootOf(options);
265
- if (!coordRoot || !isValidServerId(id)) return false;
266
- const path = recordPath(coordRoot, id);
267
- if (options?.pid !== undefined) {
268
- const record = readRecord(path);
269
- if (record && record.pid !== options.pid) return false;
299
+ if (!coordRoot || !record || !isValidServerId(record.id)) return false;
300
+ return withServerMutation(coordRoot, record.id, () => removeServerReceipt(coordRoot, record));
301
+ }
302
+
303
+ /** Hold the exact registration receipt until a synchronous or asynchronous effect settles. */
304
+ export function withServerRegistrationCustody<T>(
305
+ record: ServerRecord,
306
+ options: ServerOptions,
307
+ effect: () => PromiseLike<T>,
308
+ ): Promise<T>;
309
+ export function withServerRegistrationCustody<T>(
310
+ record: ServerRecord,
311
+ options: ServerOptions,
312
+ effect: () => T,
313
+ ): T;
314
+ export function withServerRegistrationCustody<T>(
315
+ record: ServerRecord,
316
+ options: ServerOptions,
317
+ effect: () => T | PromiseLike<T>,
318
+ ): T | Promise<T> {
319
+ const coordRoot = rootOf(options);
320
+ if (!coordRoot) throw new Error("registration custody root is unavailable");
321
+ const lease = serverMutationLease(coordRoot, record.id);
322
+ try {
323
+ const current = readServer(record.id, { coordRoot });
324
+ if (
325
+ !current ||
326
+ current.registration_id !== record.registration_id ||
327
+ !sameProcessCustody(current, record)
328
+ )
329
+ throw new Error(`registration ${record.id} custody changed`);
330
+ const result = effect();
331
+ if (
332
+ result !== null &&
333
+ (typeof result === "object" || typeof result === "function") &&
334
+ typeof (result as PromiseLike<T>).then === "function"
335
+ )
336
+ return Promise.resolve(result).finally(() => lease.release());
337
+ lease.release();
338
+ return result as T;
339
+ } catch (error) {
340
+ lease.release();
341
+ throw error;
270
342
  }
271
- if (!existsSync(path)) return false;
272
- rmSync(path, { force: true });
343
+ }
344
+
345
+ function removeServerReceipt(coordRoot: string, record: ServerRecord): boolean {
346
+ const path = recordPath(coordRoot, record.id);
347
+ const current = readRecord(path);
348
+ if (
349
+ !current ||
350
+ current.registration_id !== record.registration_id ||
351
+ !sameProcessCustody(current, record)
352
+ )
353
+ return false;
354
+ rmSync(path);
273
355
  return true;
274
356
  }
275
357
 
358
+ function serverMutationLease(coordRoot: string, id: string) {
359
+ recordPath(coordRoot, id);
360
+ const scope = `server-registration-${id}`;
361
+ return acquireNoClobberLease({
362
+ path: join(serversDir(coordRoot), ".custody", id),
363
+ scope,
364
+ authoritySha256: createHash("sha256").update(scope).digest("hex"),
365
+ staleAfterMs: 1,
366
+ });
367
+ }
368
+
369
+ function withServerMutation<T>(coordRoot: string, id: string, operation: () => T): T {
370
+ const lease = serverMutationLease(coordRoot, id);
371
+ try {
372
+ return operation();
373
+ } finally {
374
+ lease.release();
375
+ }
376
+ }
377
+
378
+ function sameProcessCustody(a: ServerRecord, b: ServerRecord): boolean {
379
+ const helpers = (record: ServerRecord) =>
380
+ [...new Set(record.pids ?? [])]
381
+ .sort((x, y) => x - y)
382
+ .map((pid) => [pid, record.pids_start_tokens?.[pid] ?? null]);
383
+ return (
384
+ a.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
385
+ b.schema_version === SERVER_RECORD_SCHEMA_VERSION &&
386
+ a.id === b.id &&
387
+ a.pid === b.pid &&
388
+ a.start_token === b.start_token &&
389
+ a.host === b.host &&
390
+ a.kind === b.kind &&
391
+ a.type === b.type &&
392
+ a.scope === b.scope &&
393
+ a.owner?.instance_id === b.owner?.instance_id &&
394
+ JSON.stringify(helpers(a)) === JSON.stringify(helpers(b))
395
+ );
396
+ }
397
+
276
398
  export function readServer(id: string, options?: ServerOptions): ServerRecord | null {
277
399
  const coordRoot = rootOf(options);
278
400
  if (!coordRoot || !isValidServerId(id)) return null;
279
- return readRecord(recordPath(coordRoot, id));
401
+ const path = recordPath(coordRoot, id);
402
+ const record = readRecord(path);
403
+ if (!record && existsSync(path))
404
+ throw new Error(
405
+ `server record ${id} is invalid or uses an older schema; retained without custody`,
406
+ );
407
+ return record;
280
408
  }
281
409
 
282
410
  /** Mark a server as used now. */
283
411
  export function touchServer(id: string, options?: ServerOptions & { now?: Date }): void {
284
412
  const coordRoot = rootOf(options);
285
413
  if (!coordRoot || !isValidServerId(id)) return;
286
- const path = recordPath(coordRoot, id);
287
- const record = readRecord(path);
288
- if (!record) return;
289
- writePrivateJsonAtomic(path, {
290
- ...record,
291
- last_active_at: (options?.now ?? new Date()).toISOString(),
414
+ withServerMutation(coordRoot, id, () => {
415
+ const path = recordPath(coordRoot, id);
416
+ const record = readRecord(path);
417
+ if (!record) return;
418
+ writePrivateJsonAtomic(path, {
419
+ ...record,
420
+ last_active_at: (options?.now ?? new Date()).toISOString(),
421
+ });
292
422
  });
293
423
  }
294
424
 
@@ -317,6 +447,8 @@ function readRecord(path: string): ServerRecord | null {
317
447
  if (
318
448
  value?.schema_version !== SERVER_RECORD_SCHEMA_VERSION ||
319
449
  typeof value.id !== "string" ||
450
+ typeof value.registration_id !== "string" ||
451
+ !/^[0-9a-f-]{36}$/.test(value.registration_id) ||
320
452
  !Number.isSafeInteger(value.pid) ||
321
453
  (value.kind !== "service" && value.kind !== "session")
322
454
  )
@@ -334,8 +466,14 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
334
466
  if (!existsSync(dir)) return [];
335
467
  return readdirSync(dir)
336
468
  .filter((name) => name.endsWith(".json"))
337
- .map((name) => readRecord(join(dir, name)))
338
- .filter((record): record is ServerRecord => record !== null)
469
+ .map((name) => {
470
+ const record = readRecord(join(dir, name));
471
+ if (!record)
472
+ throw new Error(
473
+ `server record ${name} is invalid or uses an older schema; retained without custody`,
474
+ );
475
+ return record;
476
+ })
339
477
  .sort(
340
478
  (a, b) =>
341
479
  a.kind.localeCompare(b.kind) || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
@@ -343,18 +481,40 @@ export function readServerRecords(options?: ServerOptions): ServerRecord[] {
343
481
  }
344
482
 
345
483
  export function pidAlive(pid: number): boolean {
484
+ return processPresence(pid) !== "absent";
485
+ }
486
+
487
+ function processPresence(
488
+ pid: number,
489
+ probePid?: (pid: number) => boolean,
490
+ ): "present" | "absent" | "unknown" {
346
491
  try {
492
+ if (probePid) return probePid(pid) ? "present" : "absent";
347
493
  process.kill(pid, 0);
348
- return true;
494
+ return "present";
349
495
  } catch (error) {
350
- return (error as NodeJS.ErrnoException).code === "EPERM";
496
+ const code = (error as NodeJS.ErrnoException).code;
497
+ return code === "ESRCH" ? "absent" : code === "EPERM" ? "present" : "unknown";
351
498
  }
352
499
  }
353
500
 
354
- export function serverState(record: ServerRecord): ServerState {
501
+ function allProcessesAbsent(record: ServerRecord, probePid?: (pid: number) => boolean): boolean {
502
+ return [record.pid, ...(record.pids ?? [])].every(
503
+ (pid) => processPresence(pid, probePid) === "absent",
504
+ );
505
+ }
506
+
507
+ export function serverState(
508
+ record: ServerRecord,
509
+ readToken?: (pid: number) => string | null,
510
+ probePid?: (pid: number) => boolean,
511
+ ): ServerState {
355
512
  if (record.host !== hostname()) return "other-host";
356
- if (!pidAlive(record.pid)) return "dead";
357
- return checkPidToken(record.pid, record.start_token) === "mismatch" ? "dead" : "running";
513
+ const presence = processPresence(record.pid, probePid);
514
+ if (presence === "absent") return allProcessesAbsent(record, probePid) ? "dead" : "unknown";
515
+ if (presence === "unknown") return "unknown";
516
+ const verdict = verifyProcessGeneration(record.pid, record.start_token, readToken);
517
+ return verdict === "same" ? "running" : verdict === "different" ? "dead" : "unknown";
358
518
  }
359
519
 
360
520
  type OwnerReader = (instanceId: string) => OwnerState;
@@ -395,7 +555,6 @@ export function listServers(options?: ListServersOptions): ServersReport {
395
555
  const now = options?.now ?? new Date();
396
556
  const nowMs = now.getTime();
397
557
  const ownerStaleMs = hours(options?.ownerStaleHours ?? SERVER_DEFAULT_OWNER_STALE_HOURS);
398
- adoptRunningTunnels(coordRoot);
399
558
  const records = readServerRecords({ coordRoot });
400
559
  const ownerOf = ownerReader(coordRoot, ownerStaleMs, nowMs);
401
560
  const states = records.map((record) => serverState(record));
@@ -407,8 +566,8 @@ export function listServers(options?: ListServersOptions): ServersReport {
407
566
  const servers: ServerView[] = [];
408
567
  records.forEach((record, i) => {
409
568
  const state = states[i]!;
410
- if (state === "dead" && options?.prune !== false) {
411
- unregisterServer(record.id, { coordRoot, pid: record.pid });
569
+ if (state === "dead" && allProcessesAbsent(record) && options?.prune !== false) {
570
+ unregisterServer(record, { coordRoot });
412
571
  pruned.push(record.id);
413
572
  return;
414
573
  }
@@ -446,24 +605,6 @@ export function listServers(options?: ListServersOptions): ServersReport {
446
605
  };
447
606
  }
448
607
 
449
- /** Register running tunnels that have no current record. Returns adopted ids. */
450
- export function adoptRunningTunnels(coordRoot: string): string[] {
451
- const adopted: string[] = [];
452
- let states: ReturnType<typeof listStates>;
453
- try {
454
- states = listStates(coordRoot);
455
- } catch {
456
- return adopted;
457
- }
458
- for (const state of states) {
459
- if (!state.gate_pid || !pidAlive(state.gate_pid)) continue;
460
- const id = tunnelServerId(state.name);
461
- if (readServer(id, { coordRoot })?.pid === state.gate_pid) continue;
462
- if (tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot })) adopted.push(id);
463
- }
464
- return adopted;
465
- }
466
-
467
608
  /** Processes that browser automation and editors start; never project servers. */
468
609
  const IGNORED_LISTENER_COMMANDS = [
469
610
  /(^|\/)(chrome|chromium|chrome-headless-shell|headless_shell|msedge|firefox)( |$)/i,
@@ -481,10 +622,10 @@ export function findUnregisteredListeners(
481
622
  coordRoot: string,
482
623
  servers: Array<Pick<ServerView, "record" | "state">>,
483
624
  ): UnregisteredListener[] {
484
- const running = servers.filter((server) => server.state === "running");
625
+ const registered = servers.filter((server) => server.record.host === hostname());
485
626
  const covered = new Set<number>();
486
627
  const ports = new Set<number>();
487
- for (const { record } of running) {
628
+ for (const { record } of registered) {
488
629
  covered.add(record.pid);
489
630
  for (const pid of record.pids ?? []) covered.add(pid);
490
631
  if (record.port) ports.add(record.port);
@@ -492,7 +633,8 @@ export function findUnregisteredListeners(
492
633
  const results: UnregisteredListener[] = [];
493
634
  const seenPids = new Map<number, boolean>();
494
635
  for (const socket of listListeningSockets()) {
495
- if (ports.has(socket.port) || socket.pid === process.pid) continue;
636
+ if (ports.has(socket.port) || covered.has(socket.pid) || socket.pid === process.pid) continue;
637
+ const startToken = processStartToken(socket.pid);
496
638
  let inside = seenPids.get(socket.pid);
497
639
  const info = readProcessInfo(socket.pid);
498
640
  if (!info) continue;
@@ -506,6 +648,7 @@ export function findUnregisteredListeners(
506
648
  if (!inside) continue;
507
649
  results.push({
508
650
  pid: socket.pid,
651
+ ...(startToken ? { start_token: startToken } : {}),
509
652
  port: socket.port,
510
653
  address: socket.address,
511
654
  command: info.command.slice(0, 300),
@@ -568,77 +711,156 @@ export function planServerGc(views: ServerView[], policy?: GcPolicy): GcDecision
568
711
  export interface StopResult {
569
712
  id: string;
570
713
  stopped: boolean;
571
- method: "not-running" | "stop-command" | "signal" | "refused";
714
+ method: "not-running" | "signal" | "refused";
715
+ generation?: ProcessGenerationVerdict;
572
716
  detail?: string;
573
717
  }
574
718
 
719
+ export interface ServerProcessDependencies extends ProcessGenerationSignalDependencies {
720
+ probePid?: (pid: number) => boolean;
721
+ }
722
+
575
723
  /**
576
- * Stop one server: its graceful stop command first when recorded, then
577
- * SIGTERM to its process group, then SIGKILL after the grace period. A pid
578
- * whose start token no longer matches is never signalled.
724
+ * Stop a verified server with SIGTERM, then SIGKILL after the grace period.
725
+ * Every signal targets one retained process handle, including auxiliary processes.
726
+ * Each target requires a fresh generation check. Unknown or different generations retain the record.
579
727
  */
580
728
  export async function stopServer(
581
729
  record: ServerRecord,
582
- options?: ServerOptions & { graceMs?: number },
730
+ options?: ServerOptions & {
731
+ graceMs?: number;
732
+ signalDependencies?: ServerProcessDependencies;
733
+ },
583
734
  ): Promise<StopResult> {
584
735
  const coordRoot = rootOf(options);
736
+ if (!coordRoot)
737
+ return {
738
+ id: record.id,
739
+ stopped: false,
740
+ method: "refused",
741
+ generation: "unknown",
742
+ detail: "registration custody root is unavailable",
743
+ };
744
+ let lease: ReturnType<typeof serverMutationLease>;
745
+ try {
746
+ lease = serverMutationLease(coordRoot, record.id);
747
+ } catch {
748
+ return {
749
+ id: record.id,
750
+ stopped: false,
751
+ method: "refused",
752
+ generation: "unknown",
753
+ detail: "registration custody is unavailable",
754
+ };
755
+ }
756
+ try {
757
+ const current = readServer(record.id, { coordRoot });
758
+ if (!current || current.registration_id !== record.registration_id)
759
+ return {
760
+ id: record.id,
761
+ stopped: false,
762
+ method: "refused",
763
+ generation: "unknown",
764
+ detail: "registration custody changed; retained current server",
765
+ };
766
+ return await stopServerWithCustody(record, coordRoot, options);
767
+ } finally {
768
+ lease.release();
769
+ }
770
+ }
771
+
772
+ async function stopServerWithCustody(
773
+ record: ServerRecord,
774
+ coordRoot: string,
775
+ options?: ServerOptions & { graceMs?: number; signalDependencies?: ServerProcessDependencies },
776
+ ): Promise<StopResult> {
585
777
  const graceMs = options?.graceMs ?? 5_000;
778
+ const dependencies = options?.signalDependencies ?? {};
779
+ const targets = [
780
+ { pid: record.pid, token: record.start_token },
781
+ ...[...new Set(record.pids ?? [])]
782
+ .filter((pid) => pid !== record.pid)
783
+ .map((pid) => ({ pid, token: record.pids_start_tokens?.[pid] })),
784
+ ];
785
+ const refuse = (pid: number, generation: ProcessGenerationVerdict): StopResult => ({
786
+ id: record.id,
787
+ stopped: false,
788
+ method: "refused",
789
+ generation,
790
+ detail: `pid ${pid} generation is ${generation}; left the server record in place`,
791
+ });
586
792
  const finish = (result: StopResult): StopResult => {
587
- if (result.stopped || result.method === "not-running")
588
- unregisterServer(record.id, { coordRoot, pid: record.pid });
793
+ if (result.stopped || result.method === "not-running") removeServerReceipt(coordRoot, record);
589
794
  return result;
590
795
  };
591
- const state = serverState(record);
592
- if (state === "other-host")
796
+ if (record.host !== hostname())
593
797
  return { id: record.id, stopped: false, method: "refused", detail: `runs on ${record.host}` };
594
- if (state === "dead") return finish({ id: record.id, stopped: false, method: "not-running" });
595
-
596
- if (record.stop_argv?.length) {
597
- const [command, ...args] = record.stop_argv;
598
- spawnSync(command!, args, { stdio: "ignore", timeout: 30_000, cwd: record.cwd });
599
- if (await exited(record, graceMs))
600
- return finish({ id: record.id, stopped: true, method: "stop-command" });
798
+ const leaderPresence = processPresence(record.pid, dependencies.probePid);
799
+ if (
800
+ leaderPresence === "unknown" ||
801
+ (leaderPresence === "absent" && !allProcessesAbsent(record, dependencies.probePid))
802
+ )
803
+ return refuse(record.pid, "unknown");
804
+ // Check every recorded live process before sending the first signal.
805
+ for (const target of targets) {
806
+ const presence = processPresence(target.pid, dependencies.probePid);
807
+ if (presence === "absent") continue;
808
+ if (presence === "unknown") return refuse(target.pid, "unknown");
809
+ const verdict = verifyProcessGeneration(target.pid, target.token, dependencies.readToken);
810
+ if (verdict !== "same") return refuse(target.pid, verdict);
601
811
  }
602
- signal(record.pid, "SIGTERM");
603
- for (const pid of record.pids ?? []) signal(pid, "SIGTERM");
604
- if (await exited(record, graceMs))
605
- return finish({ id: record.id, stopped: true, method: "signal" });
606
- if (serverState(record) === "running") {
607
- signal(record.pid, "SIGKILL");
608
- for (const pid of record.pids ?? []) signal(pid, "SIGKILL");
812
+ if (targets.every((target) => processPresence(target.pid, dependencies.probePid) === "absent"))
813
+ return finish({ id: record.id, stopped: false, method: "not-running" });
814
+
815
+ const current = readServer(record.id, { coordRoot });
816
+ if (!current || !sameProcessCustody(current, record)) return refuse(record.pid, "unknown");
817
+ for (const name of ["SIGTERM", "SIGKILL"] as const) {
818
+ for (const target of targets) {
819
+ const presence = processPresence(target.pid, dependencies.probePid);
820
+ if (presence === "absent") continue;
821
+ if (presence === "unknown") return refuse(target.pid, "unknown");
822
+ const result = signalIfSameGeneration(target.pid, target.token, name, dependencies);
823
+ if (!result.sent || result.verdict !== "same")
824
+ return refuse(target.pid, result.verdict === "same" ? "unknown" : result.verdict);
825
+ }
826
+ const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
827
+ if (observed.refused) return refuse(observed.refused.pid, observed.refused.verdict);
828
+ if (observed.exited)
829
+ return finish({ id: record.id, stopped: true, method: "signal", generation: "same" });
609
830
  }
610
- const stopped = await exited(record, 2_000);
611
- return finish({
831
+ return {
612
832
  id: record.id,
613
- stopped,
833
+ stopped: false,
614
834
  method: "signal",
615
- ...(stopped ? {} : { detail: "process survived SIGKILL" }),
616
- });
617
- }
618
-
619
- function signal(pid: number, name: NodeJS.Signals): void {
620
- // Detached servers lead their own process group; signal the group so a
621
- // wrapper (npm, bunx) does not orphan the real listener.
622
- try {
623
- process.kill(-pid, name);
624
- return;
625
- } catch {
626
- // Not a group leader, or already gone.
627
- }
628
- try {
629
- process.kill(pid, name);
630
- } catch {
631
- // Already gone.
632
- }
835
+ generation: "same",
836
+ detail: "process survived SIGKILL",
837
+ };
633
838
  }
634
839
 
635
- async function exited(record: ServerRecord, waitMs: number): Promise<boolean> {
840
+ async function waitForExit(
841
+ targets: Array<{ pid: number; token: string | undefined }>,
842
+ waitMs: number,
843
+ dependencies: ServerProcessDependencies,
844
+ ): Promise<{
845
+ exited: boolean;
846
+ refused?: { pid: number; verdict: ProcessGenerationVerdict };
847
+ }> {
636
848
  const deadline = Date.now() + waitMs;
637
- while (Date.now() < deadline) {
638
- if (serverState(record) !== "running") return true;
849
+ while (true) {
850
+ let live = false;
851
+ for (const target of targets) {
852
+ const presence = processPresence(target.pid, dependencies.probePid);
853
+ if (presence === "absent") continue;
854
+ if (presence === "unknown")
855
+ return { exited: false, refused: { pid: target.pid, verdict: "unknown" } };
856
+ const verdict = verifyProcessGeneration(target.pid, target.token, dependencies.readToken);
857
+ if (verdict !== "same") return { exited: false, refused: { pid: target.pid, verdict } };
858
+ live = true;
859
+ }
860
+ if (!live) return { exited: true };
861
+ if (Date.now() >= deadline) return { exited: false };
639
862
  await new Promise((r) => setTimeout(r, 100));
640
863
  }
641
- return serverState(record) !== "running";
642
864
  }
643
865
 
644
866
  /**
@@ -653,7 +875,8 @@ export function allocateServerPort(type: string, options?: ServerOptions): numbe
653
875
  if (!range) return null;
654
876
  const taken = listListeningPorts();
655
877
  for (const record of readServerRecords({ coordRoot })) {
656
- if (record.port && serverState(record) === "running") taken.add(record.port);
878
+ const state = serverState(record);
879
+ if (record.port && (state === "running" || state === "unknown")) taken.add(record.port);
657
880
  }
658
881
  for (let port = range[0]; port <= range[1]; port++) if (!taken.has(port)) return port;
659
882
  throw new Error(`no free port for ${type} in ${range[0]}-${range[1]}`);
@@ -666,6 +889,10 @@ export interface AdoptOptions extends ServerOptions {
666
889
  scope?: string;
667
890
  /** `self` (default) records the adopting agent as owner; `none` records no owner. */
668
891
  owner?: "self" | "none";
892
+ dependencies?: {
893
+ listServers?: typeof listServers;
894
+ readToken?: (pid: number) => string | null;
895
+ };
669
896
  }
670
897
 
671
898
  /**
@@ -675,12 +902,19 @@ export interface AdoptOptions extends ServerOptions {
675
902
  export function adoptServer(pid: number, options?: AdoptOptions): ServerRecord {
676
903
  const coordRoot = rootOf(options);
677
904
  if (!coordRoot) throw new Error("no Harnery project found (coordination root did not resolve)");
678
- const report = listServers({ coordRoot, sample: false });
905
+ const report = (options?.dependencies?.listServers ?? listServers)({ coordRoot, sample: false });
679
906
  const listeners = report.unregistered.filter((item) => item.pid === pid);
680
907
  if (!listeners.length)
681
908
  throw new Error(`pid ${pid} is not an unregistered listener inside this project`);
682
909
  const ports = listeners.map((item) => item.port).sort((a, b) => a - b);
683
910
  const first = listeners[0]!;
911
+ const generation = verifyProcessGeneration(
912
+ pid,
913
+ first.start_token,
914
+ options?.dependencies?.readToken,
915
+ );
916
+ if (generation !== "same")
917
+ throw new Error(`pid ${pid} generation is ${generation}; adoption refused`);
684
918
  const type = options?.type ?? "adopted";
685
919
  const scope = options?.scope ?? first.cwd ?? undefined;
686
920
  const started = processStartedAt(pid);
@@ -693,6 +927,7 @@ export function adoptServer(pid: number, options?: AdoptOptions): ServerRecord {
693
927
  url: `http://127.0.0.1:${ports[0]}/`,
694
928
  port: ports[0],
695
929
  pid,
930
+ start_token: first.start_token,
696
931
  ...(scope ? { scope } : {}),
697
932
  ...(first.cwd ? { cwd: first.cwd } : {}),
698
933
  ...(options?.owner === "none" ? { owner: null } : {}),