@reticlehq/server 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/LICENSE-ENTERPRISE +1 -1
  2. package/README.md +3 -3
  3. package/SKILL.md +12 -14
  4. package/dist/chromium-launch-options.d.ts +4 -3
  5. package/dist/chromium-launch-options.js +6 -2
  6. package/dist/command/cli/adhoc-suite.d.ts +22 -0
  7. package/dist/command/cli/adhoc-suite.js +90 -19
  8. package/dist/command/cli/adhoc-verdict.d.ts +35 -0
  9. package/dist/command/cli/adhoc-verdict.js +90 -3
  10. package/dist/command/cli/answers/gate-hook-message.d.ts +6 -0
  11. package/dist/command/cli/answers/gate-hook-message.js +6 -0
  12. package/dist/command/cli/auth/cloud-keystore.d.ts +0 -2
  13. package/dist/command/cli/auth/cloud-keystore.js +0 -3
  14. package/dist/command/cli/auth/cloud-session.d.ts +4 -0
  15. package/dist/command/cli/auth/cloud-session.js +2 -0
  16. package/dist/command/cli/cli-doctor.js +7 -5
  17. package/dist/command/cli/cli-flow-commands.d.ts +3 -6
  18. package/dist/command/cli/cli-flow-commands.js +67 -4
  19. package/dist/command/cli/cli-parse.d.ts +8 -1
  20. package/dist/command/cli/cli-parse.js +21 -3
  21. package/dist/command/cli/cli-update-commands.js +8 -1
  22. package/dist/command/cli/cli-verify.d.ts +24 -3
  23. package/dist/command/cli/cli-verify.js +54 -11
  24. package/dist/command/cli/cloud-cli.js +5 -2
  25. package/dist/command/cli/cloud-kit.js +3 -5
  26. package/dist/command/cli/cloud-login.js +21 -3
  27. package/dist/command/cli/demo/demo-page.js +6 -3
  28. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +5 -0
  29. package/dist/command/cli/doctor/browser/chromium-hint.js +5 -0
  30. package/dist/command/cli/launch/cli-launch.d.ts +8 -0
  31. package/dist/command/cli/launch/cli-launch.js +21 -9
  32. package/dist/command/cli/launch/sdk-version-match.d.ts +84 -0
  33. package/dist/command/cli/launch/sdk-version-match.js +206 -0
  34. package/dist/command/cli/lifecycle/daemon-lifecycle.js +2 -16
  35. package/dist/command/cli/ports/resolve/cli-port.d.ts +25 -0
  36. package/dist/command/cli/ports/resolve/cli-port.js +125 -15
  37. package/dist/command/cli/setup-mcp-cli.js +13 -13
  38. package/dist/command/cli/tutorial.d.ts +2 -2
  39. package/dist/command/cli/tutorial.js +21 -7
  40. package/dist/command/cli.js +62 -10
  41. package/dist/command/daemon/binding/daemon-status-probe.d.ts +14 -2
  42. package/dist/command/daemon/binding/daemon-status-probe.js +34 -6
  43. package/dist/command/daemon/daemon-resolve.d.ts +9 -0
  44. package/dist/command/daemon/daemon-resolve.js +1 -1
  45. package/dist/command/daemon/lifetime/daemon-usefulness.d.ts +7 -1
  46. package/dist/command/daemon/lifetime/daemon-usefulness.js +30 -7
  47. package/dist/command/dev/stale-issue-guard.d.ts +3 -4
  48. package/dist/command/dev/stale-issue-guard.js +8 -8
  49. package/dist/command/setup/bringup/connect-lease.d.ts +17 -0
  50. package/dist/command/setup/bringup/connect-lease.js +38 -0
  51. package/dist/command/setup/bringup/dev-server-files.d.ts +68 -0
  52. package/dist/command/setup/bringup/dev-server-files.js +131 -0
  53. package/dist/command/setup/bringup/dev-server-log-cap.d.ts +35 -0
  54. package/dist/command/setup/bringup/dev-server-log-cap.js +121 -0
  55. package/dist/command/setup/bringup/ensure-daemon.d.ts +65 -4
  56. package/dist/command/setup/bringup/ensure-daemon.js +135 -16
  57. package/dist/command/setup/bringup/static-page-server.d.ts +35 -0
  58. package/dist/command/setup/bringup/static-page-server.js +151 -0
  59. package/dist/command/setup/desktop-shape.d.ts +4 -0
  60. package/dist/command/setup/desktop-shape.js +18 -2
  61. package/dist/command/setup/init/init-port.d.ts +15 -0
  62. package/dist/command/setup/init/init-port.js +44 -0
  63. package/dist/command/setup/init/init-runtime.d.ts +1 -1
  64. package/dist/command/setup/init/init-runtime.js +39 -2
  65. package/dist/command/setup/node-effects.d.ts +27 -14
  66. package/dist/command/setup/node-effects.js +80 -44
  67. package/dist/command/setup/probe/loopback-probe-urls.d.ts +7 -0
  68. package/dist/command/setup/probe/loopback-probe-urls.js +40 -0
  69. package/dist/command/setup/probe/page-probe.d.ts +13 -2
  70. package/dist/command/setup/probe/page-probe.js +18 -3
  71. package/dist/command/setup/run-setup.d.ts +26 -1
  72. package/dist/command/setup/run-setup.js +96 -13
  73. package/dist/command/setup/session-pick.d.ts +13 -0
  74. package/dist/command/setup/session-pick.js +20 -0
  75. package/dist/command/setup/setup-command.d.ts +2 -14
  76. package/dist/command/setup/setup-command.js +105 -31
  77. package/dist/command/setup/setup-mcp.js +3 -3
  78. package/dist/command/setup/terminal/wait-progress.d.ts +8 -0
  79. package/dist/command/setup/terminal/wait-progress.js +17 -0
  80. package/dist/features/crawl/crawl-empty.js +1 -1
  81. package/dist/features/crawl/crawl-tools.d.ts +6 -0
  82. package/dist/features/crawl/crawl-tools.js +57 -0
  83. package/dist/features/crawl/crawl.js +171 -157
  84. package/dist/features/exhaust/app-coverage.d.ts +33 -0
  85. package/dist/features/exhaust/app-coverage.js +31 -0
  86. package/dist/features/exhaust/code-coverage.d.ts +47 -0
  87. package/dist/features/exhaust/code-coverage.js +77 -0
  88. package/dist/features/exhaust/exhaust-run.d.ts +33 -0
  89. package/dist/features/exhaust/exhaust-run.js +82 -0
  90. package/dist/features/exhaust/explorer.d.ts +103 -0
  91. package/dist/features/exhaust/explorer.js +195 -0
  92. package/dist/features/exhaust/failure-branches.d.ts +33 -0
  93. package/dist/features/exhaust/failure-branches.js +92 -0
  94. package/dist/features/exhaust/ledger.d.ts +202 -0
  95. package/dist/features/exhaust/ledger.js +252 -0
  96. package/dist/features/exhaust/session-fold.d.ts +31 -0
  97. package/dist/features/exhaust/session-fold.js +79 -0
  98. package/dist/features/exhaust/session-port.d.ts +36 -0
  99. package/dist/features/exhaust/session-port.js +102 -0
  100. package/dist/features/harness/drive-report.d.ts +0 -9
  101. package/dist/features/harness/drive-report.js +18 -0
  102. package/dist/features/harness/driver.js +4 -7
  103. package/dist/features/harness/fill-values.d.ts +6 -0
  104. package/dist/features/harness/fill-values.js +3 -0
  105. package/dist/features/harness/jev-driver.js +30 -13
  106. package/dist/features/harness/openai-driver.js +4 -7
  107. package/dist/features/harness/platform-config.js +5 -6
  108. package/dist/index.d.ts +9 -0
  109. package/dist/index.js +40 -11
  110. package/dist/judgement/domain/domain-model.js +3 -1
  111. package/dist/judgement/oracles/self-instrument.js +4 -2
  112. package/dist/judgement/runs/artifact/replay-mapping.d.ts +2 -2
  113. package/dist/judgement/runs/artifact/replay-mapping.js +44 -9
  114. package/dist/judgement/runs/verification-sync.d.ts +3 -1
  115. package/dist/judgement/runs/verification-sync.js +1 -1
  116. package/dist/language/flows/change/affected.d.ts +9 -0
  117. package/dist/language/flows/change/affected.js +35 -0
  118. package/dist/language/flows/change/gate.d.ts +20 -0
  119. package/dist/language/flows/change/gate.js +12 -1
  120. package/dist/language/flows/flow-author.d.ts +7 -0
  121. package/dist/language/flows/flow-author.js +19 -0
  122. package/dist/language/flows/flow-expect-grammar.js +1 -1
  123. package/dist/language/flows/flow-journey.d.ts +54 -0
  124. package/dist/language/flows/flow-journey.js +119 -0
  125. package/dist/language/flows/flow-replay-run.d.ts +2 -3
  126. package/dist/language/flows/flow-replay-run.js +13 -47
  127. package/dist/language/flows/flow-replay.d.ts +22 -8
  128. package/dist/language/flows/flow-replay.js +55 -9
  129. package/dist/language/flows/flow-result.d.ts +2 -0
  130. package/dist/language/flows/flow-result.js +25 -0
  131. package/dist/language/flows/flow-tools.js +17 -5
  132. package/dist/language/flows/flows.d.ts +10 -1
  133. package/dist/language/flows/flows.js +90 -14
  134. package/dist/language/flows/recording/replay-halt.js +3 -1
  135. package/dist/language/flows/recording/tape/recordings.d.ts +30 -1
  136. package/dist/language/flows/recording/tape/recordings.js +55 -1
  137. package/dist/language/flows/replay.d.ts +19 -0
  138. package/dist/language/flows/replay.js +42 -6
  139. package/dist/language/flows/verify-change-tools.js +5 -2
  140. package/dist/launch-chromium.d.ts +80 -0
  141. package/dist/launch-chromium.js +218 -0
  142. package/dist/memory/cloud/cloud-config.d.ts +11 -2
  143. package/dist/memory/cloud/cloud-config.js +47 -7
  144. package/dist/memory/cloud/cloud-sync.d.ts +4 -1
  145. package/dist/memory/cloud/cloud-sync.js +6 -9
  146. package/dist/memory/cloud/harness-offer.js +5 -6
  147. package/dist/memory/cloud/harness-switch.js +5 -6
  148. package/dist/memory/cloud/sync-cycle.d.ts +68 -3
  149. package/dist/memory/cloud/sync-cycle.js +298 -55
  150. package/dist/memory/cloud/sync-daemon.js +24 -20
  151. package/dist/memory/cloud/sync-disk.js +16 -2
  152. package/dist/memory/intent/intent-shard.d.ts +10 -32
  153. package/dist/memory/intent/intent-shard.js +0 -10
  154. package/dist/memory/journal/drive-flow.d.ts +18 -13
  155. package/dist/memory/journal/drive-flow.js +45 -37
  156. package/dist/memory/journal/on-disk/workspace-tiers.js +2 -0
  157. package/dist/memory/journal/session-end.d.ts +9 -1
  158. package/dist/memory/journal/session-end.js +24 -12
  159. package/dist/memory/project/dir/reticle-dir.d.ts +2 -0
  160. package/dist/memory/project/dir/reticle-dir.js +1 -0
  161. package/dist/memory/project/project-tools.js +3 -1
  162. package/dist/portal/bridge/auth-failure-reason.d.ts +1 -1
  163. package/dist/portal/bridge/auth-failure-reason.js +4 -2
  164. package/dist/portal/bridge/bridge.d.ts +8 -0
  165. package/dist/portal/bridge/bridge.js +43 -2
  166. package/dist/portal/input/js-coverage.d.ts +25 -0
  167. package/dist/portal/input/js-coverage.js +30 -0
  168. package/dist/portal/input/real-input.d.ts +11 -0
  169. package/dist/portal/input/real-input.js +29 -4
  170. package/dist/portal/pool/playwright-launcher.d.ts +6 -0
  171. package/dist/portal/pool/playwright-launcher.js +20 -13
  172. package/dist/portal/session/dev-server/dev-command.d.ts +2 -2
  173. package/dist/portal/session/dev-server/dev-command.js +30 -12
  174. package/dist/portal/session/dev-server/dev-server-probe.js +17 -1
  175. package/dist/portal/session/dev-server/route-status-probe.d.ts +24 -0
  176. package/dist/portal/session/dev-server/route-status-probe.js +57 -0
  177. package/dist/portal/session/facts/observed-state.d.ts +11 -0
  178. package/dist/portal/session/facts/observed-state.js +34 -0
  179. package/dist/portal/session/no-session-diagnosis.d.ts +29 -0
  180. package/dist/portal/session/no-session-diagnosis.js +62 -5
  181. package/dist/portal/session/no-session-next-action.d.ts +13 -0
  182. package/dist/portal/session/no-session-next-action.js +48 -9
  183. package/dist/portal/session/no-session-watch.d.ts +18 -0
  184. package/dist/portal/session/no-session-watch.js +88 -2
  185. package/dist/portal/session/session-health.js +3 -2
  186. package/dist/portal/session/session-manager.d.ts +20 -6
  187. package/dist/portal/session/session-manager.js +19 -3
  188. package/dist/portal/session/session-reaper.d.ts +28 -0
  189. package/dist/portal/session/session-reaper.js +49 -1
  190. package/dist/portal/session/session.d.ts +5 -0
  191. package/dist/portal/session/session.js +15 -19
  192. package/dist/surface/http-server.d.ts +2 -1
  193. package/dist/surface/http-server.js +4 -2
  194. package/dist/surface/mcp/mcp-proxy.d.ts +2 -2
  195. package/dist/surface/mcp/mcp-proxy.js +2 -2
  196. package/dist/surface/mcp/mcp.js +4 -2
  197. package/dist/surface/mcp/proxy/proxy-daemon-probe.d.ts +13 -1
  198. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +27 -2
  199. package/dist/surface/mcp/server-instructions.js +9 -7
  200. package/dist/surface/tools/act/after-match.d.ts +17 -0
  201. package/dist/surface/tools/act/after-match.js +30 -0
  202. package/dist/surface/tools/act/durable.d.ts +69 -0
  203. package/dist/surface/tools/act/durable.js +139 -0
  204. package/dist/surface/tools/act/navigation/navigate-arrival.d.ts +13 -6
  205. package/dist/surface/tools/act/navigation/navigate-arrival.js +47 -10
  206. package/dist/surface/tools/act/navigation/navigate-result.d.ts +1 -12
  207. package/dist/surface/tools/act/navigation/navigate-result.js +21 -3
  208. package/dist/surface/tools/act/proved-controls.d.ts +9 -0
  209. package/dist/surface/tools/act/proved-controls.js +11 -0
  210. package/dist/surface/tools/act/sequence-grade.d.ts +5 -0
  211. package/dist/surface/tools/act/sequence-grade.js +32 -1
  212. package/dist/surface/tools/act-capsule.d.ts +7 -0
  213. package/dist/surface/tools/act-capsule.js +46 -1
  214. package/dist/surface/tools/act-sequence-tool.js +25 -8
  215. package/dist/surface/tools/act-tools.js +26 -50
  216. package/dist/surface/tools/assert/assert-grade.js +12 -1
  217. package/dist/surface/tools/assert/assert-verdict.js +2 -2
  218. package/dist/surface/tools/browser-tools.js +14 -2
  219. package/dist/surface/tools/coverage-tools.js +27 -1
  220. package/dist/surface/tools/error-recovery.js +4 -4
  221. package/dist/surface/tools/feedback-invite.js +6 -4
  222. package/dist/surface/tools/harness-explore.d.ts +3 -2
  223. package/dist/surface/tools/harness-explore.js +7 -6
  224. package/dist/surface/tools/lease-tools.js +15 -6
  225. package/dist/surface/tools/live-call-text.js +4 -1
  226. package/dist/surface/tools/observe-tools.js +9 -1
  227. package/dist/surface/tools/tools.js +4 -0
  228. package/dist/telemetry/argument-shape.d.ts +0 -2
  229. package/dist/telemetry/argument-shape.js +0 -4
  230. package/dist/telemetry/error-class.d.ts +2 -3
  231. package/dist/telemetry/error-class.js +2 -3
  232. package/dist/telemetry/harness-drive.d.ts +0 -2
  233. package/dist/telemetry/harness-drive.js +0 -4
  234. package/dist/wire-journal.d.ts +3 -0
  235. package/dist/wire-journal.js +4 -0
  236. package/dist/workspace-packages.d.ts +0 -2
  237. package/dist/workspace-packages.js +1 -9
  238. package/docs/README.md +4 -2
  239. package/docs/actions.mdx +3 -3
  240. package/docs/agent-cheatsheet.md +4 -4
  241. package/docs/architecture.md +6 -6
  242. package/docs/best-practices.mdx +1 -1
  243. package/docs/capabilities.mdx +32 -1
  244. package/docs/cli/cloud.mdx +14 -4
  245. package/docs/cli/doctor.mdx +4 -0
  246. package/docs/cli/feedback.mdx +2 -2
  247. package/docs/cli/gate.mdx +9 -4
  248. package/docs/cli/update.mdx +10 -0
  249. package/docs/cli.mdx +9 -0
  250. package/docs/desktop.mdx +14 -10
  251. package/docs/environment.mdx +87 -0
  252. package/docs/faq.mdx +18 -16
  253. package/docs/first-drive.md +5 -13
  254. package/docs/flows.md +21 -2
  255. package/docs/frameworks.mdx +29 -10
  256. package/docs/getting-started.md +3 -3
  257. package/docs/http-transport.md +6 -3
  258. package/docs/human-control.md +2 -2
  259. package/docs/index.mdx +16 -6
  260. package/docs/install-agentic.mdx +1 -1
  261. package/docs/install-manual.mdx +1 -1
  262. package/docs/integration-patterns.md +3 -3
  263. package/docs/multi-agent-testing.md +1 -1
  264. package/docs/packages/babel-plugin.mdx +1 -1
  265. package/docs/packages/browser.mdx +2 -2
  266. package/docs/packages/react.mdx +2 -2
  267. package/docs/packages/tauri.mdx +1 -1
  268. package/docs/packages/vite-plugin.mdx +2 -2
  269. package/docs/packages.mdx +9 -9
  270. package/docs/predicates.mdx +50 -5
  271. package/docs/quickstart.mdx +2 -2
  272. package/docs/recipes.mdx +2 -2
  273. package/docs/state-management.mdx +2 -2
  274. package/docs/system-map.md +1 -1
  275. package/docs/telemetry.md +1 -1
  276. package/docs/token-efficiency.md +3 -3
  277. package/docs/tools/act-and-wait.mdx +1 -0
  278. package/docs/tools/act-sequence.mdx +1 -1
  279. package/docs/tools/console.mdx +1 -1
  280. package/docs/tools/inspect.mdx +1 -1
  281. package/docs/tools/network.mdx +1 -1
  282. package/docs/tools/query.mdx +1 -1
  283. package/docs/tools/sessions.mdx +1 -1
  284. package/docs/tools/snapshot.mdx +1 -1
  285. package/docs/tools/state.mdx +1 -1
  286. package/docs/tools/wait-for.mdx +1 -1
  287. package/docs/usage.md +7 -6
  288. package/docs/use-cases.mdx +157 -0
  289. package/docs/vs-screenshots.mdx +3 -3
  290. package/docs/what-is-recorded.md +2 -2
  291. package/docs/what-is-reticle.mdx +17 -20
  292. package/docs/why-reticle.mdx +14 -21
  293. package/package.json +10 -9
  294. package/dist/judgement/capsule/minimize.d.ts +0 -12
  295. package/dist/judgement/capsule/minimize.js +0 -22
  296. package/dist/judgement/phenomena/phenomena.d.ts +0 -27
  297. package/dist/judgement/phenomena/phenomena.js +0 -101
  298. package/dist/language/flows/flow-report.d.ts +0 -22
  299. package/dist/language/flows/flow-report.js +0 -106
