@code-yeongyu/senpi 2026.9.27-4 → 2026.9.28

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 (271) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/bundle/chunks/{app-server-command-3GVJZFFH.js → app-server-command-TOPTGVQB.js} +1 -1
  3. package/dist/bundle/chunks/{azure-openai-responses-4SUOOZZ4.js → azure-openai-responses-FCZNBRWE.js} +1 -1
  4. package/dist/bundle/chunks/bedrock-converse-stream.js +1 -1
  5. package/dist/bundle/chunks/{chunk-PSCNHY7D.js → chunk-3GMFUEU7.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-HBG6SQU5.js → chunk-62XHQXCZ.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-SLL375O7.js → chunk-7DY2JPUX.js} +1 -1
  8. package/dist/bundle/chunks/chunk-7YUGCTEV.js +15 -0
  9. package/dist/bundle/chunks/{chunk-FWBSGMAE.js → chunk-A4UMPXQF.js} +1 -1
  10. package/dist/bundle/chunks/chunk-BBK6SSYT.js +2 -0
  11. package/dist/bundle/chunks/{chunk-C2425O2Q.js → chunk-CNQ4JB45.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-27IFXX3B.js → chunk-DRN4FQCE.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-22MGY5JQ.js → chunk-EHSZ7UCW.js} +1 -1
  14. package/dist/bundle/chunks/chunk-FHJYIU3U.js +5 -0
  15. package/dist/bundle/chunks/chunk-GJUCSTAG.js +19 -0
  16. package/dist/bundle/chunks/{chunk-VMKAYXHL.js → chunk-KIOTFUG6.js} +25 -20
  17. package/dist/bundle/chunks/{chunk-2BMD37YV.js → chunk-LUPPSEQW.js} +1 -1
  18. package/dist/bundle/chunks/chunk-OAIVTS5X.js +8 -0
  19. package/dist/bundle/chunks/{chunk-NRGMT74E.js → chunk-QOVUQZO4.js} +1 -1
  20. package/dist/bundle/chunks/chunk-SN4ZHPMN.js +135 -0
  21. package/dist/bundle/chunks/{chunk-IKXPBYSO.js → chunk-UJFLZ2M2.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-4T4GFAGK.js → chunk-VFSXUT7K.js} +1 -1
  23. package/dist/bundle/chunks/{cli-main-66NFBKVH.js → cli-main-ARR54ET6.js} +1 -1
  24. package/dist/bundle/chunks/{google-generative-ai-ZTGKZTGU.js → google-generative-ai-LFCBHG3Y.js} +1 -1
  25. package/dist/bundle/chunks/{google-vertex-CCZ376YK.js → google-vertex-C474FXU7.js} +1 -1
  26. package/dist/bundle/chunks/{help-fast-path-NZ3I2UEN.js → help-fast-path-3U3YDEZ6.js} +1 -1
  27. package/dist/bundle/chunks/{host-command-RORPAC5X.js → host-command-6CJ7YM6W.js} +1 -1
  28. package/dist/bundle/chunks/{host-lifecycle-UDMJGLOR.js → host-lifecycle-RZIMOFDM.js} +1 -1
  29. package/dist/bundle/chunks/host-lifecycle.js +6 -6
  30. package/dist/bundle/chunks/interactive-host-runtime-A3GEIFTU.js +4 -0
  31. package/dist/bundle/chunks/{interactive-mode-LOAADKLH.js → interactive-mode-ITSFLGF6.js} +1 -1
  32. package/dist/bundle/chunks/{list-tips-S5CANO5W.js → list-tips-FPXDXN7R.js} +1 -1
  33. package/dist/bundle/chunks/multi-session-host-B4WMZE2G.js +21 -0
  34. package/dist/bundle/chunks/{openai-codex-responses-YSGZZDD5.js → openai-codex-responses-2LDIFL6Z.js} +1 -1
  35. package/dist/bundle/chunks/{openai-completions-KWHRWCBN.js → openai-completions-7KZLPKYP.js} +1 -1
  36. package/dist/bundle/chunks/{openai-images-6U5FCN5S.js → openai-images-I7DR7ERP.js} +1 -1
  37. package/dist/bundle/chunks/{openai-responses-7PWMBGYX.js → openai-responses-LCHS3AF3.js} +1 -1
  38. package/dist/bundle/chunks/{openrouter-images-QIKWQZUD.js → openrouter-images-IPNDRK6C.js} +1 -1
  39. package/dist/bundle/chunks/{package-manager-cli-5EEDA35I.js → package-manager-cli-DXHQ6RKA.js} +1 -1
  40. package/dist/bundle/chunks/{rotation-stream-KZG4OVR7.js → rotation-stream-ZVJEGA2G.js} +1 -1
  41. package/dist/bundle/chunks/{rpc-mode-HOEA5NGQ.js → rpc-mode-PFPNP65F.js} +1 -1
  42. package/dist/bundle/chunks/session-picker-KOPOV6Q4.js +2 -0
  43. package/dist/bundle/chunks/session-worker.js +311 -302
  44. package/dist/bundle/cli.js +1 -1
  45. package/dist/bundle/index.js +1 -1
  46. package/dist/bundle/rpc-entry.js +1 -1
  47. package/dist/cli/args.js +1 -1
  48. package/dist/cli/args.js.map +1 -1
  49. package/dist/cli/host-command.d.ts +8 -1
  50. package/dist/cli/host-command.d.ts.map +1 -1
  51. package/dist/cli/host-command.js +66 -14
  52. package/dist/cli/host-command.js.map +1 -1
  53. package/dist/core/agent-session.d.ts +18 -1
  54. package/dist/core/agent-session.d.ts.map +1 -1
  55. package/dist/core/agent-session.js +119 -3
  56. package/dist/core/agent-session.js.map +1 -1
  57. package/dist/core/retry-fallback/candidates.d.ts +26 -0
  58. package/dist/core/retry-fallback/candidates.d.ts.map +1 -0
  59. package/dist/core/retry-fallback/candidates.js +41 -0
  60. package/dist/core/retry-fallback/candidates.js.map +1 -0
  61. package/dist/core/retry-fallback/circuit-probes.d.ts +30 -0
  62. package/dist/core/retry-fallback/circuit-probes.d.ts.map +1 -0
  63. package/dist/core/retry-fallback/circuit-probes.js +60 -0
  64. package/dist/core/retry-fallback/circuit-probes.js.map +1 -0
  65. package/dist/core/retry-fallback/circuit.d.ts +101 -0
  66. package/dist/core/retry-fallback/circuit.d.ts.map +1 -0
  67. package/dist/core/retry-fallback/circuit.js +175 -0
  68. package/dist/core/retry-fallback/circuit.js.map +1 -0
  69. package/dist/core/retry-fallback/controller-types.d.ts +58 -0
  70. package/dist/core/retry-fallback/controller-types.d.ts.map +1 -0
  71. package/dist/core/retry-fallback/controller-types.js +2 -0
  72. package/dist/core/retry-fallback/controller-types.js.map +1 -0
  73. package/dist/core/retry-fallback/controller.d.ts +21 -49
  74. package/dist/core/retry-fallback/controller.d.ts.map +1 -1
  75. package/dist/core/retry-fallback/controller.js +88 -42
  76. package/dist/core/retry-fallback/controller.js.map +1 -1
  77. package/dist/core/sdk.d.ts.map +1 -1
  78. package/dist/core/sdk.js +25 -17
  79. package/dist/core/sdk.js.map +1 -1
  80. package/dist/core/session-failure-report.d.ts +33 -0
  81. package/dist/core/session-failure-report.d.ts.map +1 -0
  82. package/dist/core/session-failure-report.js +62 -0
  83. package/dist/core/session-failure-report.js.map +1 -0
  84. package/dist/core/settings-manager.d.ts +3 -0
  85. package/dist/core/settings-manager.d.ts.map +1 -1
  86. package/dist/core/settings-manager.js +4 -0
  87. package/dist/core/settings-manager.js.map +1 -1
  88. package/dist/core/slash-commands.d.ts.map +1 -1
  89. package/dist/core/slash-commands.js +1 -0
  90. package/dist/core/slash-commands.js.map +1 -1
  91. package/dist/index.d.ts +1 -1
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +1 -1
  94. package/dist/index.js.map +1 -1
  95. package/dist/modes/index.d.ts +1 -0
  96. package/dist/modes/index.d.ts.map +1 -1
  97. package/dist/modes/index.js +2 -0
  98. package/dist/modes/index.js.map +1 -1
  99. package/dist/modes/interactive/help-content.d.ts.map +1 -1
  100. package/dist/modes/interactive/help-content.js +3 -0
  101. package/dist/modes/interactive/help-content.js.map +1 -1
  102. package/dist/modes/interactive/interactive-host-attach.d.ts +10 -0
  103. package/dist/modes/interactive/interactive-host-attach.d.ts.map +1 -0
  104. package/dist/modes/interactive/interactive-host-attach.js +14 -0
  105. package/dist/modes/interactive/interactive-host-attach.js.map +1 -0
  106. package/dist/modes/interactive/interactive-host-runtime.d.ts.map +1 -1
  107. package/dist/modes/interactive/interactive-host-runtime.js +3 -6
  108. package/dist/modes/interactive/interactive-host-runtime.js.map +1 -1
  109. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  110. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  111. package/dist/modes/interactive/interactive-mode.js +66 -1
  112. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  113. package/dist/modes/interactive/session-failure-info.d.ts +3 -0
  114. package/dist/modes/interactive/session-failure-info.d.ts.map +1 -0
  115. package/dist/modes/interactive/session-failure-info.js +21 -0
  116. package/dist/modes/interactive/session-failure-info.js.map +1 -0
  117. package/dist/modes/interactive/tips/catalog/model-tips.d.ts +7 -7
  118. package/dist/modes/interactive/tips/catalog/model-tips.d.ts.map +1 -1
  119. package/dist/modes/interactive/tips/catalog/model-tips.js +7 -1
  120. package/dist/modes/interactive/tips/catalog/model-tips.js.map +1 -1
  121. package/dist/modes/interactive/tips/catalog/session-tips.d.ts.map +1 -1
  122. package/dist/modes/interactive/tips/catalog/session-tips.js +1 -1
  123. package/dist/modes/interactive/tips/catalog/session-tips.js.map +1 -1
  124. package/dist/modes/rpc/host-attach-hold.d.ts +16 -0
  125. package/dist/modes/rpc/host-attach-hold.d.ts.map +1 -0
  126. package/dist/modes/rpc/host-attach-hold.js +9 -0
  127. package/dist/modes/rpc/host-attach-hold.js.map +1 -0
  128. package/dist/modes/rpc/host-cleanup-paths.d.ts +20 -0
  129. package/dist/modes/rpc/host-cleanup-paths.d.ts.map +1 -0
  130. package/dist/modes/rpc/host-cleanup-paths.js +13 -0
  131. package/dist/modes/rpc/host-cleanup-paths.js.map +1 -0
  132. package/dist/modes/rpc/host-client-occupancy.d.ts +24 -0
  133. package/dist/modes/rpc/host-client-occupancy.d.ts.map +1 -0
  134. package/dist/modes/rpc/host-client-occupancy.js +53 -0
  135. package/dist/modes/rpc/host-client-occupancy.js.map +1 -0
  136. package/dist/modes/rpc/host-daemon-paths.d.ts +61 -8
  137. package/dist/modes/rpc/host-daemon-paths.d.ts.map +1 -1
  138. package/dist/modes/rpc/host-daemon-paths.js +128 -11
  139. package/dist/modes/rpc/host-daemon-paths.js.map +1 -1
  140. package/dist/modes/rpc/host-daemon-registration.d.ts +11 -1
  141. package/dist/modes/rpc/host-daemon-registration.d.ts.map +1 -1
  142. package/dist/modes/rpc/host-daemon-registration.js +28 -15
  143. package/dist/modes/rpc/host-daemon-registration.js.map +1 -1
  144. package/dist/modes/rpc/host-endpoints.d.ts +12 -0
  145. package/dist/modes/rpc/host-endpoints.d.ts.map +1 -0
  146. package/dist/modes/rpc/host-endpoints.js +59 -0
  147. package/dist/modes/rpc/host-endpoints.js.map +1 -0
  148. package/dist/modes/rpc/host-ensure-lock.d.ts +11 -0
  149. package/dist/modes/rpc/host-ensure-lock.d.ts.map +1 -0
  150. package/dist/modes/rpc/host-ensure-lock.js +49 -0
  151. package/dist/modes/rpc/host-ensure-lock.js.map +1 -0
  152. package/dist/modes/rpc/host-ensure.d.ts +7 -0
  153. package/dist/modes/rpc/host-ensure.d.ts.map +1 -1
  154. package/dist/modes/rpc/host-ensure.js +42 -65
  155. package/dist/modes/rpc/host-ensure.js.map +1 -1
  156. package/dist/modes/rpc/host-gc-evidence.d.ts +18 -0
  157. package/dist/modes/rpc/host-gc-evidence.d.ts.map +1 -0
  158. package/dist/modes/rpc/host-gc-evidence.js +128 -0
  159. package/dist/modes/rpc/host-gc-evidence.js.map +1 -0
  160. package/dist/modes/rpc/host-gc.d.ts +28 -0
  161. package/dist/modes/rpc/host-gc.d.ts.map +1 -0
  162. package/dist/modes/rpc/host-gc.js +110 -0
  163. package/dist/modes/rpc/host-gc.js.map +1 -0
  164. package/dist/modes/rpc/host-generations.d.ts +10 -2
  165. package/dist/modes/rpc/host-generations.d.ts.map +1 -1
  166. package/dist/modes/rpc/host-generations.js +12 -7
  167. package/dist/modes/rpc/host-generations.js.map +1 -1
  168. package/dist/modes/rpc/host-handoff.d.ts +6 -0
  169. package/dist/modes/rpc/host-handoff.d.ts.map +1 -1
  170. package/dist/modes/rpc/host-handoff.js +31 -4
  171. package/dist/modes/rpc/host-handoff.js.map +1 -1
  172. package/dist/modes/rpc/host-lifecycle-policy.d.ts +53 -0
  173. package/dist/modes/rpc/host-lifecycle-policy.d.ts.map +1 -0
  174. package/dist/modes/rpc/host-lifecycle-policy.js +64 -0
  175. package/dist/modes/rpc/host-lifecycle-policy.js.map +1 -0
  176. package/dist/modes/rpc/host-lifecycle.d.ts +1 -44
  177. package/dist/modes/rpc/host-lifecycle.d.ts.map +1 -1
  178. package/dist/modes/rpc/host-lifecycle.js +23 -85
  179. package/dist/modes/rpc/host-lifecycle.js.map +1 -1
  180. package/dist/modes/rpc/host-observe-request.d.ts +17 -0
  181. package/dist/modes/rpc/host-observe-request.d.ts.map +1 -0
  182. package/dist/modes/rpc/host-observe-request.js +30 -0
  183. package/dist/modes/rpc/host-observe-request.js.map +1 -0
  184. package/dist/modes/rpc/host-probe.d.ts +12 -0
  185. package/dist/modes/rpc/host-probe.d.ts.map +1 -1
  186. package/dist/modes/rpc/host-probe.js +28 -1
  187. package/dist/modes/rpc/host-probe.js.map +1 -1
  188. package/dist/modes/rpc/host-protocol-info.d.ts +2 -0
  189. package/dist/modes/rpc/host-protocol-info.d.ts.map +1 -1
  190. package/dist/modes/rpc/host-protocol-info.js +1 -0
  191. package/dist/modes/rpc/host-protocol-info.js.map +1 -1
  192. package/dist/modes/rpc/host-readiness.d.ts +22 -0
  193. package/dist/modes/rpc/host-readiness.d.ts.map +1 -0
  194. package/dist/modes/rpc/host-readiness.js +44 -0
  195. package/dist/modes/rpc/host-readiness.js.map +1 -0
  196. package/dist/modes/rpc/host-runner.d.ts +13 -0
  197. package/dist/modes/rpc/host-runner.d.ts.map +1 -1
  198. package/dist/modes/rpc/host-runner.js +23 -4
  199. package/dist/modes/rpc/host-runner.js.map +1 -1
  200. package/dist/modes/rpc/host-status-all.d.ts +31 -0
  201. package/dist/modes/rpc/host-status-all.d.ts.map +1 -0
  202. package/dist/modes/rpc/host-status-all.js +78 -0
  203. package/dist/modes/rpc/host-status-all.js.map +1 -0
  204. package/dist/modes/rpc/host-status-rows.d.ts +35 -0
  205. package/dist/modes/rpc/host-status-rows.d.ts.map +1 -0
  206. package/dist/modes/rpc/host-status-rows.js +42 -0
  207. package/dist/modes/rpc/host-status-rows.js.map +1 -0
  208. package/dist/modes/rpc/host-status.d.ts +32 -1
  209. package/dist/modes/rpc/host-status.d.ts.map +1 -1
  210. package/dist/modes/rpc/host-status.js +33 -27
  211. package/dist/modes/rpc/host-status.js.map +1 -1
  212. package/dist/modes/rpc/host-successor.d.ts +3 -1
  213. package/dist/modes/rpc/host-successor.d.ts.map +1 -1
  214. package/dist/modes/rpc/host-successor.js +105 -42
  215. package/dist/modes/rpc/host-successor.js.map +1 -1
  216. package/dist/modes/rpc/host-watchdog.d.ts +6 -0
  217. package/dist/modes/rpc/host-watchdog.d.ts.map +1 -1
  218. package/dist/modes/rpc/host-watchdog.js +12 -13
  219. package/dist/modes/rpc/host-watchdog.js.map +1 -1
  220. package/dist/modes/rpc/loop-lag-watchdog.d.ts +16 -0
  221. package/dist/modes/rpc/loop-lag-watchdog.d.ts.map +1 -1
  222. package/dist/modes/rpc/loop-lag-watchdog.js +34 -2
  223. package/dist/modes/rpc/loop-lag-watchdog.js.map +1 -1
  224. package/dist/modes/rpc/multi-session-host.d.ts +2 -1
  225. package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
  226. package/dist/modes/rpc/multi-session-host.js +33 -6
  227. package/dist/modes/rpc/multi-session-host.js.map +1 -1
  228. package/dist/modes/rpc/rpc-mode.d.ts +2 -2
  229. package/dist/modes/rpc/rpc-mode.js +2 -2
  230. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  231. package/dist/modes/rpc/rpc-types.d.ts +16 -0
  232. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  233. package/dist/modes/rpc/rpc-types.js.map +1 -1
  234. package/dist/modes/rpc/session-command-router.d.ts +9 -1
  235. package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
  236. package/dist/modes/rpc/session-command-router.js +4 -1
  237. package/dist/modes/rpc/session-command-router.js.map +1 -1
  238. package/dist/modes/rpc/session-path-key.d.ts +3 -1
  239. package/dist/modes/rpc/session-path-key.d.ts.map +1 -1
  240. package/dist/modes/rpc/session-path-key.js +3 -1
  241. package/dist/modes/rpc/session-path-key.js.map +1 -1
  242. package/docs/rpc.md +186 -19
  243. package/docs/settings.md +25 -1
  244. package/docs/usage.md +5 -2
  245. package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +12 -0
  246. package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
  247. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  248. package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
  249. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  250. package/node_modules/@earendil-works/pi-ai/dist/utils/error-body.d.ts +6 -0
  251. package/node_modules/@earendil-works/pi-ai/dist/utils/error-body.d.ts.map +1 -1
  252. package/node_modules/@earendil-works/pi-ai/dist/utils/error-body.js +36 -0
  253. package/node_modules/@earendil-works/pi-ai/dist/utils/error-body.js.map +1 -1
  254. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.d.ts +1 -0
  255. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.d.ts.map +1 -1
  256. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.js +14 -1
  257. package/node_modules/@earendil-works/pi-ai/dist/utils/retry.js.map +1 -1
  258. package/node_modules/@earendil-works/pi-ai/package.json +2 -2
  259. package/node_modules/@earendil-works/pi-pty/package.json +1 -1
  260. package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
  261. package/node_modules/@earendil-works/pi-tui/package.json +1 -1
  262. package/package.json +7 -7
  263. package/dist/bundle/chunks/chunk-3DEXCYNN.js +0 -135
  264. package/dist/bundle/chunks/chunk-5X4KSPK5.js +0 -12
  265. package/dist/bundle/chunks/chunk-KMD5ZONU.js +0 -5
  266. package/dist/bundle/chunks/chunk-PEHZYGYA.js +0 -8
  267. package/dist/bundle/chunks/chunk-PRC5XIIH.js +0 -2
  268. package/dist/bundle/chunks/chunk-QKY33652.js +0 -18
  269. package/dist/bundle/chunks/interactive-host-runtime-TIFT2SKR.js +0 -4
  270. package/dist/bundle/chunks/multi-session-host-GRHKB7Q2.js +0 -21
  271. package/dist/bundle/chunks/session-picker-FTFCQYHH.js +0 -2
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * <agentDir>/rpc-host-daemon/ the flat directory - shared, and left legacy-empty
8
8
  * layout.json { layout: 2, dir } - the only file this build writes here
