@reticlehq/server 2.11.0 → 2.12.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 (252) hide show
  1. package/SKILL.md +26 -2
  2. package/dist/bridge/bridge.d.ts +0 -13
  3. package/dist/bridge/bridge.js +1 -1
  4. package/dist/bridge/bridge.test-harness.d.ts +2 -0
  5. package/dist/bridge/bridge.test-harness.js +8 -1
  6. package/dist/capsule/capsule-store.d.ts +2 -1
  7. package/dist/capsule/capsule-store.js +1 -1
  8. package/dist/capsule/causal-summary.d.ts +2 -2
  9. package/dist/chromium-launch-options.d.ts +28 -0
  10. package/dist/chromium-launch-options.js +29 -0
  11. package/dist/cli/cli-doctor.js +8 -0
  12. package/dist/cli/cli-flow-commands.d.ts +3 -1
  13. package/dist/cli/cli-flow-commands.js +34 -4
  14. package/dist/cli/cli-kill.d.ts +2 -1
  15. package/dist/cli/cli-parse.d.ts +3 -1
  16. package/dist/cli/cli-parse.js +29 -7
  17. package/dist/cli/cli-update-commands.js +20 -1
  18. package/dist/cli/cli-verify.d.ts +1 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/cloud-cli.js +12 -6
  21. package/dist/cli/config-discovery.d.ts +2 -1
  22. package/dist/cli/doctor-rows.d.ts +3 -1
  23. package/dist/cli/doctor-rows.js +3 -1
  24. package/dist/cli/gate-exit.d.ts +21 -0
  25. package/dist/cli/gate-exit.js +21 -0
  26. package/dist/cli/gate-hook-message.d.ts +33 -0
  27. package/dist/cli/gate-hook-message.js +46 -0
  28. package/dist/cli/mcp-command.d.ts +25 -0
  29. package/dist/cli/mcp-command.js +143 -0
  30. package/dist/cli/sibling-ports.d.ts +37 -0
  31. package/dist/cli/sibling-ports.js +56 -0
  32. package/dist/cli.js +37 -110
  33. package/dist/cloud/cloud-config.d.ts +2 -1
  34. package/dist/cloud/cloud-sync.d.ts +11 -0
  35. package/dist/cloud/cloud-sync.js +32 -0
  36. package/dist/crawl/crawl.d.ts +9 -13
  37. package/dist/crawl/crawl.js +20 -3
  38. package/dist/daemon/daemon-resolve.d.ts +79 -0
  39. package/dist/daemon/daemon-resolve.js +118 -0
  40. package/dist/daemon/free-port.d.ts +29 -0
  41. package/dist/daemon/free-port.js +55 -0
  42. package/dist/daemon/heartbeat.d.ts +0 -4
  43. package/dist/daemon/heartbeat.js +2 -2
  44. package/dist/dev/stale-issue-guard.d.ts +0 -2
  45. package/dist/dev/stale-issue-guard.js +1 -1
  46. package/dist/events/contradictions.d.ts +41 -0
  47. package/dist/events/contradictions.js +125 -12
  48. package/dist/events/declared.d.ts +14 -0
  49. package/dist/events/declared.js +33 -0
  50. package/dist/events/echo-mismatch.js +14 -0
  51. package/dist/events/observed-in-window.d.ts +12 -0
  52. package/dist/events/observed-in-window.js +17 -0
  53. package/dist/events/predicate-asks.d.ts +13 -0
  54. package/dist/events/predicate-asks.js +22 -0
  55. package/dist/events/predicate-eval.js +64 -22
  56. package/dist/events/predicate-parse.d.ts +0 -6
  57. package/dist/events/predicate-parse.js +43 -3
  58. package/dist/events/predicate-schema.d.ts +44 -0
  59. package/dist/events/predicate-schema.js +126 -23
  60. package/dist/events/predicate.d.ts +15 -0
  61. package/dist/events/predicate.js +41 -2
  62. package/dist/flows/coverage.d.ts +0 -8
  63. package/dist/flows/coverage.js +1 -1
  64. package/dist/flows/decision.d.ts +5 -1
  65. package/dist/flows/decision.js +33 -7
  66. package/dist/flows/flake.d.ts +0 -2
  67. package/dist/flows/flake.js +1 -1
  68. package/dist/flows/flow-intent.d.ts +52 -0
  69. package/dist/flows/flow-intent.js +123 -0
  70. package/dist/flows/flow-replay-run.js +27 -4
  71. package/dist/flows/flow-success.js +15 -3
  72. package/dist/flows/flow-tools.js +32 -4
  73. package/dist/flows/flows.d.ts +6 -1
  74. package/dist/flows/flows.js +34 -22
  75. package/dist/flows/predicate-to-expect.js +6 -3
  76. package/dist/flows/server-verify.js +16 -2
  77. package/dist/flows/verify-change-tools.js +4 -1
  78. package/dist/honesty/blind-spots.d.ts +0 -17
  79. package/dist/honesty/blind-spots.js +1 -1
  80. package/dist/honesty/feature-capture.d.ts +150 -0
  81. package/dist/honesty/feature-capture.js +233 -0
  82. package/dist/honesty/instrumentation-gaps.d.ts +30 -6
  83. package/dist/honesty/instrumentation-gaps.js +61 -5
  84. package/dist/honesty/tool-hit-rate.d.ts +86 -0
  85. package/dist/honesty/tool-hit-rate.js +90 -0
  86. package/dist/honesty/undeclared-change.d.ts +51 -0
  87. package/dist/honesty/undeclared-change.js +56 -0
  88. package/dist/honesty/verified.d.ts +15 -0
  89. package/dist/honesty/verified.js +68 -4
  90. package/dist/http-server.d.ts +0 -1
  91. package/dist/http-server.js +1 -1
  92. package/dist/hunt/hunt-report.d.ts +0 -2
  93. package/dist/hunt/hunt-report.js +1 -1
  94. package/dist/impact/impact-store.d.ts +0 -5
  95. package/dist/impact/impact-store.js +1 -1
  96. package/dist/index.d.ts +4 -1
  97. package/dist/init/agent-rules.d.ts +17 -0
  98. package/dist/init/agent-rules.js +31 -1
  99. package/dist/init/capabilities.d.ts +0 -2
  100. package/dist/init/capabilities.js +1 -1
  101. package/dist/init/closing-hint.d.ts +36 -0
  102. package/dist/init/closing-hint.js +120 -0
  103. package/dist/init/confirm.d.ts +0 -25
  104. package/dist/init/confirm.js +3 -3
  105. package/dist/init/cra.d.ts +14 -1
  106. package/dist/init/cra.js +43 -12
  107. package/dist/init/dev-script.d.ts +48 -0
  108. package/dist/init/dev-script.js +63 -0
  109. package/dist/init/mcp.d.ts +16 -5
  110. package/dist/init/mcp.js +26 -10
  111. package/dist/init/plan-framework.d.ts +0 -5
  112. package/dist/init/plan-framework.js +83 -9
  113. package/dist/init/plan.d.ts +10 -30
  114. package/dist/init/plan.js +30 -15
  115. package/dist/init/refresh-rules.d.ts +39 -0
  116. package/dist/init/refresh-rules.js +51 -0
  117. package/dist/init/run.d.ts +0 -26
  118. package/dist/init/run.js +14 -87
  119. package/dist/init/snippets.js +53 -10
  120. package/dist/input/real-input.d.ts +2 -0
  121. package/dist/input/real-input.js +3 -2
  122. package/dist/input/scroll-find.d.ts +7 -4
  123. package/dist/input/scroll-find.js +63 -25
  124. package/dist/intent/inline-intent.d.ts +46 -0
  125. package/dist/intent/inline-intent.js +103 -0
  126. package/dist/intent/intent-store.d.ts +37 -0
  127. package/dist/intent/intent-store.js +112 -0
  128. package/dist/intent/intent-tools.d.ts +2 -0
  129. package/dist/intent/intent-tools.js +80 -0
  130. package/dist/intent/open-intents.d.ts +21 -0
  131. package/dist/intent/open-intents.js +25 -0
  132. package/dist/journal/envelope.d.ts +26 -26
  133. package/dist/journal/journal-recorder.d.ts +23 -0
  134. package/dist/journal/journal-recorder.js +35 -0
  135. package/dist/mcp/attach-memory.d.ts +67 -0
  136. package/dist/mcp/attach-memory.js +142 -0
  137. package/dist/mcp/mcp-proxy.d.ts +0 -7
  138. package/dist/mcp/mcp-proxy.js +14 -2
  139. package/dist/mcp/mcp.d.ts +2 -0
  140. package/dist/mcp/mcp.js +45 -23
  141. package/dist/mcp/server-instructions.js +45 -3
  142. package/dist/mcp/tool-catalog-cache.d.ts +8 -0
  143. package/dist/mcp/tool-catalog-cache.js +10 -0
  144. package/dist/oracles/flow-instrument-gaps.d.ts +4 -4
  145. package/dist/oracles/flow-instrument-gaps.js +4 -5
  146. package/dist/oracles/self-instrument.d.ts +9 -9
  147. package/dist/oracles/self-instrument.js +19 -13
  148. package/dist/pool/browser-pool.d.ts +12 -1
  149. package/dist/pool/browser-pool.js +31 -1
  150. package/dist/pool/playwright-launcher.js +3 -1
  151. package/dist/project/fs-port.d.ts +3 -0
  152. package/dist/project/fs-port.js +3 -2
  153. package/dist/project/memory-fs.js +6 -1
  154. package/dist/project/project-tools.js +3 -32
  155. package/dist/project/reticle-dir.d.ts +2 -0
  156. package/dist/project/reticle-dir.js +1 -0
  157. package/dist/runs/context-tools.d.ts +2 -0
  158. package/dist/runs/context-tools.js +89 -0
  159. package/dist/runs/run-context.d.ts +40 -0
  160. package/dist/runs/run-context.js +158 -0
  161. package/dist/runs/run-diff.d.ts +2 -1
  162. package/dist/runs/verification-sync.js +2 -2
  163. package/dist/session/dev-server-probe.d.ts +0 -8
  164. package/dist/session/dev-server-probe.js +1 -1
  165. package/dist/session/last-act.d.ts +7 -1
  166. package/dist/session/last-act.js +2 -2
  167. package/dist/session/live-control-tools.js +1 -42
  168. package/dist/session/no-session-diagnosis.d.ts +10 -0
  169. package/dist/session/no-session-diagnosis.js +24 -7
  170. package/dist/session/no-session-next-action.d.ts +5 -0
  171. package/dist/session/no-session-next-action.js +35 -3
  172. package/dist/session/no-session-watch.d.ts +8 -0
  173. package/dist/session/no-session-watch.js +82 -64
  174. package/dist/session/output-budget.js +4 -1
  175. package/dist/session/session-health.d.ts +9 -0
  176. package/dist/session/session-health.js +23 -0
  177. package/dist/session/session-manager.js +50 -1
  178. package/dist/session/session-successor.d.ts +37 -0
  179. package/dist/session/session-successor.js +78 -0
  180. package/dist/session/session.d.ts +31 -1
  181. package/dist/session/session.js +75 -2
  182. package/dist/telemetry/automation-hint.d.ts +2 -1
  183. package/dist/telemetry/feedback-outbox.d.ts +2 -1
  184. package/dist/telemetry/project-profile.d.ts +0 -8
  185. package/dist/telemetry/project-profile.js +1 -1
  186. package/dist/telemetry/run-telemetry.d.ts +0 -2
  187. package/dist/telemetry/run-telemetry.js +1 -1
  188. package/dist/telemetry/verification-of.js +2 -2
  189. package/dist/tools/act-tools.js +126 -21
  190. package/dist/tools/assert-source.d.ts +22 -0
  191. package/dist/tools/assert-source.js +53 -0
  192. package/dist/tools/assert-verdict.d.ts +20 -3
  193. package/dist/tools/assert-verdict.js +36 -4
  194. package/dist/tools/browser-tools.js +0 -20
  195. package/dist/tools/complete-snapshot.d.ts +48 -0
  196. package/dist/tools/complete-snapshot.js +75 -0
  197. package/dist/tools/coverage-tools.d.ts +10 -1
  198. package/dist/tools/coverage-tools.js +123 -69
  199. package/dist/tools/dynamic-tools.js +29 -8
  200. package/dist/tools/error-recovery.d.ts +24 -14
  201. package/dist/tools/error-recovery.js +70 -9
  202. package/dist/tools/feedback-tools.d.ts +1 -1
  203. package/dist/tools/feedback-tools.js +2 -2
  204. package/dist/tools/invoke-tool.js +62 -2
  205. package/dist/tools/lease-hint.d.ts +10 -0
  206. package/dist/tools/lease-hint.js +53 -4
  207. package/dist/tools/lease-tools.d.ts +5 -1
  208. package/dist/tools/lease-tools.js +22 -8
  209. package/dist/tools/navigate-arrival.d.ts +0 -2
  210. package/dist/tools/navigate-arrival.js +1 -1
  211. package/dist/tools/observe-tools.js +63 -26
  212. package/dist/tools/read-tools.js +5 -3
  213. package/dist/tools/real-input-attempt.d.ts +22 -0
  214. package/dist/tools/real-input-attempt.js +153 -1
  215. package/dist/tools/reconcile-tools.js +18 -4
  216. package/dist/tools/resolve-target.d.ts +0 -7
  217. package/dist/tools/sdk-marker-probe.d.ts +29 -0
  218. package/dist/tools/sdk-marker-probe.js +57 -0
  219. package/dist/tools/snapshot-delta.d.ts +1 -1
  220. package/dist/tools/tool-kit.d.ts +12 -0
  221. package/dist/tools/tool-kit.js +16 -0
  222. package/dist/tools/tool-names.d.ts +3 -0
  223. package/dist/tools/tool-names.js +3 -0
  224. package/dist/tools/tool-surface.d.ts +85 -6
  225. package/dist/tools/tool-surface.js +122 -2
  226. package/dist/tools/tools.js +22 -4
  227. package/dist/tools/unadvertised-help.js +4 -4
  228. package/dist/tools/verify-next-baton.d.ts +53 -0
  229. package/dist/tools/verify-next-baton.js +52 -0
  230. package/dist/update/update-nudge.d.ts +7 -10
  231. package/dist/update/update-nudge.js +12 -3
  232. package/dist/update/updater.d.ts +1 -1
  233. package/docs/README.md +2 -1
  234. package/docs/cli/cloud.mdx +1 -1
  235. package/docs/cli/doctor.mdx +1 -0
  236. package/docs/cli/verify.mdx +4 -5
  237. package/docs/desktop.mdx +1 -1
  238. package/docs/docs.json +2 -2
  239. package/docs/gates.md +1 -1
  240. package/docs/install-manual.mdx +1 -1
  241. package/docs/packages/browser.mdx +2 -2
  242. package/docs/packages/server.mdx +1 -1
  243. package/docs/packages.mdx +8 -6
  244. package/docs/platform-integration.md +17 -0
  245. package/docs/predicates.mdx +8 -0
  246. package/docs/reticle-manual.md +106 -0
  247. package/docs/tools-tools-and-run.mdx +2 -4
  248. package/docs/troubleshooting.mdx +9 -0
  249. package/docs/usage.md +3 -2
  250. package/package.json +2 -2
  251. package/dist/session/session-readiness.d.ts +0 -34
  252. package/dist/session/session-readiness.js +0 -36
