@reticlehq/server 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/LICENSE-ENTERPRISE +1 -1
  2. package/README.md +3 -3
  3. package/SKILL.md +12 -14
  4. package/dist/chromium-launch-options.d.ts +4 -3
  5. package/dist/chromium-launch-options.js +6 -2
  6. package/dist/command/cli/adhoc-suite.d.ts +22 -0
  7. package/dist/command/cli/adhoc-suite.js +90 -19
  8. package/dist/command/cli/adhoc-verdict.d.ts +35 -0
  9. package/dist/command/cli/adhoc-verdict.js +90 -3
  10. package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
  11. package/dist/command/cli/answers/gate-hook-message.js +6 -0
  12. package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
  13. package/dist/command/cli/auth/cloud-keystore.js +0 -3
  14. package/dist/command/cli/auth/cloud-session.d.ts +4 -0
  15. package/dist/command/cli/auth/cloud-session.js +2 -0
  16. package/dist/command/cli/cli-doctor.js +7 -5
  17. package/dist/command/cli/cli-flow-commands.d.ts +3 -6
  18. package/dist/command/cli/cli-flow-commands.js +67 -4
  19. package/dist/command/cli/cli-parse.d.ts +8 -1
  20. package/dist/command/cli/cli-parse.js +21 -3
  21. package/dist/command/cli/cli-update-commands.js +8 -1
  22. package/dist/command/cli/cli-verify.d.ts +24 -3
  23. package/dist/command/cli/cli-verify.js +54 -11
  24. package/dist/command/cli/cloud-cli.js +5 -2
  25. package/dist/command/cli/cloud-kit.js +3 -5
  26. package/dist/command/cli/cloud-login.js +21 -3
  27. package/dist/command/cli/demo/demo-page.js +6 -3
  28. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
  29. package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
  30. package/dist/command/cli/launch/cli-launch.d.ts +8 -0
  31. package/dist/command/cli/launch/cli-launch.js +21 -9
  32. package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
  33. package/dist/command/cli/launch/sdk-version-match.js +206 -0
  34. package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
  35. package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
  36. package/dist/command/cli/ports/resolve/cli-port.js +125 -15
  37. package/dist/command/cli/setup-mcp-cli.js +13 -13
  38. package/dist/command/cli/tutorial.d.ts +2 -2
  39. package/dist/command/cli/tutorial.js +21 -7
  40. package/dist/command/cli.js +62 -10
  41. package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
  42. package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
  43. package/dist/command/daemon/daemon-resolve.d.ts +9 -0
  44. package/dist/command/daemon/daemon-resolve.js +1 -1
  45. package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
  46. package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
  47. package/dist/command/dev/stale-issue-guard.d.ts +3 -4
  48. package/dist/command/dev/stale-issue-guard.js +8 -8
  49. package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
  50. package/dist/command/setup/bringup/connect-lease.js +38 -0
  51. package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
  52. package/dist/command/setup/bringup/dev-server-files.js +131 -0
  53. package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
  54. package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
  55. package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
  56. package/dist/command/setup/bringup/ensure-daemon.js +135 -16
  57. package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
  58. package/dist/command/setup/bringup/static-page-server.js +151 -0
  59. package/dist/command/setup/desktop-shape.d.ts +4 -0
  60. package/dist/command/setup/desktop-shape.js +18 -2
  61. package/dist/command/setup/init/init-port.d.ts +15 -0
  62. package/dist/command/setup/init/init-port.js +44 -0
  63. package/dist/command/setup/init/init-runtime.d.ts +1 -1
  64. package/dist/command/setup/init/init-runtime.js +39 -2
  65. package/dist/command/setup/node-effects.d.ts +27 -14
  66. package/dist/command/setup/node-effects.js +80 -44
  67. package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
  68. package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
  69. package/dist/command/setup/probe/page-probe.d.ts +13 -2
  70. package/dist/command/setup/probe/page-probe.js +18 -3
  71. package/dist/command/setup/run-setup.d.ts +26 -1
  72. package/dist/command/setup/run-setup.js +96 -13
  73. package/dist/command/setup/session-pick.d.ts +13 -0
  74. package/dist/command/setup/session-pick.js +20 -0
  75. package/dist/command/setup/setup-command.d.ts +2 -14
  76. package/dist/command/setup/setup-command.js +105 -31
  77. package/dist/command/setup/setup-mcp.js +3 -3
  78. package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
  79. package/dist/command/setup/terminal/wait-progress.js +17 -0
  80. package/dist/features/crawl/crawl-empty.js +1 -1
  81. package/dist/features/crawl/crawl-tools.d.ts +6 -0
  82. package/dist/features/crawl/crawl-tools.js +57 -0
  83. package/dist/features/crawl/crawl.js +171 -157
  84. package/dist/features/exhaust/app-coverage.d.ts +33 -0
  85. package/dist/features/exhaust/app-coverage.js +31 -0
  86. package/dist/features/exhaust/code-coverage.d.ts +47 -0
  87. package/dist/features/exhaust/code-coverage.js +77 -0
  88. package/dist/features/exhaust/exhaust-run.d.ts +33 -0
  89. package/dist/features/exhaust/exhaust-run.js +82 -0
  90. package/dist/features/exhaust/explorer.d.ts +103 -0
  91. package/dist/features/exhaust/explorer.js +195 -0
  92. package/dist/features/exhaust/failure-branches.d.ts +33 -0
  93. package/dist/features/exhaust/failure-branches.js +92 -0
  94. package/dist/features/exhaust/ledger.d.ts +202 -0
  95. package/dist/features/exhaust/ledger.js +252 -0
  96. package/dist/features/exhaust/session-fold.d.ts +31 -0
  97. package/dist/features/exhaust/session-fold.js +79 -0
  98. package/dist/features/exhaust/session-port.d.ts +36 -0
  99. package/dist/features/exhaust/session-port.js +102 -0
  100. package/dist/features/harness/drive-report.d.ts +0 -9
  101. package/dist/features/harness/drive-report.js +18 -0
  102. package/dist/features/harness/driver.js +4 -7
  103. package/dist/features/harness/fill-values.d.ts +6 -0
  104. package/dist/features/harness/fill-values.js +3 -0
  105. package/dist/features/harness/jev-driver.js +30 -13
  106. package/dist/features/harness/openai-driver.js +4 -7
  107. package/dist/features/harness/platform-config.js +5 -6
  108. package/dist/index.d.ts +9 -0
  109. package/dist/index.js +40 -11
  110. package/dist/judgement/domain/domain-model.js +3 -1
  111. package/dist/judgement/oracles/self-instrument.js +4 -2
  112. package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
  113. package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
  114. package/dist/judgement/runs/verification-sync.d.ts +3 -1
  115. package/dist/judgement/runs/verification-sync.js +1 -1
  116. package/dist/language/flows/change/affected.d.ts +9 -0
  117. package/dist/language/flows/change/affected.js +35 -0
  118. package/dist/language/flows/change/gate.d.ts +20 -0
  119. package/dist/language/flows/change/gate.js +12 -1
  120. package/dist/language/flows/flow-author.d.ts +7 -0
  121. package/dist/language/flows/flow-author.js +19 -0
  122. package/dist/language/flows/flow-expect-grammar.js +1 -1
  123. package/dist/language/flows/flow-journey.d.ts +54 -0
  124. package/dist/language/flows/flow-journey.js +119 -0
  125. package/dist/language/flows/flow-replay-run.d.ts +2 -3
  126. package/dist/language/flows/flow-replay-run.js +13 -47
  127. package/dist/language/flows/flow-replay.d.ts +22 -8
  128. package/dist/language/flows/flow-replay.js +55 -9
  129. package/dist/language/flows/flow-result.d.ts +2 -0
  130. package/dist/language/flows/flow-result.js +25 -0
  131. package/dist/language/flows/flow-tools.js +17 -5
  132. package/dist/language/flows/flows.d.ts +10 -1
  133. package/dist/language/flows/flows.js +90 -14
  134. package/dist/language/flows/recording/replay-halt.js +3 -1
  135. package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
  136. package/dist/language/flows/recording/tape/recordings.js +55 -1
  137. package/dist/language/flows/replay.d.ts +19 -0
  138. package/dist/language/flows/replay.js +42 -6
  139. package/dist/language/flows/verify-change-tools.js +5 -2
  140. package/dist/launch-chromium.d.ts +80 -0
  141. package/dist/launch-chromium.js +218 -0
  142. package/dist/memory/cloud/cloud-config.d.ts +11 -2
  143. package/dist/memory/cloud/cloud-config.js +47 -7
  144. package/dist/memory/cloud/cloud-sync.d.ts +4 -1
  145. package/dist/memory/cloud/cloud-sync.js +6 -9
  146. package/dist/memory/cloud/harness-offer.js +5 -6
  147. package/dist/memory/cloud/harness-switch.js +5 -6
  148. package/dist/memory/cloud/sync-cycle.d.ts +68 -3
  149. package/dist/memory/cloud/sync-cycle.js +298 -55
  150. package/dist/memory/cloud/sync-daemon.js +24 -20
  151. package/dist/memory/cloud/sync-disk.js +16 -2
  152. package/dist/memory/intent/intent-shard.d.ts +10 -32
  153. package/dist/memory/intent/intent-shard.js +0 -10
  154. package/dist/memory/journal/drive-flow.d.ts +18 -13
  155. package/dist/memory/journal/drive-flow.js +45 -37
  156. package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
  157. package/dist/memory/journal/session-end.d.ts +9 -1
  158. package/dist/memory/journal/session-end.js +24 -12
  159. package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
  160. package/dist/memory/project/dir/reticle-dir.js +1 -0
  161. package/dist/memory/project/project-tools.js +3 -1
  162. package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
  163. package/dist/portal/bridge/auth-failure-reason.js +4 -2
  164. package/dist/portal/bridge/bridge.d.ts +8 -0
  165. package/dist/portal/bridge/bridge.js +43 -2
  166. package/dist/portal/input/js-coverage.d.ts +25 -0
  167. package/dist/portal/input/js-coverage.js +30 -0
  168. package/dist/portal/input/real-input.d.ts +11 -0
  169. package/dist/portal/input/real-input.js +29 -4
  170. package/dist/portal/pool/playwright-launcher.d.ts +6 -0
  171. package/dist/portal/pool/playwright-launcher.js +20 -13
  172. package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
  173. package/dist/portal/session/dev-server/dev-command.js +30 -12
  174. package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
  175. package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
  176. package/dist/portal/session/dev-server/route-status-probe.js +57 -0
  177. package/dist/portal/session/facts/observed-state.d.ts +11 -0
  178. package/dist/portal/session/facts/observed-state.js +34 -0
  179. package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
  180. package/dist/portal/session/no-session-diagnosis.js +62 -5
  181. package/dist/portal/session/no-session-next-action.d.ts +13 -0
  182. package/dist/portal/session/no-session-next-action.js +48 -9
  183. package/dist/portal/session/no-session-watch.d.ts +18 -0
  184. package/dist/portal/session/no-session-watch.js +88 -2
  185. package/dist/portal/session/session-health.js +3 -2
  186. package/dist/portal/session/session-manager.d.ts +20 -6
  187. package/dist/portal/session/session-manager.js +19 -3
  188. package/dist/portal/session/session-reaper.d.ts +28 -0
  189. package/dist/portal/session/session-reaper.js +49 -1
  190. package/dist/portal/session/session.d.ts +5 -0
  191. package/dist/portal/session/session.js +15 -19
  192. package/dist/surface/http-server.d.ts +2 -1
  193. package/dist/surface/http-server.js +4 -2
  194. package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
  195. package/dist/surface/mcp/mcp-proxy.js +2 -2
  196. package/dist/surface/mcp/mcp.js +4 -2
  197. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
  198. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
  199. package/dist/surface/mcp/server-instructions.js +9 -7
  200. package/dist/surface/tools/act/after-match.d.ts +17 -0
  201. package/dist/surface/tools/act/after-match.js +30 -0
  202. package/dist/surface/tools/act/durable.d.ts +69 -0
  203. package/dist/surface/tools/act/durable.js +139 -0
  204. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
  205. package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
  206. package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
  207. package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
  208. package/dist/surface/tools/act/proved-controls.d.ts +9 -0
  209. package/dist/surface/tools/act/proved-controls.js +11 -0
  210. package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
  211. package/dist/surface/tools/act/sequence-grade.js +32 -1
  212. package/dist/surface/tools/act-capsule.d.ts +7 -0
  213. package/dist/surface/tools/act-capsule.js +46 -1
  214. package/dist/surface/tools/act-sequence-tool.js +25 -8
  215. package/dist/surface/tools/act-tools.js +26 -50
  216. package/dist/surface/tools/assert/assert-grade.js +12 -1
  217. package/dist/surface/tools/assert/assert-verdict.js +2 -2
  218. package/dist/surface/tools/browser-tools.js +14 -2
  219. package/dist/surface/tools/coverage-tools.js +27 -1
  220. package/dist/surface/tools/error-recovery.js +4 -4
  221. package/dist/surface/tools/feedback-invite.js +6 -4
  222. package/dist/surface/tools/harness-explore.d.ts +3 -2
  223. package/dist/surface/tools/harness-explore.js +7 -6
  224. package/dist/surface/tools/lease-tools.js +15 -6
  225. package/dist/surface/tools/live-call-text.js +4 -1
  226. package/dist/surface/tools/observe-tools.js +9 -1
  227. package/dist/surface/tools/tools.js +4 -0
  228. package/dist/telemetry/argument-shape.d.ts +0 -2
  229. package/dist/telemetry/argument-shape.js +0 -4
  230. package/dist/telemetry/error-class.d.ts +2 -3
  231. package/dist/telemetry/error-class.js +2 -3
  232. package/dist/telemetry/harness-drive.d.ts +0 -2
  233. package/dist/telemetry/harness-drive.js +0 -4
  234. package/dist/wire-journal.d.ts +3 -0
  235. package/dist/wire-journal.js +4 -0
  236. package/dist/workspace-packages.d.ts +0 -2
  237. package/dist/workspace-packages.js +1 -9
  238. package/docs/README.md +4 -2
  239. package/docs/actions.mdx +3 -3
  240. package/docs/agent-cheatsheet.md +4 -4
  241. package/docs/architecture.md +6 -6
  242. package/docs/best-practices.mdx +1 -1
  243. package/docs/capabilities.mdx +32 -1
  244. package/docs/cli/cloud.mdx +14 -4
  245. package/docs/cli/doctor.mdx +4 -0
  246. package/docs/cli/feedback.mdx +2 -2
  247. package/docs/cli/gate.mdx +9 -4
  248. package/docs/cli/update.mdx +10 -0
  249. package/docs/cli.mdx +9 -0
  250. package/docs/desktop.mdx +14 -10
  251. package/docs/environment.mdx +87 -0
  252. package/docs/faq.mdx +18 -16
  253. package/docs/first-drive.md +5 -13
  254. package/docs/flows.md +21 -2
  255. package/docs/frameworks.mdx +29 -10
  256. package/docs/getting-started.md +3 -3
  257. package/docs/http-transport.md +6 -3
  258. package/docs/human-control.md +2 -2
  259. package/docs/index.mdx +16 -6
  260. package/docs/install-agentic.mdx +1 -1
  261. package/docs/install-manual.mdx +1 -1
  262. package/docs/integration-patterns.md +3 -3
  263. package/docs/multi-agent-testing.md +1 -1
  264. package/docs/packages/babel-plugin.mdx +1 -1
  265. package/docs/packages/browser.mdx +2 -2
  266. package/docs/packages/react.mdx +2 -2
  267. package/docs/packages/tauri.mdx +1 -1
  268. package/docs/packages/vite-plugin.mdx +2 -2
  269. package/docs/packages.mdx +9 -9
  270. package/docs/predicates.mdx +50 -5
  271. package/docs/quickstart.mdx +2 -2
  272. package/docs/recipes.mdx +2 -2
  273. package/docs/state-management.mdx +2 -2
  274. package/docs/system-map.md +1 -1
  275. package/docs/telemetry.md +1 -1
  276. package/docs/token-efficiency.md +3 -3
  277. package/docs/tools/act-and-wait.mdx +1 -0
  278. package/docs/tools/act-sequence.mdx +1 -1
  279. package/docs/tools/console.mdx +1 -1
  280. package/docs/tools/inspect.mdx +1 -1
  281. package/docs/tools/network.mdx +1 -1
  282. package/docs/tools/query.mdx +1 -1
  283. package/docs/tools/sessions.mdx +1 -1
  284. package/docs/tools/snapshot.mdx +1 -1
  285. package/docs/tools/state.mdx +1 -1
  286. package/docs/tools/wait-for.mdx +1 -1
  287. package/docs/usage.md +7 -6
  288. package/docs/use-cases.mdx +157 -0
  289. package/docs/vs-screenshots.mdx +3 -3
  290. package/docs/what-is-recorded.md +2 -2
  291. package/docs/what-is-reticle.mdx +17 -20
  292. package/docs/why-reticle.mdx +14 -21
  293. package/package.json +10 -9
  294. package/dist/judgement/capsule/minimize.d.ts +0 -12
  295. package/dist/judgement/capsule/minimize.js +0 -22
  296. package/dist/judgement/phenomena/phenomena.d.ts +0 -27
  297. package/dist/judgement/phenomena/phenomena.js +0 -101
  298. package/dist/language/flows/flow-report.d.ts +0 -22
  299. package/dist/language/flows/flow-report.js +0 -106
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What `init` leaves in the state home about a dev server it handed over: the log, its one rotated
3
+ * predecessor, and a record of where the server is.
4
+ *
5
+ * All keyed on the app directory, beside the daemon logs and so outside the project: no bundler
6
+ * watcher ever sees them change (a `.reticle/` write inside the app once put Vite into a continuous
7
+ * full-reload loop), and two projects never share a file.
8
+ */
9
+ import { z } from 'zod';
10
+ export declare function devServerLogName(cwd: string): string;
11
+ /** Where a handed-over server is: enough to find it again and to prove it is still that server. */
12
+ declare const HandedOverSchema: z.ZodObject<{
13
+ pid: z.ZodNumber;
14
+ url: z.ZodString;
15
+ appDir: z.ZodString;
16
+ }, "strip", z.ZodTypeAny, {
17
+ url: string;
18
+ pid: number;
19
+ appDir: string;
20
+ }, {
21
+ url: string;
22
+ pid: number;
23
+ appDir: string;
24
+ }>;
25
+ export type HandedOver = z.infer<typeof HandedOverSchema>;
26
+ /**
27
+ * Written when `init` leaves a server running, so the next `init` can attach to it.
28
+ *
29
+ * Only Vite announces itself (through its plugin). Re-running init while a Next or Angular server
30
+ * it had started was still up spawned a second `dev`, which failed with "port 3000 is already in use
31
+ * — something else is serving there" — the something being the server init itself had left.
32
+ */
33
+ export declare function recordHandOver(dir: string, record: HandedOver): void;
34
+ /**
35
+ * The url of the server `init` handed over for `appDir`, when that process is alive AND the url
36
+ * still answers — both, because a recycled pid proves nothing and a url answering proves only that
37
+ * SOMETHING is there. Undefined otherwise, and the caller starts one.
38
+ */
39
+ export declare function handedOverUrl(dir: string, appDir: string, deps: {
40
+ alive: (pid: number) => boolean;
41
+ answers: (url: string) => Promise<boolean>;
42
+ }): Promise<string | undefined>;
43
+ /**
44
+ * Stop the server `init` handed over for `appDir`, and forget it. True when one was stopped.
45
+ *
46
+ * For a port move: Next reads the daemon url into `NEXT_PUBLIC_RETICLE_URL` once, when `next dev`
47
+ * starts, so attaching to the running server after `init --port <new>` left the page dialling the
48
+ * old port — which the same run had just stopped. Only a server this record proves init started is
49
+ * touched; one the user started is theirs to restart.
50
+ */
51
+ export declare function stopHandedOver(dir: string, appDir: string, deps: {
52
+ alive: (pid: number) => boolean;
53
+ kill: (pid: number) => void;
54
+ }): boolean;
55
+ /**
56
+ * Remove what servers that are gone left behind: their logs, rotated logs and records.
57
+ *
58
+ * Every run wrote a log per project and nothing ever deleted one, so the state home kept a file for
59
+ * every app `init` had ever started. A record whose pid is dead takes its logs with it; a log with no
60
+ * record at all goes once it is old. `keep` is the app about to start, whose files are replaced
61
+ * anyway.
62
+ */
63
+ export declare function sweepDeadDevServers(dir: string, deps: {
64
+ alive: (pid: number) => boolean;
65
+ now: number;
66
+ keep?: string;
67
+ }): void;
68
+ export {};
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What `init` leaves in the state home about a dev server it handed over: the log, its one rotated
3
+ * predecessor, and a record of where the server is.
4
+ *
5
+ * All keyed on the app directory, beside the daemon logs and so outside the project: no bundler
6
+ * watcher ever sees them change (a `.reticle/` write inside the app once put Vite into a continuous
7
+ * full-reload loop), and two projects never share a file.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ import { readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { z } from 'zod';
13
+ import { ROTATED_SUFFIX } from './dev-server-log-cap.js';
14
+ const DEV_SERVER_PREFIX = 'dev-server-';
15
+ const LOG_SUFFIX = '.log';
16
+ const RECORD_SUFFIX = '.json';
17
+ /**
18
+ * A log with no record is from a build before records existed, so there is no pid to ask. Anything
19
+ * this old is from a server long gone; a live one that went this quiet loses a file it was not using.
20
+ */
21
+ const UNRECORDED_LOG_MAX_AGE_MS = 24 * 60 * 60_000;
22
+ const OWN_FILE = /^dev-server-([0-9a-f]{12})\.(?:log|log\.1|json)$/;
23
+ function keyOf(cwd) {
24
+ return createHash('sha256').update(cwd).digest('hex').slice(0, 12);
25
+ }
26
+ export function devServerLogName(cwd) {
27
+ return `${DEV_SERVER_PREFIX}${keyOf(cwd)}${LOG_SUFFIX}`;
28
+ }
29
+ function recordName(cwd) {
30
+ return `${DEV_SERVER_PREFIX}${keyOf(cwd)}${RECORD_SUFFIX}`;
31
+ }
32
+ /** Where a handed-over server is: enough to find it again and to prove it is still that server. */
33
+ const HandedOverSchema = z.object({
34
+ pid: z.number().int().positive(),
35
+ url: z.string().min(1),
36
+ appDir: z.string().min(1),
37
+ });
38
+ /**
39
+ * Written when `init` leaves a server running, so the next `init` can attach to it.
40
+ *
41
+ * Only Vite announces itself (through its plugin). Re-running init while a Next or Angular server
42
+ * it had started was still up spawned a second `dev`, which failed with "port 3000 is already in use
43
+ * — something else is serving there" — the something being the server init itself had left.
44
+ */
45
+ export function recordHandOver(dir, record) {
46
+ try {
47
+ writeFileSync(join(dir, recordName(record.appDir)), JSON.stringify(record));
48
+ }
49
+ catch {
50
+ // Without the record the next run starts a server as it always did. Never fail a handover on it.
51
+ }
52
+ }
53
+ function readRecord(path) {
54
+ try {
55
+ const parsed = HandedOverSchema.safeParse(JSON.parse(readFileSync(path, 'utf8')));
56
+ return parsed.success ? parsed.data : undefined;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ /**
63
+ * The url of the server `init` handed over for `appDir`, when that process is alive AND the url
64
+ * still answers — both, because a recycled pid proves nothing and a url answering proves only that
65
+ * SOMETHING is there. Undefined otherwise, and the caller starts one.
66
+ */
67
+ export async function handedOverUrl(dir, appDir, deps) {
68
+ const record = readRecord(join(dir, recordName(appDir)));
69
+ if (undefined === record || record.appDir !== appDir || !deps.alive(record.pid))
70
+ return undefined;
71
+ return (await deps.answers(record.url)) ? record.url : undefined;
72
+ }
73
+ /**
74
+ * Stop the server `init` handed over for `appDir`, and forget it. True when one was stopped.
75
+ *
76
+ * For a port move: Next reads the daemon url into `NEXT_PUBLIC_RETICLE_URL` once, when `next dev`
77
+ * starts, so attaching to the running server after `init --port <new>` left the page dialling the
78
+ * old port — which the same run had just stopped. Only a server this record proves init started is
79
+ * touched; one the user started is theirs to restart.
80
+ */
81
+ export function stopHandedOver(dir, appDir, deps) {
82
+ const path = join(dir, recordName(appDir));
83
+ const record = readRecord(path);
84
+ if (undefined === record || record.appDir !== appDir || !deps.alive(record.pid))
85
+ return false;
86
+ deps.kill(record.pid);
87
+ rmSync(path, { force: true });
88
+ return true;
89
+ }
90
+ /**
91
+ * Remove what servers that are gone left behind: their logs, rotated logs and records.
92
+ *
93
+ * Every run wrote a log per project and nothing ever deleted one, so the state home kept a file for
94
+ * every app `init` had ever started. A record whose pid is dead takes its logs with it; a log with no
95
+ * record at all goes once it is old. `keep` is the app about to start, whose files are replaced
96
+ * anyway.
97
+ */
98
+ export function sweepDeadDevServers(dir, deps) {
99
+ let files;
100
+ try {
101
+ files = readdirSync(dir);
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ const keepKey = undefined === deps.keep ? undefined : keyOf(deps.keep);
107
+ // Exactly the names this module writes, so nothing else in the state home can match.
108
+ const keys = new Set(files.map((f) => OWN_FILE.exec(f)?.[1]).filter((k) => undefined !== k));
109
+ for (const key of keys) {
110
+ if (key === keepKey)
111
+ continue;
112
+ const log = join(dir, `${DEV_SERVER_PREFIX}${key}${LOG_SUFFIX}`);
113
+ const recordPath = join(dir, `${DEV_SERVER_PREFIX}${key}${RECORD_SUFFIX}`);
114
+ const record = readRecord(recordPath);
115
+ const dead = undefined === record
116
+ ? deps.now - lastWrittenAt(log) > UNRECORDED_LOG_MAX_AGE_MS
117
+ : !deps.alive(record.pid);
118
+ if (!dead)
119
+ continue;
120
+ for (const path of [log, `${log}${ROTATED_SUFFIX}`, recordPath])
121
+ rmSync(path, { force: true });
122
+ }
123
+ }
124
+ function lastWrittenAt(path) {
125
+ try {
126
+ return statSync(path).mtimeMs;
127
+ }
128
+ catch {
129
+ return 0;
130
+ }
131
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The dev server's log, with a ceiling — and the small process that enforces it.
3
+ *
4
+ * The handed-over dev server wrote straight into a file descriptor, which is what keeps it alive once
5
+ * `init` exits (a pipe whose reader has gone is an EPIPE on the next write). But a descriptor nobody
6
+ * reads has no ceiling: an upstream echo loop — TanStack devtools and Vite 8 forwarding each other's
7
+ * console lines — grew `~/.reticle/dev-server-<hash>.log` to 2.9 GB in about five minutes.
8
+ *
9
+ * So the server's output goes through a pipe again, and the reader on the other end is THIS script,
10
+ * run as its own process in the server's process group rather than inside `init`. It lives exactly
11
+ * as long as the server's output does: it exits when the pipes close, never before, so the EPIPE
12
+ * that killed handed-over servers cannot come back. It ignores the signals a group stop sends, for
13
+ * the same reason — the server gets them too, and may still be writing on its way out.
14
+ *
15
+ * The bound is rotation, not truncation: past the cap the file is renamed to `<log>.1` (replacing
16
+ * the previous one) and a fresh file started, so disk use stays under twice the cap while the most
17
+ * recent output — the part anybody reads — is always there.
18
+ *
19
+ * Why not truncate from the daemon on a timer: the daemon idles out on its own schedule and a
20
+ * handed-over server does not, so the one bound that matters would stop being enforced exactly when
21
+ * nobody is watching. A reader that shares the server's lifetime cannot be outlived by it.
22
+ */
23
+ /** Per file. The log and its one rotated predecessor together never exceed twice this. */
24
+ export declare const DEV_SERVER_LOG_CAP_BYTES: number;
25
+ /** The single predecessor a rotation keeps. */
26
+ export declare const ROTATED_SUFFIX = ".1";
27
+ /** Appends to `path`, rotating it to `path.1` whenever the next write would pass `capBytes`. */
28
+ export declare class BoundedLog {
29
+ #private;
30
+ private readonly path;
31
+ private readonly capBytes;
32
+ constructor(path: string, capBytes?: number);
33
+ write(chunk: Buffer): void;
34
+ close(): void;
35
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The dev server's log, with a ceiling — and the small process that enforces it.
3
+ *
4
+ * The handed-over dev server wrote straight into a file descriptor, which is what keeps it alive once
5
+ * `init` exits (a pipe whose reader has gone is an EPIPE on the next write). But a descriptor nobody
6
+ * reads has no ceiling: an upstream echo loop — TanStack devtools and Vite 8 forwarding each other's
7
+ * console lines — grew `~/.reticle/dev-server-<hash>.log` to 2.9 GB in about five minutes.
8
+ *
9
+ * So the server's output goes through a pipe again, and the reader on the other end is THIS script,
10
+ * run as its own process in the server's process group rather than inside `init`. It lives exactly
11
+ * as long as the server's output does: it exits when the pipes close, never before, so the EPIPE
12
+ * that killed handed-over servers cannot come back. It ignores the signals a group stop sends, for
13
+ * the same reason — the server gets them too, and may still be writing on its way out.
14
+ *
15
+ * The bound is rotation, not truncation: past the cap the file is renamed to `<log>.1` (replacing
16
+ * the previous one) and a fresh file started, so disk use stays under twice the cap while the most
17
+ * recent output — the part anybody reads — is always there.
18
+ *
19
+ * Why not truncate from the daemon on a timer: the daemon idles out on its own schedule and a
20
+ * handed-over server does not, so the one bound that matters would stop being enforced exactly when
21
+ * nobody is watching. A reader that shares the server's lifetime cannot be outlived by it.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import { closeSync, openSync, renameSync, writeSync } from 'node:fs';
25
+ import { pathToFileURL } from 'node:url';
26
+ /** Per file. The log and its one rotated predecessor together never exceed twice this. */
27
+ export const DEV_SERVER_LOG_CAP_BYTES = 8 * 1024 * 1024;
28
+ const APPEND = 'a';
29
+ const TRUNCATE = 'w';
30
+ /** The single predecessor a rotation keeps. */
31
+ export const ROTATED_SUFFIX = '.1';
32
+ /** Appends to `path`, rotating it to `path.1` whenever the next write would pass `capBytes`. */
33
+ export class BoundedLog {
34
+ path;
35
+ capBytes;
36
+ /** Undefined only between a rotation's close and a reopen that failed; the next write retries. */
37
+ #fd;
38
+ #size = 0;
39
+ constructor(path, capBytes = DEV_SERVER_LOG_CAP_BYTES) {
40
+ this.path = path;
41
+ this.capBytes = capBytes;
42
+ this.#fd = openSync(path, APPEND);
43
+ }
44
+ write(chunk) {
45
+ try {
46
+ if (this.#fd === undefined || (0 < this.#size && this.#size + chunk.length > this.capBytes)) {
47
+ this.#rotate();
48
+ }
49
+ const fd = this.#fd;
50
+ if (fd === undefined)
51
+ return;
52
+ // A single chunk bigger than the cap is kept only in its tail: it is still bounded.
53
+ const kept = chunk.length > this.capBytes ? chunk.subarray(chunk.length - this.capBytes) : chunk;
54
+ writeSync(fd, kept);
55
+ this.#size += kept.length;
56
+ }
57
+ catch {
58
+ // Disk full, a removed state dir: the output is lost, the server is not. Never throw here.
59
+ }
60
+ }
61
+ close() {
62
+ const fd = this.#fd;
63
+ this.#fd = undefined;
64
+ if (fd === undefined)
65
+ return;
66
+ try {
67
+ closeSync(fd);
68
+ }
69
+ catch {
70
+ /* already closed */
71
+ }
72
+ }
73
+ /**
74
+ * Close, rename to the predecessor, reopen. A rename that fails (the rotated name is taken, or on
75
+ * Windows something holds the file) truncates in place instead: the predecessor is lost, the bound
76
+ * and the newest output are not. It used to leave the descriptor closed, and every later write
77
+ * vanished inside `write`'s catch.
78
+ */
79
+ #rotate() {
80
+ this.close();
81
+ let flags = APPEND;
82
+ try {
83
+ renameSync(this.path, `${this.path}${ROTATED_SUFFIX}`);
84
+ }
85
+ catch {
86
+ flags = TRUNCATE;
87
+ }
88
+ this.#fd = openSync(this.path, flags);
89
+ this.#size = 0;
90
+ }
91
+ }
92
+ /** Arguments are `<log path> <cap bytes> <shell command>`, the way `OwnedDevServer` spawns this. */
93
+ function main(argv) {
94
+ const [logPath, cap, command] = argv;
95
+ if (undefined === logPath || undefined === command)
96
+ process.exit(2);
97
+ const log = new BoundedLog(logPath, Number(cap) || DEV_SERVER_LOG_CAP_BYTES);
98
+ // Stopping the group signals both of us. The server decides when it is done; this outlives it.
99
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'])
100
+ process.on(signal, () => { });
101
+ const child = spawn(command, {
102
+ shell: true,
103
+ stdio: ['ignore', 'pipe', 'pipe'],
104
+ env: process.env,
105
+ });
106
+ child.stdout.on('data', (chunk) => log.write(chunk));
107
+ child.stderr.on('data', (chunk) => log.write(chunk));
108
+ // `close`, not `exit`: every byte the server wrote is read before this process goes.
109
+ child.on('close', (code) => {
110
+ log.close();
111
+ process.exit(code ?? 1);
112
+ });
113
+ child.on('error', () => {
114
+ log.close();
115
+ process.exit(1);
116
+ });
117
+ }
118
+ const entry = process.argv[1];
119
+ if (undefined !== entry && import.meta.url === pathToFileURL(entry).href) {
120
+ main(process.argv.slice(2));
121
+ }
@@ -8,12 +8,35 @@
8
8
  * dial and setup ends with "the SDK is in the page and never dialled the bridge" — the SDK being
9
9
  * blamed for the absence of the thing it was dialling.
10
10
  *
11
- * Idempotent by construction: an alive daemon for this project is adopted, and one belonging to
12
- * somebody else is refused rather than stolen, which is the same rule `reticle mcp` follows.
11
+ * Idempotent by construction: a daemon that speaks our wire contract is adopted. One that does NOT
12
+ * is not usable, however healthy its `/status`: an older Reticle turns away every hello from the page
13
+ * init just instrumented ("bridge refused the connection: invalid message"), and init used to adopt
14
+ * it anyway, wait out its budget, and blame the page. So a skewed daemon nobody is using is replaced
15
+ * (stopped by its LISTENER, never `lsof -ti`, which also kills the agent's MCP proxy), and one with
16
+ * live sessions is left alone with the exact command to run once they are done.
17
+ *
18
+ * A stranger on the port that is not a Reticle daemon at all is refused before this runs; see
19
+ * `bridge-port.ts`.
13
20
  */
21
+ import type { DaemonRegistryEntry } from '@reticlehq/core/artifacts';
14
22
  interface EnsureDaemonDeps {
23
+ /**
24
+ * Whether a daemon's `/status` body says it speaks a different wire contract from this build.
25
+ * Injected: the judge is `daemonSkew` in cli-launch, the same rule `reticle mcp` warns with.
26
+ */
27
+ readonly skewed: (status: unknown) => boolean;
15
28
  readonly usable: (port: number) => Promise<boolean>;
29
+ /** The daemon's `/status` body, or undefined. */
30
+ readonly status: (port: number) => Promise<unknown>;
31
+ /** Stop the daemon on the port. True once the port is free. */
32
+ readonly replace: (port: number) => Promise<boolean>;
16
33
  readonly spawn: (port: number) => boolean;
34
+ /**
35
+ * A live process already owns this port's pid file — a daemon mid-start that this spawn lost the
36
+ * lock to. That is exactly what `spawn` returning false means on a fresh HOME where the editor's
37
+ * `reticle mcp` got there first, and it is a reason to wait, not to give up.
38
+ */
39
+ readonly starting: (port: number) => boolean;
17
40
  readonly waitReady: (port: number) => Promise<unknown>;
18
41
  readonly scriptPath: string | undefined;
19
42
  }
@@ -21,10 +44,48 @@ export declare const EnsureDaemon: {
21
44
  /** Something usable was already there. */
22
45
  readonly ADOPTED: "adopted";
23
46
  readonly STARTED: "started";
47
+ /** A daemon on another wire contract, serving nobody, was stopped and ours started in its place. */
48
+ readonly REPLACED: "replaced";
49
+ /** A daemon on another wire contract is serving live sessions, so it was left alone. */
50
+ readonly SKEWED_IN_USE: "skewed-in-use";
24
51
  /** Could not be started, and the caller has to say so rather than blame the page. */
25
52
  readonly UNAVAILABLE: "unavailable";
26
53
  };
27
54
  export type EnsureDaemon = (typeof EnsureDaemon)[keyof typeof EnsureDaemon];
28
- export declare function ensureDaemon(port: number, deps: EnsureDaemonDeps): Promise<EnsureDaemon>;
29
- export declare function nodeEnsureDaemonDeps(): EnsureDaemonDeps;
55
+ interface EnsureDaemonResult {
56
+ readonly state: EnsureDaemon;
57
+ /** One line for the person watching, when the state needs one. */
58
+ readonly message?: string;
59
+ }
60
+ export declare function ensureDaemon(port: number, deps: EnsureDaemonDeps): Promise<EnsureDaemonResult>;
61
+ interface RetireDeps {
62
+ /** Every LIVE daemon in the registry. */
63
+ readonly registered: () => readonly DaemonRegistryEntry[];
64
+ readonly status: (port: number) => Promise<unknown>;
65
+ /** Stop the daemon on the port by its listener. True once the port is free. */
66
+ readonly stop: (port: number) => Promise<boolean>;
67
+ }
68
+ /**
69
+ * Stop this project's daemons on any port but `port`, the one init is about to use. Returns the
70
+ * ports stopped.
71
+ *
72
+ * `init --port <new>` started a daemon on the new port and left the old one serving: the build
73
+ * plugin found both, dialled the old one, and init — waiting on the new one — exited 1 with
74
+ * "connected to a DIFFERENT Reticle daemon". A daemon another project's tab is still on is left
75
+ * alone, because stopping it would take that app down to fix this one. Stopped by its listener
76
+ * (`runKill` without force), never `lsof -ti`, which also kills the agent's MCP proxy.
77
+ */
78
+ export declare function retireMovedDaemons(projectId: string | undefined, port: number, deps: RetireDeps): Promise<number[]>;
79
+ /** The real registry, `/status` and listener-only stop. */
80
+ export declare function nodeRetireDeps(): RetireDeps;
81
+ /**
82
+ * Hold the daemon on `port` until the returned release is called, by renewing a hold on its
83
+ * `/status` well inside the hold window.
84
+ *
85
+ * `init` starts the daemon and can then wait on the app for longer than the idle grace — a cold
86
+ * desktop build, a slow first compile. None of that counted as activity, and the daemon init had
87
+ * just started idled out (`reticle_daemon_idle_exit`) while init was still waiting on it.
88
+ */
89
+ export declare function holdDaemon(port: number): () => void;
90
+ export declare function nodeEnsureDaemonDeps(skewed: (status: unknown) => boolean): EnsureDaemonDeps;
30
91
  export {};
@@ -8,51 +8,170 @@
8
8
  * dial and setup ends with "the SDK is in the page and never dialled the bridge" — the SDK being
9
9
  * blamed for the absence of the thing it was dialling.
10
10
  *
11
- * Idempotent by construction: an alive daemon for this project is adopted, and one belonging to
12
- * somebody else is refused rather than stolen, which is the same rule `reticle mcp` follows.
11
+ * Idempotent by construction: a daemon that speaks our wire contract is adopted. One that does NOT
12
+ * is not usable, however healthy its `/status`: an older Reticle turns away every hello from the page
13
+ * init just instrumented ("bridge refused the connection: invalid message"), and init used to adopt
14
+ * it anyway, wait out its budget, and blame the page. So a skewed daemon nobody is using is replaced
15
+ * (stopped by its LISTENER, never `lsof -ti`, which also kills the agent's MCP proxy), and one with
16
+ * live sessions is left alone with the exact command to run once they are done.
17
+ *
18
+ * A stranger on the port that is not a Reticle daemon at all is refused before this runs; see
19
+ * `bridge-port.ts`.
13
20
  */
14
21
  import { log } from '../../../log.js';
15
- import { spawnDaemon } from '../../daemon/daemon.js';
22
+ import { isAlive, readPid, reticleStateHome, spawnDaemon } from '../../daemon/daemon.js';
23
+ import { readDaemonRegistry } from '../../daemon/daemon-resolve.js';
24
+ import { LOOPBACK_HOST, STATUS_HOLD_MS } from '@reticlehq/core';
16
25
  import { probePresence, presenceIsUsable } from '../../daemon/binding/port-presence.js';
17
- import { waitForDaemon, probeDaemon } from '../../../surface/mcp/mcp-proxy.js';
26
+ import { probeDaemon, waitForDaemonBind } from '../../../surface/mcp/mcp-proxy.js';
18
27
  import { daemonSpawnArgs } from '../../cli/daemon-start-options.js';
19
- import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
28
+ import { fetchStatus, summarizeStatus } from '../../daemon/binding/daemon-status-probe.js';
29
+ import { runKill } from '../../cli/cli-kill.js';
20
30
  export const EnsureDaemon = {
21
31
  /** Something usable was already there. */
22
32
  ADOPTED: 'adopted',
23
33
  STARTED: 'started',
34
+ /** A daemon on another wire contract, serving nobody, was stopped and ours started in its place. */
35
+ REPLACED: 'replaced',
36
+ /** A daemon on another wire contract is serving live sessions, so it was left alone. */
37
+ SKEWED_IN_USE: 'skewed-in-use',
24
38
  /** Could not be started, and the caller has to say so rather than blame the page. */
25
39
  UNAVAILABLE: 'unavailable',
26
40
  };
27
- export async function ensureDaemon(port, deps) {
28
- if (await deps.usable(port))
29
- return EnsureDaemon.ADOPTED;
41
+ /** A string field off the /status body, or undefined on a daemon too old to report it. */
42
+ function statusString(payload, key) {
43
+ if ('object' !== typeof payload || null === payload)
44
+ return undefined;
45
+ const value = Reflect.get(payload, key);
46
+ return 'string' === typeof value && 0 < value.length ? value : undefined;
47
+ }
48
+ async function spawnAndWait(port, deps) {
30
49
  if (undefined === deps.scriptPath)
31
- return EnsureDaemon.UNAVAILABLE;
32
- if (!deps.spawn(port))
33
- return EnsureDaemon.UNAVAILABLE;
50
+ return false;
51
+ // A lost spawn race is not a failed start: `init` printed "could not start the Reticle daemon"
52
+ // over a daemon that bound seconds later. Only a spawn that failed with nobody else starting one
53
+ // is a reason to stop without waiting.
54
+ if (!deps.spawn(port) && !deps.starting(port))
55
+ return false;
34
56
  try {
35
57
  // Readiness means the port ACCEPTS, not that a child was spawned: a readiness signal that
36
58
  // precedes readiness is worse than none, because what waits on it stops waiting too early.
37
59
  await deps.waitReady(port);
38
60
  }
39
61
  catch {
40
- return EnsureDaemon.UNAVAILABLE;
62
+ return false;
63
+ }
64
+ return deps.usable(port);
65
+ }
66
+ export async function ensureDaemon(port, deps) {
67
+ if (await deps.usable(port)) {
68
+ const status = await deps.status(port);
69
+ if (!deps.skewed(status))
70
+ return { state: EnsureDaemon.ADOPTED };
71
+ const theirs = statusString(status, 'version') ?? 'an older build';
72
+ const { sessionCount } = summarizeStatus(status);
73
+ if (0 < sessionCount) {
74
+ return {
75
+ state: EnsureDaemon.SKEWED_IN_USE,
76
+ message: `The Reticle daemon on port ${String(port)} is ${theirs}, which speaks a different wire ` +
77
+ `contract than this init, so it will refuse your app — and ` +
78
+ `${String(sessionCount)} session(s) are still using it, so init will not stop it. When ` +
79
+ `they are done, run \`npx @reticlehq/server kill --port ${String(port)}\` and re-run init.`,
80
+ };
81
+ }
82
+ if (!(await deps.replace(port)))
83
+ return { state: EnsureDaemon.UNAVAILABLE };
84
+ return (await spawnAndWait(port, deps))
85
+ ? {
86
+ state: EnsureDaemon.REPLACED,
87
+ message: `replaced the idle Reticle daemon on port ${String(port)} (${theirs}), which speaks a ` +
88
+ `different wire contract and would have refused your app, with a current one.`,
89
+ }
90
+ : { state: EnsureDaemon.UNAVAILABLE };
91
+ }
92
+ return (await spawnAndWait(port, deps))
93
+ ? { state: EnsureDaemon.STARTED }
94
+ : { state: EnsureDaemon.UNAVAILABLE };
95
+ }
96
+ /**
97
+ * Stop this project's daemons on any port but `port`, the one init is about to use. Returns the
98
+ * ports stopped.
99
+ *
100
+ * `init --port <new>` started a daemon on the new port and left the old one serving: the build
101
+ * plugin found both, dialled the old one, and init — waiting on the new one — exited 1 with
102
+ * "connected to a DIFFERENT Reticle daemon". A daemon another project's tab is still on is left
103
+ * alone, because stopping it would take that app down to fix this one. Stopped by its listener
104
+ * (`runKill` without force), never `lsof -ti`, which also kills the agent's MCP proxy.
105
+ */
106
+ export async function retireMovedDaemons(projectId, port, deps) {
107
+ if (undefined === projectId || 0 === projectId.length)
108
+ return [];
109
+ const stopped = [];
110
+ for (const entry of deps.registered()) {
111
+ if (entry.projectId !== projectId || entry.port === port)
112
+ continue;
113
+ const { sessions } = summarizeStatus(await deps.status(entry.port));
114
+ const shared = sessions.some((s) => undefined !== s.projectId && projectId !== s.projectId);
115
+ if (shared)
116
+ continue;
117
+ if (await deps.stop(entry.port))
118
+ stopped.push(entry.port);
41
119
  }
42
- return (await deps.usable(port)) ? EnsureDaemon.STARTED : EnsureDaemon.UNAVAILABLE;
120
+ return stopped;
121
+ }
122
+ /** The real registry, `/status` and listener-only stop. */
123
+ export function nodeRetireDeps() {
124
+ return {
125
+ registered: () => readDaemonRegistry(reticleStateHome()).filter((e) => isAlive(e.pid)),
126
+ status: (port) => fetchStatus(port),
127
+ stop: (port) => runKill(port, false),
128
+ };
129
+ }
130
+ /**
131
+ * Hold the daemon on `port` until the returned release is called, by renewing a hold on its
132
+ * `/status` well inside the hold window.
133
+ *
134
+ * `init` starts the daemon and can then wait on the app for longer than the idle grace — a cold
135
+ * desktop build, a slow first compile. None of that counted as activity, and the daemon init had
136
+ * just started idled out (`reticle_daemon_idle_exit`) while init was still waiting on it.
137
+ */
138
+ export function holdDaemon(port) {
139
+ const renew = () => void fetchStatus(port, LOOPBACK_HOST, true);
140
+ renew();
141
+ const timer = setInterval(renew, STATUS_HOLD_MS / 3);
142
+ timer.unref();
143
+ return () => clearInterval(timer);
43
144
  }
44
- export function nodeEnsureDaemonDeps() {
145
+ export function nodeEnsureDaemonDeps(skewed) {
45
146
  return {
147
+ skewed,
46
148
  usable: async (port) => presenceIsUsable(await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus })),
149
+ status: (port) => fetchStatus(port),
150
+ // `reticle kill` without `--force`: it signals only the LISTENER, and only one that answers
151
+ // /status, so the agent's MCP proxy on the same port survives.
152
+ replace: (port) => runKill(port, false),
47
153
  spawn: (port) => {
48
154
  const scriptPath = process.argv[1];
49
155
  if (undefined === scriptPath)
50
156
  return false;
51
157
  const started = spawnDaemon(process.execPath, scriptPath, daemonSpawnArgs({ port, headless: true, http: false }), port);
52
- log('reticle_setup_daemon_started', { port, started });
158
+ // For a log or a pipe. On a terminal it was a JSON line in the middle of `init`'s output, read
159
+ // by a person, about a step the next lines already report in words.
160
+ if (true !== process.stderr.isTTY)
161
+ log('reticle_setup_daemon_started', { port, started });
53
162
  return started;
54
163
  },
55
- waitReady: (port) => waitForDaemon(port),
164
+ starting: (port) => {
165
+ const pid = readPid(port);
166
+ return null !== pid && isAlive(pid);
167
+ },
168
+ // The same wait `reticle restart` does: poll until `/status` answers as a daemon, bounded. A
169
+ // cold daemon on a fresh HOME outlived the proxy's shorter reconnect budget this used to borrow.
170
+ waitReady: async (port) => {
171
+ if (!(await waitForDaemonBind(port)))
172
+ throw new Error(`no daemon bound :${String(port)}`);
173
+ return undefined;
174
+ },
56
175
  scriptPath: process.argv[1],
57
176
  };
58
177
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The dev server `init` starts for a plain HTML page that has none of its own.
3
+ *
4
+ * A directory holding only `index.html` (and a script or two) used to be wired correctly — init
5
+ * writes the connect snippet into the page — and then stop at "No dev command … start the app
6
+ * yourself and pass --url". The one thing left between that user and a connected session was a web
7
+ * server, which is the one thing a page like that never had. So init starts this one, the same way it
8
+ * starts `npm run dev`, and the rest of setup (wait, open, prove the connect) runs unchanged.
9
+ *
10
+ * It is run as its own process (`node static-page-server.js --dir <app>`), not inside init, because it
11
+ * has to outlive init exactly like any handed-over dev server. It imports nothing but Node, so the
12
+ * built file runs on its own.
13
+ *
14
+ * Loopback only, on both families: a page served to the LAN would publish the pairing token the
15
+ * snippet carries.
16
+ */
17
+ export declare const STATIC_PAGE_INDEX = "index.html";
18
+ /** Where the search for a free port starts when none is given — clear of Vite's 5173 run. */
19
+ export declare const STATIC_PAGE_FIRST_PORT = 5500;
20
+ export interface StaticPageServer {
21
+ readonly url: string;
22
+ close(): Promise<void>;
23
+ }
24
+ export declare function startStaticPageServer(opts: {
25
+ root: string;
26
+ port: number;
27
+ }): Promise<StaticPageServer>;
28
+ /**
29
+ * The command setup runs for a page with no dev server of its own, or undefined when this is not
30
+ * that page. Quoted so an app path with a space survives the shell.
31
+ */
32
+ export declare function staticPageDevCommand(opts: {
33
+ appDir: string;
34
+ isStaticPage: boolean;
35
+ }): string | undefined;