@reticlehq/server 2.6.0 → 2.8.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 (276) hide show
  1. package/README.md +14 -5
  2. package/SKILL.md +251 -33
  3. package/dist/bridge/bridge.d.ts +6 -1
  4. package/dist/bridge/bridge.js +27 -4
  5. package/dist/capsule/causal-summary.d.ts +20 -0
  6. package/dist/capsule/causal-summary.js +31 -6
  7. package/dist/capsule/divergence.js +7 -1
  8. package/dist/cli/chromium-hint.d.ts +68 -0
  9. package/dist/cli/chromium-hint.js +145 -0
  10. package/dist/cli/cli-doctor.js +31 -36
  11. package/dist/cli/cli-kill.d.ts +58 -0
  12. package/dist/cli/cli-kill.js +162 -0
  13. package/dist/cli/cli-launch.d.ts +18 -0
  14. package/dist/cli/cli-launch.js +32 -1
  15. package/dist/cli/cli-parse.d.ts +16 -1
  16. package/dist/cli/cli-parse.js +32 -9
  17. package/dist/cli/cli-port.d.ts +8 -0
  18. package/dist/cli/cli-port.js +36 -1
  19. package/dist/cli/cli-verify.js +2 -2
  20. package/dist/cli/doctor-daemon-line.js +10 -1
  21. package/dist/cli/doctor-sessions-line.d.ts +12 -0
  22. package/dist/cli/doctor-sessions-line.js +66 -0
  23. package/dist/cli/port-holder.d.ts +10 -0
  24. package/dist/cli/port-holder.js +26 -2
  25. package/dist/cli.js +139 -23
  26. package/dist/crawl/crawl-tools.js +12 -2
  27. package/dist/crawl/crawl.d.ts +13 -0
  28. package/dist/crawl/crawl.js +50 -2
  29. package/dist/daemon/daemon-resilience.js +2 -2
  30. package/dist/daemon/loopback-alias.d.ts +32 -0
  31. package/dist/daemon/loopback-alias.js +80 -0
  32. package/dist/events/contradictions.d.ts +14 -0
  33. package/dist/events/contradictions.js +26 -17
  34. package/dist/events/predicate-eval.d.ts +26 -3
  35. package/dist/events/predicate-eval.js +284 -95
  36. package/dist/events/predicate-parse.d.ts +1 -1
  37. package/dist/events/predicate-parse.js +18 -4
  38. package/dist/events/predicate.js +170 -18
  39. package/dist/events/ring-buffer.d.ts +8 -0
  40. package/dist/events/ring-buffer.js +34 -1
  41. package/dist/flows/flow-classify.js +1 -1
  42. package/dist/flows/flow-tools.js +12 -4
  43. package/dist/flows/heal-run.js +1 -1
  44. package/dist/honesty/blind-spots.js +6 -2
  45. package/dist/honesty/honesty.d.ts +11 -0
  46. package/dist/honesty/honesty.js +9 -1
  47. package/dist/honesty/unsettled.d.ts +40 -0
  48. package/dist/honesty/unsettled.js +78 -0
  49. package/dist/honesty/verified.d.ts +7 -0
  50. package/dist/honesty/verified.js +27 -4
  51. package/dist/http-server.js +11 -1
  52. package/dist/index.js +29 -2
  53. package/dist/init/agent-rules.d.ts +16 -0
  54. package/dist/init/agent-rules.js +96 -17
  55. package/dist/init/astro-patch.d.ts +2 -1
  56. package/dist/init/astro-patch.js +11 -5
  57. package/dist/init/capabilities.d.ts +22 -0
  58. package/dist/init/capabilities.js +76 -0
  59. package/dist/init/confirm.d.ts +96 -0
  60. package/dist/init/confirm.js +138 -0
  61. package/dist/init/cra.js +3 -2
  62. package/dist/init/detect.d.ts +8 -0
  63. package/dist/init/detect.js +15 -0
  64. package/dist/init/non-js-project.d.ts +27 -0
  65. package/dist/init/non-js-project.js +84 -0
  66. package/dist/init/plan-framework.d.ts +9 -0
  67. package/dist/init/plan-framework.js +72 -16
  68. package/dist/init/plan.d.ts +40 -4
  69. package/dist/init/plan.js +183 -43
  70. package/dist/init/run.d.ts +24 -2
  71. package/dist/init/run.js +181 -46
  72. package/dist/init/slash-command.d.ts +1 -1
  73. package/dist/init/slash-command.js +46 -6
  74. package/dist/init/snippets.d.ts +64 -5
  75. package/dist/init/snippets.js +220 -31
  76. package/dist/init/vite-config.js +29 -3
  77. package/dist/input/network-mock-tools.d.ts +19 -0
  78. package/dist/input/network-mock-tools.js +29 -4
  79. package/dist/license/license.js +1 -1
  80. package/dist/mcp/mcp-proxy.d.ts +69 -3
  81. package/dist/mcp/mcp-proxy.js +215 -23
  82. package/dist/mcp/mcp.js +58 -7
  83. package/dist/mcp/proxy-lifecycle.d.ts +20 -1
  84. package/dist/mcp/proxy-lifecycle.js +22 -1
  85. package/dist/pool/browser-pool.d.ts +22 -2
  86. package/dist/pool/browser-pool.js +44 -4
  87. package/dist/pool/playwright-launcher.js +10 -2
  88. package/dist/project/learned-routes.d.ts +24 -0
  89. package/dist/project/learned-routes.js +63 -0
  90. package/dist/project/project-store.d.ts +6 -0
  91. package/dist/project/project-store.js +25 -1
  92. package/dist/session/live-control-tools.js +39 -2
  93. package/dist/session/live-control.d.ts +7 -9
  94. package/dist/session/live-control.js +34 -1
  95. package/dist/session/no-session-diagnosis.d.ts +23 -4
  96. package/dist/session/no-session-diagnosis.js +128 -44
  97. package/dist/session/no-session-watch.js +12 -0
  98. package/dist/session/ref-provenance.d.ts +54 -0
  99. package/dist/session/ref-provenance.js +61 -0
  100. package/dist/session/review-store.d.ts +19 -0
  101. package/dist/session/review-store.js +34 -5
  102. package/dist/session/session-lease.js +1 -1
  103. package/dist/session/session-manager.d.ts +9 -0
  104. package/dist/session/session-manager.js +11 -0
  105. package/dist/session/session.d.ts +8 -0
  106. package/dist/session/session.js +12 -0
  107. package/dist/status-payload.d.ts +13 -1
  108. package/dist/status-payload.js +6 -1
  109. package/dist/telemetry/app-instrumented.d.ts +37 -0
  110. package/dist/telemetry/app-instrumented.js +54 -0
  111. package/dist/telemetry/automation-hint.d.ts +17 -0
  112. package/dist/telemetry/automation-hint.js +71 -0
  113. package/dist/telemetry/bug-found.d.ts +0 -20
  114. package/dist/telemetry/bug-found.js +38 -21
  115. package/dist/telemetry/cli-telemetry.js +7 -1
  116. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  117. package/dist/telemetry/daemon-telemetry.js +8 -4
  118. package/dist/telemetry/feedback.js +1 -1
  119. package/dist/telemetry/init-telemetry.js +9 -1
  120. package/dist/telemetry/install-source.d.ts +36 -0
  121. package/dist/telemetry/install-source.js +40 -0
  122. package/dist/telemetry/run-telemetry.js +4 -1
  123. package/dist/telemetry/session-metrics.d.ts +3 -3
  124. package/dist/telemetry/session-metrics.js +9 -4
  125. package/dist/telemetry/telemetry.d.ts +7 -1
  126. package/dist/telemetry/telemetry.js +17 -2
  127. package/dist/telemetry/tool-refused.d.ts +19 -0
  128. package/dist/telemetry/tool-refused.js +55 -0
  129. package/dist/telemetry/verification-of.js +72 -8
  130. package/dist/temp-dir.d.ts +27 -0
  131. package/dist/temp-dir.js +46 -0
  132. package/dist/tools/act-tools.js +77 -11
  133. package/dist/tools/assert-grade.d.ts +1 -1
  134. package/dist/tools/assert-grade.js +27 -5
  135. package/dist/tools/assert-verdict.js +25 -1
  136. package/dist/tools/contract-tools.js +4 -0
  137. package/dist/tools/dynamic-tools.js +1 -1
  138. package/dist/tools/error-recovery.d.ts +10 -0
  139. package/dist/tools/error-recovery.js +70 -5
  140. package/dist/tools/feedback-invite.js +1 -1
  141. package/dist/tools/invoke-tool.js +149 -32
  142. package/dist/tools/lease-tools.d.ts +29 -1
  143. package/dist/tools/lease-tools.js +58 -5
  144. package/dist/tools/navigate-result.js +1 -1
  145. package/dist/tools/observe-tools.js +1 -1
  146. package/dist/tools/observed-nothing.js +1 -1
  147. package/dist/tools/react-grace.d.ts +48 -0
  148. package/dist/tools/react-grace.js +87 -0
  149. package/dist/tools/read-tools.js +20 -4
  150. package/dist/tools/settle-in-flight.d.ts +79 -0
  151. package/dist/tools/settle-in-flight.js +106 -0
  152. package/dist/tools/snapshot-delta.d.ts +7 -0
  153. package/dist/tools/snapshot-delta.js +130 -6
  154. package/dist/tools/tool-kit.d.ts +52 -5
  155. package/dist/tools/tool-kit.js +55 -24
  156. package/dist/tools/tool-surface.js +28 -9
  157. package/dist/tools/tools.js +38 -4
  158. package/dist/visual/capture-cleanup.d.ts +16 -0
  159. package/dist/visual/capture-cleanup.js +39 -0
  160. package/dist/visual/visual-tools.d.ts +18 -0
  161. package/dist/visual/visual-tools.js +24 -5
  162. package/docs/README.md +56 -11
  163. package/docs/actions.mdx +87 -0
  164. package/docs/agent-cheatsheet.md +102 -26
  165. package/docs/architecture.md +46 -35
  166. package/docs/benchmarks.md +76 -72
  167. package/docs/best-practices.mdx +211 -0
  168. package/docs/capabilities.mdx +387 -0
  169. package/docs/cli/affected.mdx +51 -0
  170. package/docs/cli/capsules.mdx +69 -0
  171. package/docs/cli/cloud.mdx +127 -0
  172. package/docs/cli/doctor.mdx +76 -0
  173. package/docs/cli/drive.mdx +55 -0
  174. package/docs/cli/feedback.mdx +67 -0
  175. package/docs/cli/gate.mdx +64 -0
  176. package/docs/cli/help.mdx +92 -0
  177. package/docs/cli/hunt.mdx +57 -0
  178. package/docs/cli/identify.mdx +62 -0
  179. package/docs/cli/init.mdx +94 -0
  180. package/docs/cli/kill.mdx +91 -0
  181. package/docs/cli/license.mdx +48 -0
  182. package/docs/cli/mcp.mdx +61 -0
  183. package/docs/cli/open.mdx +68 -0
  184. package/docs/cli/restart.mdx +60 -0
  185. package/docs/cli/rollback.mdx +35 -0
  186. package/docs/cli/serve.mdx +62 -0
  187. package/docs/cli/status.mdx +64 -0
  188. package/docs/cli/stop.mdx +56 -0
  189. package/docs/cli/telemetry.mdx +57 -0
  190. package/docs/cli/update.mdx +52 -0
  191. package/docs/cli/verify.mdx +57 -0
  192. package/docs/cli/version.mdx +39 -0
  193. package/docs/cli/watch.mdx +55 -0
  194. package/docs/cli.mdx +219 -0
  195. package/docs/debugging.md +13 -9
  196. package/docs/deploy-checks.md +15 -11
  197. package/docs/desktop-apps.md +49 -40
  198. package/docs/desktop.mdx +188 -0
  199. package/docs/docs.json +363 -0
  200. package/docs/enterprise.md +20 -16
  201. package/docs/faq.mdx +88 -0
  202. package/docs/fixtures.md +11 -7
  203. package/docs/flows.md +26 -22
  204. package/docs/for-agents.md +71 -0
  205. package/docs/frameworks.mdx +199 -0
  206. package/docs/gate-plan.md +45 -41
  207. package/docs/gates.md +23 -19
  208. package/docs/getting-started.md +76 -65
  209. package/docs/human-control.md +22 -18
  210. package/docs/index.mdx +101 -0
  211. package/docs/install-agentic.mdx +215 -0
  212. package/docs/install-manual.mdx +194 -0
  213. package/docs/instrumentation.mdx +232 -0
  214. package/docs/integration-patterns.md +38 -34
  215. package/docs/local-registry.md +16 -12
  216. package/docs/multi-agent-testing.md +16 -12
  217. package/docs/packages/babel-plugin.mdx +70 -0
  218. package/docs/packages/browser.mdx +111 -0
  219. package/docs/packages/core.mdx +106 -0
  220. package/docs/packages/electron.mdx +87 -0
  221. package/docs/packages/eslint-plugin.mdx +84 -0
  222. package/docs/packages/next.mdx +91 -0
  223. package/docs/packages/react.mdx +91 -0
  224. package/docs/packages/server.mdx +98 -0
  225. package/docs/packages/tauri.mdx +63 -0
  226. package/docs/packages/test.mdx +108 -0
  227. package/docs/packages/vite-plugin.mdx +96 -0
  228. package/docs/packages.mdx +217 -0
  229. package/docs/platform-integration.md +33 -26
  230. package/docs/predicates.mdx +179 -0
  231. package/docs/quickstart.mdx +211 -0
  232. package/docs/recipes.mdx +242 -0
  233. package/docs/skill-file.mdx +105 -0
  234. package/docs/state-management.mdx +197 -0
  235. package/docs/style.css +14 -0
  236. package/docs/system-map.md +47 -32
  237. package/docs/telemetry-contract.md +101 -46
  238. package/docs/telemetry-events.mdx +430 -0
  239. package/docs/telemetry.md +63 -50
  240. package/docs/testing.md +10 -6
  241. package/docs/token-efficiency.md +30 -26
  242. package/docs/tools-act-and-wait.mdx +123 -0
  243. package/docs/tools-act-sequence.mdx +120 -0
  244. package/docs/tools-act.mdx +82 -0
  245. package/docs/tools-assert.mdx +70 -0
  246. package/docs/tools-console.mdx +59 -0
  247. package/docs/tools-inspect.mdx +85 -0
  248. package/docs/tools-navigate.mdx +63 -0
  249. package/docs/tools-network.mdx +86 -0
  250. package/docs/tools-observe.mdx +89 -0
  251. package/docs/tools-overview.mdx +157 -0
  252. package/docs/tools-query.mdx +82 -0
  253. package/docs/tools-session-and-feedback.mdx +91 -0
  254. package/docs/tools-sessions.mdx +65 -0
  255. package/docs/tools-snapshot.mdx +75 -0
  256. package/docs/tools-state.mdx +81 -0
  257. package/docs/tools-tools-and-run.mdx +125 -0
  258. package/docs/tools-wait-for.mdx +66 -0
  259. package/docs/troubleshooting.mdx +193 -0
  260. package/docs/usage.md +175 -153
  261. package/docs/vs-chrome-devtools-mcp.mdx +82 -0
  262. package/docs/vs-playwright-mcp.mdx +85 -0
  263. package/docs/vs-screenshots.mdx +96 -0
  264. package/docs/what-is-reticle.mdx +63 -0
  265. package/docs/why-reticle.mdx +107 -0
  266. package/package.json +2 -2
  267. package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
  268. package/docs/matrix/2.5.0/codex-machine.json +0 -20
  269. package/docs/matrix/2.5.0/cursor-machine.json +0 -22
  270. package/docs/matrix/2.5.0/gemini-machine.json +0 -22
  271. package/docs/matrix/2.5.0/opencode-machine.json +0 -22
  272. package/docs/matrix/2.5.0/vscode-machine.json +0 -22
  273. package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
  274. package/docs/matrix/MATRIX.md +0 -26
  275. package/docs/matrix/README.md +0 -59
  276. package/docs/mint.json +0 -61