9
- * <sha256(socket)[:16]>/ 0700, one per endpoint
9
+ * <sha256(canonical socket)[:16]>/ 0700, one per endpoint
10
+ * endpoint.json { layout, socket, created_at } - WHICH socket this is
10
11
  * host.pid POINTER: { layout, instance_id, generation_dir, writer }
11
12
  * settings.json what the supervisor reads at boot
12
13
  * daemon.lock stderr.log
@@ -22,13 +23,20 @@
22
23
  * ever writes a legacy-shaped file, and nothing here ever removes one: a flat `host.pid` that DOES
23
24
  * exist belongs to a legacy host that may still be running, and is read-only to this build.
24
25
  *
26
+ * `endpoint.json` is the endpoint's durable identity: written once and whole (linked into place), never
27
+ * rewritten while it names this directory's socket (an ensure repairs one that does not), and the one file
28
+ * a generation's release leaves behind - so an endpoint whose host exited (cleanly or not) can still
29
+ * be enumerated and named. The pointer, `settings.json` and the generation directories all describe
30
+ * a LIVE host and go with it.
31
+ *
25
32
  * What those files CONTAIN is `host-daemon-state.ts` (settings, and the primitives every state file
26
33
  * is written through) and `host-daemon-registration.ts` (the pointer and the generation records).
27
34
  */