@@ -12,6 +12,13 @@ export { summarizeStatus } from '../../daemon/binding/daemon-status-probe.js';
12
12
  * another agent's session on a version bump, which is worse than a loud line.
13
13
  */
14
14
  export declare function warnOnDaemonSkew(port: number): Promise<void>;
15
+ /**
16
+ * The skew sentence for a daemon's `/status` body, or undefined when it speaks our contract.
17
+ *
18
+ * Shared with `init`, which must not ADOPT a skewed daemon: an older one refuses every hello from
19
+ * the page init just instrumented.
20
+ */
21
+ export declare function daemonSkew(status: unknown): string | undefined;
15
22
  /** What `reticle open` should do: reuse an already-connected tab, open a new one, or ask for a url. */
16
23
  type OpenDecision = {
17
24
  action: 'reuse';
@@ -48,6 +55,7 @@ export declare function decideOpen(all: {
48
55
  url: string;
49
56
  unresponsive?: boolean;
50
57
  hidden?: boolean;
58
+ lastSeenMs?: number;
51
59
  }[], url: string | undefined): OpenDecision;
52
60
  /** The OS command that opens a URL in the default browser, per platform. Pure — unit-tested. */
53
61
  export declare function openCommand(url: string, platform: NodeJS.Platform): {
@@ -1,6 +1,6 @@
1
1
  import { NodePlatform } from '../../../machine/platform.js';
2
2
  import { spawn } from 'node:child_process';
3
- import { isOpaqueOrigin } from '@reticlehq/core';
3
+ import { isOpaqueOrigin, SESSION_HEALTH } from '@reticlehq/core';
4
4
  import { daemonFix, describeSkew } from '../../version/version-skew.js';
5
5
  import { CONTRACT_FINGERPRINT } from '@reticlehq/core';
6
6
  import { SERVER_VERSION } from '../../version/identity/server-version.js';
@@ -29,17 +29,25 @@ function statusField(payload, key) {
29
29
  * another agent's session on a version bump, which is worse than a loud line.
30
30
  */
31
31
  export async function warnOnDaemonSkew(port) {
32
- const status = await fetchStatus(port);
32
+ const skew = daemonSkew(await fetchStatus(port));
33
+ if (skew !== undefined)
34
+ log('reticle_daemon_skew', { port, warning: skew });
35
+ }
36
+ /**
37
+ * The skew sentence for a daemon's `/status` body, or undefined when it speaks our contract.
38
+ *
39
+ * Shared with `init`, which must not ADOPT a skewed daemon: an older one refuses every hello from
40
+ * the page init just instrumented.
41
+ */
42
+ export function daemonSkew(status) {
33
43
  const daemonVersion = statusField(status, 'version');
34
- const skew = describeSkew({
44
+ return describeSkew({
35
45
  what: 'the daemon already running on this port',
36
46
  version: daemonVersion,
37
47
  contract: statusField(status, 'contract'),
38
48
  // The daemon is the peer here and this process is the agent side.
39
49
  fix: daemonFix(daemonVersion, SERVER_VERSION),
40
50
  }, { version: SERVER_VERSION, contract: CONTRACT_FINGERPRINT });
41
- if (skew !== undefined)
42
- log('reticle_daemon_skew', { port, warning: skew });
43
51
  }
44
52
  function sameOrigin(a, b) {
45
53
  try {
@@ -82,10 +90,14 @@ export function decideOpen(all, url) {
82
90
  // three checks that could never resolve. Opening the url instead costs one foreground tab and the
83
91
  // hidden one is left exactly where it was — nothing is navigated out from under anybody.
84
92
  //
85
- // HIDDEN, not `throttled`: throttled is `hidden || stale`, and a stale-but-visible tab is usually
86
- // a quiet page that drives fine. Excluding those would pile up a duplicate tab per run, which is
87
- // the thing the origin match below exists to prevent.
88
- const sessions = all.filter((s) => true !== s.unresponsive && true !== s.hidden);
93
+ // And a tab the SDK has gone SILENT in. The page heartbeats on a native timer every few seconds
94
+ // whatever it is doing, so a quiet page is not a silent one: past the daemon's own staleness
95
+ // threshold the page is frozen (a discarded tab, a sleeping machine, a wedged main thread). This
96
+ // looked only at the url, and `verify --expect` drove a tab last heard from 105s earlier into
97
+ // "command 'match' timed out after 8000ms" instead of opening the url it had been given.
98
+ const sessions = all.filter((s) => true !== s.unresponsive &&
99
+ true !== s.hidden &&
100
+ (s.lastSeenMs ?? 0) <= SESSION_HEALTH.STALE_THRESHOLD_MS);
89
101
  if (url === undefined) {
90
102
  const first = sessions[0];
91
103
  if (first !== undefined)
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Run the CLI whose major matches the SDK the project actually installed.
3
+ *
4
+ * The agent's MCP entry and every rule file `init` writes say `npx @reticlehq/server`, deliberately
5
+ * unpinned (see `npxServerArgs` in init), which resolves the LATEST server — while the project's SDK
6
+ * stays locked at whatever `init` installed. A project wired on the previous major therefore got a
7
+ * daemon from the next one, and every verdict came back `version_skew`; a global install from an
8
+ * older release shadows npx the same way in the other direction. Nothing the project could do fixed
9
+ * it short of upgrading, and the report named the skew without resolving it.
10
+ *
11
+ * So the one entry every command passes through asks, before doing anything: is the SDK in this
12
+ * project on my major? If not, it hands the same arguments to `npx @reticlehq/server@<that version>`
13
+ * and steps aside. Only across majors — a minor apart verifies fine, and re-running for it would
14
+ * cost an npx resolve on every command for nothing.
15
+ *
16
+ * Deliberately free of `@/` and relative imports: the stdio test runs this file in a bare Node child
17
+ * to see the real file descriptors, and that child has neither the alias nor the build.
18
+ */
19
+ import { type ChildProcess } from 'node:child_process';
20
+ /** The packages an instrumented project installs; they release in lockstep, so any one will do. */
21
+ export declare const SDK_PACKAGES: readonly ["@reticlehq/react", "@reticlehq/browser", "@reticlehq/next", "@reticlehq/vite-plugin"];
22
+ export declare const VersionMatchEnv: {
23
+ /** Set to anything to keep the CLI you ran, whatever the project installed. */
24
+ readonly OPT_OUT: "RETICLE_NO_VERSION_MATCH";
25
+ /** Set on the re-run child, so it can never re-run itself again. */
26
+ readonly REEXECUTED: "RETICLE_VERSION_MATCHED";
27
+ /** What npm puts here for `npx <spec>` — how a version typed on the npx line is recognised. */
28
+ readonly NPX_PACKAGE: "npm_config_package";
29
+ };
30
+ /**
31
+ * The SDK version installed for `projectDir`, found the way Node resolves it: the nearest
32
+ * `node_modules` at or above the directory, so a package hoisted to a workspace root counts.
33
+ */
34
+ export declare function installedSdkVersion(projectDir: string, readFile: (path: string) => string | undefined): string | undefined;
35
+ /** `readFile` for the real filesystem: the text, or undefined when there is nothing readable. */
36
+ export declare function readTextFile(path: string): string | undefined;
37
+ /** The one line a person sees on stderr when the CLI hands over — stdout belongs to the protocol. */
38
+ export declare function versionMatchNote(sdkVersion: string, cliVersion: string): string;
39
+ export interface VersionMatchInput {
40
+ argv: readonly string[];
41
+ cliVersion: string;
42
+ env: Readonly<Record<string, string | undefined>>;
43
+ projectDir: string;
44
+ readFile: (path: string) => string | undefined;
45
+ }
46
+ /**
47
+ * The version to re-run at, or undefined to carry on in this process. Pure: the filesystem and the
48
+ * environment are handed in.
49
+ */
50
+ export declare function versionToMatch(input: VersionMatchInput): string | undefined;
51
+ /** How to launch `npx --yes @reticlehq/server@<version> <args>` on this platform. */
52
+ export declare function npxInvocation(version: string, args: readonly string[], on: string): {
53
+ file: string;
54
+ argv: string[];
55
+ shell: boolean;
56
+ };
57
+ /** The two things the re-run needs from its child: when it ends, and a way to pass a signal on. */
58
+ export interface ReexecChild {
59
+ /** Called once, with the exit code — or undefined when the child never started or was killed. */
60
+ onEnd(listener: (code: number | undefined, error: Error | undefined) => void): void;
61
+ kill(signal: NodeJS.Signals): void;
62
+ }
63
+ export type ReexecSpawner = (file: string, argv: string[], options: {
64
+ stdio: 'inherit';
65
+ env: NodeJS.ProcessEnv;
66
+ shell: boolean;
67
+ }) => ReexecChild;
68
+ /** A ChildProcess, seen through `ReexecChild`. */
69
+ export declare function asReexecChild(child: ChildProcess): ReexecChild;
70
+ /** The real spawner. */
71
+ export declare const nodeSpawner: ReexecSpawner;
72
+ /**
73
+ * Hand this invocation to the matching version and exit with its code.
74
+ *
75
+ * `stdio: 'inherit'` gives the child this process's own file descriptors, so an MCP client's JSON-RPC
76
+ * stream reaches it without passing through a byte of JavaScript here — nothing to buffer, reframe or
77
+ * lose. For that to hold, this must run before anything writes to stdout.
78
+ */
79
+ export declare function reexecAtVersion(version: string, args: readonly string[], env: NodeJS.ProcessEnv, deps: {
80
+ spawn: ReexecSpawner;
81
+ exit: (code: number) => void;
82
+ platform?: string;
83
+ warn?: (line: string) => void;
84
+ }): void;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Run the CLI whose major matches the SDK the project actually installed.
3
+ *
4
+ * The agent's MCP entry and every rule file `init` writes say `npx @reticlehq/server`, deliberately
5
+ * unpinned (see `npxServerArgs` in init), which resolves the LATEST server — while the project's SDK
6
+ * stays locked at whatever `init` installed. A project wired on the previous major therefore got a
7
+ * daemon from the next one, and every verdict came back `version_skew`; a global install from an
8
+ * older release shadows npx the same way in the other direction. Nothing the project could do fixed
9
+ * it short of upgrading, and the report named the skew without resolving it.
10
+ *
11
+ * So the one entry every command passes through asks, before doing anything: is the SDK in this
12
+ * project on my major? If not, it hands the same arguments to `npx @reticlehq/server@<that version>`
13
+ * and steps aside. Only across majors — a minor apart verifies fine, and re-running for it would
14
+ * cost an npx resolve on every command for nothing.
15
+ *
16
+ * Deliberately free of `@/` and relative imports: the stdio test runs this file in a bare Node child
17
+ * to see the real file descriptors, and that child has neither the alias nor the build.
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import { readFileSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+ import { NodePlatform, windowsShellArg } from '@reticlehq/init';
23
+ /** The packages an instrumented project installs; they release in lockstep, so any one will do. */
24
+ export const SDK_PACKAGES = [
25
+ '@reticlehq/react',
26
+ '@reticlehq/browser',
27
+ '@reticlehq/next',
28
+ '@reticlehq/vite-plugin',
29
+ ];
30
+ /** The package that carries the `reticle` bin — what the re-run asks npx for. */
31
+ const SERVER_PACKAGE = '@reticlehq/server';
32
+ const NPX = 'npx';
33
+ const NPX_WINDOWS = 'npx.cmd';
34
+ const NPX_YES = '--yes';
35
+ const NODE_MODULES = 'node_modules';
36
+ const PACKAGE_JSON = 'package.json';
37
+ const LATEST_TAG = 'latest';
38
+ const MARKED = '1';
39
+ export const VersionMatchEnv = {
40
+ /** Set to anything to keep the CLI you ran, whatever the project installed. */
41
+ OPT_OUT: 'RETICLE_NO_VERSION_MATCH',
42
+ /** Set on the re-run child, so it can never re-run itself again. */
43
+ REEXECUTED: 'RETICLE_VERSION_MATCHED',
44
+ /** What npm puts here for `npx <spec>` — how a version typed on the npx line is recognised. */
45
+ NPX_PACKAGE: 'npm_config_package',
46
+ };
47
+ /**
48
+ * Commands whose job is to CHANGE the version, or to report on this binary. Re-running `init` or
49
+ * `update` at the project's old version would undo exactly what the person asked for; `version` and
50
+ * `help` describe the binary they ran. `_daemon` is spawned by a CLI that already decided.
51
+ */
52
+ const KEEP_COMMANDS = new Set([
53
+ 'init',
54
+ 'update',
55
+ 'rollback',
56
+ 'version',
57
+ 'help',
58
+ '_daemon',
59
+ ]);
60
+ const KEEP_FLAGS = new Set(['--version', '-v', '--help', '-h']);
61
+ const SETUP_COMMAND = 'setup';
62
+ const SETUP_INSTALL = 'install';
63
+ /** A published release — not `workspace:*`, a link, or a tag npx could not pin. */
64
+ const RELEASE_VERSION = /^(\d+)\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
65
+ function majorOf(version) {
66
+ return RELEASE_VERSION.exec(version)?.[1];
67
+ }
68
+ /**
69
+ * The SDK version installed for `projectDir`, found the way Node resolves it: the nearest
70
+ * `node_modules` at or above the directory, so a package hoisted to a workspace root counts.
71
+ */
72
+ export function installedSdkVersion(projectDir, readFile) {
73
+ let dir = projectDir;
74
+ for (;;) {
75
+ for (const pkg of SDK_PACKAGES) {
76
+ const raw = readFile(join(dir, NODE_MODULES, pkg, PACKAGE_JSON));
77
+ if (raw === undefined)
78
+ continue;
79
+ try {
80
+ const version = JSON.parse(raw).version;
81
+ if ('string' === typeof version)
82
+ return version;
83
+ }
84
+ catch {
85
+ // A manifest that will not parse is no evidence; try the next package.
86
+ }
87
+ }
88
+ const parent = dirname(dir);
89
+ if (parent === dir)
90
+ return undefined;
91
+ dir = parent;
92
+ }
93
+ }
94
+ /** `readFile` for the real filesystem: the text, or undefined when there is nothing readable. */
95
+ export function readTextFile(path) {
96
+ try {
97
+ return readFileSync(path, 'utf8');
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ /** The one line a person sees on stderr when the CLI hands over — stdout belongs to the protocol. */
104
+ export function versionMatchNote(sdkVersion, cliVersion) {
105
+ return (`This project's Reticle SDK is ${sdkVersion}; running ${SERVER_PACKAGE}@${sdkVersion} to match ` +
106
+ `(this CLI is ${cliVersion}, a different major). Set ${VersionMatchEnv.OPT_OUT}=1 to keep this one.`);
107
+ }
108
+ /** Did the person name a version on the npx line (`npx @reticlehq/server@<version> …`)? */
109
+ function pinnedOnNpxLine(spec) {
110
+ if (spec === undefined || !spec.startsWith(`${SERVER_PACKAGE}@`))
111
+ return false;
112
+ return spec.slice(SERVER_PACKAGE.length + 1) !== LATEST_TAG;
113
+ }
114
+ function keepsThisBinary(argv) {
115
+ const [command, sub] = argv;
116
+ if (command !== undefined && KEEP_COMMANDS.has(command))
117
+ return true;
118
+ if (SETUP_COMMAND === command && SETUP_INSTALL === sub)
119
+ return true;
120
+ return argv.some((arg) => KEEP_FLAGS.has(arg));
121
+ }
122
+ /**
123
+ * The version to re-run at, or undefined to carry on in this process. Pure: the filesystem and the
124
+ * environment are handed in.
125
+ */
126
+ export function versionToMatch(input) {
127
+ const { env } = input;
128
+ if (env[VersionMatchEnv.REEXECUTED] !== undefined)
129
+ return undefined;
130
+ if (env[VersionMatchEnv.OPT_OUT] !== undefined)
131
+ return undefined;
132
+ if (pinnedOnNpxLine(env[VersionMatchEnv.NPX_PACKAGE]))
133
+ return undefined;
134
+ if (keepsThisBinary(input.argv))
135
+ return undefined;
136
+ const sdk = installedSdkVersion(input.projectDir, input.readFile);
137
+ if (sdk === undefined)
138
+ return undefined;
139
+ const sdkMajor = majorOf(sdk);
140
+ if (sdkMajor === undefined)
141
+ return undefined;
142
+ return sdkMajor === majorOf(input.cliVersion) ? undefined : sdk;
143
+ }
144
+ /** How to launch `npx --yes @reticlehq/server@<version> <args>` on this platform. */
145
+ export function npxInvocation(version, args, on) {
146
+ const argv = [NPX_YES, `${SERVER_PACKAGE}@${version}`, ...args];
147
+ // A `.cmd` cannot be spawned without a shell since the CVE-2024-27980 fix, and a shell needs every
148
+ // argument quoted — the same rule `npmSpawn` in the updater follows.
149
+ if (NodePlatform.WINDOWS !== on)
150
+ return { file: NPX, argv, shell: false };
151
+ return { file: [NPX_WINDOWS, ...argv.map(windowsShellArg)].join(' '), argv: [], shell: true };
152
+ }
153
+ /** A ChildProcess, seen through `ReexecChild`. */
154
+ export function asReexecChild(child) {
155
+ return {
156
+ onEnd: (listener) => {
157
+ // Node can emit `error` AND `exit` for one child; the listener exits the process, so once.
158
+ let ended = false;
159
+ const end = (code, error) => {
160
+ if (ended)
161
+ return;
162
+ ended = true;
163
+ listener(code, error);
164
+ };
165
+ child.once('error', (error) => end(undefined, error));
166
+ child.once('exit', (code) => end(code ?? undefined, undefined));
167
+ },
168
+ kill: (signal) => {
169
+ child.kill(signal);
170
+ },
171
+ };
172
+ }
173
+ /** The real spawner. */
174
+ export const nodeSpawner = (file, argv, options) => asReexecChild(spawn(file, argv, options));
175
+ const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
176
+ const SPAWN_FAILED_EXIT = 1;
177
+ /**
178
+ * Hand this invocation to the matching version and exit with its code.
179
+ *
180
+ * `stdio: 'inherit'` gives the child this process's own file descriptors, so an MCP client's JSON-RPC
181
+ * stream reaches it without passing through a byte of JavaScript here — nothing to buffer, reframe or
182
+ * lose. For that to hold, this must run before anything writes to stdout.
183
+ */
184
+ export function reexecAtVersion(version, args, env, deps) {
185
+ const inv = npxInvocation(version, args, deps.platform ?? process.platform);
186
+ const child = deps.spawn(inv.file, inv.argv, {
187
+ stdio: 'inherit',
188
+ env: { ...env, [VersionMatchEnv.REEXECUTED]: MARKED },
189
+ shell: inv.shell,
190
+ });
191
+ // A client that stops the proxy signals THIS pid; the real server is the child.
192
+ const forward = (signal) => {
193
+ child.kill(signal);
194
+ };
195
+ for (const signal of FORWARDED_SIGNALS)
196
+ process.on(signal, forward);
197
+ child.onEnd((code, error) => {
198
+ for (const signal of FORWARDED_SIGNALS)
199
+ process.off(signal, forward);
200
+ if (error !== undefined) {
201
+ deps.warn?.(`Could not start ${SERVER_PACKAGE}@${version} to match this project's SDK (${error.message}). ` +
202
+ `Set ${VersionMatchEnv.OPT_OUT}=1 to use the installed CLI instead.`);
203
+ }
204
+ deps.exit(code ?? SPAWN_FAILED_EXIT);
205
+ });
206
+ }
@@ -17,7 +17,7 @@
17
17
  import { log } from '../../../log.js';
18
18
  import { RETICLE_VERIFY_DEFAULT_PORT } from '../../../index.js';
19
19
  import { verifyEndpointMismatch } from '../../../status-payload.js';
20
- import { probeDaemon } from '../../../surface/mcp/mcp-proxy.js';
20
+ import { probeDaemon, waitForDaemonBind } from '../../../surface/mcp/mcp-proxy.js';
21
21
  import { stateDirProblem } from '../../daemon/state-dir.js';
22
22
  import { readPid, isAlive, removePid, spawnDaemon, logPath, reticleStateHome, } from '../../daemon/daemon.js';
23
23
  import { readDaemonStartupCause } from '../../daemon/lifetime/startup-failure.js';
@@ -84,7 +84,7 @@ async function serveWithHonestExit(parsed) {
84
84
  // The child binds asynchronously and, when it cannot, exits 1 long after this process would have
85
85
  // reported success. Wait for it to ANSWER — `/status` responding is the only evidence a daemon
86
86
  // exists that does not come from the pid file the child may never have earned.
87
- const bound = await waitForPresence(parsed.port, PortPresence.DAEMON, SERVE_BIND_TIMEOUT_MS);
87
+ const bound = await waitForDaemonBind(parsed.port);
88
88
  if (!bound) {
89
89
  const settled = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
90
90
  const startupCause = readDaemonStartupCause(logPath(parsed.port), startupStartedAt);
@@ -113,24 +113,10 @@ async function serveWithHonestExit(parsed) {
113
113
  }
114
114
  log('reticle_daemon_spawned', { port: parsed.port, ...(parsed.http ? { http: true } : {}) });
115
115
  }
116
- /** How long `serve` waits for the child to bind. Generous: a cold daemon start is seconds. */
117
- const SERVE_BIND_TIMEOUT_MS = 15_000;
118
- const PRESENCE_POLL_MS = 150;
119
116
  /** How long a daemon gets to shut down on its own before `stop` stops asking politely. */
120
117
  const GRACEFUL_STOP_MS = 5000;
121
118
  /** How long after SIGKILL before we accept the process is not ours to kill and say so. */
122
119
  const FORCED_STOP_MS = 2000;
123
- async function waitForPresence(port, want, timeoutMs) {
124
- const deadline = Date.now() + timeoutMs;
125
- for (;;) {
126
- const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
127
- if (presence === want)
128
- return true;
129
- if (Date.now() >= deadline)
130
- return false;
131
- await new Promise((resolve) => setTimeout(resolve, PRESENCE_POLL_MS));
132
- }
133
- }
134
120
  export function handleStop(port, quiet) {
135
121
  const pid = readPid(port);
136
122
  if (null === pid || !isAlive(pid)) {
@@ -23,6 +23,13 @@ export declare const RETICLE_CONFIG_BASENAME = ".reticle.json";
23
23
  * Returns undefined when nothing is found, which keeps every caller's existing default intact.
24
24
  */
25
25
  export declare function findProjectConfig(cwd: string): Record<string, unknown> | undefined;
26
+ /**
27
+ * The directory of the project this invocation serves: where its `.reticle.json` is, else the one
28
+ * wired workspace app below a monorepo root, else `cwd` itself. What the CLI reads the installed SDK
29
+ * from, so it answers for the app the port above belongs to rather than for the directory it was
30
+ * started in.
31
+ */
32
+ export declare function projectDirOf(cwd: string): string;
26
33
  /**
27
34
  * The dev-server port heuristics live in `@reticlehq/init` — `existing-config.ts` is what diagnoses
28
35
  * a `.reticle.json` whose `port` is the app's own dev-server port, and the scaffolder may not import
@@ -33,8 +40,20 @@ export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning } from '@
33
40
  /**
34
41
  * Read the port stored in the project's .reticle.json (written by `reticle init`).
35
42
  * Returns undefined if the file is absent, unreadable, or has no valid numeric port.
43
+ *
44
+ * With no config at or above `cwd`, it looks DOWN, the same way `projectIdsAt` does. Running from a
45
+ * monorepo root whose app was wired in `apps/web` used to find nothing and fall back to the default
46
+ * port, so `status`, `mcp` and `verify` quietly talked to another project's daemon. One wired app
47
+ * below, or several that agree, give their port; several that disagree give none, and
48
+ * `workspacePortConflict` is what says so rather than letting the default win in silence.
36
49
  */
37
50
  export declare function readProjectPort(cwd: string): number | undefined;
51
+ /**
52
+ * The sentence for a monorepo root whose wired apps name different ports, or undefined when there is
53
+ * nothing to choose between. `readProjectPort` refuses to pick one; this is what tells the person
54
+ * which ports exist and how to name the one they meant.
55
+ */
56
+ export declare function workspacePortConflict(cwd: string): string | undefined;
38
57
  /**
39
58
  * Read the stable projectId stored in the project's .reticle.json (written by `reticle init`). The daemon
40
59
  * uses it as the default resolve scope so auto-selection stays within the active app. Returns
@@ -63,6 +82,12 @@ export declare function projectIdsAt(cwd: string): ProjectId[];
63
82
  * time and the hint the agent reads hours later never mentioned it.
64
83
  */
65
84
  export declare function readProjectFramework(cwd: string): string | undefined;
85
+ /**
86
+ * Whether the project this directory serves is a desktop shell (Electron, Tauri) — the same question
87
+ * `doctor` asks, answered from the same files. Read so the no-session guidance stops sending a
88
+ * desktop user to a browser URL.
89
+ */
90
+ export declare function readProjectIsDesktop(cwd: string): boolean;
66
91
  /**
67
92
  * Whether the durable causal journal is enabled. On by default (the journal IS the loop); off only via
68
93
  * explicit opt-out — `.reticle.json` `"journal": false`, or `RETICLE_JOURNAL` set to `0`/`false`. The env
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { dirname, join, resolve } from 'node:path';
12
- import { SILENT_HOST, buildNodeIo, deriveProjectId, findWorkspaceApps, packageName, } from '@reticlehq/init';
12
+ import { SILENT_HOST, buildNodeIo, deriveProjectId, findWorkspaceApps, isDesktopProject, packageName, } from '@reticlehq/init';
13
13
  import { asProjectId } from '@reticlehq/core';
14
14
  /** The project config `reticle init` writes. */
15
15
  export const RETICLE_CONFIG_BASENAME = '.reticle.json';
@@ -35,18 +35,15 @@ const MAX_CONFIG_SEARCH_DEPTH = 6;
35
35
  * Returns undefined when nothing is found, which keeps every caller's existing default intact.
36
36
  */
37
37
  export function findProjectConfig(cwd) {
38
+ const dir = findProjectConfigDir(cwd);
39
+ return dir === undefined ? undefined : readConfigAt(dir);
40
+ }
41
+ /** The directory holding the nearest `.reticle.json` at or above `cwd`, by the same walk. */
42
+ function findProjectConfigDir(cwd) {
38
43
  let dir = resolve(cwd);
39
44
  for (let depth = 0; depth <= MAX_CONFIG_SEARCH_DEPTH; depth += 1) {
40
- try {
41
- const raw = readFileSync(`${dir}/${RETICLE_CONFIG_BASENAME}`, 'utf8');
42
- const config = JSON.parse(raw);
43
- if ('object' === typeof config && config !== null && !Array.isArray(config)) {
44
- return config;
45
- }
46
- }
47
- catch {
48
- // Absent, unreadable, or not JSON at this level — keep walking.
49
- }
45
+ if (readConfigAt(dir) !== undefined)
46
+ return dir;
50
47
  const parent = dirname(dir);
51
48
  if (parent === dir)
52
49
  break;
@@ -54,6 +51,48 @@ export function findProjectConfig(cwd) {
54
51
  }
55
52
  return undefined;
56
53
  }
54
+ /**
55
+ * The directory of the project this invocation serves: where its `.reticle.json` is, else the one
56
+ * wired workspace app below a monorepo root, else `cwd` itself. What the CLI reads the installed SDK
57
+ * from, so it answers for the app the port above belongs to rather than for the directory it was
58
+ * started in.
59
+ */
60
+ export function projectDirOf(cwd) {
61
+ const configDir = findProjectConfigDir(cwd);
62
+ if (configDir !== undefined)
63
+ return appPointedAtBy(configDir) ?? configDir;
64
+ const apps = workspaceAppPorts(cwd);
65
+ const only = 1 === apps.length ? apps[0] : undefined;
66
+ return only === undefined ? resolve(cwd) : join(resolve(cwd), only.app);
67
+ }
68
+ /**
69
+ * The one workspace app below `dir` whose own config names the project `dir`'s does, or undefined.
70
+ *
71
+ * `init` writes a copy of the app's `.reticle.json` at the root the agent runs from, so the root's
72
+ * config is a pointer to that app rather than a project of its own. The projectId is the link — no
73
+ * path is stored — and a root whose id no app shares, or several do, is its own project.
74
+ */
75
+ function appPointedAtBy(dir) {
76
+ const id = readConfigAt(dir)?.['projectId'];
77
+ if ('string' !== typeof id || 0 === id.length)
78
+ return undefined;
79
+ const named = workspaceApps(dir).filter((app) => id === readConfigAt(join(dir, app))?.['projectId']);
80
+ const only = 1 === named.length ? named[0] : undefined;
81
+ return only === undefined ? undefined : join(dir, only);
82
+ }
83
+ /** The `.reticle.json` in exactly `dir` — no walk — or undefined when absent or not an object. */
84
+ function readConfigAt(dir) {
85
+ try {
86
+ const config = JSON.parse(readFileSync(join(dir, RETICLE_CONFIG_BASENAME), 'utf8'));
87
+ if ('object' === typeof config && config !== null && !Array.isArray(config)) {
88
+ return config;
89
+ }
90
+ }
91
+ catch {
92
+ // Absent, unreadable, or not JSON at this level.
93
+ }
94
+ return undefined;
95
+ }
57
96
  /**
58
97
  * The dev-server port heuristics live in `@reticlehq/init` — `existing-config.ts` is what diagnoses
59
98
  * a `.reticle.json` whose `port` is the app's own dev-server port, and the scaffolder may not import
@@ -61,15 +100,70 @@ export function findProjectConfig(cwd) {
61
100
  * diagnosis, and `cli.ts`) are unchanged.
62
101
  */
63
102
  export { DEV_SERVER_PORTS, isLikelyDevServerPort, devServerPortWarning } from '@reticlehq/init';
103
+ /** A config's `port`, when it is a usable TCP port. */
104
+ function configPort(config) {
105
+ const p = config?.['port'];
106
+ if ('number' === typeof p && Number.isInteger(p) && p > 0 && p < 65536)
107
+ return p;
108
+ return undefined;
109
+ }
64
110
  /**
65
111
  * Read the port stored in the project's .reticle.json (written by `reticle init`).
66
112
  * Returns undefined if the file is absent, unreadable, or has no valid numeric port.
113
+ *
114
+ * With no config at or above `cwd`, it looks DOWN, the same way `projectIdsAt` does. Running from a
115
+ * monorepo root whose app was wired in `apps/web` used to find nothing and fall back to the default
116
+ * port, so `status`, `mcp` and `verify` quietly talked to another project's daemon. One wired app
117
+ * below, or several that agree, give their port; several that disagree give none, and
118
+ * `workspacePortConflict` is what says so rather than letting the default win in silence.
67
119
  */
68
120
  export function readProjectPort(cwd) {
69
- const p = findProjectConfig(cwd)?.['port'];
70
- if ('number' === typeof p && Number.isInteger(p) && p > 0 && p < 65536)
71
- return p;
72
- return undefined;
121
+ const config = findProjectConfig(cwd);
122
+ if (config !== undefined)
123
+ return configPort(config);
124
+ const ports = new Set(workspaceAppPorts(cwd).map((a) => a.port));
125
+ return 1 === ports.size ? [...ports][0] : undefined;
126
+ }
127
+ /** Each workspace app under `cwd` whose own `.reticle.json` records a port. */
128
+ function workspaceAppPorts(cwd) {
129
+ const found = [];
130
+ for (const app of workspaceApps(cwd)) {
131
+ // The app's OWN file only — walking up from an unwired sibling would reach the root, or past it.
132
+ const port = configPort(readConfigAt(join(cwd, app)));
133
+ if (port !== undefined)
134
+ found.push({ app, port });
135
+ }
136
+ return found;
137
+ }
138
+ /** init's workspace discovery under `cwd`, relative app directories; empty when `cwd` is no package. */
139
+ function workspaceApps(cwd) {
140
+ // Only from a directory that is itself a package: a root with no package.json is not a workspace,
141
+ // and scanning, say, a home directory would adopt the port of whichever checkout happened to sit
142
+ // there.
143
+ if (packageNameAt(cwd) === undefined)
144
+ return [];
145
+ try {
146
+ return findWorkspaceApps(buildNodeIo(cwd, SILENT_HOST));
147
+ }
148
+ catch {
149
+ // Discovery touches the filesystem; an unreadable tree is simply no further evidence.
150
+ return [];
151
+ }
152
+ }
153
+ /**
154
+ * The sentence for a monorepo root whose wired apps name different ports, or undefined when there is
155
+ * nothing to choose between. `readProjectPort` refuses to pick one; this is what tells the person
156
+ * which ports exist and how to name the one they meant.
157
+ */
158
+ export function workspacePortConflict(cwd) {
159
+ if (findProjectConfig(cwd) !== undefined)
160
+ return undefined;
161
+ const apps = workspaceAppPorts(cwd);
162
+ if (new Set(apps.map((a) => a.port)).size < 2)
163
+ return undefined;
164
+ const listed = apps.map((a) => `${a.app} (:${String(a.port)})`).join(', ');
165
+ return (`This workspace has more than one app wired for Reticle, on different ports: ${listed}. ` +
166
+ 'Not guessing which one you meant — run the command from inside that app, or pass --port.');
73
167
  }
74
168
  /**
75
169
  * Read the stable projectId stored in the project's .reticle.json (written by `reticle init`). The daemon
@@ -142,6 +236,22 @@ export function readProjectFramework(cwd) {
142
236
  return framework;
143
237
  return undefined;
144
238
  }
239
+ /**
240
+ * Whether the project this directory serves is a desktop shell (Electron, Tauri) — the same question
241
+ * `doctor` asks, answered from the same files. Read so the no-session guidance stops sending a
242
+ * desktop user to a browser URL.
243
+ */
244
+ export function readProjectIsDesktop(cwd) {
245
+ const dir = projectDirOf(cwd);
246
+ return isDesktopProject((relative) => {
247
+ try {
248
+ return readFileSync(join(dir, relative), 'utf8');
249
+ }
250
+ catch {
251
+ return undefined;
252
+ }
253
+ });
254
+ }
145
255
  /**
146
256
  * Whether the durable causal journal is enabled. On by default (the journal IS the loop); off only via
147
257
  * explicit opt-out — `.reticle.json` `"journal": false`, or `RETICLE_JOURNAL` set to `0`/`false`. The env