@@ -1,4 +1,23 @@
1
1
  import { EventType, PerfMetric, isDevToolingUrl } from '@reticlehq/core';
2
+ /**
3
+ * THE INVARIANT: every field of this summary has a bounded size that does not depend on how much the
4
+ * app did. A verdict travels in the same payload as its evidence, so an unbounded evidence field is
5
+ * an unbounded chance that the verdict is the part a client truncates — and an agent that cannot read
6
+ * the verdict has run the verification and got nothing, which is indistinguishable from not running
7
+ * it. Measured: one click on a page with a registered TanStack Query store returned ~363KB, because
8
+ * `capValue` bounded each diff and nothing bounded the COUNT of them.
9
+ *
10
+ * 20 keeps the signal that is actually read — the first paths to move, and the settle window over all
11
+ * of them — while the full names of everything that changed stay one `reticle_state` call away.
12
+ */
13
+ export const MAX_SUMMARY_ENTRIES = 20;
14
+ /** Trim one list to the cap and report the loss. Order is arrival order, so the head is the earliest. */
15
+ function cap(list, name, elided) {
16
+ if (list.length <= MAX_SUMMARY_ENTRIES)
17
+ return list;
18
+ elided[name] = list.length - MAX_SUMMARY_ENTRIES;
19
+ return list.slice(0, MAX_SUMMARY_ENTRIES);
20
+ }
2
21
  function pushUnique(list, value) {
3
22
  if ('string' === typeof value && value.length > 0 && !list.includes(value))
4
23
  list.push(value);
@@ -118,6 +137,11 @@ export function causalSummary(events) {
118
137
  break;
119
138
  }
120
139
  }
