@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,151 @@
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
+ import { existsSync, readFile, statSync } from 'node:fs';
18
+ import { createServer } from 'node:http';
19
+ import { extname, join, resolve, sep } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ export const STATIC_PAGE_INDEX = 'index.html';
22
+ /** Where the search for a free port starts when none is given — clear of Vite's 5173 run. */
23
+ export const STATIC_PAGE_FIRST_PORT = 5500;
24
+ const PORT_SEARCH_SPAN = 50;
25
+ const DIR_FLAG = '--dir';
26
+ const PORT_FLAG = '--port';
27
+ const LOOPBACK_V4 = '127.0.0.1';
28
+ const LOOPBACK_V6 = '::1';
29
+ const ANNOUNCED_HOST = 'localhost';
30
+ const CONTENT_TYPES = {
31
+ '.html': 'text/html; charset=utf-8',
32
+ '.js': 'text/javascript; charset=utf-8',
33
+ '.mjs': 'text/javascript; charset=utf-8',
34
+ '.css': 'text/css; charset=utf-8',
35
+ '.json': 'application/json; charset=utf-8',
36
+ '.svg': 'image/svg+xml',
37
+ '.png': 'image/png',
38
+ '.jpg': 'image/jpeg',
39
+ '.jpeg': 'image/jpeg',
40
+ '.gif': 'image/gif',
41
+ '.webp': 'image/webp',
42
+ '.ico': 'image/x-icon',
43
+ '.woff2': 'font/woff2',
44
+ '.txt': 'text/plain; charset=utf-8',
45
+ };
46
+ const FALLBACK_TYPE = 'application/octet-stream';
47
+ /** The file a request names, or null when it is missing or outside `root`. */
48
+ function fileFor(root, rawUrl) {
49
+ let path;
50
+ try {
51
+ path = decodeURIComponent(new URL(rawUrl, 'http://x').pathname);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ const target = resolve(root, `.${path}`);
57
+ if (target !== root && !target.startsWith(`${root}${sep}`))
58
+ return null;
59
+ const file = path.endsWith('/') ? join(target, STATIC_PAGE_INDEX) : target;
60
+ try {
61
+ return statSync(file).isFile() ? file : null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function listen(server, port, host) {
68
+ return new Promise((ok, fail) => {
69
+ server.once('error', fail);
70
+ server.listen(port, host, () => {
71
+ server.off('error', fail);
72
+ ok();
73
+ });
74
+ });
75
+ }
76
+ function closeServer(server) {
77
+ return new Promise((done) => server.close(() => done()));
78
+ }
79
+ export async function startStaticPageServer(opts) {
80
+ const root = resolve(opts.root);
81
+ const handler = (req, res) => {
82
+ const file = fileFor(root, req.url ?? '/');
83
+ if (null === file) {
84
+ res.writeHead(404).end();
85
+ return;
86
+ }
87
+ readFile(file, (err, body) => {
88
+ if (null !== err) {
89
+ res.writeHead(404).end();
90
+ return;
91
+ }
92
+ res.writeHead(200, {
93
+ 'content-type': CONTENT_TYPES[extname(file).toLowerCase()] ?? FALLBACK_TYPE,
94
+ 'cache-control': 'no-store',
95
+ });
96
+ res.end(body);
97
+ });
98
+ };
99
+ const v4 = createServer(handler);
100
+ // Port 0 means "any": the v4 bind picks it, and the v6 listener joins the same number.
101
+ const candidates = 0 === opts.port ? [0] : Array.from({ length: PORT_SEARCH_SPAN }, (_, i) => opts.port + i);
102
+ let bound;
103
+ for (const port of candidates) {
104
+ try {
105
+ await listen(v4, port, LOOPBACK_V4);
106
+ bound = v4.address().port;
107
+ break;
108
+ }
109
+ catch {
110
+ /* taken: try the next one */
111
+ }
112
+ }
113
+ if (undefined === bound) {
114
+ throw new Error(`no free port in ${String(opts.port)}–${String(opts.port + PORT_SEARCH_SPAN)}`);
115
+ }
116
+ // `localhost` resolves to ::1 first on macOS, so the page must answer there too. A machine with
117
+ // no IPv6 loopback just keeps the v4 listener.
118
+ const v6 = createServer(handler);
119
+ const hasV6 = await listen(v6, bound, LOOPBACK_V6).then(() => true, () => false);
120
+ return {
121
+ url: `http://${ANNOUNCED_HOST}:${String(bound)}/`,
122
+ close: async () => {
123
+ await closeServer(v4);
124
+ if (hasV6)
125
+ await closeServer(v6);
126
+ },
127
+ };
128
+ }
129
+ /**
130
+ * The command setup runs for a page with no dev server of its own, or undefined when this is not
131
+ * that page. Quoted so an app path with a space survives the shell.
132
+ */
133
+ export function staticPageDevCommand(opts) {
134
+ if (!opts.isStaticPage || !existsSync(join(opts.appDir, STATIC_PAGE_INDEX)))
135
+ return undefined;
136
+ const script = fileURLToPath(import.meta.url).replace(/\.ts$/, '.js');
137
+ return `"${process.execPath}" "${script}" ${DIR_FLAG} "${opts.appDir}"`;
138
+ }
139
+ function flag(argv, name) {
140
+ const at = argv.indexOf(name);
141
+ return -1 === at ? undefined : argv[at + 1];
142
+ }
143
+ // Run as a script: serve until killed, printing the url the setup wait reads.
144
+ if (undefined !== process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
145
+ const dir = flag(process.argv, DIR_FLAG) ?? process.cwd();
146
+ const port = Number(flag(process.argv, PORT_FLAG) ?? STATIC_PAGE_FIRST_PORT);
147
+ startStaticPageServer({ root: dir, port }).then((server) => process.stdout.write(`serving ${dir}\n Local: ${server.url}\n`), (err) => {
148
+ process.stderr.write(`static page server: ${String(err)}\n`);
149
+ process.exit(1);
150
+ });
151
+ }
@@ -53,6 +53,10 @@ interface ShapePolicy {
53
53
  readonly connectBudgetMs: number;
54
54
  /** Said out loud, because a desktop run looks stuck if nobody explains the wait. */
55
55
  readonly note: string | undefined;
56
+ /** What the connect wait is waiting on, for the progress lines it prints while it waits. */
57
+ readonly awaiting: string;
56
58
  }
59
+ /** "a Tauri app", "an Electron app": printed when the shape is detected. */
60
+ export declare function describeShape(shape: AppShape): string;
57
61
  export declare function policyFor(shape: AppShape): ShapePolicy;
58
62
  export {};
@@ -42,7 +42,21 @@ export function isDesktopOrigin(url) {
42
42
  }
43
43
  const WEB_CONNECT_MS = 120_000;
44
44
  /** A cold `tauri dev` compiles the Rust side before a window exists. */
45
- const DESKTOP_CONNECT_MS = 10 * 60_000;
45
+ const TAURI_CONNECT_MS = 10 * 60_000;
46
+ /**
47
+ * Electron has no compile step of that size: its window is a Node process loading a dev server that
48
+ * is already up. It was given Tauri's ten minutes, so a run whose window was never going to dial in
49
+ * sat silent for all of them — longer than the daemon it was waiting on stayed alive.
50
+ */
51
+ const ELECTRON_CONNECT_MS = 3 * 60_000;
52
+ /** "a Tauri app", "an Electron app": printed when the shape is detected. */
53
+ export function describeShape(shape) {
54
+ if (AppShape.ELECTRON === shape)
55
+ return 'an Electron app';
56
+ if (AppShape.TAURI === shape)
57
+ return 'a Tauri app';
58
+ return 'a web app';
59
+ }
46
60
  export function policyFor(shape) {
47
61
  if (AppShape.WEB === shape) {
48
62
  return {
@@ -50,12 +64,14 @@ export function policyFor(shape) {
50
64
  requireHttpReady: true,
51
65
  connectBudgetMs: WEB_CONNECT_MS,
52
66
  note: undefined,
67
+ awaiting: 'the page',
53
68
  };
54
69
  }
55
70
  return {
56
71
  openBrowser: false,
57
72
  requireHttpReady: false,
58
- connectBudgetMs: DESKTOP_CONNECT_MS,
73
+ connectBudgetMs: AppShape.TAURI === shape ? TAURI_CONNECT_MS : ELECTRON_CONNECT_MS,
74
+ awaiting: AppShape.TAURI === shape ? 'the Tauri window' : 'the Electron window',
59
75
  note: AppShape.TAURI === shape
60
76
  ? 'Tauri app: not opening a browser, because its own window is the client. A cold `tauri dev` builds the Rust side first, so the window can take minutes to appear.'
61
77
  : 'Electron app: not opening a browser, because its own window is the client. Waiting for the app to launch and dial in.',
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `RETICLE_PORT`, when it names a port. The one reader of it for the CLI, `init` included.
3
+ *
4
+ * `init` took only `--port`, so a shell that exported RETICLE_PORT got a project wired, and a daemon
5
+ * started, on the default port while every other command it ran dialled the exported one. Fed to
6
+ * `portForInit` as the explicit port, so the files phase writes it into `.reticle.json` and the
7
+ * runtime phase binds it: the two halves of `init` cannot disagree.
8
+ */
9
+ export declare function portFromEnv(env: Readonly<Record<string, string | undefined>>): number | undefined;
10
+ export declare function portForInit(explicit: number | undefined, configured: number | undefined, projectId: string | undefined, deps: {
11
+ /** The projects the daemon on `port` belongs to or is serving right now. */
12
+ daemonProjects: (port: number) => Promise<readonly string[]>;
13
+ daemonPresent: (port: number) => Promise<boolean>;
14
+ pickPort: (preferred: number) => Promise<number>;
15
+ }): Promise<number | undefined>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The daemon port `reticle init` wires a project to.
3
+ *
4
+ * Init used to leave this unset, so every project on a machine was wired to the default port. The
5
+ * second one to run was connected to the first one's daemon, and the bridge refused its app. When a
6
+ * daemon for another project already holds the default, the new project gets its own port, and init
7
+ * records it in `.reticle.json`, where the CLI and the build plugins both read it.
8
+ *
9
+ * Undefined means the default.
10
+ */
11
+ import { RETICLE_DEFAULT_PORT, ReticleEnv } from '@reticlehq/core';
12
+ const MAX_PORT = 65_535;
13
+ /**
14
+ * `RETICLE_PORT`, when it names a port. The one reader of it for the CLI, `init` included.
15
+ *
16
+ * `init` took only `--port`, so a shell that exported RETICLE_PORT got a project wired, and a daemon
17
+ * started, on the default port while every other command it ran dialled the exported one. Fed to
18
+ * `portForInit` as the explicit port, so the files phase writes it into `.reticle.json` and the
19
+ * runtime phase binds it: the two halves of `init` cannot disagree.
20
+ */
21
+ export function portFromEnv(env) {
22
+ const raw = env[ReticleEnv.PORT];
23
+ if (undefined === raw || !/^\d+$/.test(raw))
24
+ return undefined;
25
+ const port = Number(raw);
26
+ return 0 < port && port <= MAX_PORT ? port : undefined;
27
+ }
28
+ export async function portForInit(explicit, configured, projectId, deps) {
29
+ if (explicit !== undefined)
30
+ return explicit;
31
+ if (configured !== undefined)
32
+ return configured;
33
+ // Undefined, not the default's number: an explicit default would be written into every generated
34
+ // config as a `port` nobody chose.
35
+ if (!(await deps.daemonPresent(RETICLE_DEFAULT_PORT)))
36
+ return undefined;
37
+ // The bridge's own rule: it refuses a page only when it has served exactly ONE other project. A
38
+ // daemon serving nobody, several projects, or ours accepts this one. A project that has never
39
+ // been through init has no id yet, so a single named holder is somebody else.
40
+ const holders = await deps.daemonProjects(RETICLE_DEFAULT_PORT);
41
+ if (1 !== holders.length || holders[0] === projectId)
42
+ return undefined;
43
+ return deps.pickPort(RETICLE_DEFAULT_PORT + 1);
44
+ }
@@ -5,7 +5,7 @@
5
5
  * cli.ts is a dispatcher: a command that grew a second half should not make the file that routes
6
6
  * every command harder to read.
7
7
  */
8
- import type { InitResult } from '@reticlehq/init';
8
+ import { type InitResult } from '@reticlehq/init';
9
9
  /** Just enough of the parsed command to decide and run. */
10
10
  interface InitRuntimeArgs {
11
11
  readonly port: number | undefined;
@@ -7,18 +7,27 @@
7
7
  */
8
8
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { RETICLE_DEFAULT_PORT } from '@reticlehq/core';
10
+ import { FEEDBACK_HINT, Framework } from '@reticlehq/init';
10
11
  import { confirmInstall, nodeConfirmDeps } from '../terminal/confirm.js';
11
12
  import { writeLicenseKey } from '../license-key.js';
12
13
  import { registerOtherAgents, runSetupCommand } from '../setup-command.js';
13
14
  import { bridgeOccupied } from '../bringup/bridge-port.js';
15
+ import { defaultPairingTokenDir, readOrCreatePairingTokenSync, } from '../../../portal/bridge/pairing-token.js';
14
16
  import { relaunchDecision } from '../bringup/relaunch.js';
15
17
  import { claudeTranscriptExists, codexSessionFor } from '../terminal/transcripts.js';
16
18
  import { probePresence } from '../../daemon/binding/port-presence.js';
17
19
  import { probeDaemon } from '../../../surface/mcp/proxy/proxy-daemon-probe.js';
18
20
  import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
19
21
  import { collectEnv, DEFAULT_PHASE_TIMEOUT_MS } from '../setup-options.js';
22
+ import { staticPageDevCommand } from '../bringup/static-page-server.js';
20
23
  /** How often the runtime phases look again: fast enough not to be the wait, slow enough to be free. */
21
24
  const POLL_MS = 250;
25
+ /**
26
+ * The shapes whose served HTML carries the SDK marker: Vite injects it into index.html, and a plain
27
+ * HTML page has the snippet pasted in. Everything else connects from the JS bundle, where a fetch
28
+ * of the document can never see it. See `htmlCarriesSdk` in run-setup.ts.
29
+ */
30
+ const HTML_CARRIES_SDK = new Set([Framework.VITE, Framework.HTML]);
22
31
  /** Registration is global MCP wiring, so both flags that disown it are read here. */
23
32
  function wantsAgents(parsed) {
24
33
  return false !== parsed.agents && false !== parsed.mcp;
@@ -77,9 +86,16 @@ export async function continueAfterInit(parsed, result, io, cwd) {
77
86
  process.exit(1);
78
87
  });
79
88
  }
89
+ // From here on init left the ask to us, so it is the last thing on every exit below.
90
+ const ask = (spaced = true) => {
91
+ if (spaced)
92
+ io.print('');
93
+ io.print(FEEDBACK_HINT);
94
+ };
80
95
  const context = result.context;
81
96
  if (context === undefined) {
82
97
  // Nothing was established, so there is nothing to run against. init has already said why.
98
+ ask();
83
99
  process.exit(1);
84
100
  }
85
101
  // A PENDING connect step is not a reason to skip the runtime phase, and treating it as one made
@@ -100,12 +116,16 @@ export async function continueAfterInit(parsed, result, io, cwd) {
100
116
  const refusal = bridgeOccupied(await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }), port);
101
117
  if (refusal !== undefined) {
102
118
  io.print(refusal);
119
+ ask();
103
120
  process.exit(1);
104
121
  }
105
122
  return runSetupCommand({
106
123
  appDir: context.appDir,
107
124
  invokedAt: cwd,
108
125
  bridgePort: port,
126
+ // Read here because this is the layer that already owns the bridge: setup opens a lease over
127
+ // the daemon's MCP transport for `--no-open`, and that transport is gated on this token.
128
+ pairingToken: readOrCreatePairingTokenSync(defaultPairingTokenDir()),
109
129
  env: collectEnv(parsed.env ?? []),
110
130
  openBrowser: false !== parsed.open,
111
131
  registerAgents: wantsAgents(parsed),
@@ -118,7 +138,19 @@ export async function continueAfterInit(parsed, result, io, cwd) {
118
138
  ? {}
119
139
  : { connectBudgetMs: parsed.timeoutSeconds * 1000 }),
120
140
  pollMs: POLL_MS,
121
- ...(undefined === context.devCommand ? {} : { devCommand: context.devCommand }),
141
+ htmlCarriesSdk: HTML_CARRIES_SDK.has(context.framework),
142
+ // A plain page with no dev script gets Reticle's own static server, so the page is actually
143
+ // served and the connect can be proved, instead of stopping at "start the app yourself".
144
+ ...(() => {
145
+ const devCommand = context.devCommand ??
146
+ (undefined === parsed.url
147
+ ? staticPageDevCommand({
148
+ appDir: context.appDir,
149
+ isStaticPage: Framework.HTML === context.framework,
150
+ })
151
+ : undefined);
152
+ return undefined === devCommand ? {} : { devCommand };
153
+ })(),
122
154
  ...(undefined === parsed.url ? {} : { suppliedUrl: parsed.url }),
123
155
  }, (line) => io.print(line)).then((outcome) => {
124
156
  // One object, so an agent reads a result instead of interpreting a report.
@@ -145,9 +177,12 @@ export async function continueAfterInit(parsed, result, io, cwd) {
145
177
  // Success, and no flow. Saying "a flow was driven" here would replace a wrong exit code with
146
178
  // a wrong sentence, which is the worse of the two: the exit code is read by CI and the
147
179
  // sentence is read by a person deciding whether their app is verified. It is not.
148
- io.print(`✓ ${outcome.url ?? 'the app'} is instrumented and connected — but NOT verified.`);
180
+ // The connect already said "connected, nothing verified yet" and listed what to do next;
181
+ // repeating it here was the same sentence twice, three lines apart.
149
182
  for (const [i, step] of outcome.fallback.entries())
150
183
  io.print(` ${String(i + 1)}. ${step}`);
184
+ // Already spaced by the blank line above when nothing was listed.
185
+ ask(0 < outcome.fallback.length);
151
186
  return;
152
187
  }
153
188
  if (outcome.ok) {
@@ -156,6 +191,7 @@ export async function continueAfterInit(parsed, result, io, cwd) {
156
191
  // themselves, and it deserves a line of its own rather than a paragraph that gets skimmed.
157
192
  io.print(' Read the FINDINGS above before moving on: a flow can pass with a failed request or a ' +
158
193
  'console error behind it, and that is the app, not the check.');
194
+ ask();
159
195
  return;
160
196
  }
161
197
  // A run that produced no verdict did not succeed, and the exit code is the one place a caller
@@ -163,6 +199,7 @@ export async function continueAfterInit(parsed, result, io, cwd) {
163
199
  io.print('⚠ setup did not finish. To carry on from here:');
164
200
  for (const [i, step] of outcome.fallback.entries())
165
201
  io.print(` ${i + 1}. ${step}`);
202
+ ask();
166
203
  process.exit(1);
167
204
  });
168
205
  }
@@ -15,31 +15,42 @@ import type { CandidateSession } from './session-pick.js';
15
15
  import type { PageProbe } from './probe/page-probe.js';
16
16
  /** The dev server this process started, and the only one it will ever stop. */
17
17
  export declare class OwnedDevServer {
18
+ private readonly logDir;
18
19
  private child;
19
- private buffer;
20
- private lastOutputAt;
20
+ private log;
21
+ private cwd;
22
+ private startedAt;
21
23
  private handedOver;
24
+ constructor(logDir?: string);
25
+ /**
26
+ * Start it with its output going to a bounded log, through a supervisor that outlives `init`.
27
+ *
28
+ * Not a pipe into THIS process. A pipe's read end belonged to `init`; once it exited nobody held
29
+ * it, and the server's next write (a compile error, an HMR line, SvelteKit's first warning) was an
30
+ * EPIPE that killed it: "it connected, then the app went away". And not a bare file either, which
31
+ * has no ceiling: an upstream echo loop grew one to 2.9 GB in five minutes. The supervisor reads
32
+ * the server's pipes for as long as they are open and rotates the file past a cap — see
33
+ * dev-server-log-cap.ts. It is the process group leader, so `pid()` and `stop()` cover both.
34
+ */
22
35
  start(command: string, cwd: string, env: Readonly<Record<string, string>>): void;
36
+ /** Where the server's output goes, for the line that hands it over. */
37
+ logPath(): string | undefined;
23
38
  /** The process group leader, for a caller that has to clean up what it handed over. */
24
39
  pid(): number | undefined;
25
40
  output(): string;
26
41
  exited(): boolean;
42
+ /** Since the log last grew, which is since the server last printed anything. */
27
43
  quietForMs(): number;
28
44
  /** Ports anything in this server's process tree is listening on. */
29
45
  listeningPorts(): number[];
30
- /** Hand the running server to the user. After this, `stop()` does nothing. */
31
46
  /**
32
- * Leave the server running and stop holding it.
33
- *
34
- * `unref` on the child is NOT enough: its stdout and stderr are pipes owned by this process, and
35
- * an open pipe keeps the event loop alive on its own. So `init` printed "setup complete" and then
36
- * hung until something killed it, turning a successful run into a non-zero exit — which is
37
- * exactly what the install gate reported, on a run whose every other assertion passed.
47
+ * Leave the server running and stop holding it. After this, `stop()` does nothing.
38
48
  *
39
- * The streams are unref'd rather than destroyed: the server is still writing to them, and
40
- * destroying the read end of a live pipe risks an EPIPE in somebody else's dev server.
49
+ * Only `unref` is needed: the output is the supervisor's, so no pipe of ours holds the event loop
50
+ * (`init` once printed "setup complete" and hung on one). `url` is recorded beside the log, so the
51
+ * next `init` attaches to this server rather than starting a second one beside it.
41
52
  */
42
- handOver(): void;
53
+ handOver(url?: string): void;
43
54
  stop(): void;
44
55
  }
45
56
  /**
@@ -50,9 +61,11 @@ export declare class OwnedDevServer {
50
61
  * server that is already running.
51
62
  *
52
63
  * On loopback, the announced host is tried first and the other loopback families follow when it
53
- * misses — so an IPv6-only Vite that printed `127.0.0.1` still counts as served (#884).
64
+ * is REFUSED — so an IPv6-only Vite that printed `127.0.0.1` still counts as served (#884). Only
65
+ * refused: a timeout means the right server is slow, and the caller polls again on the same host.
66
+ * See `isAddressMiss` for the Next 16 first run that hopping on a timeout broke.
54
67
  */
55
- export declare function probePage(url: string): Promise<PageProbe>;
68
+ export declare function probePage(url: string, timeoutMs?: number): Promise<PageProbe>;
56
69
  /** Sessions the daemon is holding, in the shape the picker reads. */
57
70
  export declare function listSessions(port: number): Promise<CandidateSession[]>;
58
71
  /** Whether this project has a saved flow, wherever the project keeps one. */
@@ -12,60 +12,96 @@
12
12
  * listening indefinitely.
13
13
  */
14
14
  import { spawn, spawnSync } from 'node:child_process';
15
- import { Socket } from 'node:net';
16
- import { existsSync, readdirSync } from 'node:fs';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
17
16
  import { join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { isAlive } from '../daemon/daemon.js';
19
+ import { DEV_SERVER_LOG_CAP_BYTES, ROTATED_SUFFIX } from './bringup/dev-server-log-cap.js';
20
+ import { devServerLogName, recordHandOver, sweepDeadDevServers, } from './bringup/dev-server-files.js';
18
21
  import { fetchStatus } from '../daemon/binding/daemon-status-probe.js';
22
+ import { reticleStateHome } from '../daemon/daemon.js';
19
23
  import { descendants, parseLsofPorts, parseNetstatListeners, parseWmicProcesses, } from './terminal/listeners.js';
20
- import { loopbackProbeUrls } from './probe/loopback-probe-urls.js';
24
+ import { isAddressMiss, loopbackProbeUrls } from './probe/loopback-probe-urls.js';
21
25
  const WINDOWS = 'win32' === process.platform;
22
26
  /** A page fetch that is slow is a page fetch that failed, for our purposes. */
23
27
  const PROBE_TIMEOUT_MS = 5_000;
24
- /**
25
- * Stop an inherited stdio pipe from holding the event loop open.
26
- *
27
- * A child's piped stdout is a Socket at runtime and a Readable in the types, which has no `unref`.
28
- * Narrowing through Socket keeps that honest rather than asserting it.
29
- */
30
- function releasePipe(stream) {
31
- if (stream instanceof Socket)
32
- stream.unref();
33
- }
28
+ /** The supervisor that owns the server's output and keeps its log bounded. */
29
+ const LOG_CAP_SCRIPT = fileURLToPath(new URL('./bringup/dev-server-log-cap.js', import.meta.url));
34
30
  /** The dev server this process started, and the only one it will ever stop. */
35
31
  export class OwnedDevServer {
32
+ logDir;
36
33
  child;
37
- buffer = '';
38
- lastOutputAt = Date.now();
34
+ log;
35
+ cwd;
36
+ startedAt = Date.now();
39
37
  handedOver = false;
38
+ constructor(logDir = reticleStateHome()) {
39
+ this.logDir = logDir;
40
+ }
41
+ /**
42
+ * Start it with its output going to a bounded log, through a supervisor that outlives `init`.
43
+ *
44
+ * Not a pipe into THIS process. A pipe's read end belonged to `init`; once it exited nobody held
45
+ * it, and the server's next write (a compile error, an HMR line, SvelteKit's first warning) was an
46
+ * EPIPE that killed it: "it connected, then the app went away". And not a bare file either, which
47
+ * has no ceiling: an upstream echo loop grew one to 2.9 GB in five minutes. The supervisor reads
48
+ * the server's pipes for as long as they are open and rotates the file past a cap — see
49
+ * dev-server-log-cap.ts. It is the process group leader, so `pid()` and `stop()` cover both.
50
+ */
40
51
  start(command, cwd, env) {
41
- const child = spawn(command, {
52
+ mkdirSync(this.logDir, { recursive: true });
53
+ // Logs of servers that are gone, from every project, so the state home stops growing a file
54
+ // per app ever started. This one's are replaced below anyway.
55
+ sweepDeadDevServers(this.logDir, { alive: isAlive, now: Date.now(), keep: cwd });
56
+ const log = join(this.logDir, devServerLogName(cwd));
57
+ // Unlinked rather than truncated: a previous server for this project may still hold the old
58
+ // file open, and truncating under it would interleave its lines into this run's output.
59
+ rmSync(log, { force: true });
60
+ rmSync(`${log}${ROTATED_SUFFIX}`, { force: true });
61
+ this.child = spawn(process.execPath, [LOG_CAP_SCRIPT, log, String(DEV_SERVER_LOG_CAP_BYTES), command], {
42
62
  cwd,
43
- shell: true,
44
63
  // Its own process group, so stopping it stops what it started rather than only the wrapper.
45
64
  detached: !WINDOWS,
46
- stdio: ['ignore', 'pipe', 'pipe'],
65
+ // Nothing of ours: the supervisor holds the server's pipes, and init holds none of its.
66
+ stdio: 'ignore',
47
67
  env: { ...process.env, ...env },
48
68
  });
49
- const collect = (chunk) => {
50
- this.buffer += String(chunk);
51
- this.lastOutputAt = Date.now();
52
- };
53
- child.stdout?.on('data', collect);
54
- child.stderr?.on('data', collect);
55
- this.child = child;
69
+ this.log = log;
70
+ this.cwd = cwd;
71
+ this.startedAt = Date.now();
72
+ }
73
+ /** Where the server's output goes, for the line that hands it over. */
74
+ logPath() {
75
+ return this.log;
56
76
  }
57
77
  /** The process group leader, for a caller that has to clean up what it handed over. */
58
78
  pid() {
59
79
  return this.child?.pid;
60
80
  }
61
81
  output() {
62
- return this.buffer;
82
+ if (undefined === this.log)
83
+ return '';
84
+ try {
85
+ return readFileSync(this.log, 'utf8');
86
+ }
87
+ catch {
88
+ return '';
89
+ }
63
90
  }
64
91
  exited() {
65
92
  return undefined !== this.child && null !== this.child.exitCode;
66
93
  }
94
+ /** Since the log last grew, which is since the server last printed anything. */
67
95
  quietForMs() {
68
- return Date.now() - this.lastOutputAt;
96
+ let lastOutputAt = this.startedAt;
97
+ try {
98
+ if (undefined !== this.log)
99
+ lastOutputAt = Math.max(lastOutputAt, statSync(this.log).mtimeMs);
100
+ }
101
+ catch {
102
+ /* not written yet */
103
+ }
104
+ return Date.now() - lastOutputAt;
69
105
  }
70
106
  /** Ports anything in this server's process tree is listening on. */
71
107
  listeningPorts() {
@@ -90,24 +126,20 @@ export class OwnedDevServer {
90
126
  `lsof -a -p ${pids.join(',')} -iTCP -sTCP:LISTEN -P -n 2>/dev/null | awk 'NR>1{print $9}'`,
91
127
  ]));
92
128
  }
93
- /** Hand the running server to the user. After this, `stop()` does nothing. */
94
129
  /**
95
- * Leave the server running and stop holding it.
96
- *
97
- * `unref` on the child is NOT enough: its stdout and stderr are pipes owned by this process, and
98
- * an open pipe keeps the event loop alive on its own. So `init` printed "setup complete" and then
99
- * hung until something killed it, turning a successful run into a non-zero exit — which is
100
- * exactly what the install gate reported, on a run whose every other assertion passed.
130
+ * Leave the server running and stop holding it. After this, `stop()` does nothing.
101
131
  *
102
- * The streams are unref'd rather than destroyed: the server is still writing to them, and
103
- * destroying the read end of a live pipe risks an EPIPE in somebody else's dev server.
132
+ * Only `unref` is needed: the output is the supervisor's, so no pipe of ours holds the event loop
133
+ * (`init` once printed "setup complete" and hung on one). `url` is recorded beside the log, so the
134
+ * next `init` attaches to this server rather than starting a second one beside it.
104
135
  */
105
- handOver() {
136
+ handOver(url) {
106
137
  this.handedOver = true;
107
- const child = this.child;
108
- releasePipe(child?.stdout);
109
- releasePipe(child?.stderr);
110
- child?.unref();
138
+ this.child?.unref();
139
+ const pid = this.child?.pid;
140
+ if (undefined !== url && undefined !== pid && undefined !== this.cwd) {
141
+ recordHandOver(this.logDir, { pid, url, appDir: this.cwd });
142
+ }
111
143
  }
112
144
  stop() {
113
145
  const child = this.child;
@@ -164,14 +196,16 @@ function windowsProcessPairs() {
164
196
  * server that is already running.
165
197
  *
166
198
  * On loopback, the announced host is tried first and the other loopback families follow when it
167
- * misses — so an IPv6-only Vite that printed `127.0.0.1` still counts as served (#884).
199
+ * is REFUSED — so an IPv6-only Vite that printed `127.0.0.1` still counts as served (#884). Only
200
+ * refused: a timeout means the right server is slow, and the caller polls again on the same host.
201
+ * See `isAddressMiss` for the Next 16 first run that hopping on a timeout broke.
168
202
  */
169
- export async function probePage(url) {
203
+ export async function probePage(url, timeoutMs = PROBE_TIMEOUT_MS) {
170
204
  const candidates = loopbackProbeUrls(url);
171
205
  let sawTlsRefused = false;
172
206
  for (const candidate of candidates) {
173
207
  try {
174
- const res = await fetch(candidate, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
208
+ const res = await fetch(candidate, { signal: AbortSignal.timeout(timeoutMs) });
175
209
  const body = await res.text();
176
210
  const sdkInPage = /@reticlehq|@reticle-connect|reticle-dev/.test(body);
177
211
  // Only stamp reachedUrl when we had to leave the announcement — callers that already have
@@ -187,6 +221,8 @@ export async function probePage(url) {
187
221
  if (/certificate|SELF_SIGNED|DEPTH_ZERO|ERR_TLS|unable to verify/i.test(message)) {
188
222
  sawTlsRefused = true;
189
223
  }
224
+ if (!isAddressMiss(err))
225
+ break;
190
226
  }
191
227
  }
192
228
  return { served: false, sdkInPage: false, ...(sawTlsRefused ? { tlsRefused: true } : {}) };
@@ -16,3 +16,10 @@
16
16
  * family-split problem, and inventing alternates would probe the wrong machine.
17
17
  */
18
18
  export declare function loopbackProbeUrls(url: string): string[];
19
+ /**
20
+ * Whether a failed fetch was refused at the connection level on this address.
21
+ *
22
+ * `fetch` wraps the socket error as `cause`; with happy-eyeballs that cause is an AggregateError of
23
+ * one error per address tried, and it is a miss only when every one of them was.
24
+ */
25
+ export declare function isAddressMiss(err: unknown): boolean;