@@ -4,33 +4,39 @@
4
4
  * apply instrumentation proposals as concrete diffs, so the AGENT closes the gap and the app's
5
5
  * observability compounds run over run — instead of a human being a prerequisite. Deterministic mapping
6
6
  * from a located gap to the code to insert; pure and testable. Result-enrichment wiring is a later step.
7
+ *
8
+ * Kinds come from `@reticlehq/core`. A second enum here is how `reticle_domain` used to say
9
+ * `missing-signal` while honesty said `no-signal-on-mutation` for the same absence.
7
10
  */
8
- export const InstrumentationGapKind = {
9
- MISSING_SIGNAL: 'missing-signal',
10
- UNREGISTERED_STORE: 'unregistered-store',
11
- MISSING_TESTID: 'missing-testid',
12
- };
11
+ import { InstrumentationGapKind } from '@reticlehq/core';
13
12
  /** PascalCase a store name for a `useX.getState` hint (cart → Cart). */
14
13
  function pascal(name) {
15
14
  return 0 === name.length ? name : name[0]?.toUpperCase() + name.slice(1);
16
15
  }
17
16
  function insertFor(gap) {
18
17
  switch (gap.kind) {
19
- case InstrumentationGapKind.MISSING_SIGNAL:
18
+ case InstrumentationGapKind.NO_SIGNAL_ON_MUTATION:
20
19
  return `reticle.signal('${gap.name}');`;
21
- case InstrumentationGapKind.UNREGISTERED_STORE:
20
+ case InstrumentationGapKind.NO_STORE_REGISTERED:
22
21
  return `registerStore('${gap.name}', () => use${pascal(gap.name)}.getState());`;
23
22
  case InstrumentationGapKind.MISSING_TESTID:
24
23
  return `data-testid="${gap.name}"`;
25
24
  }
26
25
  }