140
+ const elided = {};
141
+ // `stateSettleMs` is computed over EVERY diff, before the cap: the interval is a fact about the
142
+ // store, and shrinking it because the evidence was trimmed would make the transient window read as
143
+ // narrower than it was — the one number here that must survive the trim intact.
144
+ const settle = stateSettle(stateDiffs);
121
145
  return {
122
146
  net: {
123
147
  total: netTotal,
@@ -126,13 +150,14 @@ export function causalSummary(events) {
126
150
  ...(headline === undefined ? {} : { headline }),
127
151
  },
128
152
  consoleErrors,
129
- statePathsChanged,
130
- storageKeysChanged,
131
- stateDiffs,
132
- ...stateSettle(stateDiffs),
133
- storageDiffs,
153
+ statePathsChanged: cap(statePathsChanged, 'statePathsChanged', elided),
154
+ storageKeysChanged: cap(storageKeysChanged, 'storageKeysChanged', elided),
155
+ stateDiffs: cap(stateDiffs, 'stateDiffs', elided),
156
+ ...settle,
157
+ storageDiffs: cap(storageDiffs, 'storageDiffs', elided),
134
158
  ...(route === undefined ? {} : { route }),
135
- signals,
159
+ signals: cap(signals, 'signals', elided),
160
+ ...(0 === Object.keys(elided).length ? {} : { elided }),
136
161
  ...(layoutShift === undefined ? {} : { layoutShift }),
137
162
  longTasks,
138
163
  };
@@ -7,7 +7,13 @@ function satisfies(link, events) {
7
7
  case ConsequenceKind.SIGNAL:
8
8
  return events.some((e) => e.type === EventType.SIGNAL && e.data['name'] === link.name);
9
9
  case ConsequenceKind.STATE:
10
- return events.some((e) => e.type === EventType.STATE_CHANGE && e.data['name'] === link.name);
10
+ // Matched against the store name OR the changed path, because the link carries whichever half
11
+ // the predicate gave: `store` when it named one, and `path` otherwise (predicateToExpectedLinks).
12
+ // The event carries both. Comparing only the store meant every assertion written by path
13
+ // reported "never changed" however the app behaved — a false claim printed beside a `stateDiffs`
14
+ // entry showing that exact path changing.
15
+ return events.some((e) => e.type === EventType.STATE_CHANGE &&
16
+ (e.data['name'] === link.name || e.data['path'] === link.name));
11
17
  case ConsequenceKind.NET:
12
18
  return net(events).some((e) => 'string' === typeof e.data['url'] &&
13
19
  e.data['url'].includes(link.urlContains) &&
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A Chromium hint you can satisfy by following it.
3
+ *
4
+ * `npx playwright install chromium` looks obviously right and is quietly wrong. `npx` resolves the
5
+ * LATEST playwright, which pins a different browser revision than the playwright the daemon actually
6
+ * bundles — so the command downloads ~90 MiB of a build the daemon will not use, and the check still
7
+ * says missing afterwards. Reported from the field by an agent whose machine already had five
8
+ * chromium builds, none of them the wanted one, with no way to learn which one was wanted.
9
+ *
10
+ * The same reporter came back, because pinning the command fixes only half of it. Their five builds
11
+ * sat in the standard Windows browsers root and the line still read a flat `missing`, which leaves
12
+ * two readings — "the check is broken" and "none of these count" — and no way to choose. Present at
13
+ * the wrong revision and absent entirely are different problems: one is a single pinned download,
14
+ * the other means the lookup is aimed at a root nothing was ever installed into. Collapsing them is
15
+ * what made the loop unbreakable.
16
+ *
17
+ * Three things fix it, and they are what any unsatisfiable check needs: pin the command to the
18
+ * version doing the asking, say what was probed, and separate the verdicts that have different
19
+ * fixes. The wanted revision and the browsers root are both read back off the path Playwright itself
20
+ * resolves, so `PLAYWRIGHT_BROWSERS_PATH` and every platform default are honoured by construction
21
+ * rather than by a table of roots this file would have to keep in step with Playwright's.
22
+ */
23
+ /** What the check found, gathered by the caller so this stays pure and testable. */
24
+ export interface ChromiumProbe {
25
+ /** Executable path the daemon's own playwright resolves to; undefined when playwright is absent. */
26
+ executablePath?: string | undefined;
27
+ /** Whether that path exists on disk. */
28
+ exists: boolean;
29
+ /** Version of the playwright the DAEMON bundles — not whatever `npx` would fetch. */
30
+ playwrightVersion?: string | undefined;
31
+ /** The revision that path names, e.g. `chromium-1223`. Undefined if the path has no revision in it. */
32
+ wantedRevision?: string | undefined;
33
+ /** The browsers root the lookup used, wherever `PLAYWRIGHT_BROWSERS_PATH` and the platform put it. */
34
+ browsersRoot?: string | undefined;
35
+ /** Chromium revisions actually sitting in that root, wanted or not. */
36
+ installedRevisions?: readonly string[] | undefined;
37
+ }
38
+ /**
39
+ * The install command, pinned when we know what to pin to.
40
+ *
41
+ * Unpinned is still the right fallback: a wrong-revision download is a bad outcome, but no command
42
+ * at all is a worse one.
43
+ */
44
+ export declare function chromiumInstallCommand(playwrightVersion?: string): string;
45
+ /**
46
+ * Split a resolved executable path into the browsers root and the revision under it, or null.
47
+ *
48
+ * Reading them back off Playwright's own answer is the point. The alternative is reconstructing the
49
+ * root from `PLAYWRIGHT_BROWSERS_PATH` plus a per-platform default, which is a copy of Playwright's
50
+ * resolution rules that drifts the first time they change one — and getting it wrong here means
51
+ * telling a user their browsers are missing while looking in the wrong directory, which is the exact
52
+ * failure this file exists to stop.
53
+ */
54
+ export declare function parseChromiumRevision(executablePath: string): {
55
+ revision: string;
56
+ root: string;
57
+ } | null;
58
+ /**
59
+ * Ask the playwright THIS process would use, not the one `npx` would fetch.
60
+ *
61
+ * `executablePath()` resolves the revision the daemon will actually launch AND the root it will look
62
+ * in, which is why the probe never reads `PLAYWRIGHT_BROWSERS_PATH` itself.
63
+ */
64
+ export declare function probeChromium(): Promise<ChromiumProbe>;
65
+ /** The bundled playwright's version, or undefined if it cannot be read (the hint then goes unpinned). */
66
+ export declare function bundledPlaywrightVersion(): string | undefined;
67
+ /** The doctor line for the Chromium check — verdict, what was probed, and how to satisfy it. */
68
+ export declare function chromiumHint(probe: ChromiumProbe): string;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * A Chromium hint you can satisfy by following it.
3
+ *
4
+ * `npx playwright install chromium` looks obviously right and is quietly wrong. `npx` resolves the
5
+ * LATEST playwright, which pins a different browser revision than the playwright the daemon actually
6
+ * bundles — so the command downloads ~90 MiB of a build the daemon will not use, and the check still
7
+ * says missing afterwards. Reported from the field by an agent whose machine already had five
8
+ * chromium builds, none of them the wanted one, with no way to learn which one was wanted.
9
+ *
10
+ * The same reporter came back, because pinning the command fixes only half of it. Their five builds
11
+ * sat in the standard Windows browsers root and the line still read a flat `missing`, which leaves
12
+ * two readings — "the check is broken" and "none of these count" — and no way to choose. Present at
13
+ * the wrong revision and absent entirely are different problems: one is a single pinned download,
14
+ * the other means the lookup is aimed at a root nothing was ever installed into. Collapsing them is
15
+ * what made the loop unbreakable.
16
+ *
17
+ * Three things fix it, and they are what any unsatisfiable check needs: pin the command to the
18
+ * version doing the asking, say what was probed, and separate the verdicts that have different
19
+ * fixes. The wanted revision and the browsers root are both read back off the path Playwright itself
20
+ * resolves, so `PLAYWRIGHT_BROWSERS_PATH` and every platform default are honoured by construction
21
+ * rather than by a table of roots this file would have to keep in step with Playwright's.
22
+ */
23
+ import { existsSync, readdirSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+ /**
26
+ * A browsers-root entry that is a chromium build.
27
+ *
28
+ * Deliberately not matching `chromium_headless_shell-*`: it lives beside the full build and is a
29
+ * different artifact, so counting it would let a root holding only headless shells report the full
30
+ * browser as present-but-mismatched.
31
+ */
32
+ const CHROMIUM_DIR = /^chromium-\d+$/;
33
+ /** The same directory name as a path segment, on either separator, so Windows paths split too. */
34
+ const CHROMIUM_SEGMENT = /[\\/](chromium-\d+)[\\/]/;
35
+ /**
36
+ * The install command, pinned when we know what to pin to.
37
+ *
38
+ * Unpinned is still the right fallback: a wrong-revision download is a bad outcome, but no command
39
+ * at all is a worse one.
40
+ */
41
+ export function chromiumInstallCommand(playwrightVersion) {
42
+ const pin = playwrightVersion === undefined ? 'playwright' : `playwright@${playwrightVersion}`;
43
+ return `npx ${pin} install chromium`;
44
+ }
45
+ /**
46
+ * Split a resolved executable path into the browsers root and the revision under it, or null.
47
+ *
48
+ * Reading them back off Playwright's own answer is the point. The alternative is reconstructing the
49
+ * root from `PLAYWRIGHT_BROWSERS_PATH` plus a per-platform default, which is a copy of Playwright's
50
+ * resolution rules that drifts the first time they change one — and getting it wrong here means
51
+ * telling a user their browsers are missing while looking in the wrong directory, which is the exact
52
+ * failure this file exists to stop.
53
+ */
54
+ export function parseChromiumRevision(executablePath) {
55
+ const match = CHROMIUM_SEGMENT.exec(executablePath);
56
+ if (null === match)
57
+ return null;
58
+ const revision = match[1];
59
+ if (revision === undefined)
60
+ return null;
61
+ return { revision, root: executablePath.slice(0, match.index) };
62
+ }
63
+ /** Chromium revisions present in a browsers root. Empty on an unreadable or absent root. */
64
+ function installedRevisions(root) {
65
+ try {
66
+ return readdirSync(root)
67
+ .filter((entry) => CHROMIUM_DIR.test(entry))
68
+ .sort();
69
+ }
70
+ catch {
71
+ // No root at all is the commonest case here and is not an error: it means nothing was installed.
72
+ return [];
73
+ }
74
+ }
75
+ /**
76
+ * Ask the playwright THIS process would use, not the one `npx` would fetch.
77
+ *
78
+ * `executablePath()` resolves the revision the daemon will actually launch AND the root it will look
79
+ * in, which is why the probe never reads `PLAYWRIGHT_BROWSERS_PATH` itself.
80
+ */
81
+ export async function probeChromium() {
82
+ try {
83
+ const { chromium } = await import('playwright');
84
+ const executablePath = chromium.executablePath();
85
+ const parsed = parseChromiumRevision(executablePath);
86
+ return {
87
+ executablePath,
88
+ exists: existsSync(executablePath),
89
+ playwrightVersion: bundledPlaywrightVersion(),
90
+ ...(null === parsed
91
+ ? {}
92
+ : {
93
+ wantedRevision: parsed.revision,
94
+ browsersRoot: parsed.root,
95
+ installedRevisions: installedRevisions(parsed.root),
96
+ }),
97
+ };
98
+ }
99
+ catch {
100
+ // Playwright itself is absent — a different problem from a missing browser, and the hint says so.
101
+ return { exists: false };
102
+ }
103
+ }
104
+ /** The bundled playwright's version, or undefined if it cannot be read (the hint then goes unpinned). */
105
+ export function bundledPlaywrightVersion() {
106
+ try {
107
+ const require = createRequire(import.meta.url);
108
+ const pkg = require('playwright/package.json');
109
+ if ('object' === typeof pkg && null !== pkg && 'version' in pkg) {
110
+ const { version } = pkg;
111
+ if ('string' === typeof version)
112
+ return version;
113
+ }
114
+ }
115
+ catch {
116
+ /* an unreadable package.json is not worth failing a diagnostic over */
117
+ }
118
+ return undefined;
119
+ }
120
+ /** The doctor line for the Chromium check — verdict, what was probed, and how to satisfy it. */
121
+ export function chromiumHint(probe) {
122
+ if (probe.exists) {
123
+ // Naming the revision on the happy line too: it is the number the mismatch line talks about, and
124
+ // a reader comparing two machines has nothing to compare without it.
125
+ return probe.wantedRevision === undefined
126
+ ? '✓ installed'
127
+ : `✓ installed (${probe.wantedRevision})`;
128
+ }
129
+ const command = chromiumInstallCommand(probe.playwrightVersion);
130
+ if (probe.executablePath === undefined) {
131
+ return `✗ the playwright package is not installed; run: ${command}`;
132
+ }
133
+ const present = probe.installedRevisions ?? [];
134
+ if (present.length > 0 && probe.wantedRevision !== undefined) {
135
+ // The reported machine. Every fact here is one the reader could not otherwise get: what is
136
+ // wanted, what is there, and where "there" is. `npx playwright install chromium` unpinned adds
137
+ // a build to this list and changes nothing, which is what happened.
138
+ return (`✗ wrong revision — the bundled playwright wants ${probe.wantedRevision}; ` +
139
+ `${probe.browsersRoot ?? ''} holds ${present.join(', ')}. run: ${command}`);
140
+ }
141
+ // Naming the path is what turns "missing" from a verdict into evidence: a reader with browsers on
142
+ // disk can see immediately whether this is a missing install or a lookup pointed somewhere else
143
+ // (PLAYWRIGHT_BROWSERS_PATH, a different browsers root, a revision bump).
144
+ return `✗ missing — looked for ${probe.executablePath}; run: ${command}`;
145
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { ReticleEnv } from '@reticlehq/core';
4
4
  import { readPid, reticleStateHome } from '../daemon/daemon.js';
@@ -6,36 +6,18 @@ import { PortPresence, probePresence } from '../daemon/port-presence.js';
6
6
  import { probeDaemon } from '../mcp/mcp-proxy.js';
7
7
  import { fetchStatus } from './cli-launch.js';
8
8
  import { daemonLine } from './doctor-daemon-line.js';
9
- import { describeForeignHolder, findPortHolder } from './port-holder.js';
10
- import { spawnSync } from 'node:child_process';
9
+ import { sessionsLine } from './doctor-sessions-line.js';
10
+ import { captureLookup, describeForeignHolder, findPortHolder } from './port-holder.js';
11
+ import { chromiumHint, probeChromium } from './chromium-hint.js';
11
12
  import { SERVER_VERSION } from '../version/server-version.js';
12
13
  import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
13
14
  import { diagnoseDesktop, isDesktopProject } from '../init/desktop-doctor.js';
15
+ import { diagnosePortMismatch, readProjectPort } from './cli-port.js';
14
16
  /**
15
17
  * `reticle doctor` — collapse the ~6 independent first-run failure modes into one command. Checks the
16
18
  * Chromium install (the #1 silent failure), whether a daemon is up on the resolved bridge port, and
17
19
  * reminds the user which port the app must dial. Human-readable to stdout (not the JSON log).
18
20
  */
19
- /**
20
- * Run a lookup and return its stdout, or null if it could not run.
21
- *
22
- * Null on ANY failure — no lsof (every Windows user, a slim container), a non-zero exit, a throw.
23
- * This is a diagnostic nicety inside the command people run when things are already broken; it must
24
- * never be the reason `doctor` fails. `describeForeignHolder` handles the null by saying it could
25
- * not identify the holder and naming our recorded pid, rather than asserting the holder is not ours
26
- * — a claim it has no evidence for, and one that was wrong for every Windows user.
27
- */
28
- function runCapture(command, args) {
29
- try {
30
- const result = spawnSync(command, [...args], { encoding: 'utf8', timeout: LOOKUP_TIMEOUT_MS });
31
- return 0 === result.status && 'string' === typeof result.stdout ? result.stdout : null;
32
- }
33
- catch {
34
- return null;
35
- }
36
- }
37
- /** Bound on the holder lookup. A hung `lsof` must not hang the command that diagnoses hangs. */
38
- const LOOKUP_TIMEOUT_MS = 2_000;
39
21
  /** Narrow the `/status` payload to the two fields the daemon line reads. */
40
22
  function asIdentity(payload) {
41
23
  if (typeof payload !== 'object' || null === payload)
@@ -60,16 +42,10 @@ export async function handleDoctor(port) {
60
42
  };
61
43
  line('reticle doctor');
62
44
  line(` node ${process.version}`);
63
- try {
64
- const { chromium } = await import('playwright');
65
- const path = chromium.executablePath();
66
- line(existsSync(path)
67
- ? ' chromium ✓ installed'
68
- : ' chromium ✗ missing — run: npx playwright install chromium');
69
- }
70
- catch {
71
- line(' chromium ✗ missing — run: npx playwright install chromium');
72
- }
45
+ // Filled in only on the daemon branch — there is nothing to ask when no daemon is answering, and
46
+ // the branches below already say so in their own terms.
47
+ let sessions;
48
+ line(` chromium ${chromiumHint(await probeChromium())}`);
73
49
  // Ask the PORT, not just the pid file. "not running on :4400" has been printed about a port that
74
50
  // was demonstrably occupied, which sends the reader to start a daemon that cannot bind. The three
75
51
  // states are genuinely different problems with different fixes, so doctor names which one it is.
@@ -79,7 +55,8 @@ export async function handleDoctor(port) {
79
55
  // Name WHICH daemon. The /status payload already carries version + contract, and doctor was
80
56
  // throwing both away — while skew is invisible everywhere else, reaching the agent as a bare
81
57
  // -32000 naming no version. This is the command a human runs at exactly that moment.
82
- const status = asIdentity(await fetchStatus(port));
58
+ const payload = await fetchStatus(port);
59
+ const status = asIdentity(payload);
83
60
  const built = daemonLine(port, pid, status, {
84
61
  version: SERVER_VERSION,
85
62
  contract: CONTRACT_FINGERPRINT,
@@ -87,6 +64,10 @@ export async function handleDoctor(port) {
87
64
  line(built.text);
88
65
  if (built.skew !== undefined)
89
66
  line(` version ✗ ${built.skew}`);
67
+ // The same payload already carries whether anything has CONNECTED, and doctor was reading
68
+ // `version` off it and dropping the rest. Without this the command is silent on the one step the
69
+ // funnel stalls on: wired correctly, daemon up, and no page has ever dialled in.
70
+ sessions = sessionsLine('object' === typeof payload && null !== payload ? payload : {});
90
71
  }
91
72
  else if (presence === PortPresence.FOREIGN) {
92
73
  // Name the holder when we can. `doctor` exists for exactly this moment, and "another process"
@@ -94,18 +75,32 @@ export async function handleDoctor(port) {
94
75
  // pipeline that also kills the agent's own MCP proxy.
95
76
  // `pid` is what our pid file recorded for this port. When it matches the process actually
96
77
  // holding it, this is our OWN daemon wedged, not a stranger — and the fix is different.
97
- line(` daemon ✗ ${describeForeignHolder(port, findPortHolder(port, runCapture), pid)}`);
78
+ line(` daemon ✗ ${describeForeignHolder(port, findPortHolder(port, captureLookup), pid)}`);
98
79
  }
99
80
  else {
100
81
  line(` daemon ✗ not running on :${port} — your agent runs \`reticle mcp\` (or \`reticle serve\`)`);
101
82
  }
102
- line(` bridge port ${port} (your app must dial THIS port — not your dev-server port)`);
83
+ if (sessions !== undefined)
84
+ line(sessions.text);
85
+ line(` bridge port ${port} (your app must dial THIS port, not your dev-server port)`);
86
+ const projectPort = readProjectPort(process.cwd());
87
+ const mismatch = diagnosePortMismatch(port, projectPort);
88
+ if (mismatch !== undefined)
89
+ line(` port check ✗ ${mismatch}`);
103
90
  // Where to LOOK when something is wrong. The daemon has always written a structured log here and
104
91
  // nothing ever said so, so the first move in every investigation was reading source instead of
105
92
  // reading the log. `RETICLE_TRACE=1` turns the same stream into a per-stage trace — see
106
93
  // docs/debugging.md.
107
94
  line(` daemon log ${join(reticleStateHome(), `daemon-${String(port)}.log`)}`);
108
95
  line(` tracing ${ReticleEnv.TRACE}=1 on the daemon for per-stage timings in that log`);
96
+ // Below the checklist rather than inline: it is a paragraph, and a paragraph in the middle of a
97
+ // column of one-line checks buries the checks under it. This is the daemon's own no-session
98
+ // diagnosis, the same one an agent gets from an empty reticle_sessions.
99
+ if (sessions?.why !== undefined) {
100
+ line('');
101
+ line(' nothing has connected. What the daemon can tell from here:');
102
+ line(` ${sessions.why}`);
103
+ }
109
104
  // Desktop setup RCA. Every one of these fails SILENTLY — a Tauri app with the default CSP runs
110
105
  // perfectly and never connects; an Electron app without the preload line reports zero network
111
106
  // activity forever, which reads as "makes no backend calls" rather than "you are blind to them".
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `reticle kill` — free the bridge port without taking the agent's transport with it.
3
+ *
4
+ * The obvious incantation, `lsof -ti tcp:4400 | xargs kill -9`, also kills `reticle mcp`: the proxy
5
+ * holds a CLIENT socket on that port, `-ti` does not filter to listeners, and so the one command
6
+ * people reach for when the bridge is stuck destroys the channel they were trying to fix. What
7
+ * follows is worse than an error — the client gets no reply at all, and nothing reaches the proxy log
8
+ * because the process that writes it is the one that died. That is the mechanism behind most
9
+ * "my MCP disconnected" reports (#114, #110).
10
+ *
11
+ * The safe form was written down, and a documented incantation is a worse answer than a command: it
12
+ * has to be found, remembered, and typed correctly at the moment someone is already stuck. So this is
13
+ * the command, and it signals exactly one pid — the LISTENER.
14
+ *
15
+ * It is not a rename of `stop`. `stop` reads the pid file, which is the right source when the daemon
16
+ * we started is the daemon that is wedged, and no source at all when the pid file is stale, was never
17
+ * written, or belongs to a daemon from another checkout. `kill` asks the PORT, which is the thing the
18
+ * user actually wants freed, and falls back to the pid file only where the lookup cannot run.
19
+ */
20
+ import { type PortHolder } from './port-holder.js';
21
+ /** What the plan says to do with the port. */
22
+ export declare const KillAction: {
23
+ readonly NOTHING: "nothing";
24
+ readonly KILL: "kill";
25
+ readonly REFUSE: "refuse";
26
+ };
27
+ export type KillAction = (typeof KillAction)[keyof typeof KillAction];
28
+ export interface KillPlan {
29
+ action: KillAction;
30
+ /** The single pid to signal. Present only for `kill`. */
31
+ pid?: number;
32
+ /** True when the target was not identifiable as Reticle's and `--force` overrode the refusal. */
33
+ forced: boolean;
34
+ /** False when the listener lookup could not run, so the pid came from our own records instead. */
35
+ identifiedListener: boolean;
36
+ /** Why we will not act, for `refuse`. */
37
+ reason?: string;
38
+ holder?: PortHolder;
39
+ }
40
+ export declare const FORCE_FLAG = "--force";
41
+ /**
42
+ * Decide what to signal. Pure.
43
+ *
44
+ * `answersStatus` is the strongest evidence available and deliberately outranks the pid file: a
45
+ * daemon started from another checkout owns the port, is a Reticle daemon, and appears nowhere in our
46
+ * records. Refusing it would leave the port held by exactly the process this command exists to clear.
47
+ */
48
+ export declare function planKill(input: {
49
+ /** The listener on the port, from a `-sTCP:LISTEN` lookup. Null when there is none, or no `lsof`. */
50
+ listener: PortHolder | null;
51
+ /** The pid our records hold for this port, or null when there is none or it is dead. */
52
+ recordedPid: number | null;
53
+ /** Whether the port answers `/status`, i.e. whatever holds it is a Reticle daemon. */
54
+ answersStatus: boolean;
55
+ force: boolean;
56
+ }): KillPlan;
57
+ /** `reticle kill` — free the port. Resolves to whether the port is now free. */
58
+ export declare function runKill(port: number, force: boolean): Promise<boolean>;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `reticle kill` — free the bridge port without taking the agent's transport with it.
3
+ *
4
+ * The obvious incantation, `lsof -ti tcp:4400 | xargs kill -9`, also kills `reticle mcp`: the proxy
5
+ * holds a CLIENT socket on that port, `-ti` does not filter to listeners, and so the one command
6
+ * people reach for when the bridge is stuck destroys the channel they were trying to fix. What
7
+ * follows is worse than an error — the client gets no reply at all, and nothing reaches the proxy log
8
+ * because the process that writes it is the one that died. That is the mechanism behind most
9
+ * "my MCP disconnected" reports (#114, #110).
10
+ *
11
+ * The safe form was written down, and a documented incantation is a worse answer than a command: it
12
+ * has to be found, remembered, and typed correctly at the moment someone is already stuck. So this is
13
+ * the command, and it signals exactly one pid — the LISTENER.
14
+ *
15
+ * It is not a rename of `stop`. `stop` reads the pid file, which is the right source when the daemon
16
+ * we started is the daemon that is wedged, and no source at all when the pid file is stale, was never
17
+ * written, or belongs to a daemon from another checkout. `kill` asks the PORT, which is the thing the
18
+ * user actually wants freed, and falls back to the pid file only where the lookup cannot run.
19
+ */
20
+ import { PortPresence, probePresence } from '../daemon/port-presence.js';
21
+ import { isAlive, readPid, removePid } from '../daemon/daemon.js';
22
+ import { probeDaemon } from '../mcp/mcp-proxy.js';
23
+ import { log } from '../log.js';
24
+ import { fetchStatus } from './cli-launch.js';
25
+ import { captureLookup, findPortHolder } from './port-holder.js';
26
+ /** What the plan says to do with the port. */
27
+ export const KillAction = {
28
+ NOTHING: 'nothing',
29
+ KILL: 'kill',
30
+ REFUSE: 'refuse',
31
+ };
32
+ export const FORCE_FLAG = '--force';
33
+ /**
34
+ * Decide what to signal. Pure.
35
+ *
36
+ * `answersStatus` is the strongest evidence available and deliberately outranks the pid file: a
37
+ * daemon started from another checkout owns the port, is a Reticle daemon, and appears nowhere in our
38
+ * records. Refusing it would leave the port held by exactly the process this command exists to clear.
39
+ */
40
+ export function planKill(input) {
41
+ const { listener, recordedPid, answersStatus, force } = input;
42
+ if (null === listener) {
43
+ // No listener identified. On Windows and in slim containers that means the lookup could not run
44
+ // rather than that the port is free, so the recorded pid is the best target we have — and the
45
+ // report says which of the two it is rather than implying a lookup we never made.
46
+ if (null === recordedPid)
47
+ return { action: KillAction.NOTHING, forced: false, identifiedListener: false };
48
+ return { action: KillAction.KILL, pid: recordedPid, forced: false, identifiedListener: false };
49
+ }
50
+ const ours = answersStatus || listener.pid === recordedPid;
51
+ if (ours || force) {
52
+ return {
53
+ action: KillAction.KILL,
54
+ pid: listener.pid,
55
+ forced: !ours,
56
+ identifiedListener: true,
57
+ holder: listener,
58
+ };
59
+ }
60
+ return {
61
+ action: KillAction.REFUSE,
62
+ forced: false,
63
+ identifiedListener: true,
64
+ holder: listener,
65
+ reason: `pid ${String(listener.pid)} ("${listener.command}") is listening, and it is not a Reticle ` +
66
+ `daemon — it never answered /status and it is not the pid Reticle recorded. Killing it would ` +
67
+ `be killing someone else's process. Run it again with ${FORCE_FLAG} if that is what you ` +
68
+ `meant, or start Reticle on another port.`,
69
+ };
70
+ }
71
+ /** How long the target gets to exit on SIGTERM before we stop asking politely. */
72
+ const GRACEFUL_KILL_MS = 5_000;
73
+ /** How long after SIGKILL before we accept the process is not ours to kill and say so. */
74
+ const FORCED_KILL_MS = 2_000;
75
+ const KILL_POLL_MS = 100;
76
+ /**
77
+ * Signal ONE pid and wait for it to go: SIGTERM, then SIGKILL, then give up and say it survived.
78
+ *
79
+ * The escalation is the reason this exists rather than a bare SIGTERM — there is a field report of a
80
+ * daemon that ignored SIGTERM until a human ran `kill -9` by hand, which is the moment the `lsof -ti`
81
+ * pipeline gets typed and the agent's proxy dies with the daemon.
82
+ */
83
+ async function terminate(pid) {
84
+ const signal = (name) => {
85
+ try {
86
+ process.kill(pid, name);
87
+ }
88
+ catch {
89
+ // Gone between the liveness check and here. The next poll reports it as gone.
90
+ }
91
+ };
92
+ signal('SIGTERM');
93
+ const started = Date.now();
94
+ let escalated = false;
95
+ for (;;) {
96
+ await new Promise((resolve) => setTimeout(resolve, KILL_POLL_MS));
97
+ if (!isAlive(pid))
98
+ return { gone: true, escalated };
99
+ const waited = Date.now() - started;
100
+ if (!escalated && waited > GRACEFUL_KILL_MS) {
101
+ escalated = true;
102
+ signal('SIGKILL');
103
+ continue;
104
+ }
105
+ if (waited > GRACEFUL_KILL_MS + FORCED_KILL_MS)
106
+ return { gone: false, escalated };
107
+ }
108
+ }
109
+ /**
110
+ * What was NOT touched, said out loud on every kill.
111
+ *
112
+ * The command exists because of a pipeline that killed more than the reader intended and never told
113
+ * them, so a report that lists only the casualty repeats half the mistake. An `reticle mcp` proxy on
114
+ * this port survives, and its own recovery from a dead daemon is good: it goes dormant and restarts
115
+ * one on the next request.
116
+ */
117
+ const PROXY_SPARED_NOTE = 'only the listener was signalled. Any `reticle mcp` proxy on this port holds a client connection, ' +
118
+ 'not the port, and was left alone — it goes dormant and starts a fresh daemon on the next tool call.';
119
+ /** `reticle kill` — free the port. Resolves to whether the port is now free. */
120
+ export async function runKill(port, force) {
121
+ const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
122
+ const recorded = readPid(port);
123
+ const plan = planKill({
124
+ listener: findPortHolder(port, captureLookup),
125
+ recordedPid: null !== recorded && isAlive(recorded) ? recorded : null,
126
+ answersStatus: presence === PortPresence.DAEMON,
127
+ force,
128
+ });
129
+ if (KillAction.NOTHING === plan.action) {
130
+ removePid(port);
131
+ log('reticle_kill_nothing_to_do', { port, presence });
132
+ return true;
133
+ }
134
+ if (KillAction.REFUSE === plan.action) {
135
+ log('reticle_kill_refused', { port, reason: plan.reason, holder: plan.holder?.command });
136
+ return false;
137
+ }
138
+ const pid = plan.pid;
139
+ if (pid === undefined) {
140
+ // Unreachable by construction (planKill never returns KILL without a pid, and a test pins that),
141
+ // but the alternative to this branch is a non-null assertion, which this codebase does not use.
142
+ log('reticle_kill_nothing_to_do', { port, presence });
143
+ return true;
144
+ }
145
+ const { gone, escalated } = await terminate(pid);
146
+ if (gone)
147
+ removePid(port);
148
+ log(gone ? 'reticle_killed' : 'reticle_kill_survived', {
149
+ port,
150
+ pid,
151
+ escalated,
152
+ ...(plan.forced ? { forced: true } : {}),
153
+ // Say where the pid came from. Without `lsof` this is the pid we recorded, not the pid we
154
+ // observed listening, and those are different claims.
155
+ listenerIdentified: plan.identifiedListener,
156
+ note: gone
157
+ ? PROXY_SPARED_NOTE
158
+ : `pid ${String(pid)} survived SIGKILL, so it is not yours to kill (permissions) or is an ` +
159
+ `unkillable zombie. The port is still held. ${PROXY_SPARED_NOTE}`,
160
+ });
161
+ return gone;
162
+ }