28
- import { createHash } from "node:crypto";
29
- import { chmod, mkdir, writeFile } from "node:fs/promises";
30
- import { basename, join, win32 } from "node:path";
35
+ import { createHash, randomUUID } from "node:crypto";
36
+ import { chmod, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
37
+ import { basename, dirname, join, win32 } from "node:path";
31
38
  import { getAgentDir } from "../../config.js";
39
+ import { canonicalSessionPath } from "./session-path-key.js";
32
40
  /** The layout this build writes. A directory without the marker predates it and is never touched. */
33
41
  export const HOST_DAEMON_LAYOUT = 2;
34
42
  /** Absolute daemon directory handed to a spawned host, which binds a private socket of its own. */
@@ -36,15 +44,61 @@ export const HOST_DAEMON_DIR_ENV = "SENPI_RPC_HOST_DAEMON_DIR";
36
44
  const DIRECTORY_MODE = 0o700;
37
45
  export const HOST_STATE_FILE_MODE = 0o600;
38
46
  /**
39
- * The directory name every client recomputes from the socket alone: `sha256(<canonical endpoint>)`,
40
- * where the canonical endpoint is the socket path on POSIX and the normalized lower-cased path on
41
- * win32 (the same canonicalization the pipe name is derived from, so two spellings of one endpoint
42
- * share one directory). Deliberately total: naming a directory must never fail on a path shape the
43
- * transport would reject, or a client could not even report WHERE it was looking.
47
+ * The one spelling of an endpoint every client derives the same way. On POSIX that is the socket path
48
+ * with its directory resolved through its deepest existing ancestor (`/tmp` vs `/private/tmp`, a
49
+ * symlinked agent directory) - the identity the ensure lock is keyed by - and a path already spelled
50
+ * that way comes back unchanged. On win32 it is the normalized lower-cased path the pipe name is derived
51
+ * from. An abstract socket has no directory to resolve. Deliberately total: naming a directory must never
52
+ * fail on a path shape the transport would reject, or a client could not even report WHERE it was looking.
53
+ */
54
+ export function canonicalEndpointPath(socket, platform = process.platform) {
55
+ if (platform === "win32")
56
+ return win32.normalize(socket).toLowerCase();
57
+ if (socket.startsWith("\0"))
58
+ return socket;
59
+ return join(canonicalSessionPath(dirname(socket)), basename(socket));
60
+ }
61
+ /**
62
+ * The directory name every client recomputes from the socket alone: `sha256(<canonical endpoint>)`, so
63
+ * every spelling of one endpoint shares one directory, and a canonical spelling keeps the name it had
64
+ * when the name was hashed from the spelling itself.
44
65
  */
45
66
  export function daemonDirectoryName(socket, platform = process.platform) {
46
- const canonical = platform === "win32" ? win32.normalize(socket).toLowerCase() : socket;
47
- return createHash("sha256").update(canonical, "utf8").digest("hex").slice(0, 16);
67
+ return directoryNameOf(canonicalEndpointPath(socket, platform));
68
+ }
69
+ /** Whether two spellings name one endpoint. */
70
+ export function sameEndpoint(socket, other) {
71
+ return socket === other || canonicalEndpointPath(socket) === canonicalEndpointPath(other);
72
+ }
73
+ /**
74
+ * Whether a record naming `socket` belongs in the directory called `name`: the canonical name, or the
75
+ * name a build that hashed the spelling itself gave it - that directory has to stay listable, and
76
+ * therefore collectable by `host gc`, after the upgrade.
77
+ */
78
+ export function socketNamesDirectory(socket, name) {
79
+ return daemonDirectoryName(socket) === name || (process.platform !== "win32" && directoryNameOf(socket) === name);
80
+ }
81
+ function directoryNameOf(endpoint) {
82
+ return createHash("sha256").update(endpoint, "utf8").digest("hex").slice(0, 16);
83
+ }
84
+ export function shardKey(kind, ownerId) {
85
+ return createHash("sha256").update(`${kind}:${ownerId}`, "utf8").digest("hex").slice(0, 16);
86
+ }
87
+ /** The socket of a shard whose key is already known. No hashing: the key IS the name. */
88
+ export function shardSocketPathForKey(root, kind, key) {
89
+ return join(root, `${kind}-${key}.sock`);
90
+ }
91
+ export function shardSocketPath(root, kind, ownerId) {
92
+ return shardSocketPathForKey(root, kind, shardKey(kind, ownerId));
93
+ }
94
+ const SHARD_SOCKET_NAME = /^(p|i)-([0-9a-f]{16})\.sock$/;
95
+ /** Which shard a socket names, from its basename alone; `null` for any other endpoint. */
96
+ export function parseShardSocket(socket) {
97
+ const match = SHARD_SOCKET_NAME.exec(basename(socket));
98
+ const key = match?.[2];
99
+ if (key === undefined)
100
+ return null;
101
+ return { kind: match?.[1] === "i" ? "i" : "p", key };
48
102
  }
49
103
  /**
50
104
  * The daemon directory of ONE endpoint. Both fields are named rather than positional on purpose:
@@ -54,6 +108,7 @@ export function daemonDirectoryName(socket, platform = process.platform) {
54
108
  export function createHostDaemonPaths(target) {
55
109
  const flatDir = join(target.agentDir ?? getAgentDir(), "rpc-host-daemon");
56
110
  return {
111
+ socket: target.socket,
57
112
  flatDir,
58
113
  layoutMarker: join(flatDir, "layout.json"),
59
114
  legacyPidFile: join(flatDir, "host.pid"),
@@ -69,6 +124,7 @@ export function createHostDaemonPaths(target) {
69
124
  export function hostDaemonDirectoryPaths(dir) {
70
125
  return {
71
126
  dir,
127
+ endpointFile: join(dir, "endpoint.json"),
72
128
  pointerFile: join(dir, "host.pid"),
73
129
  lockFile: join(dir, "daemon.lock"),
74
130
  settingsFile: join(dir, "settings.json"),
@@ -116,6 +172,67 @@ export async function createDaemonDirectories(paths) {
116
172
  catch (cause) {
117
173
  throw new HostDaemonStateError(paths.dir, cause);
118
174
  }
175
+ await ensureEndpointIdentity(paths, paths.socket);
176
+ }
177
+ /**
178
+ * Writes `endpoint.json` when it is absent and never rewrites a valid one: the first writer's
179
+ * `created_at` is the endpoint's birth. The file is written whole to a temporary name and LINKED
180
+ * into place, so a reader never sees it half-written and a second writer racing the first loses on
181
+ * the link rather than replacing it. `repair` - asserted only by an ensure under this socket's ensure
182
+ * lock, where `gc` cannot be deciding about the directory - also replaces a file that does not name a
183
+ * socket of this directory (torn by a crash of an older build, or foreign), which would otherwise
184
+ * leave the endpoint unaddressable, and never `gc`-able, for good.
185
+ *
186
+ * A filesystem without hard links (exFAT/FAT, some network and FUSE mounts: `link()` fails with
187
+ * ENOTSUP, EPERM, ENOSYS...) gets the exclusive create of the final file instead. That write is not
188
+ * atomic, but the first writer still wins, and a file torn by a crash is exactly what `repair` rewrites.
189
+ */
190
+ export async function ensureEndpointIdentity(paths, socket, options = {}) {
191
+ const temporary = `${paths.endpointFile}.${process.pid}-${randomUUID()}.tmp`;
192
+ try {
193
+ await mkdir(paths.dir, { recursive: true, mode: DIRECTORY_MODE });
194
+ const record = `${JSON.stringify({ layout: HOST_DAEMON_LAYOUT, socket, created_at: new Date().toISOString() })}\n`;
195
+ await writeFile(temporary, record, { mode: HOST_STATE_FILE_MODE, flag: "wx" });
196
+ const placed = await link(temporary, paths.endpointFile).then(() => true, (cause) => (isErrorCode(cause, "EEXIST") ? false : createExclusively(paths.endpointFile, record)));
197
+ if (!placed && options.repair === true && !(await namesThisDirectory(paths))) {
198
+ await rename(temporary, paths.endpointFile);
199
+ }
200
+ }
201
+ catch (cause) {
202
+ throw new HostDaemonStateError(paths.endpointFile, cause);
203
+ }
204
+ finally {
205
+ await rm(temporary, { force: true });
206
+ }
207
+ }
208
+ /** The pre-link write: create the file only if absent. `false` when another writer got there first. */
209
+ async function createExclusively(path, content) {
210
+ try {
211
+ await writeFile(path, content, { mode: HOST_STATE_FILE_MODE, flag: "wx" });
212
+ return true;
213
+ }
214
+ catch (cause) {
215
+ if (isErrorCode(cause, "EEXIST"))
216
+ return false;
217
+ throw cause;
218
+ }
219
+ }
220
+ function isErrorCode(cause, code) {
221
+ return cause instanceof Error && "code" in cause && cause.code === code;
222
+ }
223
+ async function namesThisDirectory(paths) {
224
+ let record;
225
+ try {
226
+ record = JSON.parse(await readFile(paths.endpointFile, "utf8"));
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ if (typeof record !== "object" || record === null || !("socket" in record))
232
+ return false;
233
+ return (typeof record.socket === "string" &&
234
+ record.socket !== "" &&
235
+ socketNamesDirectory(record.socket, basename(paths.dir)));
119
236
  }
120
237
  /** Creates one generation's private directory. Same failure shape as the daemon directory itself. */
121
238
  export async function createGenerationDirectory(generation) {
@@ -1 +1 @@
1
- {"version":3,"file":"host-daemon-paths.js","sourceRoot":"","sources":["../../../src/modes/rpc/host-daemon-paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAE9C,qGAAqG;AACrG,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAEpC,mGAAmG;AACnG,MAAM,CAAC,MAAM,mBAAmB,GAAG,2BAA2B,CAAC;AAE/D,MAAM,cAAc,GAAG,KAAK,CAAC;AAC7B,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,CAAC;AA8B1C;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc,EAAE,QAAQ,GAAoB,OAAO,CAAC,QAAQ;IAC/F,MAAM,SAAS,GAAG,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;IACxF,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAClF,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAGrC;IACA,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,IAAI,WAAW,EAAE,EAAE,iBAAiB,CAAC,CAAC;IAC1E,OAAO;QACN,OAAO;QACP,YAAY,EAAE,IAAI,CAAC,OAAO,EAAE,aAAa,CAAC;QAC1C,aAAa,EAAE,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC;QACxC,GAAG,wBAAwB,CAAC,IAAI,CAAC,OAAO,EAAE,mBAAmB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;KAC9E,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB,CACvC,GAAW;IAEX,OAAO;QACN,GAAG;QACH,WAAW,EAAE,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC;QAClC,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,aAAa,CAAC;QAClC,YAAY,EAAE,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC;QACxC,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC;QAClC,cAAc,EAAE,IAAI,CAAC,GAAG,EAAE,aAAa,CAAC;QACxC,eAAe,EAAE,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC;KAC1C,CAAC;AACH,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAsB,EAAE,UAAkB;IACzE,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;IACnD,OAAO;QACN,GAAG;QACH,WAAW,EAAE,eAAe,UAAU,EAAE;QACxC,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC;QAC9B,YAAY,EAAE,IAAI,CAAC,GAAG,EAAE,eAAe,CAAC;QACxC,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,SAAS,CAAC;KAChC,CAAC;AACH,CAAC;AAED,2GAA2G;AAC3G,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAG9C,YAAY,IAAY,EAAE,KAAc;QACvC,KAAK,CAAC,8BAA8B,IAAI,mBAAmB,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE;YAC5G,KAAK;SACL,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;QACnC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IAClB,CAAC;CACD;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAAC,KAAsB;IACnE,IAAI,CAAC;QACJ,+FAA+F;QAC/F,yFAAyF;QACzF,MAAM,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC;QACtE,KAAK,MAAM,SAAS,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,eAAe,CAAC,EAAE,CAAC;YAClF,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC;YAClE,MAAM,KAAK,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACxC,CAAC;QACD,MAAM,SAAS,CACd,KAAK,CAAC,YAAY,EAClB,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,GAAG,EAAE,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAC/E,EAAE,IAAI,EAAE,oBAAoB,EAAE,CAC9B,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,MAAM,IAAI,oBAAoB,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;AACF,CAAC;AAED,qGAAqG;AACrG,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAAC,UAA+B;IAC9E,IAAI,CAAC;QACJ,MAAM,KAAK,CAAC,UAAU,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC;QAC9E,MAAM,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;IAC7C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,MAAM,IAAI,oBAAoB,CAAC,UAAU,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACvD,CAAC;AACF,CAAC","sourcesContent":["/**\n * WHERE one socket's daemon keeps its state: the per-socket directory, the files inside it, and the\n * modes they are created with.\n *\n * Layout 2 gives every endpoint its own directory, named by the socket it serves:\n *\n * <agentDir>/rpc-host-daemon/ the flat directory - shared, and left legacy-empty\n * layout.json { layout: 2, dir } - the only file this build writes here\n * <sha256(socket)[:16]>/ 0700, one per endpoint\n * host.pid POINTER: { layout, instance_id, generation_dir, writer }\n * settings.json what the supervisor reads at boot\n * daemon.lock stderr.log\n * generations/<instanceId>/ one per generation of this daemon\n * host.pid settings.json scratch/\n * reservations/ cross-generation session-path claims\n *\n * The flat directory is deliberately missing the one file every DEPLOYED client looks for. A flat\n * `host.pid` holding `{ pid, processStartTime }` is exactly what arms their kill paths - the desktop's\n * `readManagedHost` -> takeover, and a pre-layout-2 `ensureHost` -> `stopManagedHost` - so writing one\n * would make an un-updated client replace this daemon and end every other client's sessions. Without\n * it both fail CLOSED: they see no host of their own, refuse, and leave the daemon alone. Nothing here\n * ever writes a legacy-shaped file, and nothing here ever removes one: a flat `host.pid` that DOES\n * exist belongs to a legacy host that may still be running, and is read-only to this build.\n *\n * What those files CONTAIN is `host-daemon-state.ts` (settings, and the primitives every state file\n * is written through) and `host-daemon-registration.ts` (the pointer and the generation records).\n */\nimport { createHash } from \"node:crypto\";\nimport { chmod, mkdir, writeFile } from \"node:fs/promises\";\nimport { basename, join, win32 } from \"node:path\";\nimport { getAgentDir } from \"../../config.ts\";\n\n/** The layout this build writes. A directory without the marker predates it and is never touched. */\nexport const HOST_DAEMON_LAYOUT = 2;\n\n/** Absolute daemon directory handed to a spawned host, which binds a private socket of its own. */\nexport const HOST_DAEMON_DIR_ENV = \"SENPI_RPC_HOST_DAEMON_DIR\";\n\nconst DIRECTORY_MODE = 0o700;\nexport const HOST_STATE_FILE_MODE = 0o600;\n\nexport interface HostDaemonPaths {\n\t/** `<agentDir>/rpc-host-daemon`: shared by every endpoint, and by any legacy host's own state. */\n\treadonly flatDir: string;\n\t/** The only file this build writes into the flat directory: `{ layout, dir }`. */\n\treadonly layoutMarker: string;\n\t/** A LEGACY host's registration. Read-only evidence that another host may be running. */\n\treadonly legacyPidFile: string;\n\t/** This endpoint's state directory, `<flatDir>/<sha256(socket)[:16]>`. */\n\treadonly dir: string;\n\t/** The pointer at the current generation. Deliberately unparseable as a legacy pidfile. */\n\treadonly pointerFile: string;\n\t/** Cross-version ensure lock for this endpoint (the endpoint lock itself lives in the temp dir). */\n\treadonly lockFile: string;\n\treadonly settingsFile: string;\n\treadonly stderrLog: string;\n\treadonly generationsDir: string;\n\treadonly reservationsDir: string;\n}\n\nexport interface HostGenerationPaths {\n\treadonly dir: string;\n\t/** Where the pointer names this generation: relative to the daemon directory holding it. */\n\treadonly relativeDir: string;\n\treadonly pidFile: string;\n\treadonly settingsFile: string;\n\treadonly scratchDir: string;\n}\n\n/**\n * The directory name every client recomputes from the socket alone: `sha256(<canonical endpoint>)`,\n * where the canonical endpoint is the socket path on POSIX and the normalized lower-cased path on\n * win32 (the same canonicalization the pipe name is derived from, so two spellings of one endpoint\n * share one directory). Deliberately total: naming a directory must never fail on a path shape the\n * transport would reject, or a client could not even report WHERE it was looking.\n */\nexport function daemonDirectoryName(socket: string, platform: NodeJS.Platform = process.platform): string {\n\tconst canonical = platform === \"win32\" ? win32.normalize(socket).toLowerCase() : socket;\n\treturn createHash(\"sha256\").update(canonical, \"utf8\").digest(\"hex\").slice(0, 16);\n}\n\n/**\n * The daemon directory of ONE endpoint. Both fields are named rather than positional on purpose:\n * two strings in a row is exactly the call a refactor silently swaps, and swapping these two would\n * point a client at another socket's state.\n */\nexport function createHostDaemonPaths(target: {\n\treadonly socket: string;\n\treadonly agentDir?: string;\n}): HostDaemonPaths {\n\tconst flatDir = join(target.agentDir ?? getAgentDir(), \"rpc-host-daemon\");\n\treturn {\n\t\tflatDir,\n\t\tlayoutMarker: join(flatDir, \"layout.json\"),\n\t\tlegacyPidFile: join(flatDir, \"host.pid\"),\n\t\t...hostDaemonDirectoryPaths(join(flatDir, daemonDirectoryName(target.socket))),\n\t};\n}\n\n/**\n * The files inside one endpoint's directory, for a caller that was TOLD the directory instead of\n * the socket it serves - a supervised host binds a private hop, so it cannot derive the endpoint.\n * The names live here alone, so the directory a client recomputes and the one a host is handed\n * can never drift apart.\n */\nexport function hostDaemonDirectoryPaths(\n\tdir: string,\n): Omit<HostDaemonPaths, \"flatDir\" | \"layoutMarker\" | \"legacyPidFile\"> {\n\treturn {\n\t\tdir,\n\t\tpointerFile: join(dir, \"host.pid\"),\n\t\tlockFile: join(dir, \"daemon.lock\"),\n\t\tsettingsFile: join(dir, \"settings.json\"),\n\t\tstderrLog: join(dir, \"stderr.log\"),\n\t\tgenerationsDir: join(dir, \"generations\"),\n\t\treservationsDir: join(dir, \"reservations\"),\n\t};\n}\n\nexport function generationPaths(paths: HostDaemonPaths, instanceId: string): HostGenerationPaths {\n\tconst dir = join(paths.generationsDir, instanceId);\n\treturn {\n\t\tdir,\n\t\trelativeDir: `generations/${instanceId}`,\n\t\tpidFile: join(dir, \"host.pid\"),\n\t\tsettingsFile: join(dir, \"settings.json\"),\n\t\tscratchDir: join(dir, \"scratch\"),\n\t};\n}\n\n/** A daemon directory that cannot be created or written, named so the caller can say WHICH path failed. */\nexport class HostDaemonStateError extends Error {\n\treadonly path: string;\n\n\tconstructor(path: string, cause: unknown) {\n\t\tsuper(`RPC daemon state directory ${path} is not usable: ${cause instanceof Error ? cause.message : cause}`, {\n\t\t\tcause,\n\t\t});\n\t\tthis.name = \"HostDaemonStateError\";\n\t\tthis.path = path;\n\t}\n}\n\n/**\n * Creates this endpoint's directories and publishes the flat marker. The modes are set explicitly\n * rather than left to `mkdir`, because a directory that already exists keeps whatever mode it was\n * created with - and this one holds the evidence that decides who may signal the daemon.\n */\nexport async function createDaemonDirectories(paths: HostDaemonPaths): Promise<void> {\n\ttry {\n\t\t// The flat directory may predate this layout and may hold a legacy host's files: it is created\n\t\t// when missing and never re-moded, so a legacy host keeps whatever it set up for itself.\n\t\tawait mkdir(paths.flatDir, { recursive: true, mode: DIRECTORY_MODE });\n\t\tfor (const directory of [paths.dir, paths.generationsDir, paths.reservationsDir]) {\n\t\t\tawait mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });\n\t\t\tawait chmod(directory, DIRECTORY_MODE);\n\t\t}\n\t\tawait writeFile(\n\t\t\tpaths.layoutMarker,\n\t\t\t`${JSON.stringify({ layout: HOST_DAEMON_LAYOUT, dir: basename(paths.dir) })}\\n`,\n\t\t\t{ mode: HOST_STATE_FILE_MODE },\n\t\t);\n\t} catch (cause) {\n\t\tthrow new HostDaemonStateError(paths.dir, cause);\n\t}\n}\n\n/** Creates one generation's private directory. Same failure shape as the daemon directory itself. */\nexport async function createGenerationDirectory(generation: HostGenerationPaths): Promise<void> {\n\ttry {\n\t\tawait mkdir(generation.scratchDir, { recursive: true, mode: DIRECTORY_MODE });\n\t\tawait chmod(generation.dir, DIRECTORY_MODE);\n\t} catch (cause) {\n\t\tthrow new HostDaemonStateError(generation.dir, cause);\n\t}\n}\n"]}
1
+ {"version":3,"file":"host-daemon-paths.js","sourceRoot":"","sources":["../../../src/modes/rpc/host-daemon-paths.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WHERE one socket's daemon keeps its state: the per-socket directory, the files inside it, and the\n * modes they are created with.\n *\n * Layout 2 gives every endpoint its own directory, named by the socket it serves:\n *\n * <agentDir>/rpc-host-daemon/ the flat directory - shared, and left legacy-empty\n * layout.json { layout: 2, dir } - the only file this build writes here\n * <sha256(canonical socket)[:16]>/ 0700, one per endpoint\n * endpoint.json { layout, socket, created_at } - WHICH socket this is\n * host.pid POINTER: { layout, instance_id, generation_dir, writer }\n * settings.json what the supervisor reads at boot\n * daemon.lock stderr.log\n * generations/<instanceId>/ one per generation of this daemon\n * host.pid settings.json scratch/\n * reservations/ cross-generation session-path claims\n *\n * The flat directory is deliberately missing the one file every DEPLOYED client looks for. A flat\n * `host.pid` holding `{ pid, processStartTime }` is exactly what arms their kill paths - the desktop's\n * `readManagedHost` -> takeover, and a pre-layout-2 `ensureHost` -> `stopManagedHost` - so writing one\n * would make an un-updated client replace this daemon and end every other client's sessions. Without\n * it both fail CLOSED: they see no host of their own, refuse, and leave the daemon alone. Nothing here\n * ever writes a legacy-shaped file, and nothing here ever removes one: a flat `host.pid` that DOES\n * exist belongs to a legacy host that may still be running, and is read-only to this build.\n *\n * `endpoint.json` is the endpoint's durable identity: written once and whole (linked into place), never\n * rewritten while it names this directory's socket (an ensure repairs one that does not), and the one file\n * a generation's release leaves behind - so an endpoint whose host exited (cleanly or not) can still\n * be enumerated and named. The pointer, `settings.json` and the generation directories all describe\n * a LIVE host and go with it.\n *\n * What those files CONTAIN is `host-daemon-state.ts` (settings, and the primitives every state file\n * is written through) and `host-daemon-registration.ts` (the pointer and the generation records).\n */\nimport { createHash, randomUUID } from \"node:crypto\";\nimport { chmod, link, mkdir, readFile, rename, rm, writeFile } from \"node:fs/promises\";\nimport { basename, dirname, join, win32 } from \"node:path\";\nimport { getAgentDir } from \"../../config.ts\";\nimport { canonicalSessionPath } from \"./session-path-key.ts\";\n\n/** The layout this build writes. A directory without the marker predates it and is never touched. */\nexport const HOST_DAEMON_LAYOUT = 2;\n\n/** Absolute daemon directory handed to a spawned host, which binds a private socket of its own. */\nexport const HOST_DAEMON_DIR_ENV = \"SENPI_RPC_HOST_DAEMON_DIR\";\n\nconst DIRECTORY_MODE = 0o700;\nexport const HOST_STATE_FILE_MODE = 0o600;\n\nexport interface HostDaemonPaths {\n\t/** The endpoint these paths belong to, exactly as its directory name was derived from. */\n\treadonly socket: string;\n\t/** `<agentDir>/rpc-host-daemon`: shared by every endpoint, and by any legacy host's own state. */\n\treadonly flatDir: string;\n\t/** The only file this build writes into the flat directory: `{ layout, dir }`. */\n\treadonly layoutMarker: string;\n\t/** A LEGACY host's registration. Read-only evidence that another host may be running. */\n\treadonly legacyPidFile: string;\n\t/** This endpoint's state directory, `<flatDir>/<sha256(canonical socket)[:16]>`. */\n\treadonly dir: string;\n\t/** Durable identity `{ layout, socket, created_at }`: survives every generation's release. */\n\treadonly endpointFile: string;\n\t/** The pointer at the current generation. Deliberately unparseable as a legacy pidfile. */\n\treadonly pointerFile: string;\n\t/** Cross-version ensure lock for this endpoint (the endpoint lock itself lives in the temp dir). */\n\treadonly lockFile: string;\n\treadonly settingsFile: string;\n\treadonly stderrLog: string;\n\treadonly generationsDir: string;\n\treadonly reservationsDir: string;\n}\n\n/** The files inside one endpoint's directory, for a reader that has the directory but not its socket. */\nexport type HostDaemonDirectory = Omit<HostDaemonPaths, \"socket\" | \"flatDir\" | \"layoutMarker\" | \"legacyPidFile\">;\n\nexport interface HostGenerationPaths {\n\treadonly dir: string;\n\t/** Where the pointer names this generation: relative to the daemon directory holding it. */\n\treadonly relativeDir: string;\n\treadonly pidFile: string;\n\treadonly settingsFile: string;\n\treadonly scratchDir: string;\n}\n\n/**\n * The one spelling of an endpoint every client derives the same way. On POSIX that is the socket path\n * with its directory resolved through its deepest existing ancestor (`/tmp` vs `/private/tmp`, a\n * symlinked agent directory) - the identity the ensure lock is keyed by - and a path already spelled\n * that way comes back unchanged. On win32 it is the normalized lower-cased path the pipe name is derived\n * from. An abstract socket has no directory to resolve. Deliberately total: naming a directory must never\n * fail on a path shape the transport would reject, or a client could not even report WHERE it was looking.\n */\nexport function canonicalEndpointPath(socket: string, platform: NodeJS.Platform = process.platform): string {\n\tif (platform === \"win32\") return win32.normalize(socket).toLowerCase();\n\tif (socket.startsWith(\"\\0\")) return socket;\n\treturn join(canonicalSessionPath(dirname(socket)), basename(socket));\n}\n\n/**\n * The directory name every client recomputes from the socket alone: `sha256(<canonical endpoint>)`, so\n * every spelling of one endpoint shares one directory, and a canonical spelling keeps the name it had\n * when the name was hashed from the spelling itself.\n */\nexport function daemonDirectoryName(socket: string, platform: NodeJS.Platform = process.platform): string {\n\treturn directoryNameOf(canonicalEndpointPath(socket, platform));\n}\n\n/** Whether two spellings name one endpoint. */\nexport function sameEndpoint(socket: string, other: string): boolean {\n\treturn socket === other || canonicalEndpointPath(socket) === canonicalEndpointPath(other);\n}\n\n/**\n * Whether a record naming `socket` belongs in the directory called `name`: the canonical name, or the\n * name a build that hashed the spelling itself gave it - that directory has to stay listable, and\n * therefore collectable by `host gc`, after the upgrade.\n */\nexport function socketNamesDirectory(socket: string, name: string): boolean {\n\treturn daemonDirectoryName(socket) === name || (process.platform !== \"win32\" && directoryNameOf(socket) === name);\n}\n\nfunction directoryNameOf(endpoint: string): string {\n\treturn createHash(\"sha256\").update(endpoint, \"utf8\").digest(\"hex\").slice(0, 16);\n}\n\n/**\n * The owner-keyed shard naming contract every client computes identically - senpi, omo (library\n * import) and the Desktop (a local mirror checked against `senpi host shard-path`): `p` shards belong\n * to a parent session, `i` shards to an interactive thread. The key is `sha256(\"<kind>:<owner>\")` in\n * hex, first 16 characters; the socket is `<root>/<kind>-<key>.sock`.\n */\nexport type ShardKind = \"p\" | \"i\";\n\nexport function shardKey(kind: ShardKind, ownerId: string): string {\n\treturn createHash(\"sha256\").update(`${kind}:${ownerId}`, \"utf8\").digest(\"hex\").slice(0, 16);\n}\n\n/** The socket of a shard whose key is already known. No hashing: the key IS the name. */\nexport function shardSocketPathForKey(root: string, kind: ShardKind, key: string): string {\n\treturn join(root, `${kind}-${key}.sock`);\n}\n\nexport function shardSocketPath(root: string, kind: ShardKind, ownerId: string): string {\n\treturn shardSocketPathForKey(root, kind, shardKey(kind, ownerId));\n}\n\nconst SHARD_SOCKET_NAME = /^(p|i)-([0-9a-f]{16})\\.sock$/;\n\n/** Which shard a socket names, from its basename alone; `null` for any other endpoint. */\nexport function parseShardSocket(socket: string): { readonly kind: ShardKind; readonly key: string } | null {\n\tconst match = SHARD_SOCKET_NAME.exec(basename(socket));\n\tconst key = match?.[2];\n\tif (key === undefined) return null;\n\treturn { kind: match?.[1] === \"i\" ? \"i\" : \"p\", key };\n}\n\n/**\n * The daemon directory of ONE endpoint. Both fields are named rather than positional on purpose:\n * two strings in a row is exactly the call a refactor silently swaps, and swapping these two would\n * point a client at another socket's state.\n */\nexport function createHostDaemonPaths(target: {\n\treadonly socket: string;\n\treadonly agentDir?: string;\n}): HostDaemonPaths {\n\tconst flatDir = join(target.agentDir ?? getAgentDir(), \"rpc-host-daemon\");\n\treturn {\n\t\tsocket: target.socket,\n\t\tflatDir,\n\t\tlayoutMarker: join(flatDir, \"layout.json\"),\n\t\tlegacyPidFile: join(flatDir, \"host.pid\"),\n\t\t...hostDaemonDirectoryPaths(join(flatDir, daemonDirectoryName(target.socket))),\n\t};\n}\n\n/**\n * The files inside one endpoint's directory, for a caller that was TOLD the directory instead of\n * the socket it serves - a supervised host binds a private hop, so it cannot derive the endpoint.\n * The names live here alone, so the directory a client recomputes and the one a host is handed\n * can never drift apart.\n */\nexport function hostDaemonDirectoryPaths(dir: string): HostDaemonDirectory {\n\treturn {\n\t\tdir,\n\t\tendpointFile: join(dir, \"endpoint.json\"),\n\t\tpointerFile: join(dir, \"host.pid\"),\n\t\tlockFile: join(dir, \"daemon.lock\"),\n\t\tsettingsFile: join(dir, \"settings.json\"),\n\t\tstderrLog: join(dir, \"stderr.log\"),\n\t\tgenerationsDir: join(dir, \"generations\"),\n\t\treservationsDir: join(dir, \"reservations\"),\n\t};\n}\n\nexport function generationPaths(paths: HostDaemonDirectory, instanceId: string): HostGenerationPaths {\n\tconst dir = join(paths.generationsDir, instanceId);\n\treturn {\n\t\tdir,\n\t\trelativeDir: `generations/${instanceId}`,\n\t\tpidFile: join(dir, \"host.pid\"),\n\t\tsettingsFile: join(dir, \"settings.json\"),\n\t\tscratchDir: join(dir, \"scratch\"),\n\t};\n}\n\n/** A daemon directory that cannot be created or written, named so the caller can say WHICH path failed. */\nexport class HostDaemonStateError extends Error {\n\treadonly path: string;\n\n\tconstructor(path: string, cause: unknown) {\n\t\tsuper(`RPC daemon state directory ${path} is not usable: ${cause instanceof Error ? cause.message : cause}`, {\n\t\t\tcause,\n\t\t});\n\t\tthis.name = \"HostDaemonStateError\";\n\t\tthis.path = path;\n\t}\n}\n\n/**\n * Creates this endpoint's directories and publishes the flat marker. The modes are set explicitly\n * rather than left to `mkdir`, because a directory that already exists keeps whatever mode it was\n * created with - and this one holds the evidence that decides who may signal the daemon.\n */\nexport async function createDaemonDirectories(paths: HostDaemonPaths): Promise<void> {\n\ttry {\n\t\t// The flat directory may predate this layout and may hold a legacy host's files: it is created\n\t\t// when missing and never re-moded, so a legacy host keeps whatever it set up for itself.\n\t\tawait mkdir(paths.flatDir, { recursive: true, mode: DIRECTORY_MODE });\n\t\tfor (const directory of [paths.dir, paths.generationsDir, paths.reservationsDir]) {\n\t\t\tawait mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });\n\t\t\tawait chmod(directory, DIRECTORY_MODE);\n\t\t}\n\t\tawait writeFile(\n\t\t\tpaths.layoutMarker,\n\t\t\t`${JSON.stringify({ layout: HOST_DAEMON_LAYOUT, dir: basename(paths.dir) })}\\n`,\n\t\t\t{ mode: HOST_STATE_FILE_MODE },\n\t\t);\n\t} catch (cause) {\n\t\tthrow new HostDaemonStateError(paths.dir, cause);\n\t}\n\tawait ensureEndpointIdentity(paths, paths.socket);\n}\n\n/**\n * Writes `endpoint.json` when it is absent and never rewrites a valid one: the first writer's\n * `created_at` is the endpoint's birth. The file is written whole to a temporary name and LINKED\n * into place, so a reader never sees it half-written and a second writer racing the first loses on\n * the link rather than replacing it. `repair` - asserted only by an ensure under this socket's ensure\n * lock, where `gc` cannot be deciding about the directory - also replaces a file that does not name a\n * socket of this directory (torn by a crash of an older build, or foreign), which would otherwise\n * leave the endpoint unaddressable, and never `gc`-able, for good.\n *\n * A filesystem without hard links (exFAT/FAT, some network and FUSE mounts: `link()` fails with\n * ENOTSUP, EPERM, ENOSYS...) gets the exclusive create of the final file instead. That write is not\n * atomic, but the first writer still wins, and a file torn by a crash is exactly what `repair` rewrites.\n */\nexport async function ensureEndpointIdentity(\n\tpaths: HostDaemonPaths,\n\tsocket: string,\n\toptions: { readonly repair?: boolean } = {},\n): Promise<void> {\n\tconst temporary = `${paths.endpointFile}.${process.pid}-${randomUUID()}.tmp`;\n\ttry {\n\t\tawait mkdir(paths.dir, { recursive: true, mode: DIRECTORY_MODE });\n\t\tconst record = `${JSON.stringify({ layout: HOST_DAEMON_LAYOUT, socket, created_at: new Date().toISOString() })}\\n`;\n\t\tawait writeFile(temporary, record, { mode: HOST_STATE_FILE_MODE, flag: \"wx\" });\n\t\tconst placed = await link(temporary, paths.endpointFile).then(\n\t\t\t() => true,\n\t\t\t(cause: unknown) => (isErrorCode(cause, \"EEXIST\") ? false : createExclusively(paths.endpointFile, record)),\n\t\t);\n\t\tif (!placed && options.repair === true && !(await namesThisDirectory(paths))) {\n\t\t\tawait rename(temporary, paths.endpointFile);\n\t\t}\n\t} catch (cause) {\n\t\tthrow new HostDaemonStateError(paths.endpointFile, cause);\n\t} finally {\n\t\tawait rm(temporary, { force: true });\n\t}\n}\n\n/** The pre-link write: create the file only if absent. `false` when another writer got there first. */\nasync function createExclusively(path: string, content: string): Promise<boolean> {\n\ttry {\n\t\tawait writeFile(path, content, { mode: HOST_STATE_FILE_MODE, flag: \"wx\" });\n\t\treturn true;\n\t} catch (cause) {\n\t\tif (isErrorCode(cause, \"EEXIST\")) return false;\n\t\tthrow cause;\n\t}\n}\n\nfunction isErrorCode(cause: unknown, code: string): boolean {\n\treturn cause instanceof Error && \"code\" in cause && cause.code === code;\n}\n\nasync function namesThisDirectory(paths: HostDaemonPaths): Promise<boolean> {\n\tlet record: unknown;\n\ttry {\n\t\trecord = JSON.parse(await readFile(paths.endpointFile, \"utf8\"));\n\t} catch {\n\t\treturn false;\n\t}\n\tif (typeof record !== \"object\" || record === null || !(\"socket\" in record)) return false;\n\treturn (\n\t\ttypeof record.socket === \"string\" &&\n\t\trecord.socket !== \"\" &&\n\t\tsocketNamesDirectory(record.socket, basename(paths.dir))\n\t);\n}\n\n/** Creates one generation's private directory. Same failure shape as the daemon directory itself. */\nexport async function createGenerationDirectory(generation: HostGenerationPaths): Promise<void> {\n\ttry {\n\t\tawait mkdir(generation.scratchDir, { recursive: true, mode: DIRECTORY_MODE });\n\t\tawait chmod(generation.dir, DIRECTORY_MODE);\n\t} catch (cause) {\n\t\tthrow new HostDaemonStateError(generation.dir, cause);\n\t}\n}\n"]}
@@ -1,5 +1,5 @@
1
1
  import { type DaemonPidFile } from "../app-server/daemon/process.ts";
2
- import { type HostDaemonPaths } from "./host-daemon-paths.ts";
2
+ import { type HostDaemonPaths, type HostGenerationPaths } from "./host-daemon-paths.ts";
3
3
  /** Who wrote a registration: the process identity a later stop must match to be allowed. */
4
4
  export interface HostPidFileWriter {
5
5
  readonly pid: number;
@@ -33,6 +33,16 @@ export declare function readHostRegistration(paths: HostDaemonPaths): Promise<Re
33
33
  * host it names, and the recorded start time keeps a recycled pid from inheriting that right.
34
34
  */
35
35
  export declare function writeHostRegistration(paths: HostDaemonPaths, registration: HostRegistration): Promise<void>;
36
+ /**
37
+ * Records ONE generation's process in its own directory without moving the pointer. A handoff writes
38
+ * this the moment its successor is spawned: until the rename lands the pointer must keep naming the
39
+ * predecessor, yet the successor is already running, and `host gc` has to see that from its own record
40
+ * rather than judge the endpoint by a predecessor that may have died.
41
+ */
42
+ export declare function writeGenerationRecord(paths: HostDaemonPaths, registration: HostRegistration): Promise<{
43
+ generation: HostGenerationPaths;
44
+ writer: HostPidFileWriter;
45
+ }>;
36
46
  /** Drops the pointer, the generation it names and the boot settings: the host behind them is gone. */
37
47
  export declare function clearHostRegistration(paths: HostDaemonPaths): Promise<void>;
38
48
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"host-daemon-registration.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/host-daemon-registration.ts"],"names":[],"mappings":"AA0BA,OAAO,EACN,KAAK,aAAa,EAKlB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAIN,KAAK,eAAe,EACpB,MAAM,wBAAwB,CAAC;AAIhC,4FAA4F;AAC5F,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,iEAAiE;AACjE,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,8FAA8F;IAC9F,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IACpC,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAetG;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CA4BjH;AAED,sGAAsG;AACtG,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAOjF;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACtC,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE;IAAE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC1D,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAGrG;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAChC,UAAU,EAAE,cAAc,GAAG,SAAS,EACtC,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAQpF;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACrC,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,GACjD,OAAO,CAAC,OAAO,CAAC,CASlB;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAGlG;AAID,wBAAgB,oBAAoB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAM7D"}
1
+ {"version":3,"file":"host-daemon-registration.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/host-daemon-registration.ts"],"names":[],"mappings":"AA0BA,OAAO,EACN,KAAK,aAAa,EAKlB,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAIN,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAExB,MAAM,wBAAwB,CAAC;AAIhC,4FAA4F;AAC5F,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,iEAAiE;AACjE,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,8FAA8F;IAC9F,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,cAAc;IAC9B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IACpC,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAetG;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAejH;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CAC1C,KAAK,EAAE,eAAe,EACtB,YAAY,EAAE,gBAAgB,GAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,iBAAiB,CAAA;CAAE,CAAC,CAgBzE;AAED,sGAAsG;AACtG,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAQjF;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACtC,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE;IAAE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC1D,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAGrG;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAChC,UAAU,EAAE,cAAc,GAAG,SAAS,EACtC,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAQpF;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACrC,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,GACjD,OAAO,CAAC,OAAO,CAAC,CASlB;AAED;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,MAAM,EAAE,iBAAiB,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAGlG;AAID,wBAAgB,oBAAoB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAM7D"}
@@ -25,7 +25,7 @@
25
25
  import { rename, rm } from "node:fs/promises";
26
26
  import { engineBuildIdentity } from "../../core/engine-build-identity.js";
27
27
  import { ProcessIdentityUnreadableError, parseDaemonPidFile, processMatchesPidFile, readProcessStartTime, } from "../app-server/daemon/process.js";
28
- import { createGenerationDirectory, generationPaths, HOST_DAEMON_LAYOUT, } from "./host-daemon-paths.js";
28
+ import { createGenerationDirectory, generationPaths, HOST_DAEMON_LAYOUT, sameEndpoint, } from "./host-daemon-paths.js";
29
29
  import { isRecord, parseJson, readFileOrUndefined, writeStateFile } from "./host-daemon-state.js";
30
30
  import { pruneDeadGenerations } from "./host-generations.js";
31
31
  /**
@@ -60,6 +60,24 @@ export async function writeHostRegistration(paths, registration) {
60
60
  // and their session-path claims otherwise accumulate for the life of the agent directory, and a
61
61
  // stale pointer among them reads as "a daemon serves this endpoint" (#1893).
62
62
  await pruneDeadGenerations(paths);
63
+ const { generation, writer } = await writeGenerationRecord(paths, registration);
64
+ // The pointer is replaced by rename: a reader either sees the generation that owned the socket
65
+ // before this call or the one that owns it now, never a half-written pointer.
66
+ await writeStateFile(`${paths.pointerFile}.${process.pid}.tmp`, {
67
+ layout: HOST_DAEMON_LAYOUT,
68
+ instance_id: registration.instanceId,
69
+ generation_dir: generation.relativeDir,
70
+ writer,
71
+ });
72
+ await rename(`${paths.pointerFile}.${process.pid}.tmp`, paths.pointerFile);
73
+ }
74
+ /**
75
+ * Records ONE generation's process in its own directory without moving the pointer. A handoff writes
76
+ * this the moment its successor is spawned: until the rename lands the pointer must keep naming the
77
+ * predecessor, yet the successor is already running, and `host gc` has to see that from its own record
78
+ * rather than judge the endpoint by a predecessor that may have died.
79
+ */
80
+ export async function writeGenerationRecord(paths, registration) {
63
81
  const generation = generationPaths(paths, registration.instanceId);
64
82
  const writer = { pid: process.pid, startTime: await thisProcessStartTime() };
65
83
  const build = engineBuildIdentity();
@@ -74,15 +92,7 @@ export async function writeHostRegistration(paths, registration) {
74
92
  socket: registration.socket,
75
93
  writer,
76
94
  });
77
- // The pointer is replaced by rename: a reader either sees the generation that owned the socket
78
- // before this call or the one that owns it now, never a half-written pointer.
79
- await writeStateFile(`${paths.pointerFile}.${process.pid}.tmp`, {
80
- layout: HOST_DAEMON_LAYOUT,
81
- instance_id: registration.instanceId,
82
- generation_dir: generation.relativeDir,
83
- writer,
84
- });
85
- await rename(`${paths.pointerFile}.${process.pid}.tmp`, paths.pointerFile);
95
+ return { generation, writer };
86
96
  }
87
97
  /** Drops the pointer, the generation it names and the boot settings: the host behind them is gone. */
88
98
  export async function clearHostRegistration(paths) {
@@ -90,8 +100,9 @@ export async function clearHostRegistration(paths) {
90
100
  if (typeof pointer?.instance_id === "string") {
91
101
  await rm(generationPaths(paths, pointer.instance_id).dir, { recursive: true, force: true });
92
102
  }
93
- await rm(paths.pointerFile, { force: true });
94
103
  await rm(paths.settingsFile, { force: true });
104
+ // Last: "no pointer" is what every reader takes as "no host here" (senpi#2241).
105
+ await rm(paths.pointerFile, { force: true });
95
106
  }
96
107
  /**
97
108
  * Drops ONE generation's registration while the files still name it. After a handoff the pointer
@@ -104,11 +115,13 @@ export async function releaseGeneration(paths, owner) {
104
115
  if (record !== undefined && record.pid !== owner.pid)
105
116
  return;
106
117
  const pointer = parseJson(await readFileOrUndefined(paths.pointerFile));
107
- if (pointer?.instance_id === owner.instanceId) {
108
- await rm(paths.pointerFile, { force: true });
118
+ const ownsPointer = pointer?.instance_id === owner.instanceId;
119
+ if (ownsPointer)
109
120
  await rm(paths.settingsFile, { force: true });
110
- }
111
121
  await rm(generation.dir, { recursive: true, force: true });
122
+ // Last: "no pointer" is what every reader takes as "no host here" (senpi#2241).
123
+ if (ownsPointer)
124
+ await rm(paths.pointerFile, { force: true });
112
125
  }
113
126
  /**
114
127
  * A LEGACY host's flat registration, if one is there. This build never writes and never removes it:
@@ -127,7 +140,7 @@ export async function provenOwner(registered, socket) {
127
140
  const record = registered?.record;
128
141
  if (!record || record.processStartTime === null)
129
142
  return undefined;
130
- if (registered?.socket !== undefined && registered.socket !== socket)
143
+ if (registered?.socket !== undefined && !sameEndpoint(registered.socket, socket))
131
144
  return undefined;
132
145
  const identity = { pid: record.pid, processStartTime: record.processStartTime };
133
146
  return (await processMatchesPidFile(identity, readProcessStartTime).catch(() => false))
@@ -1 +1 @@
1
- {"version":3,"file":"host-daemon-registration.js","sourceRoot":"","sources":["../../../src/modes/rpc/host-daemon-registration.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What a running daemon's records SAY, and who is allowed to act on what they say.\n *\n * Two files describe a running daemon, and that split is the point:\n *\n * - the POINTER (`<daemonDir>/host.pid`) names the generation that currently owns the socket, and\n * carries no `pid` or `processStartTime` key, so a client from before layout 2 can neither parse\n * it nor derive a pid to signal from it;\n * - `generations/<instanceId>/host.pid` is the RECORD of one generation: which process serves the\n * socket, which identity guards that pid, which build it runs, and who registered it.\n *\n * Three fields carry the evidence invariant I1 rests on (never terminate, signal or replace a host\n * you did not start), and each exists because a specific mistake is otherwise unprovable:\n *\n * - `processStartTime` - a pid alone is recycled by the OS, so a stale record would authorize a\n * signal to an unrelated process.\n * - `writer` - the identity of the process that WROTE the record. Only that process may stop the\n * host it names; anyone else attaches or refuses.\n * - `instance_id` - which generation the pointer is about, so a predecessor draining after a handoff\n * removes its own directory and leaves the successor's registration alone.\n *\n * Where those files LIVE, and the modes they are written with, belong to `host-daemon-state.ts`;\n * this module reads and writes through its primitives and never builds a path of its own.\n */\nimport { rename, rm } from \"node:fs/promises\";\nimport { engineBuildIdentity } from \"../../core/engine-build-identity.ts\";\nimport {\n\ttype DaemonPidFile,\n\tProcessIdentityUnreadableError,\n\tparseDaemonPidFile,\n\tprocessMatchesPidFile,\n\treadProcessStartTime,\n} from \"../app-server/daemon/process.ts\";\nimport {\n\tcreateGenerationDirectory,\n\tgenerationPaths,\n\tHOST_DAEMON_LAYOUT,\n\ttype HostDaemonPaths,\n} from \"./host-daemon-paths.ts\";\nimport { isRecord, parseJson, readFileOrUndefined, writeStateFile } from \"./host-daemon-state.ts\";\nimport { pruneDeadGenerations } from \"./host-generations.ts\";\n\n/** Who wrote a registration: the process identity a later stop must match to be allowed. */\nexport interface HostPidFileWriter {\n\treadonly pid: number;\n\treadonly startTime: string | null;\n}\n\n/** What an ensure knows about the generation it just spawned. */\nexport interface HostRegistration {\n\treadonly record: DaemonPidFile;\n\treadonly socket: string;\n\treadonly instanceId: string;\n\treadonly generation: number;\n\t/** The profile the spawned host was launched with, for a client comparing two generations. */\n\treadonly launchProfileId: string;\n}\n\nexport interface RegisteredHost {\n\treadonly record: DaemonPidFile;\n\treadonly writer?: HostPidFileWriter;\n\t/** The endpoint this record is about. Absent in records written before the field existed. */\n\treadonly socket?: string;\n\treadonly instanceId: string;\n\treadonly generation: number;\n}\n\n/**\n * The generation the pointer names, or nothing. A pointer without a readable generation record\n * describes no process, so it authorizes nothing - the next ensure overwrites it.\n */\nexport async function readHostRegistration(paths: HostDaemonPaths): Promise<RegisteredHost | undefined> {\n\tconst pointer = parseJson(await readFileOrUndefined(paths.pointerFile));\n\tif (!pointer || typeof pointer.instance_id !== \"string\") return undefined;\n\tconst text = await readFileOrUndefined(generationPaths(paths, pointer.instance_id).pidFile);\n\tconst record = text === undefined ? undefined : parseDaemonPidFile(text);\n\tif (record === undefined) return undefined;\n\tconst parsed = parseJson(text) ?? {};\n\tconst writer = parseWriter(parsed);\n\treturn {\n\t\trecord,\n\t\t...(writer && { writer }),\n\t\t...(typeof parsed.socket === \"string\" && { socket: parsed.socket }),\n\t\tinstanceId: pointer.instance_id,\n\t\tgeneration: typeof parsed.generation === \"number\" ? parsed.generation : 0,\n\t};\n}\n\n/**\n * Registers a generation and points the daemon directory at it, under this process's writer stamp.\n * The stamp is what authorizes a later stop: only the process that wrote a record may signal the\n * host it names, and the recorded start time keeps a recycled pid from inheriting that right.\n */\nexport async function writeHostRegistration(paths: HostDaemonPaths, registration: HostRegistration): Promise<void> {\n\t// Every write is also the moment to drop what is no longer running: records of dead generations\n\t// and their session-path claims otherwise accumulate for the life of the agent directory, and a\n\t// stale pointer among them reads as \"a daemon serves this endpoint\" (#1893).\n\tawait pruneDeadGenerations(paths);\n\tconst generation = generationPaths(paths, registration.instanceId);\n\tconst writer: HostPidFileWriter = { pid: process.pid, startTime: await thisProcessStartTime() };\n\tconst build = engineBuildIdentity();\n\tawait createGenerationDirectory(generation);\n\tawait writeStateFile(generation.pidFile, {\n\t\t...registration.record,\n\t\tinstance_id: registration.instanceId,\n\t\tgeneration: registration.generation,\n\t\tengineVersion: build.text,\n\t\tengineOrdinal: build.ordinal,\n\t\tlaunchProfileId: registration.launchProfileId,\n\t\tsocket: registration.socket,\n\t\twriter,\n\t});\n\t// The pointer is replaced by rename: a reader either sees the generation that owned the socket\n\t// before this call or the one that owns it now, never a half-written pointer.\n\tawait writeStateFile(`${paths.pointerFile}.${process.pid}.tmp`, {\n\t\tlayout: HOST_DAEMON_LAYOUT,\n\t\tinstance_id: registration.instanceId,\n\t\tgeneration_dir: generation.relativeDir,\n\t\twriter,\n\t});\n\tawait rename(`${paths.pointerFile}.${process.pid}.tmp`, paths.pointerFile);\n}\n\n/** Drops the pointer, the generation it names and the boot settings: the host behind them is gone. */\nexport async function clearHostRegistration(paths: HostDaemonPaths): Promise<void> {\n\tconst pointer = parseJson(await readFileOrUndefined(paths.pointerFile));\n\tif (typeof pointer?.instance_id === \"string\") {\n\t\tawait rm(generationPaths(paths, pointer.instance_id).dir, { recursive: true, force: true });\n\t}\n\tawait rm(paths.pointerFile, { force: true });\n\tawait rm(paths.settingsFile, { force: true });\n}\n\n/**\n * Drops ONE generation's registration while the files still name it. After a handoff the pointer\n * belongs to the successor, so a draining predecessor removes only its own directory - taking the\n * pointer with it would leave every client reading no daemon at all while one is serving.\n */\nexport async function releaseGeneration(\n\tpaths: HostDaemonPaths,\n\towner: { readonly instanceId: string; readonly pid: number },\n): Promise<void> {\n\tconst generation = generationPaths(paths, owner.instanceId);\n\tconst record = parseDaemonPidFile((await readFileOrUndefined(generation.pidFile)) ?? \"\");\n\tif (record !== undefined && record.pid !== owner.pid) return;\n\tconst pointer = parseJson(await readFileOrUndefined(paths.pointerFile));\n\tif (pointer?.instance_id === owner.instanceId) {\n\t\tawait rm(paths.pointerFile, { force: true });\n\t\tawait rm(paths.settingsFile, { force: true });\n\t}\n\tawait rm(generation.dir, { recursive: true, force: true });\n}\n\n/**\n * A LEGACY host's flat registration, if one is there. This build never writes and never removes it:\n * it is evidence that a host from before layout 2 may still own the socket, and nothing more.\n */\nexport async function readLegacyHostRecord(paths: HostDaemonPaths): Promise<DaemonPidFile | undefined> {\n\tconst text = await readFileOrUndefined(paths.legacyPidFile);\n\treturn text === undefined ? undefined : parseDaemonPidFile(text);\n}\n\n/**\n * The generation serving this socket, when - and only when - its record PROVES which process that\n * is. An owner nobody can prove may not be signalled at all (I1), so an unreadable identity, a\n * missing guard or a record about another endpoint all read as \"no owner\".\n */\nexport async function provenOwner(\n\tregistered: RegisteredHost | undefined,\n\tsocket: string,\n): Promise<{ pid: number; processStartTime: string; instanceId: string } | undefined> {\n\tconst record = registered?.record;\n\tif (!record || record.processStartTime === null) return undefined;\n\tif (registered?.socket !== undefined && registered.socket !== socket) return undefined;\n\tconst identity = { pid: record.pid, processStartTime: record.processStartTime };\n\treturn (await processMatchesPidFile(identity, readProcessStartTime).catch(() => false))\n\t\t? { ...identity, instanceId: registered.instanceId }\n\t\t: undefined;\n}\n\n/**\n * Whether a LEGACY host is still running behind the flat registration. An identity that cannot be\n * read on a live pid counts as running: the point of asking is to refuse rather than start a second\n * host beside a process that may still own the socket.\n */\nexport async function legacyHostIsLive(\n\tpaths: HostDaemonPaths,\n\tprobe: (pid: number) => Promise<string | undefined>,\n): Promise<boolean> {\n\tconst record = await readLegacyHostRecord(paths);\n\tif (record === undefined) return false;\n\ttry {\n\t\treturn await processMatchesPidFile(record, probe);\n\t} catch (error: unknown) {\n\t\tif (error instanceof ProcessIdentityUnreadableError) return true;\n\t\tthrow error;\n\t}\n}\n\n/**\n * I1 in one predicate: the registration names a host THIS process started. A writer that cannot be\n * proven ours reads as foreign, so the worst case of an unreadable identity is a refusal rather\n * than a signal sent to another owner's host.\n */\nexport async function writtenByThisProcess(writer: HostPidFileWriter | undefined): Promise<boolean> {\n\tif (writer === undefined || writer.pid !== process.pid || writer.startTime === null) return false;\n\treturn writer.startTime === (await thisProcessStartTime());\n}\n\nlet selfStartTime: Promise<string | null> | undefined;\n\nexport function thisProcessStartTime(): Promise<string | null> {\n\tselfStartTime ??= readProcessStartTime(process.pid).then(\n\t\t(value) => value ?? null,\n\t\t() => null,\n\t);\n\treturn selfStartTime;\n}\n\n/** A record from a host started before writer stamps existed simply has no writer: it reads as foreign. */\nfunction parseWriter(parsed: Record<string, unknown>): HostPidFileWriter | undefined {\n\tif (!isRecord(parsed.writer) || typeof parsed.writer.pid !== \"number\") return undefined;\n\tconst { pid, startTime } = parsed.writer;\n\treturn { pid, startTime: typeof startTime === \"string\" ? startTime : null };\n}\n"]}
1
+ {"version":3,"file":"host-daemon-registration.js","sourceRoot":"","sources":["../../../src/modes/rpc/host-daemon-registration.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What a running daemon's records SAY, and who is allowed to act on what they say.\n *\n * Two files describe a running daemon, and that split is the point:\n *\n * - the POINTER (`<daemonDir>/host.pid`) names the generation that currently owns the socket, and\n * carries no `pid` or `processStartTime` key, so a client from before layout 2 can neither parse\n * it nor derive a pid to signal from it;\n * - `generations/<instanceId>/host.pid` is the RECORD of one generation: which process serves the\n * socket, which identity guards that pid, which build it runs, and who registered it.\n *\n * Three fields carry the evidence invariant I1 rests on (never terminate, signal or replace a host\n * you did not start), and each exists because a specific mistake is otherwise unprovable:\n *\n * - `processStartTime` - a pid alone is recycled by the OS, so a stale record would authorize a\n * signal to an unrelated process.\n * - `writer` - the identity of the process that WROTE the record. Only that process may stop the\n * host it names; anyone else attaches or refuses.\n * - `instance_id` - which generation the pointer is about, so a predecessor draining after a handoff\n * removes its own directory and leaves the successor's registration alone.\n *\n * Where those files LIVE, and the modes they are written with, belong to `host-daemon-state.ts`;\n * this module reads and writes through its primitives and never builds a path of its own.\n */\nimport { rename, rm } from \"node:fs/promises\";\nimport { engineBuildIdentity } from \"../../core/engine-build-identity.ts\";\nimport {\n\ttype DaemonPidFile,\n\tProcessIdentityUnreadableError,\n\tparseDaemonPidFile,\n\tprocessMatchesPidFile,\n\treadProcessStartTime,\n} from \"../app-server/daemon/process.ts\";\nimport {\n\tcreateGenerationDirectory,\n\tgenerationPaths,\n\tHOST_DAEMON_LAYOUT,\n\ttype HostDaemonPaths,\n\ttype HostGenerationPaths,\n\tsameEndpoint,\n} from \"./host-daemon-paths.ts\";\nimport { isRecord, parseJson, readFileOrUndefined, writeStateFile } from \"./host-daemon-state.ts\";\nimport { pruneDeadGenerations } from \"./host-generations.ts\";\n\n/** Who wrote a registration: the process identity a later stop must match to be allowed. */\nexport interface HostPidFileWriter {\n\treadonly pid: number;\n\treadonly startTime: string | null;\n}\n\n/** What an ensure knows about the generation it just spawned. */\nexport interface HostRegistration {\n\treadonly record: DaemonPidFile;\n\treadonly socket: string;\n\treadonly instanceId: string;\n\treadonly generation: number;\n\t/** The profile the spawned host was launched with, for a client comparing two generations. */\n\treadonly launchProfileId: string;\n}\n\nexport interface RegisteredHost {\n\treadonly record: DaemonPidFile;\n\treadonly writer?: HostPidFileWriter;\n\t/** The endpoint this record is about. Absent in records written before the field existed. */\n\treadonly socket?: string;\n\treadonly instanceId: string;\n\treadonly generation: number;\n}\n\n/**\n * The generation the pointer names, or nothing. A pointer without a readable generation record\n * describes no process, so it authorizes nothing - the next ensure overwrites it.\n */\nexport async function readHostRegistration(paths: HostDaemonPaths): Promise<RegisteredHost | undefined> {\n\tconst pointer = parseJson(await readFileOrUndefined(paths.pointerFile));\n\tif (!pointer || typeof pointer.instance_id !== \"string\") return undefined;\n\tconst text = await readFileOrUndefined(generationPaths(paths, pointer.instance_id).pidFile);\n\tconst record = text === undefined ? undefined : parseDaemonPidFile(text);\n\tif (record === undefined) return undefined;\n\tconst parsed = parseJson(text) ?? {};\n\tconst writer = parseWriter(parsed);\n\treturn {\n\t\trecord,\n\t\t...(writer && { writer }),\n\t\t...(typeof parsed.socket === \"string\" && { socket: parsed.socket }),\n\t\tinstanceId: pointer.instance_id,\n\t\tgeneration: typeof parsed.generation === \"number\" ? parsed.generation : 0,\n\t};\n}\n\n/**\n * Registers a generation and points the daemon directory at it, under this process's writer stamp.\n * The stamp is what authorizes a later stop: only the process that wrote a record may signal the\n * host it names, and the recorded start time keeps a recycled pid from inheriting that right.\n */\nexport async function writeHostRegistration(paths: HostDaemonPaths, registration: HostRegistration): Promise<void> {\n\t// Every write is also the moment to drop what is no longer running: records of dead generations\n\t// and their session-path claims otherwise accumulate for the life of the agent directory, and a\n\t// stale pointer among them reads as \"a daemon serves this endpoint\" (#1893).\n\tawait pruneDeadGenerations(paths);\n\tconst { generation, writer } = await writeGenerationRecord(paths, registration);\n\t// The pointer is replaced by rename: a reader either sees the generation that owned the socket\n\t// before this call or the one that owns it now, never a half-written pointer.\n\tawait writeStateFile(`${paths.pointerFile}.${process.pid}.tmp`, {\n\t\tlayout: HOST_DAEMON_LAYOUT,\n\t\tinstance_id: registration.instanceId,\n\t\tgeneration_dir: generation.relativeDir,\n\t\twriter,\n\t});\n\tawait rename(`${paths.pointerFile}.${process.pid}.tmp`, paths.pointerFile);\n}\n\n/**\n * Records ONE generation's process in its own directory without moving the pointer. A handoff writes\n * this the moment its successor is spawned: until the rename lands the pointer must keep naming the\n * predecessor, yet the successor is already running, and `host gc` has to see that from its own record\n * rather than judge the endpoint by a predecessor that may have died.\n */\nexport async function writeGenerationRecord(\n\tpaths: HostDaemonPaths,\n\tregistration: HostRegistration,\n): Promise<{ generation: HostGenerationPaths; writer: HostPidFileWriter }> {\n\tconst generation = generationPaths(paths, registration.instanceId);\n\tconst writer: HostPidFileWriter = { pid: process.pid, startTime: await thisProcessStartTime() };\n\tconst build = engineBuildIdentity();\n\tawait createGenerationDirectory(generation);\n\tawait writeStateFile(generation.pidFile, {\n\t\t...registration.record,\n\t\tinstance_id: registration.instanceId,\n\t\tgeneration: registration.generation,\n\t\tengineVersion: build.text,\n\t\tengineOrdinal: build.ordinal,\n\t\tlaunchProfileId: registration.launchProfileId,\n\t\tsocket: registration.socket,\n\t\twriter,\n\t});\n\treturn { generation, writer };\n}\n\n/** Drops the pointer, the generation it names and the boot settings: the host behind them is gone. */\nexport async function clearHostRegistration(paths: HostDaemonPaths): Promise<void> {\n\tconst pointer = parseJson(await readFileOrUndefined(paths.pointerFile));\n\tif (typeof pointer?.instance_id === \"string\") {\n\t\tawait rm(generationPaths(paths, pointer.instance_id).dir, { recursive: true, force: true });\n\t}\n\tawait rm(paths.settingsFile, { force: true });\n\t// Last: \"no pointer\" is what every reader takes as \"no host here\" (senpi#2241).\n\tawait rm(paths.pointerFile, { force: true });\n}\n\n/**\n * Drops ONE generation's registration while the files still name it. After a handoff the pointer\n * belongs to the successor, so a draining predecessor removes only its own directory - taking the\n * pointer with it would leave every client reading no daemon at all while one is serving.\n */\nexport async function releaseGeneration(\n\tpaths: HostDaemonPaths,\n\towner: { readonly instanceId: string; readonly pid: number },\n): Promise<void> {\n\tconst generation = generationPaths(paths, owner.instanceId);\n\tconst record = parseDaemonPidFile((await readFileOrUndefined(generation.pidFile)) ?? \"\");\n\tif (record !== undefined && record.pid !== owner.pid) return;\n\tconst pointer = parseJson(await readFileOrUndefined(paths.pointerFile));\n\tconst ownsPointer = pointer?.instance_id === owner.instanceId;\n\tif (ownsPointer) await rm(paths.settingsFile, { force: true });\n\tawait rm(generation.dir, { recursive: true, force: true });\n\t// Last: \"no pointer\" is what every reader takes as \"no host here\" (senpi#2241).\n\tif (ownsPointer) await rm(paths.pointerFile, { force: true });\n}\n\n/**\n * A LEGACY host's flat registration, if one is there. This build never writes and never removes it:\n * it is evidence that a host from before layout 2 may still own the socket, and nothing more.\n */\nexport async function readLegacyHostRecord(paths: HostDaemonPaths): Promise<DaemonPidFile | undefined> {\n\tconst text = await readFileOrUndefined(paths.legacyPidFile);\n\treturn text === undefined ? undefined : parseDaemonPidFile(text);\n}\n\n/**\n * The generation serving this socket, when - and only when - its record PROVES which process that\n * is. An owner nobody can prove may not be signalled at all (I1), so an unreadable identity, a\n * missing guard or a record about another endpoint all read as \"no owner\".\n */\nexport async function provenOwner(\n\tregistered: RegisteredHost | undefined,\n\tsocket: string,\n): Promise<{ pid: number; processStartTime: string; instanceId: string } | undefined> {\n\tconst record = registered?.record;\n\tif (!record || record.processStartTime === null) return undefined;\n\tif (registered?.socket !== undefined && !sameEndpoint(registered.socket, socket)) return undefined;\n\tconst identity = { pid: record.pid, processStartTime: record.processStartTime };\n\treturn (await processMatchesPidFile(identity, readProcessStartTime).catch(() => false))\n\t\t? { ...identity, instanceId: registered.instanceId }\n\t\t: undefined;\n}\n\n/**\n * Whether a LEGACY host is still running behind the flat registration. An identity that cannot be\n * read on a live pid counts as running: the point of asking is to refuse rather than start a second\n * host beside a process that may still own the socket.\n */\nexport async function legacyHostIsLive(\n\tpaths: HostDaemonPaths,\n\tprobe: (pid: number) => Promise<string | undefined>,\n): Promise<boolean> {\n\tconst record = await readLegacyHostRecord(paths);\n\tif (record === undefined) return false;\n\ttry {\n\t\treturn await processMatchesPidFile(record, probe);\n\t} catch (error: unknown) {\n\t\tif (error instanceof ProcessIdentityUnreadableError) return true;\n\t\tthrow error;\n\t}\n}\n\n/**\n * I1 in one predicate: the registration names a host THIS process started. A writer that cannot be\n * proven ours reads as foreign, so the worst case of an unreadable identity is a refusal rather\n * than a signal sent to another owner's host.\n */\nexport async function writtenByThisProcess(writer: HostPidFileWriter | undefined): Promise<boolean> {\n\tif (writer === undefined || writer.pid !== process.pid || writer.startTime === null) return false;\n\treturn writer.startTime === (await thisProcessStartTime());\n}\n\nlet selfStartTime: Promise<string | null> | undefined;\n\nexport function thisProcessStartTime(): Promise<string | null> {\n\tselfStartTime ??= readProcessStartTime(process.pid).then(\n\t\t(value) => value ?? null,\n\t\t() => null,\n\t);\n\treturn selfStartTime;\n}\n\n/** A record from a host started before writer stamps existed simply has no writer: it reads as foreign. */\nfunction parseWriter(parsed: Record<string, unknown>): HostPidFileWriter | undefined {\n\tif (!isRecord(parsed.writer) || typeof parsed.writer.pid !== \"number\") return undefined;\n\tconst { pid, startTime } = parsed.writer;\n\treturn { pid, startTime: typeof startTime === \"string\" ? startTime : null };\n}\n"]}
@@ -0,0 +1,12 @@
1
+ export type HostEndpointIdentitySource = "endpoint" | "settings" | "generation-settings" | "unknown";
2
+ export interface HostEndpointEntry {
3
+ /** The endpoint this directory serves, or `null` when nothing in it names one. */
4
+ readonly socket: string | null;
5
+ /** The endpoint's daemon directory, `<agentDir>/rpc-host-daemon/<16hex>`. */
6
+ readonly dir: string;
7
+ /** Which file supplied `socket`. */
8
+ readonly identity: HostEndpointIdentitySource;
9
+ }
10
+ /** Every endpoint directory under `agentDir`, sorted by directory name; `[]` before layout 2. */
11
+ export declare function listHostEndpoints(agentDir: string): Promise<readonly HostEndpointEntry[]>;
12
+ //# sourceMappingURL=host-endpoints.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host-endpoints.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/host-endpoints.ts"],"names":[],"mappings":"AAmBA,MAAM,MAAM,0BAA0B,GAAG,UAAU,GAAG,UAAU,GAAG,qBAAqB,GAAG,SAAS,CAAC;AAErG,MAAM,WAAW,iBAAiB;IACjC,kFAAkF;IAClF,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,6EAA6E;IAC7E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,0BAA0B,CAAC;CAC9C;AAID,iGAAiG;AACjG,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,iBAAiB,EAAE,CAAC,CAY/F"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * WHICH endpoints one agent directory holds state for, read from disk alone.
3
+ *
4
+ * A client that runs many hosts under one agent directory (omo: one per parent session, the Desktop:
5
+ * one per thread) cannot enumerate them from the socket side - an endpoint whose host exited serves
6
+ * nothing. The daemon directory is the only record, and each endpoint names itself in it:
7
+ * `endpoint.json` first (durable, survives every generation's release), then the boot `settings.json`,
8
+ * then any generation's own `settings.json`. Every source is accepted only when its socket hashes to
9
+ * the directory it was found in, so a copied or foreign directory never lends its name to another.
10
+ *
11
+ * A directory none of them names is still reported, with `socket: null`: the ensure lock is keyed by
12
+ * a longer hash of the socket's transport address and cannot be rebuilt from the 16-hex name, so such
13
+ * a directory can be shown but never addressed. Reading only (I3): nothing here writes or unlinks.
14
+ */
15
+ import { readdir } from "node:fs/promises";
16
+ import { basename, join } from "node:path";
17
+ import { HOST_DAEMON_LAYOUT, hostDaemonDirectoryPaths, socketNamesDirectory } from "./host-daemon-paths.js";
18
+ import { parseJson, readFileOrUndefined } from "./host-daemon-state.js";
19
+ const ENDPOINT_DIRECTORY_NAME = /^[0-9a-f]{16}$/;
20
+ /** Every endpoint directory under `agentDir`, sorted by directory name; `[]` before layout 2. */
21
+ export async function listHostEndpoints(agentDir) {
22
+ const flatDir = join(agentDir, "rpc-host-daemon");
23
+ const marker = parseJson(await readFileOrUndefined(join(flatDir, "layout.json")).catch(() => undefined));
24
+ if (marker?.layout !== HOST_DAEMON_LAYOUT)
25
+ return [];
26
+ const entries = await readdir(flatDir, { withFileTypes: true }).catch(() => []);
27
+ const dirs = entries
28
+ .filter((entry) => entry.isDirectory() && ENDPOINT_DIRECTORY_NAME.test(entry.name))
29
+ .map((entry) => join(flatDir, entry.name))
30
+ .sort();
31
+ const endpoints = [];
32
+ for (const dir of dirs)
33
+ endpoints.push(await identifyEndpoint(dir));
34
+ return endpoints;
35
+ }
36
+ async function identifyEndpoint(dir) {
37
+ const paths = hostDaemonDirectoryPaths(dir);
38
+ const named = await socketNamedBy(paths.endpointFile, dir);
39
+ if (named !== undefined)
40
+ return { socket: named, dir, identity: "endpoint" };
41
+ const booted = await socketNamedBy(paths.settingsFile, dir);
42
+ if (booted !== undefined)
43
+ return { socket: booted, dir, identity: "settings" };
44
+ const generations = await readdir(paths.generationsDir).catch(() => []);
45
+ for (const instanceId of generations.sort()) {
46
+ const recorded = await socketNamedBy(join(paths.generationsDir, instanceId, "settings.json"), dir);
47
+ if (recorded !== undefined)
48
+ return { socket: recorded, dir, identity: "generation-settings" };
49
+ }
50
+ return { socket: null, dir, identity: "unknown" };
51
+ }
52
+ async function socketNamedBy(file, dir) {
53
+ const record = parseJson(await readFileOrUndefined(file).catch(() => undefined));
54
+ const socket = record?.socket;
55
+ if (typeof socket !== "string" || socket === "")
56
+ return undefined;
57
+ return socketNamesDirectory(socket, basename(dir)) ? socket : undefined;
58
+ }
59
+ //# sourceMappingURL=host-endpoints.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host-endpoints.js","sourceRoot":"","sources":["../../../src/modes/rpc/host-endpoints.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WHICH endpoints one agent directory holds state for, read from disk alone.\n *\n * A client that runs many hosts under one agent directory (omo: one per parent session, the Desktop:\n * one per thread) cannot enumerate them from the socket side - an endpoint whose host exited serves\n * nothing. The daemon directory is the only record, and each endpoint names itself in it:\n * `endpoint.json` first (durable, survives every generation's release), then the boot `settings.json`,\n * then any generation's own `settings.json`. Every source is accepted only when its socket hashes to\n * the directory it was found in, so a copied or foreign directory never lends its name to another.\n *\n * A directory none of them names is still reported, with `socket: null`: the ensure lock is keyed by\n * a longer hash of the socket's transport address and cannot be rebuilt from the 16-hex name, so such\n * a directory can be shown but never addressed. Reading only (I3): nothing here writes or unlinks.\n */\nimport { readdir } from \"node:fs/promises\";\nimport { basename, join } from \"node:path\";\nimport { HOST_DAEMON_LAYOUT, hostDaemonDirectoryPaths, socketNamesDirectory } from \"./host-daemon-paths.ts\";\nimport { parseJson, readFileOrUndefined } from \"./host-daemon-state.ts\";\n\nexport type HostEndpointIdentitySource = \"endpoint\" | \"settings\" | \"generation-settings\" | \"unknown\";\n\nexport interface HostEndpointEntry {\n\t/** The endpoint this directory serves, or `null` when nothing in it names one. */\n\treadonly socket: string | null;\n\t/** The endpoint's daemon directory, `<agentDir>/rpc-host-daemon/<16hex>`. */\n\treadonly dir: string;\n\t/** Which file supplied `socket`. */\n\treadonly identity: HostEndpointIdentitySource;\n}\n\nconst ENDPOINT_DIRECTORY_NAME = /^[0-9a-f]{16}$/;\n\n/** Every endpoint directory under `agentDir`, sorted by directory name; `[]` before layout 2. */\nexport async function listHostEndpoints(agentDir: string): Promise<readonly HostEndpointEntry[]> {\n\tconst flatDir = join(agentDir, \"rpc-host-daemon\");\n\tconst marker = parseJson(await readFileOrUndefined(join(flatDir, \"layout.json\")).catch(() => undefined));\n\tif (marker?.layout !== HOST_DAEMON_LAYOUT) return [];\n\tconst entries = await readdir(flatDir, { withFileTypes: true }).catch(() => []);\n\tconst dirs = entries\n\t\t.filter((entry) => entry.isDirectory() && ENDPOINT_DIRECTORY_NAME.test(entry.name))\n\t\t.map((entry) => join(flatDir, entry.name))\n\t\t.sort();\n\tconst endpoints: HostEndpointEntry[] = [];\n\tfor (const dir of dirs) endpoints.push(await identifyEndpoint(dir));\n\treturn endpoints;\n}\n\nasync function identifyEndpoint(dir: string): Promise<HostEndpointEntry> {\n\tconst paths = hostDaemonDirectoryPaths(dir);\n\tconst named = await socketNamedBy(paths.endpointFile, dir);\n\tif (named !== undefined) return { socket: named, dir, identity: \"endpoint\" };\n\tconst booted = await socketNamedBy(paths.settingsFile, dir);\n\tif (booted !== undefined) return { socket: booted, dir, identity: \"settings\" };\n\tconst generations = await readdir(paths.generationsDir).catch(() => [] as string[]);\n\tfor (const instanceId of generations.sort()) {\n\t\tconst recorded = await socketNamedBy(join(paths.generationsDir, instanceId, \"settings.json\"), dir);\n\t\tif (recorded !== undefined) return { socket: recorded, dir, identity: \"generation-settings\" };\n\t}\n\treturn { socket: null, dir, identity: \"unknown\" };\n}\n\nasync function socketNamedBy(file: string, dir: string): Promise<string | undefined> {\n\tconst record = parseJson(await readFileOrUndefined(file).catch(() => undefined));\n\tconst socket = record?.socket;\n\tif (typeof socket !== \"string\" || socket === \"\") return undefined;\n\treturn socketNamesDirectory(socket, basename(dir)) ? socket : undefined;\n}\n"]}
@@ -0,0 +1,11 @@
1
+ /** The endpoint lock every ensure of `socket` serializes on, without its `.lock` suffix. */
2
+ export declare function hostEnsureLockTarget(socket: string): string;
3
+ export declare function acquireHostEnsureLock(socket: string, waitMs: number): Promise<() => Promise<void>>;
4
+ export declare function hostEnsureLockOptions(waitMs: number): {
5
+ readonly retries: {
6
+ readonly retries: number;
7
+ readonly minTimeout: 20;
8
+ readonly maxTimeout: 100;
9
+ };
10
+ };
11
+ //# sourceMappingURL=host-ensure-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host-ensure-lock.d.ts","sourceRoot":"","sources":["../../../src/modes/rpc/host-ensure-lock.ts"],"names":[],"mappings":"AAoBA,4FAA4F;AAC5F,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,wBAAsB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAKxG;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM;aAElD,OAAO;iBAAI,OAAO;iBAAyC,UAAU,EAAE,EAAE;iBAAE,UAAU;;EAEtF"}