27
26
  function rationaleFor(gap) {
28
- const base = gap.kind === InstrumentationGapKind.MISSING_SIGNAL
29
- ? `emit a consequence signal so verification proves the outcome, not just presence`
30
- : gap.kind === InstrumentationGapKind.UNREGISTERED_STORE
31
- ? `register the store so state assertions can read the source of truth`
32
- : `add a stable testid so the anchor survives refactors`;
33
- return gap.context === undefined ? base : `${base} — ${gap.context}`;
27
+ let base;
28
+ switch (gap.kind) {
29
+ case InstrumentationGapKind.NO_SIGNAL_ON_MUTATION:
30
+ base = `emit a consequence signal so verification proves the outcome, not just presence`;
31
+ break;
32
+ case InstrumentationGapKind.NO_STORE_REGISTERED:
33
+ base = `register the store so state assertions can read the source of truth`;
34
+ break;
35
+ case InstrumentationGapKind.MISSING_TESTID:
36
+ base = `add a stable testid so the anchor survives refactors`;
37
+ break;
38
+ }
39
+ return undefined === gap.context ? base : `${base} — ${gap.context}`;
34
40
  }
35
41
  /** Turn located capability gaps into ready-to-apply instrumentation proposals. Deterministic. */
36
42
  export function proposeInstrumentation(gaps) {
@@ -9,7 +9,6 @@
9
9
  * The browser is injected via a `Launcher` so all lifecycle logic is testable without real Chromium;
10
10
  * the thin Playwright adapter that satisfies these interfaces lives separately.
11
11
  */
12
- /** The minimal page surface the pool drives. Real Playwright `Page` satisfies this. */
13
12
  export interface PooledPage {
14
13
  goto(url: string, opts?: {
15
14
  timeoutMs?: number;
@@ -17,6 +16,12 @@ export interface PooledPage {
17
16
  close(): Promise<void>;
18
17
  /** Fires when THIS page's renderer crashes — lets the pool reclaim just this lease, not the fleet. */
19
18
  onCrash(handler: () => void): void;
19
+ /**
20
+ * Fires for each console message the page logs. OPTIONAL: a fake that does not implement it makes
21
+ * the pool record nothing, which is the correct degradation — an absent dial address must read as
22
+ * "the page said nothing", never as "the page dialled correctly".
23
+ */
24
+ onConsole?(handler: (text: string) => void): void;
20
25
  }
21
26
  /** An isolated browsing context (cookies/storage). Real Playwright `BrowserContext` satisfies this. */
22
27
  export interface PooledContext {
@@ -114,6 +119,12 @@ export declare class BrowserPool {
114
119
  signal?: AbortSignal;
115
120
  sessionId?: string;
116
121
  }): Promise<Lease>;
122
+ /**
123
+ * The websocket address this lease's page reported it could not reach, if it reported one.
124
+ *
125
+ * Undefined means the page said nothing — never that it dialled the right place.
126
+ */
127
+ dialFailureUrl(sessionId: string): string | undefined;
117
128
  /** Close every context and the browser. Pending waiters are rejected (the pool is terminal now). */
118
129
  shutdown(): Promise<void>;
119
130
  }
@@ -9,6 +9,8 @@
9
9
  * The browser is injected via a `Launcher` so all lifecycle logic is testable without real Chromium;
10
10
  * the thin Playwright adapter that satisfies these interfaces lives separately.
11
11
  */
12
+ /** The minimal page surface the pool drives. Real Playwright `Page` satisfies this. */
13
+ import { unreachableUrlIn } from '@reticlehq/core';
12
14
  /** Default lease time-to-live: a lease untouched for this long is presumed orphaned and reclaimed. */
13
15
  export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
14
16
  /** Default cap on how long a lease's initial navigation may take before it fails (frees the slot). */
@@ -147,6 +149,7 @@ export class BrowserPool {
147
149
  await this.#waitForSlot(opts.signal);
148
150
  const sessionId = opts.sessionId ?? this.#genId();
149
151
  let context;
152
+ let pending;
150
153
  try {
151
154
  const browser = await this.#ensureBrowser();
152
155
  context = await browser.newContext();
@@ -156,6 +159,19 @@ export class BrowserPool {
156
159
  page.onCrash(() => {
157
160
  void this.#release(sessionId);
158
161
  });
162
+ // Listen BEFORE goto: the SDK's connect attempt races the navigation's own resolution, and a
163
+ // listener attached afterwards misses the first failures — the only ones logged before the
164
+ // retry backoff stretches out.
165
+ page.onConsole?.((text) => {
166
+ const dialled = unreachableUrlIn(text);
167
+ if (dialled === undefined)
168
+ return;
169
+ const active = this.#active.get(sessionId);
170
+ if (active !== undefined)
171
+ active.dialFailureUrl = dialled;
172
+ else
173
+ pending = dialled; // logged during goto, before the lease is registered below
174
+ });
159
175
  await page.goto(url, { timeoutMs: this.#navTimeout });
160
176
  // The browser can crash WHILE goto is resolving; #onCrash then clears #active and zeroes
161
177
  // #occupied. Registering the lease now would resurrect a dead entry against a crashed browser with
@@ -163,7 +179,13 @@ export class BrowserPool {
163
179
  // no longer the live browser, bail — the catch below closes the context and returns the slot.
164
180
  if (this.#browser !== browser)
165
181
  throw new Error('browser crashed during navigation');
166
- this.#active.set(sessionId, { context, page, url, touchedAt: this.#now() });
182
+ this.#active.set(sessionId, {
183
+ context,
184
+ page,
185
+ url,
186
+ touchedAt: this.#now(),
187
+ ...(pending === undefined ? {} : { dialFailureUrl: pending }),
188
+ });
167
189
  return {
168
190
  sessionId,
169
191
  url,
@@ -179,6 +201,14 @@ export class BrowserPool {
179
201
  throw err;
180
202
  }
181
203
  }
204
+ /**
205
+ * The websocket address this lease's page reported it could not reach, if it reported one.
206
+ *
207
+ * Undefined means the page said nothing — never that it dialled the right place.
208
+ */
209
+ dialFailureUrl(sessionId) {
210
+ return this.#active.get(sessionId)?.dialFailureUrl;
211
+ }
182
212
  /** Close every context and the browser. Pending waiters are rejected (the pool is terminal now). */
183
213
  async shutdown() {
184
214
  this.#closed = true; // set first so any woken waiter rejects instead of relaunching a browser
@@ -6,6 +6,7 @@
6
6
  * Headless by default — the pool exists for fault-tolerant headless multi-agent testing.
7
7
  */
8
8
  import { BrowserLaunchKind } from '@reticlehq/core';
9
+ import { chromiumLaunchOptions } from '../chromium-launch-options.js';
9
10
  import { getSessionMetrics } from '../telemetry/session-metrics.js';
10
11
  import { classifyConnectFailure } from '../telemetry/connect-failure.js';
11
12
  import { chromiumInstallCommand, bundledPlaywrightVersion } from '../cli/chromium-hint.js';
@@ -40,6 +41,7 @@ function wrapBrowser(browser) {
40
41
  goto: (url, opts) => page.goto(url, gotoOptions(opts?.timeoutMs)),
41
42
  close: () => page.close(),
42
43
  onCrash: (handler) => page.on('crash', handler),
44
+ onConsole: (handler) => page.on('console', (msg) => handler(msg.text())),
43
45
  };
44
46
  },
45
47
  close: () => context.close(),
@@ -72,7 +74,7 @@ export function playwrightLauncher(opts = {}) {
72
74
  // number that actually costs memory and the one that explains a slow machine.
73
75
  const settle = getSessionMetrics().recordConnectAttempt(BrowserLaunchKind.POOLED);
74
76
  try {
75
- const browser = wrapBrowser(await chromium.launch({ headless }));
77
+ const browser = wrapBrowser(await chromium.launch(chromiumLaunchOptions(headless)));
76
78
  settle();
77
79
  return browser;
78
80
  }
@@ -36,7 +36,10 @@ export interface FileSystemPort {
36
36
  /** Modification time in epoch ms — for recency-based retention pruning. Rejects if absent. */
37
37
  stat(path: string): Promise<{
38
38
  mtimeMs: number;
39
+ size: number;
39
40
  }>;
41
+ /** Resolve symlinks to their real path — for upload trust-boundary checks. Rejects if absent. */
42
+ realpath(path: string): Promise<string>;
40
43
  /** ENOENT classifier — narrows unknown without `any`, so callers can distinguish missing-file. */
41
44
  isNotFound(error: unknown): boolean;
42
45
  }
@@ -1,4 +1,4 @@
1
- import { access, appendFile, mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
1
+ import { access, appendFile, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
2
2
  /** Production adapter — the single import site of node:fs/promises in the server. */
3
3
  export function createNodeFileSystem() {
4
4
  return {
@@ -46,8 +46,9 @@ export function createNodeFileSystem() {
46
46
  },
47
47
  stat: async (path) => {
48
48
  const s = await stat(path);
49
- return { mtimeMs: s.mtimeMs };
49
+ return { mtimeMs: s.mtimeMs, size: s.size };
50
50
  },
51
+ realpath: (path) => realpath(path),
51
52
  isNotFound: (error) => 'ENOENT' === error?.code,
52
53
  };
53
54
  }
@@ -78,7 +78,12 @@ export function createMemoryFs() {
78
78
  return Promise.resolve();
79
79
  },
80
80
  stat(path) {
81
- return written.has(norm(path)) ? Promise.resolve({ mtimeMs: 0 }) : Promise.reject(notFound());
81
+ return written.has(norm(path))
82
+ ? Promise.resolve({ mtimeMs: 0, size: 0 })
83
+ : Promise.reject(notFound());
84
+ },
85
+ realpath(path) {
86
+ return written.has(norm(path)) ? Promise.resolve(norm(path)) : Promise.reject(notFound());
82
87
  },
83
88
  isNotFound(error) {
84
89
  return 'ENOENT' === error?.code;
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { ProjectReadError, RunKind, RunStatus } from '@reticlehq/core';
2
+ import { ProjectReadError, RunStatus } from '@reticlehq/core';
3
3
  import { ReticleTool } from '../tools/tool-names.js';
4
4
  import { sessionIdShape } from '../tools/tool-kit.js';
5
5
  import { asNumber, asString } from '../tools/tools-helpers.js';
6
- import { fetchProjectRegressionFromCloud, resolveCloudConfig } from '../cloud/cloud-sync.js';
6
+ import { cloudFetch, fetchProjectRegressionFromCloud, resolveCloudConfig, } from '../cloud/cloud-sync.js';
7
7
  import { RunStore } from '../runs/run-store.js';
8
8
  import { diffRuns as diffVerificationRuns, } from '../runs/run-diff.js';
9
9
  const REGRESSION_STATUSES = new Set([
@@ -45,7 +45,7 @@ async function cloudRegression(deps, sessionId) {
45
45
  catch {
46
46
  projectId = undefined;
47
47
  }
48
- const report = await fetchProjectRegressionFromCloud(config, projectId, (url, init) => fetch(url, init));
48
+ const report = await fetchProjectRegressionFromCloud(config, projectId, cloudFetch);
49
49
  return report ?? undefined;
50
50
  }
51
51
  /** The two most-recent runs for `name`, oldest-first, or undefined if there are fewer than two. */
@@ -152,33 +152,4 @@ export const PROJECT_TOOLS = [
152
152
  });
153
153
  },
154
154
  },
155
- {
156
- name: ReticleTool.RUN_RECORD,
157
- description: 'Explicitly record a run outcome into .reticle/project.json (the manual companion to the auto-record on reticle_flow_replay). Use it to log the result of an assertion sequence or a manual journey so future runs can diff against it. Returns { recorded:true, name, status }.',
158
- inputSchema: {
159
- name: z.string().describe('Run name for grouping in reticle_project history.'),
160
- status: z.nativeEnum(RunStatus).describe('Outcome: pass | fail | drift | error'),
161
- kind: z.nativeEnum(RunKind).optional(),
162
- summary: z.string().optional().describe('One-line human summary of what this run covered.'),
163
- ...sessionIdShape,
164
- },
165
- outputSchema: {
166
- recorded: z.boolean(),
167
- runName: z.string(),
168
- status: z.string(),
169
- },
170
- handler: async (deps, args) => {
171
- const name = asString(args['name']) ?? '';
172
- const status = args['status'];
173
- const kindArg = args['kind'];
174
- const summary = asString(args['summary']);
175
- await deps.project.recordRun({
176
- kind: 'string' === typeof kindArg ? kindArg : RunKind.MANUAL,
177
- name,
178
- status,
179
- ...(summary !== undefined ? { summary } : {}),
180
- });
181
- return { recorded: true, runName: name, status };
182
- },
183
- },
184
155
  ];
@@ -15,6 +15,8 @@ export interface ReticleDirPaths {
15
15
  project: string;
16
16
  /**.../.reticle/impact.json (what Reticle has done for this user, local only) */
17
17
  impact: string;
18
+ /**.../.reticle/intent.json (what changes were supposed to make true — git-checked) */
19
+ intent: string;
18
20
  /**.../.reticle/visual (PNG baselines + diffs) */
19
21
  visual: string;
20
22
  /**.../.reticle/runs (verification-run artifacts) */
@@ -8,6 +8,7 @@ export function reticleDirPaths(root) {
8
8
  baselines: join(root, ReticleDir.BASELINES_SUBDIR),
9
9
  project: join(root, ReticleDir.PROJECT_FILE),
10
10
  impact: join(root, ReticleDir.IMPACT_FILE),
11
+ intent: join(root, ReticleDir.INTENT_FILE),
11
12
  visual: join(root, ReticleDir.VISUAL_SUBDIR),
12
13
  runs: join(root, ReticleDir.RUNS_SUBDIR),
13
14
  sessions: join(root, ReticleDir.SESSIONS_SUBDIR),
@@ -0,0 +1,2 @@
1
+ import type { ToolDef } from '../tools/tool-kit.js';
2
+ export declare const CONTEXT_TOOLS: ToolDef[];
@@ -0,0 +1,89 @@
1
+ import { z } from 'zod';
2
+ import {} from '@reticlehq/core';
3
+ import { runContextFor } from './run-context.js';
4
+ import { openSessionIntents } from '../intent/open-intents.js';
5
+ import { ReticleTool } from '../tools/tool-names.js';
6
+ import { sessionIdShape } from '../tools/tool-kit.js';
7
+ import { asString } from '../tools/tools-helpers.js';
8
+ /**
9
+ * What this run has established, asked for by the one party that knows when it is needed.
10
+ *
11
+ * Reticle holds the trajectory: the journal, the verdicts, the intent ledger. The agent holds a copy
12
+ * in its context window, and that copy is the one that disappears — at a compaction, at the end of a
13
+ * turn, at the handover to a sub-agent. Reticle's does not disappear at the same moment. So this is
14
+ * not a competing memory; it is the ground truth compaction destroyed, handed back on request.
15
+ *
16
+ * ## Why it is PULLED
17
+ *
18
+ * The same content was once pushed onto every session-bound tool response. It cost +136% on a
19
+ * verdict and was cut, because most of the time the agent still had the context and we were paying
20
+ * on every call to duplicate what it knew. Nobody but the agent can tell when that stops being true.
21
+ * So the agent asks, once, at the moment it knows its own memory is gone, and pays once.
22
+ *
23
+ * ## Why it is on the EXTENDED surface rather than the default
24
+ *
25
+ * The default surface is a hard COUNT — editors budget tools across every connected MCP server, so a
26
+ * slot taken here is taken from a tool that has already earned it, and nothing on that list has been
27
+ * demoted for this. The claim behind this tool is unmeasured, which is the same test `reticle_intent`
28
+ * and the `reticle_capabilities` demotion were held to, and it fails that test today.
29
+ *
30
+ * The reachability argument is also stronger here than for most of the cold tail. The caller is,
31
+ * by construction, an agent that has just lost its context and is re-reading the tool list it was
32
+ * handed this turn — so `reticle_tools` (advertised on every surface) is exactly the call it is
33
+ * already going to make, and this tool is one `reticle_run` hop behind it. A tool that is only useful
34
+ * after a discovery step, to a caller who is already performing a discovery step, is the cheapest
35
+ * possible thing to leave off the hot list. It moves to the default surface when a measurement says
36
+ * the pull happens often enough to be worth a permanent slot, and not before.
37
+ */
38
+ /** What the run has established, and where each part of it came from. */
39
+ const CONTEXT_OUTPUT_SCHEMA = {
40
+ step: z
41
+ .number()
42
+ .describe('How many actions this run has dispatched. Counted off the journal, not a counter of its own.'),
43
+ established: z
44
+ .array(z.unknown())
45
+ .describe('What Reticle OBSERVED, folded and capped, each `{ key, fact, source?, doc?, epoch? }`. `key` is the subject, so re-observing it supersedes rather than appends. Anything seen under a replaced document or a pre-edit epoch is already gone from this list.'),
46
+ proven: z
47
+ .array(z.unknown())
48
+ .describe('Claims a verdict already settled, each `{ claim, verified, source?, doc?, epoch? }`. Read this before re-driving something: re-proving it is slow, and assuming it is a false green.'),
49
+ remaining: z
50
+ .array(z.string())
51
+ .describe('The intents from .reticle/intent.json that no verdict has discharged. Derived from the ledger, never a guess at what you meant to do next.'),
52
+ };
53
+ /** The ledger and the live page state this run is folded out of. `[]`/undefined when nothing is connected. */
54
+ async function evidenceFor(deps, sessionId) {
55
+ try {
56
+ const session = deps.sessions.resolve(sessionId);
57
+ return {
58
+ actions: await session.readJournalActions(),
59
+ events: await session.queryEvents({}),
60
+ currentDocumentId: session.currentDocumentId,
61
+ currentEditEpoch: session.currentEditEpoch,
62
+ };
63
+ }
64
+ catch {
65
+ // `resolve` throws when nothing is connected, when the id names no session, and when several are
66
+ // connected and none was named. All three mean the same thing here: there is no run to report,
67
+ // and an empty context is the honest answer rather than a reason to fail the call.
68
+ return {
69
+ actions: [],
70
+ events: [],
71
+ currentDocumentId: undefined,
72
+ currentEditEpoch: undefined,
73
+ };
74
+ }
75
+ }
76
+ export const CONTEXT_TOOLS = [
77
+ {
78
+ name: ReticleTool.CONTEXT,
79
+ description: 'What THIS run has already established, so you do not rediscover it. Call it when your own copy is gone: right after a compaction, at the top of a fresh sub-agent, or at the start of a turn you did not begin. Returns `established` (what Reticle OBSERVED, with the source file:line where one was reported), `proven` (claims a verdict already settled, so you neither re-prove them nor assume them) and `remaining` (the intents from .reticle/intent.json that nothing has discharged). It is a FOLD over the journal, never a second store, so it cannot disagree with the ledger. Bounded and superseding rather than accumulating, and anything observed under a replaced document or before your last source edit is already dropped rather than presented as current. Only what Reticle observed goes in: it never reports what you intended or believed.',
80
+ example: {},
81
+ inputSchema: { ...sessionIdShape },
82
+ outputSchema: CONTEXT_OUTPUT_SCHEMA,
83
+ handler: async (deps, args) => {
84
+ const sessionId = asString(args['sessionId']);
85
+ const evidence = await evidenceFor(deps, sessionId);
86
+ return runContextFor({ ...evidence, intents: await openSessionIntents(deps, sessionId) });
87
+ },
88
+ },
89
+ ];
@@ -0,0 +1,40 @@
1
+ import { type EstablishedFact, type Intent, type JournalAction, type ProvenClaim, type ReticleEvent, type RunContext } from '@reticlehq/core';
2
+ /**
3
+ * The SUBJECT a journal action was about: the ref it spent, else the target it resolved from.
4
+ *
5
+ * Exported so the feature-use instrument files a re-fetch under the same key this fold establishes
6
+ * it under. A second copy of this rule there would be a second definition of "the same subject".
7
+ */
8
+ export declare function subjectOf(action: JournalAction): string | undefined;
9
+ /**
10
+ * Fold the journal into candidate facts, newest last.
11
+ *
12
+ * Only the last `RUN_ESTABLISHED_CAP` actions are read: anything older cannot survive the fold, so
13
+ * parsing it would be work done to throw away. An action with no observed settle outcome states
14
+ * nothing and is skipped — "we drove it and cannot say what happened" is not an established fact,
15
+ * and writing it down as one is how a memory starts lying.
16
+ */
17
+ export declare function establishedFromJournal(actions: readonly JournalAction[], events: readonly ReticleEvent[]): EstablishedFact[];
18
+ /**
19
+ * What a verdict already settled, newest last.
20
+ *
21
+ * Both verdict tools write here. `reticle_act_and_wait` closes its attribution window with one;
22
+ * `reticle_assert` drives nothing, so it appends its record WITHOUT opening a window — same effect
23
+ * shape, so this fold reads one kind of verdict rather than two.
24
+ */
25
+ export declare function provenFromJournal(actions: readonly JournalAction[], events: readonly ReticleEvent[]): ProvenClaim[];
26
+ /**
27
+ * The context for one run.
28
+ *
29
+ * `step` is the journal's action count rather than a counter of its own, for the reason at the top
30
+ * of this file: a second counter is a second thing that can disagree with the ledger. The document
31
+ * and epoch are the session's own, so the staleness rule here is the same one every other verdict in
32
+ * this codebase is scoped by.
33
+ */
34
+ export declare function runContextFor(input: {
35
+ actions: readonly JournalAction[];
36
+ events: readonly ReticleEvent[];
37
+ intents: readonly Intent[];
38
+ currentDocumentId: string | undefined;
39
+ currentEditEpoch: number | undefined;
40
+ }): RunContext;
@@ -0,0 +1,158 @@
1
+ import { EventType, JournalVerdictEffectSchema, RUN_ESTABLISHED_CAP, buildRunContext, } from '@reticlehq/core';
2
+ /**
3
+ * The run, derived from the journal that is already there.
4
+ *
5
+ * `.reticle/sessions/<id>/` already holds an append-only causal ledger of every action dispatched
6
+ * and every event observed. The run is a FOLD over that, never a second store — the same argument
7
+ * that deleted `instrumentation_stalled` from this codebase applies without modification: two ways
8
+ * to compute one number is worse than one way, because the day they disagree the disagreement lands
9
+ * inside a verdict and a user finds it before we do. So nothing here writes: it reads the ledger and
10
+ * folds it, and if the fold is wrong the ledger is still the single authority.
11
+ *
12
+ * **One run per session, for now.** Nothing here assumes it: the fold is pure and takes its evidence
13
+ * as arguments, so a later multi-run or multi-agent model is a change of caller, not a rewrite. It
14
+ * is not built today because nobody has hit the case, and a coordination model invented ahead of its
15
+ * first user is a guess.
16
+ *
17
+ * Only what Reticle OBSERVED goes in. A settle outcome is an observation. A route change is an
18
+ * observation. A verdict a verification tool recorded is an observation. What the agent meant to do
19
+ * next is not, and never enters here.
20
+ */
21
+ /**
22
+ * The one key the route is filed under.
23
+ *
24
+ * The subject of "the app is on /checkout" is the route itself, so a later route change SUPERSEDES
25
+ * rather than appends. Filing it per-path would accumulate every page ever visited, which is the
26
+ * token bomb the cap exists to prevent.
27
+ */
28
+ const ROUTE_KEY = 'route';
29
+ /** The conclusions this fold is allowed to state, as prose rather than transcript. */
30
+ const Fact = {
31
+ route: (to) => `route is ${to}`,
32
+ settled: (verb) => `${verb} settled`,
33
+ unsettled: (verb) => `${verb} did not settle`,
34
+ };
35
+ /**
36
+ * The SUBJECT a journal action was about: the ref it spent, else the target it resolved from.
37
+ *
38
+ * Exported so the feature-use instrument files a re-fetch under the same key this fold establishes
39
+ * it under. A second copy of this rule there would be a second definition of "the same subject".
40
+ */
41
+ export function subjectOf(action) {
42
+ const ref = action.args['ref'];
43
+ if ('string' === typeof ref && ref.length > 0)
44
+ return ref;
45
+ const target = action.args['target'];
46
+ if ('string' === typeof target && target.length > 0)
47
+ return target;
48
+ return undefined;
49
+ }
50
+ /** What the action did, in the caller's own vocabulary — 'click', 'fill', else the tool's name. */
51
+ function verbOf(action) {
52
+ const named = action.args['action'];
53
+ return 'string' === typeof named && named.length > 0 ? named : action.tool;
54
+ }
55
+ /** The verdict a verification tool recorded on this action, if it recorded one. */
56
+ function verdictOf(action) {
57
+ const parsed = JournalVerdictEffectSchema.safeParse(action.effect);
58
+ return parsed.success ? parsed.data : undefined;
59
+ }
60
+ /** Which document and edit epoch each action was observed under, read off its attributed events. */
61
+ function scopeByAction(events) {
62
+ const out = new Map();
63
+ for (const event of events) {
64
+ if (event.actionId === undefined)
65
+ continue;
66
+ if (event.documentId === undefined && event.editEpoch === undefined)
67
+ continue;
68
+ const existing = out.get(event.actionId) ?? {};
69
+ out.set(event.actionId, {
70
+ doc: event.documentId ?? existing.doc,
71
+ epoch: event.editEpoch ?? existing.epoch,
72
+ });
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * Fold the journal into candidate facts, newest last.
78
+ *
79
+ * Only the last `RUN_ESTABLISHED_CAP` actions are read: anything older cannot survive the fold, so
80
+ * parsing it would be work done to throw away. An action with no observed settle outcome states
81
+ * nothing and is skipped — "we drove it and cannot say what happened" is not an established fact,
82
+ * and writing it down as one is how a memory starts lying.
83
+ */
84
+ export function establishedFromJournal(actions, events) {
85
+ const scope = scopeByAction(events);
86
+ let route;
87
+ for (const event of events) {
88
+ if (EventType.ROUTE_CHANGE !== event.type)
89
+ continue;
90
+ const pathname = event.data['pathname'];
91
+ const to = 'string' === typeof pathname ? pathname : event.data['to'];
92
+ if ('string' !== typeof to)
93
+ continue;
94
+ route = { key: ROUTE_KEY, fact: Fact.route(to), doc: event.documentId, epoch: event.editEpoch };
95
+ }
96
+ const out = [];
97
+ for (const action of actions.slice(-RUN_ESTABLISHED_CAP)) {
98
+ const settled = action.settled;
99
+ if (undefined === settled)
100
+ continue;
101
+ const key = subjectOf(action);
102
+ if (undefined === key)
103
+ continue;
104
+ const verb = verbOf(action);
105
+ out.push({
106
+ key,
107
+ fact: settled ? Fact.settled(verb) : Fact.unsettled(verb),
108
+ // The source pointer the verdict already reported. Read off the ledger rather than looked up
109
+ // again: a second lookup could disagree with the one the agent was shown.
110
+ source: verdictOf(action)?.source,
111
+ ...(scope.get(action.actionId) ?? {}),
112
+ });
113
+ }
114
+ if (route !== undefined)
115
+ out.push(route);
116
+ return out;
117
+ }
118
+ /**
119
+ * What a verdict already settled, newest last.
120
+ *
121
+ * Both verdict tools write here. `reticle_act_and_wait` closes its attribution window with one;
122
+ * `reticle_assert` drives nothing, so it appends its record WITHOUT opening a window — same effect
123
+ * shape, so this fold reads one kind of verdict rather than two.
124
+ */
125
+ export function provenFromJournal(actions, events) {
126
+ const scope = scopeByAction(events);
127
+ const out = [];
128
+ for (const action of actions.slice(-RUN_ESTABLISHED_CAP)) {
129
+ const verdict = verdictOf(action);
130
+ if (undefined === verdict)
131
+ continue;
132
+ out.push({
133
+ claim: verdict.claim,
134
+ verified: verdict.verified,
135
+ source: verdict.source,
136
+ ...(scope.get(action.actionId) ?? {}),
137
+ });
138
+ }
139
+ return out;
140
+ }
141
+ /**
142
+ * The context for one run.
143
+ *
144
+ * `step` is the journal's action count rather than a counter of its own, for the reason at the top
145
+ * of this file: a second counter is a second thing that can disagree with the ledger. The document
146
+ * and epoch are the session's own, so the staleness rule here is the same one every other verdict in
147
+ * this codebase is scoped by.
148
+ */
149
+ export function runContextFor(input) {
150
+ return buildRunContext({
151
+ step: input.actions.length,
152
+ intents: input.intents,
153
+ established: establishedFromJournal(input.actions, input.events),
154
+ proven: provenFromJournal(input.actions, input.events),
155
+ currentDocumentId: input.currentDocumentId,
156
+ currentEditEpoch: input.currentEditEpoch,
157
+ });
158
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ReticleVerificationRun } from '@reticlehq/core';
2
2
  /** A per-flow change between two runs. Only flows with a meaningful change are reported. */
3
- export interface FlowDelta {
3
+ interface FlowDelta {
4
4
  name: string;
5
5
  beforeMs: number;
6
6
  afterMs: number;
@@ -29,3 +29,4 @@ export interface RunDiff {
29
29
  * console diffs land once runs persist per-step detail.
30
30
  */
31
31
  export declare function diffRuns(before: ReticleVerificationRun, after: ReticleVerificationRun, noiseFloorPct?: number): RunDiff;
32
+ export {};
@@ -14,7 +14,7 @@ import { buildVerificationRun } from './build-verification-run.js';
14
14
  import { mapReplayToFlowResult } from './replay-mapping.js';
15
15
  import { defaultRunId } from './runner-port.js';
16
16
  import { RunStore } from './run-store.js';
17
- import { syncRunToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
17
+ import { cloudFetch, syncRunToCloud, SyncOutcome } from '../cloud/cloud-sync.js';
18
18
  import { resolveProjectCloud } from '../cloud/cloud-config.js';
19
19
  import { log } from '../log.js';
20
20
  /** Assemble the ReticleVerificationRun artifact for a verify suite (pure — no IO). */
@@ -58,7 +58,7 @@ export async function persistAndSyncVerificationRun(deps, timed, projectId) {
58
58
  const cloud = await resolveProjectCloud(deps.fs, deps.reticleRoot, homedir(), process.env);
59
59
  if (null === cloud.config || !cloud.policy.runs)
60
60
  return run.runId; // not attached / runs disabled → local only
61
- const result = await syncRunToCloud(run, cloud.config, (url, init) => fetch(url, init));
61
+ const result = await syncRunToCloud(run, cloud.config, cloudFetch);
62
62
  if (result.outcome !== SyncOutcome.SYNCED) {
63
63
  log('cloud-run-sync-failed', { runId: run.runId, status: result.status, error: result.error });
64
64
  }
@@ -69,13 +69,5 @@ export declare function probeDevServerStates(ports?: readonly number[], probe?:
69
69
  serving: number[];
70
70
  slow: number[];
71
71
  }>;
72
- /**
73
- * The strongest state either loopback family reports.
74
- *
75
- * Serving a document beats a bare connection beats nothing, so a server bound to only one family is
76
- * still seen at its best — the same "either, not both" rule `anyFamilyServes` follows, extended to
77
- * three states.
78
- */
79
- export declare function anyFamilyState(port: number, probe?: (port: number, host: string) => Promise<PortState>): Promise<PortState>;
80
72
  /** Every candidate port serving a document, in ascending order. Never rejects. */
81
73
  export declare function probeDevServers(ports?: readonly number[], probe?: (port: number) => Promise<boolean>): Promise<number[]>;
@@ -132,7 +132,7 @@ export async function probeDevServerStates(ports = [...DEV_SERVER_PORTS], probe
132
132
  * still seen at its best — the same "either, not both" rule `anyFamilyServes` follows, extended to
133
133
  * three states.
134
134
  */
135
- export async function anyFamilyState(port, probe = probeState) {
135
+ async function anyFamilyState(port, probe = probeState) {
136
136
  const answers = await Promise.all(PROBE_HOSTS.map((host) => probe(port, host).catch(() => PortState.CLOSED)));
137
137
  if (answers.includes(PortState.SERVES_DOCUMENT))
138
138
  return PortState.SERVES_DOCUMENT;