@reticlehq/server 2.8.0 → 2.10.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 (318) hide show
  1. package/SKILL.md +130 -813
  2. package/dist/bridge/bridge.d.ts +23 -0
  3. package/dist/bridge/bridge.js +80 -2
  4. package/dist/bridge/bridge.test-harness.d.ts +5 -0
  5. package/dist/bridge/bridge.test-harness.js +6 -4
  6. package/dist/capsule/capsule-store.d.ts +2 -2
  7. package/dist/capsule/causal-summary.d.ts +16 -1
  8. package/dist/capsule/causal-summary.js +2 -1
  9. package/dist/cli/cli-doctor.js +41 -13
  10. package/dist/cli/cli-flow-commands.js +8 -8
  11. package/dist/cli/cli-launch.d.ts +0 -17
  12. package/dist/cli/cli-launch.js +0 -28
  13. package/dist/cli/cli-parse.d.ts +1 -1
  14. package/dist/cli/cli-parse.js +1 -1
  15. package/dist/cli/cli-port.d.ts +24 -0
  16. package/dist/cli/cli-port.js +66 -35
  17. package/dist/cli/cli-verify.d.ts +21 -0
  18. package/dist/cli/cli-verify.js +45 -1
  19. package/dist/cli/config-discovery.d.ts +35 -0
  20. package/dist/cli/config-discovery.js +163 -0
  21. package/dist/cli/doctor-daemon-line.js +4 -1
  22. package/dist/cli/doctor-project-line.d.ts +37 -0
  23. package/dist/cli/doctor-project-line.js +35 -0
  24. package/dist/cli/doctor-rows.d.ts +51 -0
  25. package/dist/cli/doctor-rows.js +54 -0
  26. package/dist/cli/doctor-sessions-line.js +6 -3
  27. package/dist/cli/drive-attach.d.ts +99 -0
  28. package/dist/cli/drive-attach.js +203 -0
  29. package/dist/cli/drive-command.d.ts +13 -0
  30. package/dist/cli/drive-command.js +103 -0
  31. package/dist/cli/open-note.d.ts +20 -0
  32. package/dist/cli/open-note.js +37 -0
  33. package/dist/cli/status-next-action.d.ts +37 -0
  34. package/dist/cli/status-next-action.js +61 -0
  35. package/dist/cli.js +65 -70
  36. package/dist/crawl/crawl.d.ts +7 -2
  37. package/dist/crawl/crawl.js +52 -9
  38. package/dist/ee/audit-log.js +2 -2
  39. package/dist/events/body-failures.d.ts +2 -2
  40. package/dist/events/contradiction-folds.d.ts +45 -0
  41. package/dist/events/contradiction-folds.js +42 -0
  42. package/dist/events/contradictions.d.ts +51 -1
  43. package/dist/events/contradictions.js +26 -7
  44. package/dist/events/declared.d.ts +45 -0
  45. package/dist/events/declared.js +73 -0
  46. package/dist/events/echo-mismatch.d.ts +9 -3
  47. package/dist/events/echo-mismatch.js +33 -5
  48. package/dist/events/event-filters.d.ts +1 -1
  49. package/dist/events/event-filters.js +7 -4
  50. package/dist/events/predicate-eval.d.ts +17 -91
  51. package/dist/events/predicate-eval.js +85 -326
  52. package/dist/events/predicate-parse.js +31 -3
  53. package/dist/events/predicate-route.d.ts +32 -0
  54. package/dist/events/predicate-route.js +102 -0
  55. package/dist/events/predicate-schema.d.ts +99 -0
  56. package/dist/events/predicate-schema.js +309 -0
  57. package/dist/events/predicate.d.ts +8 -0
  58. package/dist/events/predicate.js +127 -5
  59. package/dist/events/stale-response.d.ts +2 -3
  60. package/dist/events/stale-response.js +49 -2
  61. package/dist/events/unit-mismatch.d.ts +2 -2
  62. package/dist/flows/annotate-tools.js +3 -0
  63. package/dist/flows/annotate.js +18 -4
  64. package/dist/flows/coverage.d.ts +38 -5
  65. package/dist/flows/coverage.js +30 -4
  66. package/dist/flows/decision.d.ts +15 -0
  67. package/dist/flows/decision.js +1 -1
  68. package/dist/flows/flow-replay-run.js +8 -1
  69. package/dist/flows/flow-tools.js +9 -1
  70. package/dist/flows/replay.js +3 -0
  71. package/dist/flows/verify-change-tools.js +1 -1
  72. package/dist/honesty/accepted-write.d.ts +3 -0
  73. package/dist/honesty/accepted-write.js +8 -3
  74. package/dist/honesty/already-true.js +15 -0
  75. package/dist/honesty/blind-spots.js +4 -0
  76. package/dist/honesty/honesty.d.ts +11 -0
  77. package/dist/honesty/honesty.js +1 -0
  78. package/dist/honesty/uncaptured-bodies.d.ts +4 -2
  79. package/dist/honesty/uncaptured-bodies.js +9 -2
  80. package/dist/honesty/unread-outcome.d.ts +8 -0
  81. package/dist/honesty/unread-outcome.js +36 -9
  82. package/dist/honesty/unsettled.d.ts +9 -0
  83. package/dist/honesty/unsettled.js +7 -3
  84. package/dist/honesty/verified.d.ts +29 -3
  85. package/dist/honesty/verified.js +67 -8
  86. package/dist/http-server.d.ts +19 -0
  87. package/dist/http-server.js +202 -4
  88. package/dist/impact/impact-recorder.d.ts +23 -0
  89. package/dist/impact/impact-recorder.js +76 -0
  90. package/dist/impact/impact-store.d.ts +47 -0
  91. package/dist/impact/impact-store.js +171 -0
  92. package/dist/index.d.ts +25 -0
  93. package/dist/index.js +60 -10
  94. package/dist/init/agent-rules.d.ts +20 -0
  95. package/dist/init/agent-rules.js +36 -0
  96. package/dist/init/astro-patch.js +27 -1
  97. package/dist/init/confirm.d.ts +21 -3
  98. package/dist/init/confirm.js +32 -5
  99. package/dist/init/connect-steps.d.ts +2 -0
  100. package/dist/init/connect-steps.js +50 -0
  101. package/dist/init/csp-check.d.ts +38 -0
  102. package/dist/init/csp-check.js +92 -0
  103. package/dist/init/csp-doctor.d.ts +25 -0
  104. package/dist/init/csp-doctor.js +49 -0
  105. package/dist/init/cursor.js +6 -9
  106. package/dist/init/detect.d.ts +8 -0
  107. package/dist/init/detect.js +33 -3
  108. package/dist/init/mcp-clients.d.ts +1 -0
  109. package/dist/init/mcp-clients.js +51 -7
  110. package/dist/init/mcp.d.ts +16 -2
  111. package/dist/init/mcp.js +23 -1
  112. package/dist/init/plan-framework.d.ts +10 -0
  113. package/dist/init/plan-framework.js +25 -1
  114. package/dist/init/plan.d.ts +7 -2
  115. package/dist/init/plan.js +66 -30
  116. package/dist/init/run.js +11 -5
  117. package/dist/init/slash-command.d.ts +1 -1
  118. package/dist/init/slash-command.js +12 -4
  119. package/dist/init/snippets.d.ts +1 -1
  120. package/dist/init/snippets.js +35 -7
  121. package/dist/init/vite-config.js +24 -2
  122. package/dist/input/network-mock-tools.js +8 -1
  123. package/dist/input/scroll-find.d.ts +6 -0
  124. package/dist/input/scroll-find.js +17 -2
  125. package/dist/input/scroll-tools.js +4 -0
  126. package/dist/journal/attach-journal.d.ts +9 -1
  127. package/dist/journal/attach-journal.js +13 -2
  128. package/dist/journal/journal-query.d.ts +13 -0
  129. package/dist/journal/journal-query.js +28 -3
  130. package/dist/journal/workspace-gitignore.d.ts +10 -0
  131. package/dist/journal/workspace-gitignore.js +59 -0
  132. package/dist/license/license.d.ts +69 -4
  133. package/dist/license/license.js +84 -15
  134. package/dist/mcp/mcp-proxy.d.ts +7 -93
  135. package/dist/mcp/mcp-proxy.js +183 -250
  136. package/dist/mcp/mcp.d.ts +27 -2
  137. package/dist/mcp/mcp.js +77 -36
  138. package/dist/mcp/proxy-daemon-probe.d.ts +8 -0
  139. package/dist/mcp/proxy-daemon-probe.js +48 -0
  140. package/dist/mcp/proxy-handshake.d.ts +82 -1
  141. package/dist/mcp/proxy-handshake.js +110 -3
  142. package/dist/mcp/proxy-lifecycle.d.ts +10 -1
  143. package/dist/mcp/proxy-lifecycle.js +11 -2
  144. package/dist/mcp/proxy-log.d.ts +63 -0
  145. package/dist/mcp/proxy-log.js +132 -0
  146. package/dist/mcp/server-instructions.d.ts +17 -0
  147. package/dist/mcp/server-instructions.js +56 -0
  148. package/dist/mcp/shared-params.d.ts +27 -0
  149. package/dist/mcp/shared-params.js +36 -0
  150. package/dist/mcp/sse-frame-parser.d.ts +19 -0
  151. package/dist/mcp/sse-frame-parser.js +41 -0
  152. package/dist/project/reticle-dir.d.ts +2 -0
  153. package/dist/project/reticle-dir.js +1 -0
  154. package/dist/session/connection-memory.d.ts +59 -0
  155. package/dist/session/connection-memory.js +108 -0
  156. package/dist/session/dev-command.d.ts +26 -0
  157. package/dist/session/dev-command.js +89 -0
  158. package/dist/session/dev-server-probe.d.ts +68 -0
  159. package/dist/session/dev-server-probe.js +102 -7
  160. package/dist/session/last-act.d.ts +26 -0
  161. package/dist/session/last-act.js +35 -1
  162. package/dist/session/live-control-tools.js +42 -5
  163. package/dist/session/no-session-diagnosis.d.ts +55 -2
  164. package/dist/session/no-session-diagnosis.js +199 -22
  165. package/dist/session/no-session-next-action.d.ts +46 -0
  166. package/dist/session/no-session-next-action.js +85 -0
  167. package/dist/session/no-session-watch.d.ts +40 -1
  168. package/dist/session/no-session-watch.js +172 -11
  169. package/dist/session/pending-commands.d.ts +12 -0
  170. package/dist/session/pending-commands.js +13 -1
  171. package/dist/session/session-manager.d.ts +4 -0
  172. package/dist/session/session-manager.js +35 -5
  173. package/dist/session/session-readiness.js +1 -1
  174. package/dist/session/session.d.ts +10 -1
  175. package/dist/session/session.js +146 -5
  176. package/dist/telemetry/app-instrumented.d.ts +8 -0
  177. package/dist/telemetry/app-instrumented.js +10 -0
  178. package/dist/telemetry/bug-found.d.ts +18 -0
  179. package/dist/telemetry/bug-found.js +14 -0
  180. package/dist/telemetry/daemon-telemetry.js +29 -0
  181. package/dist/telemetry/install-source.d.ts +20 -0
  182. package/dist/telemetry/install-source.js +34 -0
  183. package/dist/telemetry/instrumentation-stall.d.ts +57 -0
  184. package/dist/telemetry/instrumentation-stall.js +103 -0
  185. package/dist/telemetry/license-activation.d.ts +48 -0
  186. package/dist/telemetry/license-activation.js +57 -0
  187. package/dist/telemetry/session-metrics.d.ts +8 -0
  188. package/dist/telemetry/session-metrics.js +10 -0
  189. package/dist/telemetry/telemetry.d.ts +3 -1
  190. package/dist/telemetry/telemetry.js +23 -3
  191. package/dist/tools/act-tools.js +166 -29
  192. package/dist/tools/assert-verdict.js +32 -7
  193. package/dist/tools/browser-tools.js +10 -0
  194. package/dist/tools/dynamic-tools.js +17 -8
  195. package/dist/tools/error-recovery.js +10 -6
  196. package/dist/tools/feedback-tools.js +31 -6
  197. package/dist/tools/invoke-tool.d.ts +1 -1
  198. package/dist/tools/invoke-tool.js +30 -8
  199. package/dist/tools/lease-hint.d.ts +30 -8
  200. package/dist/tools/lease-hint.js +65 -14
  201. package/dist/tools/lease-tools.d.ts +11 -0
  202. package/dist/tools/lease-tools.js +141 -96
  203. package/dist/tools/observe-tools.js +9 -3
  204. package/dist/tools/react-grace.d.ts +4 -0
  205. package/dist/tools/react-grace.js +6 -3
  206. package/dist/tools/read-tools.js +47 -1
  207. package/dist/tools/resolve-target.d.ts +33 -0
  208. package/dist/tools/resolve-target.js +53 -0
  209. package/dist/tools/settle-in-flight.d.ts +15 -0
  210. package/dist/tools/settle-in-flight.js +42 -2
  211. package/dist/tools/snapshot-delta.d.ts +3 -0
  212. package/dist/tools/snapshot-delta.js +67 -6
  213. package/dist/tools/tool-kit.d.ts +38 -3
  214. package/dist/tools/tool-names.d.ts +2 -0
  215. package/dist/tools/tool-names.js +2 -0
  216. package/dist/tools/tool-surface.d.ts +60 -0
  217. package/dist/tools/tool-surface.js +84 -19
  218. package/dist/tools/tools.js +61 -5
  219. package/dist/tools/unadvertised-help.js +9 -6
  220. package/docs/README.md +1 -2
  221. package/docs/actions.mdx +41 -8
  222. package/docs/agent-cheatsheet.md +6 -4
  223. package/docs/architecture.md +6 -0
  224. package/docs/benchmarks.md +60 -12
  225. package/docs/best-practices.mdx +11 -6
  226. package/docs/capabilities.mdx +204 -57
  227. package/docs/cli/affected.mdx +6 -4
  228. package/docs/cli/capsules.mdx +9 -8
  229. package/docs/cli/cloud.mdx +30 -4
  230. package/docs/cli/doctor.mdx +41 -8
  231. package/docs/cli/drive.mdx +34 -15
  232. package/docs/cli/gate.mdx +6 -6
  233. package/docs/cli/help.mdx +8 -6
  234. package/docs/cli/hunt.mdx +20 -4
  235. package/docs/cli/identify.mdx +16 -2
  236. package/docs/cli/init.mdx +35 -8
  237. package/docs/cli/kill.mdx +5 -5
  238. package/docs/cli/license.mdx +1 -1
  239. package/docs/cli/open.mdx +6 -0
  240. package/docs/cli/restart.mdx +6 -6
  241. package/docs/cli/serve.mdx +10 -3
  242. package/docs/cli/status.mdx +29 -5
  243. package/docs/cli/stop.mdx +8 -0
  244. package/docs/cli/verify.mdx +20 -1
  245. package/docs/cli/version.mdx +1 -1
  246. package/docs/cli/watch.mdx +9 -2
  247. package/docs/cli.mdx +44 -22
  248. package/docs/debugging.md +3 -1
  249. package/docs/deploy-checks.md +31 -2
  250. package/docs/desktop.mdx +276 -80
  251. package/docs/docs.json +13 -12
  252. package/docs/enterprise.md +9 -3
  253. package/docs/faq.mdx +49 -35
  254. package/docs/fixtures.md +2 -0
  255. package/docs/flows.md +7 -5
  256. package/docs/for-agents.md +3 -3
  257. package/docs/frameworks.mdx +46 -7
  258. package/docs/gate-plan.md +4 -2
  259. package/docs/gates.md +8 -6
  260. package/docs/getting-started.md +53 -12
  261. package/docs/human-control.md +2 -0
  262. package/docs/index.mdx +43 -1
  263. package/docs/install-agentic.mdx +13 -4
  264. package/docs/install-manual.mdx +6 -4
  265. package/docs/instrumentation.mdx +7 -1
  266. package/docs/integration-patterns.md +3 -1
  267. package/docs/local-registry.md +4 -2
  268. package/docs/multi-agent-testing.md +3 -1
  269. package/docs/packages/babel-plugin.mdx +15 -6
  270. package/docs/packages/browser.mdx +2 -2
  271. package/docs/packages/core.mdx +26 -16
  272. package/docs/packages/electron.mdx +5 -7
  273. package/docs/packages/eslint-plugin.mdx +5 -3
  274. package/docs/packages/next.mdx +3 -5
  275. package/docs/packages/react.mdx +3 -3
  276. package/docs/packages/server.mdx +2 -2
  277. package/docs/packages/tauri.mdx +6 -6
  278. package/docs/packages/test.mdx +18 -7
  279. package/docs/packages/vite-plugin.mdx +4 -6
  280. package/docs/packages.mdx +14 -13
  281. package/docs/platform-integration.md +2 -0
  282. package/docs/predicates.mdx +76 -7
  283. package/docs/quickstart.mdx +51 -5
  284. package/docs/recipes.mdx +63 -23
  285. package/docs/skill-file.mdx +3 -1
  286. package/docs/state-management.mdx +2 -0
  287. package/docs/system-map.md +3 -1
  288. package/docs/telemetry-contract.md +33 -5
  289. package/docs/telemetry-events.mdx +63 -18
  290. package/docs/telemetry.md +8 -5
  291. package/docs/testing.md +35 -1
  292. package/docs/token-efficiency.md +8 -2
  293. package/docs/tools-act-and-wait.mdx +64 -14
  294. package/docs/tools-act-sequence.mdx +34 -17
  295. package/docs/tools-act.mdx +24 -11
  296. package/docs/tools-assert.mdx +69 -10
  297. package/docs/tools-console.mdx +11 -7
  298. package/docs/tools-inspect.mdx +11 -8
  299. package/docs/tools-navigate.mdx +41 -5
  300. package/docs/tools-network.mdx +42 -9
  301. package/docs/tools-observe.mdx +66 -10
  302. package/docs/tools-overview.mdx +26 -23
  303. package/docs/tools-query.mdx +52 -8
  304. package/docs/tools-session-and-feedback.mdx +35 -9
  305. package/docs/tools-sessions.mdx +39 -10
  306. package/docs/tools-snapshot.mdx +57 -3
  307. package/docs/tools-state.mdx +24 -1
  308. package/docs/tools-tools-and-run.mdx +55 -24
  309. package/docs/tools-wait-for.mdx +34 -15
  310. package/docs/troubleshooting.mdx +80 -10
  311. package/docs/usage.md +46 -24
  312. package/docs/vs-chrome-devtools-mcp.mdx +41 -0
  313. package/docs/vs-playwright-mcp.mdx +42 -2
  314. package/docs/vs-screenshots.mdx +42 -1
  315. package/docs/what-is-reticle.mdx +1 -1
  316. package/docs/why-reticle.mdx +3 -1
  317. package/package.json +5 -5
  318. package/docs/desktop-apps.md +0 -265
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Who gets the bridge port when `reticle drive <url>` runs — and what happens when the answer is
3
+ * "not you".
4
+ *
5
+ * `drive` used to bind :4400 unconditionally. A daemon on that port is the NORMAL state the moment
6
+ * any MCP client has started one, so the bind died with a raw `node:net` EADDRINUSE stack — in the
7
+ * exact situation the product tells people to run `drive` in, since `reticle_sessions` recommends it
8
+ * for a throttled tab and a throttled tab nearly always coexists with the daemon that makes the bind
9
+ * impossible. Two independent field reports hit it; the second lost half an hour to the workaround,
10
+ * because `reticle stop` frees the port and the MCP proxy respawns a daemon into the gap before
11
+ * `drive` can bind, so the "escape hatch" is a race the user cannot win.
12
+ *
13
+ * The fix is arbitration, not a better race:
14
+ *
15
+ * - **A healthy daemon owns the port → ATTACH.** Driving is a REQUEST to the process that already
16
+ * owns the browser plane, not a competing process. The daemon hands out a pooled, isolated,
17
+ * never-throttled context (the same one `reticle_lease` returns) and the caller gets back a
18
+ * sessionId every Reticle tool — including the ones the agent already has open on that daemon —
19
+ * can address. Nothing is bound, so there is nothing to race for and no reason to run `stop`.
20
+ * - **Nothing on the port → BIND.** Unchanged: `drive` is still a self-contained foreground engine
21
+ * with its own browser when it can have the port.
22
+ * - **A stranger owns the port → REFUSE**, naming the holder. Neither binding nor attaching can
23
+ * work, and pretending otherwise is the lie this module exists to remove.
24
+ *
25
+ * Making `stop` block until the port is genuinely free (and muzzling the proxy meanwhile) was the
26
+ * alternative. It is strictly more machinery — a cross-process handoff, a suppression window, a new
27
+ * way for the proxy to be wrong — to earn the same browser the daemon can already open, and it would
28
+ * still leave `drive` unable to run while an agent is working. Attaching deletes the race instead of
29
+ * refereeing it.
30
+ */
31
+ import { PortPresence } from '../daemon/port-presence.js';
32
+ /** What `drive` should do about the port, given what is on it. */
33
+ export declare const DriveMode: {
34
+ /** Nothing is listening: bind it and run the foreground engine. */
35
+ readonly BIND: "bind";
36
+ /** Our daemon is serving it: ask that daemon for a driveable session. */
37
+ readonly ATTACH: "attach";
38
+ /** Someone else holds it: neither is possible. */
39
+ readonly REFUSE: "refuse";
40
+ };
41
+ export type DriveMode = (typeof DriveMode)[keyof typeof DriveMode];
42
+ /** Pure. Exhaustive by switch, so a fourth presence has to decide what driving means for it. */
43
+ export declare function decideDriveMode(presence: PortPresence): DriveMode;
44
+ /** A driveable session the daemon opened on our behalf. */
45
+ export interface DriveSession {
46
+ sessionId: string;
47
+ /** Whether the tab's SDK actually registered — false ⇒ the app may not embed @reticlehq/core. */
48
+ ready: boolean;
49
+ /** How long the session survives untouched. Absent from a daemon that does not report it. */
50
+ expiresInMs?: number;
51
+ /** The daemon's own diagnosis when the tab did not connect. */
52
+ hint?: string;
53
+ }
54
+ export type DriveAttachResult = {
55
+ ok: true;
56
+ session: DriveSession;
57
+ } | {
58
+ ok: false;
59
+ reason: string;
60
+ };
61
+ /** POST the drive request. Injected so the decision logic is tested without a socket. */
62
+ export type DrivePost = (port: number, path: string, body: string) => Promise<{
63
+ status: number;
64
+ body: string;
65
+ }>;
66
+ /**
67
+ * Turn the daemon's answer into a session or a sentence. Pure.
68
+ *
69
+ * Every failure branch has to end in something the reader can DO, because this is the path that
70
+ * replaces the EADDRINUSE stack — an answer that is merely accurate ("404") repeats the original
71
+ * mistake in a politer font.
72
+ */
73
+ export declare function readAttachResponse(port: number, url: string, res: {
74
+ status: number;
75
+ body: string;
76
+ }): DriveAttachResult;
77
+ /** Ask the daemon on `port` to open a driveable browser at `url`. */
78
+ export declare function requestDriveSession(port: number, url: string, post?: DrivePost): Promise<DriveAttachResult>;
79
+ /** The success line. Names the session, because that is the handle everything downstream needs. */
80
+ export declare function describeAttached(port: number, url: string, session: DriveSession): string;
81
+ /**
82
+ * A stranger holds the port, in words `drive` can honour.
83
+ *
84
+ * The shared sentence for a foreign holder offers `--port`, which is true of `serve`, `status` and
85
+ * `doctor` and false here: `drive` rejects that flag outright and reads the port from the
86
+ * environment. Sending someone to a flag that does not exist is a second dead end at the moment they
87
+ * have already hit one, so the route that works is named instead. The shared sentence is passed in
88
+ * rather than rebuilt, so the description of the holder stays in one place.
89
+ */
90
+ export declare function driveForeignHolder(description: string): string;
91
+ /**
92
+ * The port went from free to taken between the probe and the bind.
93
+ *
94
+ * The probe cannot close that window, and the loser of the race learns about it as an EADDRINUSE on
95
+ * the server object, outside every promise `drive` holds. It is a DISTINCT state from a foreign
96
+ * holder — the likeliest winner is a daemon the proxy just spawned — so it gets its own sentence,
97
+ * and that sentence is "run it again", because the second run attaches instead of binding.
98
+ */
99
+ export declare function driveRaceLost(port: number, url: string): string;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Who gets the bridge port when `reticle drive <url>` runs — and what happens when the answer is
3
+ * "not you".
4
+ *
5
+ * `drive` used to bind :4400 unconditionally. A daemon on that port is the NORMAL state the moment
6
+ * any MCP client has started one, so the bind died with a raw `node:net` EADDRINUSE stack — in the
7
+ * exact situation the product tells people to run `drive` in, since `reticle_sessions` recommends it
8
+ * for a throttled tab and a throttled tab nearly always coexists with the daemon that makes the bind
9
+ * impossible. Two independent field reports hit it; the second lost half an hour to the workaround,
10
+ * because `reticle stop` frees the port and the MCP proxy respawns a daemon into the gap before
11
+ * `drive` can bind, so the "escape hatch" is a race the user cannot win.
12
+ *
13
+ * The fix is arbitration, not a better race:
14
+ *
15
+ * - **A healthy daemon owns the port → ATTACH.** Driving is a REQUEST to the process that already
16
+ * owns the browser plane, not a competing process. The daemon hands out a pooled, isolated,
17
+ * never-throttled context (the same one `reticle_lease` returns) and the caller gets back a
18
+ * sessionId every Reticle tool — including the ones the agent already has open on that daemon —
19
+ * can address. Nothing is bound, so there is nothing to race for and no reason to run `stop`.
20
+ * - **Nothing on the port → BIND.** Unchanged: `drive` is still a self-contained foreground engine
21
+ * with its own browser when it can have the port.
22
+ * - **A stranger owns the port → REFUSE**, naming the holder. Neither binding nor attaching can
23
+ * work, and pretending otherwise is the lie this module exists to remove.
24
+ *
25
+ * Making `stop` block until the port is genuinely free (and muzzling the proxy meanwhile) was the
26
+ * alternative. It is strictly more machinery — a cross-process handoff, a suppression window, a new
27
+ * way for the proxy to be wrong — to earn the same browser the daemon can already open, and it would
28
+ * still leave `drive` unable to run while an agent is working. Attaching deletes the race instead of
29
+ * refereeing it.
30
+ */
31
+ import * as http from 'node:http';
32
+ import { DRIVE_PATH, LOOPBACK_HOST } from '@reticlehq/core';
33
+ import { PortPresence } from '../daemon/port-presence.js';
34
+ /** What `drive` should do about the port, given what is on it. */
35
+ export const DriveMode = {
36
+ /** Nothing is listening: bind it and run the foreground engine. */
37
+ BIND: 'bind',
38
+ /** Our daemon is serving it: ask that daemon for a driveable session. */
39
+ ATTACH: 'attach',
40
+ /** Someone else holds it: neither is possible. */
41
+ REFUSE: 'refuse',
42
+ };
43
+ /** Pure. Exhaustive by switch, so a fourth presence has to decide what driving means for it. */
44
+ export function decideDriveMode(presence) {
45
+ switch (presence) {
46
+ case PortPresence.DAEMON:
47
+ return DriveMode.ATTACH;
48
+ case PortPresence.FOREIGN:
49
+ return DriveMode.REFUSE;
50
+ case PortPresence.FREE:
51
+ return DriveMode.BIND;
52
+ }
53
+ }
54
+ /** The daemon answers a tool refusal (app down, pool unavailable) as a 200 with this field. */
55
+ const ERROR_FIELD = 'error';
56
+ /**
57
+ * Turn the daemon's answer into a session or a sentence. Pure.
58
+ *
59
+ * Every failure branch has to end in something the reader can DO, because this is the path that
60
+ * replaces the EADDRINUSE stack — an answer that is merely accurate ("404") repeats the original
61
+ * mistake in a politer font.
62
+ */
63
+ export function readAttachResponse(port, url, res) {
64
+ if (404 === res.status) {
65
+ return {
66
+ ok: false,
67
+ reason: `the Reticle daemon on :${String(port)} is older than this CLI and cannot open a drive ` +
68
+ 'session — stop it with `reticle stop` and retry (the next tool call starts a current one).',
69
+ };
70
+ }
71
+ if (200 !== res.status) {
72
+ return {
73
+ ok: false,
74
+ reason: `the Reticle daemon on :${String(port)} refused to open a drive session ` +
75
+ `(HTTP ${String(res.status)}: ${res.body.slice(0, 200)}).`,
76
+ };
77
+ }
78
+ const notASession = {
79
+ ok: false,
80
+ reason: `the Reticle daemon on :${String(port)} answered the drive request with something that is ` +
81
+ 'not a session. Stop it with `reticle stop` and retry.',
82
+ };
83
+ const parsed = parseJson(res.body);
84
+ if (typeof parsed !== 'object' || null === parsed)
85
+ return notASession;
86
+ const record = parsed;
87
+ const error = record[ERROR_FIELD];
88
+ if ('string' === typeof error) {
89
+ return { ok: false, reason: `could not drive ${url} — ${error}` };
90
+ }
91
+ const sessionId = record['sessionId'];
92
+ if ('string' !== typeof sessionId || 0 === sessionId.length) {
93
+ return {
94
+ ok: false,
95
+ reason: `the Reticle daemon on :${String(port)} answered the drive request with something that is ` +
96
+ 'not a session. Stop it with `reticle stop` and retry.',
97
+ };
98
+ }
99
+ const expiresInMs = record['expiresInMs'];
100
+ const hint = record['hint'];
101
+ return {
102
+ ok: true,
103
+ session: {
104
+ sessionId,
105
+ ready: true === record['ready'],
106
+ ...('number' === typeof expiresInMs ? { expiresInMs } : {}),
107
+ ...('string' === typeof hint ? { hint } : {}),
108
+ },
109
+ };
110
+ }
111
+ function parseJson(body) {
112
+ try {
113
+ return JSON.parse(body);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ /** Ask the daemon on `port` to open a driveable browser at `url`. */
120
+ export async function requestDriveSession(port, url, post = postJson) {
121
+ let res;
122
+ try {
123
+ res = await post(port, DRIVE_PATH, JSON.stringify({ url }));
124
+ }
125
+ catch (error) {
126
+ // The daemon answered /status a moment ago and is gone now — a shutdown, a crash, a `stop` from
127
+ // another terminal. Retrying is the whole answer, and it is the one thing an errno never says.
128
+ const message = error instanceof Error ? error.message : String(error);
129
+ return {
130
+ ok: false,
131
+ reason: `could not reach the Reticle daemon on :${String(port)} (${message}) — it answered a ` +
132
+ 'moment ago, so it is shutting down or has just been stopped. Retry the command.',
133
+ };
134
+ }
135
+ return readAttachResponse(port, url, res);
136
+ }
137
+ /** The success line. Names the session, because that is the handle everything downstream needs. */
138
+ export function describeAttached(port, url, session) {
139
+ const life = session.expiresInMs === undefined
140
+ ? ''
141
+ : ` It is released after ${String(Math.round(session.expiresInMs / 1000))}s untouched; any ` +
142
+ 'tool call against it resets that.';
143
+ if (!session.ready) {
144
+ return (`the Reticle daemon on :${String(port)} opened ${url} as session ${session.sessionId}, but ` +
145
+ `the page did not connect to the bridge. ${session.hint ?? 'Check that the app embeds @reticlehq/core.'}`);
146
+ }
147
+ return (`attached to the Reticle daemon on :${String(port)} — it is driving ${url} as session ` +
148
+ `${session.sessionId}, which every Reticle tool can address.${life}`);
149
+ }
150
+ /**
151
+ * A stranger holds the port, in words `drive` can honour.
152
+ *
153
+ * The shared sentence for a foreign holder offers `--port`, which is true of `serve`, `status` and
154
+ * `doctor` and false here: `drive` rejects that flag outright and reads the port from the
155
+ * environment. Sending someone to a flag that does not exist is a second dead end at the moment they
156
+ * have already hit one, so the route that works is named instead. The shared sentence is passed in
157
+ * rather than rebuilt, so the description of the holder stays in one place.
158
+ */
159
+ export function driveForeignHolder(description) {
160
+ return `${description} \`drive\` takes the port from RETICLE_PORT or .reticle.json, not a flag.`;
161
+ }
162
+ /**
163
+ * The port went from free to taken between the probe and the bind.
164
+ *
165
+ * The probe cannot close that window, and the loser of the race learns about it as an EADDRINUSE on
166
+ * the server object, outside every promise `drive` holds. It is a DISTINCT state from a foreign
167
+ * holder — the likeliest winner is a daemon the proxy just spawned — so it gets its own sentence,
168
+ * and that sentence is "run it again", because the second run attaches instead of binding.
169
+ */
170
+ export function driveRaceLost(port, url) {
171
+ return (`:${String(port)} was taken while \`drive\` was starting — a daemon (probably one the agent's ` +
172
+ `MCP proxy just spawned) now owns it. Run \`reticle drive ${url}\` again: it will attach to ` +
173
+ 'that daemon instead of binding the port.');
174
+ }
175
+ /** How long the drive request waits. A pooled context has to launch a browser on a cold daemon. */
176
+ const DRIVE_REQUEST_TIMEOUT_MS = 60_000;
177
+ /** The real POST. Loopback-only: the daemon is always local, and the drive plane never leaves the host. */
178
+ function postJson(port, path, body) {
179
+ return new Promise((resolve, reject) => {
180
+ const req = http.request({
181
+ host: LOOPBACK_HOST,
182
+ port,
183
+ path,
184
+ method: 'POST',
185
+ timeout: DRIVE_REQUEST_TIMEOUT_MS,
186
+ headers: {
187
+ 'Content-Type': 'application/json',
188
+ 'Content-Length': Buffer.byteLength(body),
189
+ },
190
+ }, (res) => {
191
+ let received = '';
192
+ res.setEncoding('utf8');
193
+ res.on('data', (chunk) => (received += chunk));
194
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body: received }));
195
+ });
196
+ req.on('error', reject);
197
+ req.on('timeout', () => {
198
+ req.destroy();
199
+ reject(new Error(`no answer within ${String(DRIVE_REQUEST_TIMEOUT_MS)}ms`));
200
+ });
201
+ req.end(body);
202
+ });
203
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The `reticle drive <url>` command: decide who gets the bridge port, then do the right thing.
3
+ *
4
+ * Lifted out of cli.ts as one unit — the decision, the attach, and the bind are three answers to a
5
+ * single question and reading one without the others is how the port fight got shipped in the first
6
+ * place. The pure parts (which mode, and every sentence a user reads) live next door in
7
+ * drive-attach.ts, which is where the reasoning for attaching is written down.
8
+ */
9
+ export declare function handleDrive(parsed: {
10
+ port: number;
11
+ driveUrl: string;
12
+ headless: boolean;
13
+ }): void;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The `reticle drive <url>` command: decide who gets the bridge port, then do the right thing.
3
+ *
4
+ * Lifted out of cli.ts as one unit — the decision, the attach, and the bind are three answers to a
5
+ * single question and reading one without the others is how the port fight got shipped in the first
6
+ * place. The pure parts (which mode, and every sentence a user reads) live next door in
7
+ * drive-attach.ts, which is where the reasoning for attaching is written down.
8
+ */
9
+ import { start } from '../index.js';
10
+ import { log } from '../log.js';
11
+ import { probePresence, describePresence } from '../daemon/port-presence.js';
12
+ import { probeDaemon } from '../mcp/mcp-proxy.js';
13
+ import { readPid } from '../daemon/daemon.js';
14
+ import { fetchStatus } from './cli-launch.js';
15
+ import { captureLookup, findPortHolder } from './port-holder.js';
16
+ import { DriveMode, decideDriveMode, describeAttached, driveForeignHolder, driveRaceLost, requestDriveSession, } from './drive-attach.js';
17
+ export function handleDrive(parsed) {
18
+ void driveWithHonestConflict(parsed);
19
+ }
20
+ /**
21
+ * Decide who gets the port BEFORE binding it — and when the answer is "the daemon", ask it instead.
22
+ *
23
+ * `drive` went straight to `start`, which binds. The listen error surfaces asynchronously on the
24
+ * server object long after `start` has resolved, so the `.catch` on that promise could never see it
25
+ * and the process died with the raw `node:net` EADDRINUSE stack — in the one situation this command
26
+ * is most often run in, since `reticle_sessions` recommends `reticle drive` for a throttled tab and
27
+ * a throttled tab nearly always coexists with the daemon holding the port. Refusing politely was
28
+ * only half a fix: the way out it named (`reticle stop`, then retry) is a race the user loses,
29
+ * because the MCP proxy respawns a daemon into the gap. So a healthy daemon is now ATTACHED to —
30
+ * see cli/drive-attach.ts.
31
+ */
32
+ async function driveWithHonestConflict(parsed) {
33
+ const presence = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
34
+ const mode = decideDriveMode(presence);
35
+ if (DriveMode.REFUSE === mode) {
36
+ const reason = driveForeignHolder(describePresence(presence, parsed.port, {
37
+ ourPid: readPid(parsed.port),
38
+ holderPid: findPortHolder(parsed.port, captureLookup)?.pid,
39
+ }));
40
+ log('reticle_drive_port_conflict', { port: parsed.port, presence, reason });
41
+ process.stderr.write(`${reason}\n`);
42
+ process.exit(1);
43
+ return;
44
+ }
45
+ if (DriveMode.ATTACH === mode) {
46
+ await attachToRunningDaemon(parsed.port, parsed.driveUrl);
47
+ return;
48
+ }
49
+ // The probe cannot close the race: something can take the port between here and the bind, and the
50
+ // bind reports it on the server, outside every promise this function holds. Catching it at the
51
+ // process is the only place that sees it at all — and only EADDRINUSE, so a genuine crash still
52
+ // crashes rather than being dressed up as a port conflict.
53
+ process.on('uncaughtException', (error) => {
54
+ const code = error?.code;
55
+ if ('EADDRINUSE' !== code)
56
+ throw error;
57
+ const reason = driveRaceLost(parsed.port, parsed.driveUrl);
58
+ log('reticle_drive_port_race_lost', { port: parsed.port, reason });
59
+ process.stderr.write(`${reason}\n`);
60
+ process.exit(1);
61
+ });
62
+ const options = {
63
+ port: parsed.port,
64
+ driveUrl: parsed.driveUrl,
65
+ headless: parsed.headless,
66
+ };
67
+ try {
68
+ await start(options);
69
+ log('reticle_started', { port: parsed.port });
70
+ }
71
+ catch (error) {
72
+ log('reticle_start_failed', { error: error instanceof Error ? error.message : String(error) });
73
+ process.exit(1);
74
+ }
75
+ }
76
+ /**
77
+ * Drive through the daemon that already owns the port.
78
+ *
79
+ * Exits once the session exists rather than holding the foreground: the thing the caller needs is
80
+ * the sessionId, the session belongs to the daemon (it outlives this process, and any tool call
81
+ * against it renews its lease), and a foreground hold here could only keep it alive by faking tool
82
+ * calls at it.
83
+ */
84
+ async function attachToRunningDaemon(port, url) {
85
+ const result = await requestDriveSession(port, url);
86
+ if (!result.ok) {
87
+ log('reticle_drive_attach_failed', { port, url, reason: result.reason });
88
+ process.stderr.write(`${result.reason}\n`);
89
+ process.exit(1);
90
+ return;
91
+ }
92
+ log('reticle_drive_attached', {
93
+ port,
94
+ url,
95
+ sessionId: result.session.sessionId,
96
+ ready: result.session.ready,
97
+ });
98
+ process.stderr.write(`${describeAttached(port, url, result.session)}\n`);
99
+ // A tab that never connected is not a drive session, and reporting one is the false green this
100
+ // command exists to avoid.
101
+ if (!result.session.ready)
102
+ process.exit(1);
103
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What `reticle open` says when it launched a URL and no session appeared.
3
+ *
4
+ * `open` resolves the port leniently on purpose: if nothing is on the port you asked for, it finds a
5
+ * running daemon instead, so nobody has to hunt for the port. That is the right default and it has
6
+ * one sharp edge — when it substitutes, the command is now talking to a DIFFERENT daemon than the
7
+ * caller named, and every subsequent sentence is about that other daemon.
8
+ *
9
+ * Observed driving this repo's own fixture: `open --port 4470` found nothing there, silently used a
10
+ * daemon on 4400, and reported that the app "carries no Reticle SDK, or dials a port other than
11
+ * 4400". The app was wired and was dialling 4470 exactly as asked. Reticle knew both numbers and
12
+ * reported neither, so the one actionable fact — you are pointed at the wrong daemon — was the only
13
+ * thing missing, and the advice given instead was to re-run `init` on a correctly installed app.
14
+ *
15
+ * That is the same shape as the field reports where a daemon in the wrong repo produced a diagnosis
16
+ * blaming the app: an absence explained by a cause the daemon had already ruled out.
17
+ *
18
+ * So the substitution is stated FIRST, with both numbers, before any cause that assumes they match.
19
+ */
20
+ export declare function openFailureNote(port: number, requestedPort: number): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What `reticle open` says when it launched a URL and no session appeared.
3
+ *
4
+ * `open` resolves the port leniently on purpose: if nothing is on the port you asked for, it finds a
5
+ * running daemon instead, so nobody has to hunt for the port. That is the right default and it has
6
+ * one sharp edge — when it substitutes, the command is now talking to a DIFFERENT daemon than the
7
+ * caller named, and every subsequent sentence is about that other daemon.
8
+ *
9
+ * Observed driving this repo's own fixture: `open --port 4470` found nothing there, silently used a
10
+ * daemon on 4400, and reported that the app "carries no Reticle SDK, or dials a port other than
11
+ * 4400". The app was wired and was dialling 4470 exactly as asked. Reticle knew both numbers and
12
+ * reported neither, so the one actionable fact — you are pointed at the wrong daemon — was the only
13
+ * thing missing, and the advice given instead was to re-run `init` on a correctly installed app.
14
+ *
15
+ * That is the same shape as the field reports where a daemon in the wrong repo produced a diagnosis
16
+ * blaming the app: an absence explained by a cause the daemon had already ruled out.
17
+ *
18
+ * So the substitution is stated FIRST, with both numbers, before any cause that assumes they match.
19
+ */
20
+ export function openFailureNote(port, requestedPort) {
21
+ const substituted = port === requestedPort
22
+ ? ''
23
+ : `NOTE FIRST: you asked for port ${String(requestedPort)} and nothing was listening there, ` +
24
+ `so this used a daemon already running on ${String(port)}. If your app dials ` +
25
+ `${String(requestedPort)}, that is the whole problem — nothing below applies. Start a daemon ` +
26
+ `on ${String(requestedPort)} (\`npx @reticlehq/server serve --port ${String(requestedPort)}\`) ` +
27
+ `or point the app at ${String(port)}. `;
28
+ return (`${substituted}the URL was handed to the system default browser (this command does not use ` +
29
+ `Reticle's own Chromium, so a chromium warning from \`reticle doctor\` is unrelated ` +
30
+ `to this). No Reticle session appeared. By far the likeliest cause is that the app ` +
31
+ `carries no Reticle SDK, or dials a port other than ${String(port)} — run ` +
32
+ `\`npx @reticlehq/server init\` in the app's directory and restart its dev server. If the app IS ` +
33
+ `wired, give the page a moment and check the browser console: the SDK announces its ` +
34
+ `own connect failures there, including the one it refuses to make from a ` +
35
+ `non-localhost host, which needs BOTH allowNonLocalhost: true AND a pairing token ` +
36
+ `(~/.reticle/pairing-token) — the flag alone is not sufficient.`);
37
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The sentence `status` owes the user, because `init` promised it.
3
+ *
4
+ * `init` closes with "run `npx @reticlehq/server status` — it confirms the app connected, or says
5
+ * exactly why it has not". On a real install, following that instruction printed `running: false,
6
+ * presence: "free"` and nothing else: no reason, no next step, no mention of the app. This is the last
7
+ * checkpoint in the setup path, so a user who arrives here and learns nothing has nowhere left to go.
8
+ *
9
+ * Deliberately thin. The differential already exists in `nextActionFor`, which ranks these causes from
10
+ * what the daemon knows and was rewritten this week to stop leading with a port mismatch; a second
11
+ * differential here could disagree with the first, and two confident answers pointing different ways
12
+ * is worse than one. This only supplies the facts the CLI has and renders the answer.
13
+ */
14
+ export interface StatusFacts {
15
+ /** Is a usable daemon answering on this port? */
16
+ running: boolean;
17
+ /** Live browser sessions the daemon reports. */
18
+ sessionCount: number;
19
+ /** Has an app for this project ever connected on this port? Durable, survives a daemon restart. */
20
+ previouslyConnected: boolean;
21
+ /**
22
+ * Has `init` run in this project — i.e. is there a `.reticle.json` with a projectId?
23
+ *
24
+ * Was hardcoded `true` at the call below, which made the one branch that can say "run init"
25
+ * unreachable from `status` however unwired the project was. That is the commonest reason this
26
+ * command has nothing to report: registering the MCP server does not wire the app, and several
27
+ * paths do the first without the second.
28
+ */
29
+ initialized: boolean;
30
+ }
31
+ /**
32
+ * What to do next, or `undefined` when a session is connected and there is nothing to fix.
33
+ *
34
+ * The success case has to stay silent: advice printed beside a working session reads as though
35
+ * something is still wrong, which is its own kind of lie.
36
+ */
37
+ export declare function statusNextAction(facts: StatusFacts): string | undefined;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The sentence `status` owes the user, because `init` promised it.
3
+ *
4
+ * `init` closes with "run `npx @reticlehq/server status` — it confirms the app connected, or says
5
+ * exactly why it has not". On a real install, following that instruction printed `running: false,
6
+ * presence: "free"` and nothing else: no reason, no next step, no mention of the app. This is the last
7
+ * checkpoint in the setup path, so a user who arrives here and learns nothing has nowhere left to go.
8
+ *
9
+ * Deliberately thin. The differential already exists in `nextActionFor`, which ranks these causes from
10
+ * what the daemon knows and was rewritten this week to stop leading with a port mismatch; a second
11
+ * differential here could disagree with the first, and two confident answers pointing different ways
12
+ * is worse than one. This only supplies the facts the CLI has and renders the answer.
13
+ */
14
+ import { nextActionFor } from '../session/no-session-next-action.js';
15
+ /**
16
+ * What to do next, or `undefined` when a session is connected and there is nothing to fix.
17
+ *
18
+ * The success case has to stay silent: advice printed beside a working session reads as though
19
+ * something is still wrong, which is its own kind of lie.
20
+ */
21
+ export function statusNextAction(facts) {
22
+ if (facts.sessionCount > 0)
23
+ return undefined;
24
+ // Ahead of everything else, because it dominates: an app that was never wired cannot connect, and
25
+ // no advice about dev servers or ports applies until it is. `previouslyConnected` overrides it —
26
+ // an app CAN be wired by the Vite or Babel plugin with no `.reticle.json` at all, so a project
27
+ // that has connected here before is wired whatever this file says, and sending it back to `init`
28
+ // would be the same wrong answer in the other direction.
29
+ if (!facts.initialized && !facts.previouslyConnected) {
30
+ return ('no app has ever connected for this project, and there is no Reticle config here — so the ' +
31
+ 'tools are registered and the app itself is not instrumented. Those are two different halves ' +
32
+ 'of the install. Run `npx @reticlehq/server init` in the app directory, then start the dev ' +
33
+ 'server and load the page.');
34
+ }
35
+ if (!facts.running) {
36
+ // `running: false` reads as "Reticle is broken", and it usually means the opposite: the daemon is
37
+ // started by an agent, on demand, and idles out when nobody is driving. A user who has not
38
+ // attached an agent yet is exactly on track, and saying so is the whole job of this branch.
39
+ const wiring = facts.previouslyConnected
40
+ ? 'This project has connected before, so the wiring is correct.'
41
+ : '';
42
+ return (`no daemon is running on this port, which is normal — an agent starts it when it first calls a ` +
43
+ `Reticle tool, and it exits again when idle. ${wiring} Ask your agent to verify something, then ` +
44
+ `run status again.`).replace(/\s+/g, ' ');
45
+ }
46
+ // The daemon is up and no page has connected. `nextActionFor` owns the ranking; the CLI cannot see
47
+ // listening ports or a dev command from here, so it passes what it has and lets that function say
48
+ // what is missing rather than guessing at it.
49
+ const next = nextActionFor({
50
+ // The durable bit IS this side's `everConnected`. In-process the flag means "this daemon has served
51
+ // a session", which a daemon seconds old cannot know; from the CLI the honest equivalent is "an app
52
+ // for this project has connected on this port", which is the fact that decides whether the wiring
53
+ // is in question at all.
54
+ everConnected: facts.previouslyConnected,
55
+ initialized: facts.initialized,
56
+ listening: [],
57
+ dev: undefined,
58
+ previouslyConnected: facts.previouslyConnected,
59
+ });
60
+ return next.reason;
61
+ }