@reticlehq/server 3.8.2 → 3.9.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 (224) hide show
  1. package/LICENSE +201 -104
  2. package/NOTICE +5 -0
  3. package/README.md +1 -1
  4. package/bin/launch.js +13 -0
  5. package/bin/package.json +3 -0
  6. package/bin/reticle.js +66 -8
  7. package/dist/chromium-launch-options.d.ts +25 -0
  8. package/dist/chromium-launch-options.js +21 -1
  9. package/dist/command/cli/adhoc-verdict.js +34 -7
  10. package/dist/command/cli/answers/gate-exit.d.ts +6 -2
  11. package/dist/command/cli/answers/gate-exit.js +12 -2
  12. package/dist/command/cli/ci-hint.d.ts +44 -0
  13. package/dist/command/cli/ci-hint.js +72 -0
  14. package/dist/command/cli/cli-doctor.d.ts +6 -1
  15. package/dist/command/cli/cli-doctor.js +45 -4
  16. package/dist/command/cli/cli-flow-commands.d.ts +7 -1
  17. package/dist/command/cli/cli-flow-commands.js +39 -17
  18. package/dist/command/cli/cli-kill.d.ts +21 -0
  19. package/dist/command/cli/cli-kill.js +46 -3
  20. package/dist/command/cli/cli-parse.d.ts +5 -2
  21. package/dist/command/cli/cli-parse.js +10 -5
  22. package/dist/command/cli/cli-usage.js +3 -2
  23. package/dist/command/cli/cli-verify.d.ts +6 -1
  24. package/dist/command/cli/cli-verify.js +32 -13
  25. package/dist/command/cli/doctor/browser/chromium-hint.d.ts +6 -1
  26. package/dist/command/cli/doctor/browser/chromium-hint.js +11 -5
  27. package/dist/command/cli/doctor/doctor-fix.d.ts +44 -0
  28. package/dist/command/cli/doctor/doctor-fix.js +89 -0
  29. package/dist/command/cli/doctor/doctor-launcher-line.d.ts +23 -0
  30. package/dist/command/cli/doctor/doctor-launcher-line.js +60 -0
  31. package/dist/command/cli/doctor/doctor-rows.d.ts +4 -0
  32. package/dist/command/cli/doctor/doctor-rows.js +4 -0
  33. package/dist/command/cli/doctor-repairs.d.ts +64 -0
  34. package/dist/command/cli/doctor-repairs.js +214 -0
  35. package/dist/command/cli/drive/drive-command.js +3 -7
  36. package/dist/command/cli/first-use-checks.d.ts +41 -0
  37. package/dist/command/cli/first-use-checks.js +127 -0
  38. package/dist/command/cli/first-verdict-node.d.ts +3 -0
  39. package/dist/command/cli/first-verdict-node.js +189 -0
  40. package/dist/command/cli/first-verdict.d.ts +126 -0
  41. package/dist/command/cli/first-verdict.js +267 -0
  42. package/dist/command/cli/launch/sdk-version-match.d.ts +3 -2
  43. package/dist/command/cli/launch/sdk-version-match.js +3 -2
  44. package/dist/command/cli/lifecycle/daemon-lifecycle.js +6 -5
  45. package/dist/command/cli/mcp-command.js +13 -4
  46. package/dist/command/cli/ports/port-holder.d.ts +2 -0
  47. package/dist/command/cli/ports/port-holder.js +11 -3
  48. package/dist/command/cli/setup-mcp-cli.d.ts +27 -1
  49. package/dist/command/cli/setup-mcp-cli.js +98 -23
  50. package/dist/command/cli/status/status-lines.d.ts +1 -0
  51. package/dist/command/cli/status/status-lines.js +50 -17
  52. package/dist/command/cli/status-fields.js +17 -3
  53. package/dist/command/cli/try-command.d.ts +7 -0
  54. package/dist/command/cli/try-command.js +11 -3
  55. package/dist/command/cli.js +70 -33
  56. package/dist/command/daemon/binding/daemon-status-probe.d.ts +3 -1
  57. package/dist/command/daemon/binding/daemon-status-probe.js +4 -2
  58. package/dist/command/daemon/daemon-argv.d.ts +9 -0
  59. package/dist/command/daemon/daemon-argv.js +11 -0
  60. package/dist/command/daemon/daemon.d.ts +10 -3
  61. package/dist/command/daemon/daemon.js +17 -6
  62. package/dist/command/daemon/lifetime/daemon-exit.d.ts +24 -0
  63. package/dist/command/daemon/lifetime/daemon-exit.js +39 -0
  64. package/dist/command/daemon/lifetime/death-cause.d.ts +15 -0
  65. package/dist/command/daemon/lifetime/death-cause.js +130 -0
  66. package/dist/command/daemon/lifetime/heartbeat.d.ts +7 -15
  67. package/dist/command/daemon/lifetime/heartbeat.js +4 -13
  68. package/dist/command/daemon/port-state.d.ts +14 -0
  69. package/dist/command/daemon/port-state.js +31 -0
  70. package/dist/command/setup/agent-configs.d.ts +2 -0
  71. package/dist/command/setup/agent-configs.js +42 -15
  72. package/dist/command/setup/agent-next-steps.d.ts +77 -0
  73. package/dist/command/setup/agent-next-steps.js +284 -0
  74. package/dist/command/setup/agent-writer.js +11 -6
  75. package/dist/command/setup/init/init-host.js +2 -0
  76. package/dist/command/setup/init/init-runtime.d.ts +6 -1
  77. package/dist/command/setup/init/init-runtime.js +57 -21
  78. package/dist/command/setup/mcp-launcher.d.ts +3 -0
  79. package/dist/command/setup/mcp-launcher.js +11 -0
  80. package/dist/command/setup/setup-command.js +30 -12
  81. package/dist/command/setup/setup-mcp.d.ts +8 -0
  82. package/dist/command/setup/setup-mcp.js +47 -20
  83. package/dist/command/setup/start-dev-server.d.ts +8 -0
  84. package/dist/command/setup/start-dev-server.js +43 -0
  85. package/dist/command/version/identity/server-version.d.ts +2 -0
  86. package/dist/command/version/identity/server-version.js +2 -0
  87. package/dist/command/version/version-skew.d.ts +7 -0
  88. package/dist/command/version/version-skew.js +13 -2
  89. package/dist/features/license/license.d.ts +11 -71
  90. package/dist/features/license/license.js +43 -136
  91. package/dist/features/visual/desktop-capture.d.ts +33 -0
  92. package/dist/features/visual/desktop-capture.js +94 -0
  93. package/dist/features/visual/step-shots.js +139 -35
  94. package/dist/features/visual/visual-tools.d.ts +0 -18
  95. package/dist/features/visual/visual-tools.js +2 -81
  96. package/dist/import-graph.js +5 -6
  97. package/dist/index.d.ts +3 -1
  98. package/dist/index.js +14 -5
  99. package/dist/judgement/runs/run-ledger.d.ts +7 -5
  100. package/dist/judgement/runs/run-ledger.js +98 -69
  101. package/dist/judgement/runs/verification-sync.d.ts +6 -3
  102. package/dist/judgement/runs/verification-sync.js +21 -10
  103. package/dist/language/flows/flow-store-for-session.js +7 -5
  104. package/dist/language/flows/recording/tape/recordings.d.ts +2 -13
  105. package/dist/language/flows/recording/tape/recordings.js +41 -44
  106. package/dist/language/flows/replay.d.ts +17 -7
  107. package/dist/language/flows/replay.js +95 -44
  108. package/dist/launch-chromium.d.ts +1 -0
  109. package/dist/launch-chromium.js +49 -12
  110. package/dist/memory/cloud/sync-screenshots.js +6 -1
  111. package/dist/portal/bridge/bridge.d.ts +13 -4
  112. package/dist/portal/bridge/bridge.js +10 -9
  113. package/dist/portal/input/network-mock.js +3 -2
  114. package/dist/portal/input/real-input.d.ts +15 -0
  115. package/dist/portal/input/real-input.js +59 -3
  116. package/dist/portal/input/viewport-tools.js +6 -3
  117. package/dist/portal/pool/browser-pool.d.ts +5 -1
  118. package/dist/portal/pool/browser-pool.js +32 -38
  119. package/dist/portal/pool/context-permissions.d.ts +20 -17
  120. package/dist/portal/pool/context-permissions.js +63 -1
  121. package/dist/portal/pool/csp-widen.d.ts +62 -0
  122. package/dist/portal/pool/csp-widen.js +132 -0
  123. package/dist/portal/pool/lease-page-error.d.ts +29 -0
  124. package/dist/portal/pool/lease-page-error.js +18 -0
  125. package/dist/portal/pool/page-error-journal.d.ts +57 -0
  126. package/dist/portal/pool/page-error-journal.js +181 -0
  127. package/dist/portal/pool/playwright-launcher.js +21 -8
  128. package/dist/portal/pool/pool-contract.d.ts +22 -0
  129. package/dist/portal/pool/pool-lease.d.ts +35 -2
  130. package/dist/portal/pool/pool-lease.js +23 -0
  131. package/dist/portal/pool/zero-install.js +69 -1
  132. package/dist/portal/session/dev-server/dev-command.d.ts +8 -0
  133. package/dist/portal/session/dev-server/dev-command.js +56 -8
  134. package/dist/portal/session/no-session-diagnosis.js +10 -3
  135. package/dist/portal/session/no-session-next-action.d.ts +12 -0
  136. package/dist/portal/session/no-session-next-action.js +44 -4
  137. package/dist/portal/session/no-session-watch.js +3 -1
  138. package/dist/portal/session/page-commands/undeclared-command.d.ts +9 -3
  139. package/dist/portal/session/page-commands/undeclared-command.js +16 -7
  140. package/dist/portal/session/recorded-gaps.d.ts +1 -1
  141. package/dist/portal/session/recorded-gaps.js +5 -2
  142. package/dist/portal/session/session-manager.js +40 -21
  143. package/dist/portal/session/session.js +4 -3
  144. package/dist/portal/session/zero-install-first-call.d.ts +20 -1
  145. package/dist/portal/session/zero-install-first-call.js +34 -2
  146. package/dist/start-wiring.d.ts +29 -2
  147. package/dist/start-wiring.js +73 -9
  148. package/dist/surface/mcp/mcp-proxy.js +10 -2
  149. package/dist/surface/mcp/mcp.js +5 -3
  150. package/dist/surface/mcp/peer/agents-connected.d.ts +6 -0
  151. package/dist/surface/mcp/peer/agents-connected.js +58 -0
  152. package/dist/surface/mcp/proxy/proxy-daemon-probe.js +17 -5
  153. package/dist/surface/tools/act/act-preflight.js +37 -17
  154. package/dist/surface/tools/act/act-target.js +3 -1
  155. package/dist/surface/tools/act/after-match.d.ts +7 -0
  156. package/dist/surface/tools/act/after-match.js +33 -0
  157. package/dist/surface/tools/act/resolve-target.d.ts +2 -0
  158. package/dist/surface/tools/act/resolve-target.js +35 -8
  159. package/dist/surface/tools/act/sequence-grade.d.ts +3 -1
  160. package/dist/surface/tools/act/sequence-grade.js +8 -1
  161. package/dist/surface/tools/act-sequence-tool.js +3 -0
  162. package/dist/surface/tools/act-tools.js +10 -10
  163. package/dist/surface/tools/assert/assert-verdict.js +28 -1
  164. package/dist/surface/tools/browser-tools.js +75 -7
  165. package/dist/surface/tools/dynamic-tools.js +2 -1
  166. package/dist/surface/tools/empty-session-list.d.ts +2 -0
  167. package/dist/surface/tools/empty-session-list.js +4 -2
  168. package/dist/surface/tools/error-recovery.js +43 -22
  169. package/dist/surface/tools/invoke-tool.js +27 -20
  170. package/dist/surface/tools/lease-hint.d.ts +7 -0
  171. package/dist/surface/tools/lease-hint.js +10 -3
  172. package/dist/surface/tools/lease-readiness.d.ts +21 -0
  173. package/dist/surface/tools/lease-readiness.js +47 -0
  174. package/dist/surface/tools/lease-seed.d.ts +2 -0
  175. package/dist/surface/tools/lease-seed.js +2 -1
  176. package/dist/surface/tools/lease-tools.js +43 -15
  177. package/dist/surface/tools/repeat-hint.d.ts +40 -0
  178. package/dist/surface/tools/repeat-hint.js +171 -0
  179. package/dist/surface/tools/run-report.d.ts +2 -2
  180. package/dist/surface/tools/run-report.js +27 -15
  181. package/dist/surface/tools/tool-kit.d.ts +12 -0
  182. package/dist/surface/tools/tool-kit.js +7 -0
  183. package/dist/surface/tools/tools.js +26 -13
  184. package/dist/telemetry/argument-shape.js +16 -7
  185. package/dist/telemetry/cli-telemetry.d.ts +10 -1
  186. package/dist/telemetry/cli-telemetry.js +15 -0
  187. package/dist/telemetry/init-telemetry.d.ts +2 -1
  188. package/dist/telemetry/init-telemetry.js +47 -2
  189. package/dist/telemetry/install-trace.js +23 -4
  190. package/dist/telemetry/onboarding-funnel.d.ts +19 -2
  191. package/dist/telemetry/onboarding-funnel.js +25 -3
  192. package/dist/telemetry/proxy-telemetry.d.ts +2 -1
  193. package/dist/telemetry/proxy-telemetry.js +2 -2
  194. package/dist/telemetry/session-metrics.d.ts +1 -1
  195. package/dist/telemetry/session-metrics.js +67 -27
  196. package/dist/telemetry/telemetry.d.ts +3 -1
  197. package/dist/telemetry/telemetry.js +60 -28
  198. package/dist/telemetry/tool-refused.d.ts +2 -2
  199. package/dist/telemetry/tool-refused.js +9 -2
  200. package/docs/architecture.md +3 -6
  201. package/docs/cli/doctor.mdx +41 -2
  202. package/docs/cli/help.mdx +4 -2
  203. package/docs/cli/license.mdx +4 -5
  204. package/docs/cli/setup.mdx +13 -2
  205. package/docs/cli/status.mdx +4 -0
  206. package/docs/cli.mdx +1 -1
  207. package/docs/deploy-checks.md +25 -0
  208. package/docs/desktop.mdx +1 -1
  209. package/docs/enterprise.md +15 -20
  210. package/docs/environment.mdx +1 -0
  211. package/docs/first-drive.md +1 -1
  212. package/docs/install-manual.mdx +16 -1
  213. package/docs/packages/engine.mdx +1 -1
  214. package/docs/packages/server.mdx +1 -1
  215. package/docs/packages/tauri.mdx +1 -1
  216. package/docs/packages/test.mdx +1 -1
  217. package/docs/packages.mdx +1 -1
  218. package/docs/platform-integration.md +1 -1
  219. package/docs/skill.md +1 -1
  220. package/docs/telemetry.md +4 -2
  221. package/package.json +13 -9
  222. package/LICENSE-ENTERPRISE +0 -37
  223. package/dist/features/ee/audit-log.d.ts +0 -14
  224. package/dist/features/ee/audit-log.js +0 -11
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Bare `reticle` in a folder that Reticle has not wired: the first verdict, with no agent, no
3
+ * `init` and no model.
4
+ *
5
+ * It finds the app (the folder's own dev server, or the one dev server that runs on this computer),
6
+ * starts the dev server when it does not run, opens the page in a Reticle browser with the
7
+ * zero-install reader, and runs a smoke check that cannot guess: the page loads, no console error,
8
+ * no failed request, and each same-origin link opens. Then it prints the verdict and the next step.
9
+ *
10
+ * The decisions are here, behind `FirstVerdictPorts`, so each one is a unit test. The browser, the
11
+ * daemon and the dev server are in `first-verdict-node.ts`.
12
+ */
13
+ import { type ReticleEvent } from '@reticlehq/core';
14
+ import { OnboardingStop } from '@reticlehq/core/telemetry';
15
+ /** Bare `reticle` runs the first verdict only with no arguments, in a folder with no Reticle config. */
16
+ export declare function bareRunsFirstVerdict(argv: readonly string[], wired: boolean): boolean;
17
+ export declare const AppChoice: {
18
+ /** Check this url. */
19
+ readonly USE: "use";
20
+ /** Start the folder's dev command, then check what it serves. */
21
+ readonly START: "start";
22
+ /** Nothing to check: say why. */
23
+ readonly NONE: "none";
24
+ };
25
+ export type AppDecision = {
26
+ action: typeof AppChoice.USE;
27
+ url: string;
28
+ } | {
29
+ action: typeof AppChoice.START;
30
+ } | {
31
+ action: typeof AppChoice.NONE;
32
+ lines: string[];
33
+ stop: OnboardingStop;
34
+ };
35
+ export declare const MSG_NO_APP: string[];
36
+ /**
37
+ * Which app to check. In an app folder, only that app: a server on another port can be another
38
+ * app, and a verdict on the wrong app is worse than none. Outside one, the one server that runs.
39
+ */
40
+ export declare function chooseApp(facts: {
41
+ /** The folder's dev command, when the folder holds a web app. */
42
+ devCommand?: {
43
+ port?: number;
44
+ };
45
+ /** The dev command pins a port, and that port serves a page now. */
46
+ pinnedServing: boolean;
47
+ /** Dev-server ports that serve a page now, outside Reticle's own. */
48
+ running: readonly number[];
49
+ }): AppDecision;
50
+ /** What one page did, read from its own document's events. */
51
+ export interface PageFacts {
52
+ /** The url the check opened. */
53
+ url: string;
54
+ /** The page came back with Reticle connected, at the url asked for. */
55
+ arrived: boolean;
56
+ /** Where the page went instead, when it went somewhere else. */
57
+ landedOn?: string;
58
+ /** The HTTP status of the document, when the browser reported one. */
59
+ documentStatus?: number;
60
+ consoleErrors: string[];
61
+ failedRequests: string[];
62
+ }
63
+ /**
64
+ * The facts of the page now on screen. Events of an earlier document are left out: the check
65
+ * reloads each page, and what the old document did is not this page's fault.
66
+ */
67
+ export declare function pageFactsOf(url: string, events: readonly ReticleEvent[], nav: {
68
+ arrived: boolean;
69
+ landedOn?: string;
70
+ }): PageFacts;
71
+ /** The most links one check opens. Each one is a page load. */
72
+ export declare const MAX_LINKS_OPENED = 8;
73
+ /**
74
+ * The same-origin pages the links on `pageUrl` lead to, once each, without the page itself and
75
+ * without a link that ends the session. `found` counts them all, so a capped list says so.
76
+ */
77
+ export declare function linksToOpen(pageUrl: string, links: readonly {
78
+ name: string;
79
+ href: string;
80
+ }[], max?: number): {
81
+ open: string[];
82
+ found: number;
83
+ };
84
+ /** What the smoke check saw: the start page, and each link it opened. */
85
+ export interface SmokeFacts {
86
+ start: PageFacts;
87
+ links: PageFacts[];
88
+ /** Same-origin links found on the start page. More than `links` when the check opened fewer. */
89
+ linksFound: number;
90
+ }
91
+ export interface SmokeVerdict {
92
+ verified: 'yes' | 'no';
93
+ lines: string[];
94
+ }
95
+ /**
96
+ * The verdict. "yes" only when the start page loaded with no console error and no failed request,
97
+ * and every link it opened did the same. A page that sends a visitor to sign in is a verdict too:
98
+ * the check is "yes" up to there, and it says what it could not reach.
99
+ */
100
+ export declare function judgeSmoke(facts: SmokeFacts): SmokeVerdict;
101
+ /** The app to check, or why there is none. `release` stops a dev server this run started. */
102
+ export type FoundApp = {
103
+ url: string;
104
+ release?: () => void;
105
+ } | {
106
+ lines: string[];
107
+ stop: OnboardingStop;
108
+ };
109
+ export interface FirstVerdictPorts {
110
+ findApp: () => Promise<FoundApp>;
111
+ /** Open the page with the zero-install reader and collect the facts, or say why it could not. */
112
+ smoke: (url: string) => Promise<SmokeFacts | {
113
+ failed: string[];
114
+ }>;
115
+ out: (line: string) => void;
116
+ /** Record one onboarding stop. */
117
+ stop: (row: {
118
+ step: string;
119
+ stop: OnboardingStop;
120
+ }) => void;
121
+ }
122
+ export declare const MSG_NEXT_STEP: string[];
123
+ /** Find the app, check it, print the verdict. Returns the exit code: 0 only for "verified: yes". */
124
+ export declare function runFirstVerdict(ports: FirstVerdictPorts): Promise<number>;
125
+ /** The `reason` on a stop this command records, so a reader can tell it from an `init` stop. */
126
+ export declare const FIRST_VERDICT_REASON = "first_verdict";
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Bare `reticle` in a folder that Reticle has not wired: the first verdict, with no agent, no
3
+ * `init` and no model.
4
+ *
5
+ * It finds the app (the folder's own dev server, or the one dev server that runs on this computer),
6
+ * starts the dev server when it does not run, opens the page in a Reticle browser with the
7
+ * zero-install reader, and runs a smoke check that cannot guess: the page loads, no console error,
8
+ * no failed request, and each same-origin link opens. Then it prints the verdict and the next step.
9
+ *
10
+ * The decisions are here, behind `FirstVerdictPorts`, so each one is a unit test. The browser, the
11
+ * daemon and the dev server are in `first-verdict-node.ts`.
12
+ */
13
+ import { CRAWL_DEFAULTS, EventType, NetInitiator } from '@reticlehq/core';
14
+ import { OnboardingStop } from '@reticlehq/core/telemetry';
15
+ import { isAuthPath } from '../../surface/tools/lease-seed.js';
16
+ /** Bare `reticle` runs the first verdict only with no arguments, in a folder with no Reticle config. */
17
+ export function bareRunsFirstVerdict(argv, wired) {
18
+ return 0 === argv.length && !wired;
19
+ }
20
+ const LOCALHOST = 'http://localhost';
21
+ const localUrl = (port) => `${LOCALHOST}:${String(port)}`;
22
+ export const AppChoice = {
23
+ /** Check this url. */
24
+ USE: 'use',
25
+ /** Start the folder's dev command, then check what it serves. */
26
+ START: 'start',
27
+ /** Nothing to check: say why. */
28
+ NONE: 'none',
29
+ };
30
+ export const MSG_NO_APP = [
31
+ 'Reticle found no web app here, and no dev server that runs on this computer.',
32
+ 'Run this command in the folder of your web app, or start its dev server first.',
33
+ ];
34
+ /**
35
+ * Which app to check. In an app folder, only that app: a server on another port can be another
36
+ * app, and a verdict on the wrong app is worse than none. Outside one, the one server that runs.
37
+ */
38
+ export function chooseApp(facts) {
39
+ if (facts.devCommand !== undefined) {
40
+ const pinned = facts.devCommand.port;
41
+ if (pinned !== undefined && facts.pinnedServing) {
42
+ return { action: AppChoice.USE, url: localUrl(pinned) };
43
+ }
44
+ return { action: AppChoice.START };
45
+ }
46
+ const [only, ...others] = facts.running;
47
+ if (only !== undefined && 0 === others.length) {
48
+ return { action: AppChoice.USE, url: localUrl(only) };
49
+ }
50
+ if (only !== undefined) {
51
+ return {
52
+ action: AppChoice.NONE,
53
+ stop: OnboardingStop.NO_WEB_APP,
54
+ lines: [
55
+ 'Reticle found more than one dev server, so it did not choose one:',
56
+ ...facts.running.map((port) => ` ${localUrl(port)}`),
57
+ 'Run this command in the folder of the app to check.',
58
+ ],
59
+ };
60
+ }
61
+ return { action: AppChoice.NONE, stop: OnboardingStop.NO_WEB_APP, lines: MSG_NO_APP };
62
+ }
63
+ const ABORTED = /abort/i;
64
+ const text = (value) => 'string' === typeof value ? value : undefined;
65
+ const num = (value) => ('number' === typeof value ? value : undefined);
66
+ /** A request that failed: an error status, or no answer that the page did not cancel itself. */
67
+ function failure(e) {
68
+ if (EventType.NET_REQUEST !== e.type || NetInitiator.DOCUMENT === e.data['initiator']) {
69
+ return undefined;
70
+ }
71
+ const where = `${text(e.data['method']) ?? 'GET'} ${text(e.data['url']) ?? ''}`;
72
+ const status = num(e.data['status']);
73
+ if (status !== undefined && status >= CRAWL_DEFAULTS.FAILED_STATUS) {
74
+ return `${where}: HTTP ${String(status)}`;
75
+ }
76
+ const error = text(e.data['error']);
77
+ if (0 === status && error !== undefined && !ABORTED.test(error)) {
78
+ return `${where}: ${error}`;
79
+ }
80
+ return undefined;
81
+ }
82
+ /**
83
+ * The facts of the page now on screen. Events of an earlier document are left out: the check
84
+ * reloads each page, and what the old document did is not this page's fault.
85
+ */
86
+ export function pageFactsOf(url, events, nav) {
87
+ const loads = events.filter((e) => EventType.NET_REQUEST === e.type && NetInitiator.DOCUMENT === e.data['initiator']);
88
+ const load = loads.at(-1);
89
+ const documentId = load?.documentId ?? events.at(-1)?.documentId;
90
+ const current = events.filter((e) => documentId === undefined || e.documentId === undefined || e.documentId === documentId);
91
+ const documentStatus = num(load?.data['status']);
92
+ return {
93
+ url,
94
+ arrived: nav.arrived,
95
+ ...(nav.landedOn === undefined ? {} : { landedOn: nav.landedOn }),
96
+ ...(documentStatus === undefined ? {} : { documentStatus }),
97
+ consoleErrors: current
98
+ .filter((e) => EventType.CONSOLE_ERROR === e.type || EventType.ERROR_UNCAUGHT === e.type)
99
+ .map((e) => text(e.data['message']) ?? e.type),
100
+ failedRequests: current.flatMap((e) => {
101
+ const failed = failure(e);
102
+ return failed === undefined ? [] : [failed];
103
+ }),
104
+ };
105
+ }
106
+ /**
107
+ * A link that ends the signed-in session. Opening it would check the rest of the app signed out.
108
+ * ponytail: the same words the crawl leaves alone; `cli` does not reach `crawl`, so it is said here.
109
+ */
110
+ const ENDS_SESSION = /\b(?:sign ?out|log ?out|sign ?off|log ?off)\b/i;
111
+ /** The most links one check opens. Each one is a page load. */
112
+ export const MAX_LINKS_OPENED = 8;
113
+ const WEB_PROTOCOLS = new Set(['http:', 'https:']);
114
+ /**
115
+ * The same-origin pages the links on `pageUrl` lead to, once each, without the page itself and
116
+ * without a link that ends the session. `found` counts them all, so a capped list says so.
117
+ */
118
+ export function linksToOpen(pageUrl, links, max = MAX_LINKS_OPENED) {
119
+ const here = new URL(pageUrl);
120
+ const key = (u) => `${u.pathname.replace(/\/+$/, '')}${u.search}`;
121
+ const seen = new Set([key(here)]);
122
+ const pages = [];
123
+ for (const link of links) {
124
+ let target;
125
+ try {
126
+ target = new URL(link.href, here);
127
+ }
128
+ catch {
129
+ continue;
130
+ }
131
+ if (!WEB_PROTOCOLS.has(target.protocol) || target.origin !== here.origin) {
132
+ continue;
133
+ }
134
+ if (ENDS_SESSION.test(link.name) || ENDS_SESSION.test(target.pathname.replace(/[/-]/g, ' '))) {
135
+ continue;
136
+ }
137
+ if (seen.has(key(target))) {
138
+ continue;
139
+ }
140
+ seen.add(key(target));
141
+ target.hash = '';
142
+ pages.push(target.href);
143
+ }
144
+ return { open: pages.slice(0, max), found: pages.length };
145
+ }
146
+ const PASS = ' ✓';
147
+ const FAIL = ' ✗';
148
+ const NOTE = ' -';
149
+ const pathOf = (url) => {
150
+ try {
151
+ const u = new URL(url);
152
+ return `${u.pathname}${u.search}`;
153
+ }
154
+ catch {
155
+ return url;
156
+ }
157
+ };
158
+ /** Why a page did not load, or undefined when it loaded. */
159
+ function notLoaded(page) {
160
+ // A page that went somewhere else (a redirect to a dashboard, or to sign in) did open.
161
+ if (!page.arrived && page.landedOn === undefined) {
162
+ return 'it did not load, or Reticle could not connect to it';
163
+ }
164
+ if (page.documentStatus !== undefined && page.documentStatus >= CRAWL_DEFAULTS.FAILED_STATUS) {
165
+ return `the server answered HTTP ${String(page.documentStatus)}`;
166
+ }
167
+ return undefined;
168
+ }
169
+ /** The faults of one page, one line each. */
170
+ function faultsOf(page, label) {
171
+ const missed = notLoaded(page);
172
+ return [
173
+ ...(missed === undefined ? [] : [`${FAIL} ${label} did not open: ${missed}.`]),
174
+ ...page.consoleErrors.map((message) => `${FAIL} Console error on ${label}: ${message}`),
175
+ ...page.failedRequests.map((request) => `${FAIL} Failed request on ${label}: ${request}`),
176
+ ];
177
+ }
178
+ /**
179
+ * The verdict. "yes" only when the start page loaded with no console error and no failed request,
180
+ * and every link it opened did the same. A page that sends a visitor to sign in is a verdict too:
181
+ * the check is "yes" up to there, and it says what it could not reach.
182
+ */
183
+ export function judgeSmoke(facts) {
184
+ const start = facts.start;
185
+ const landed = start.landedOn === undefined ? undefined : new URL(start.landedOn);
186
+ const signIn = landed !== undefined && isAuthPath(landed.pathname) && !isAuthPath(new URL(start.url).pathname);
187
+ const faults = [
188
+ ...faultsOf(start, pathOf(start.url)),
189
+ ...facts.links.flatMap((link) => faultsOf(link, pathOf(link.url))),
190
+ ];
191
+ const lines = [];
192
+ if (signIn && landed !== undefined) {
193
+ lines.push(`${NOTE} The app sent the page to its sign-in page (${landed.pathname}). ` +
194
+ 'Reticle verified up to here. It did not check the pages behind sign-in.');
195
+ }
196
+ const linksOk = facts.links.filter((link) => 0 === faultsOf(link, '').length).length;
197
+ if (0 === faults.length) {
198
+ lines.push(`${PASS} The page loads.`, `${PASS} No console error.`, `${PASS} No failed request.`);
199
+ }
200
+ lines.push(...faults);
201
+ if (0 === facts.linksFound) {
202
+ lines.push(`${NOTE} The page has no same-origin link, so no link was opened.`);
203
+ }
204
+ else {
205
+ lines.push(`${0 === faults.length ? PASS : NOTE} ${String(linksOk)} of ${String(facts.links.length)} ` +
206
+ `link(s) opened with no fault.`);
207
+ }
208
+ if (facts.links.length < facts.linksFound) {
209
+ lines.push(`${NOTE} Reticle opened ${String(facts.links.length)} of ${String(facts.linksFound)} ` +
210
+ 'same-origin links. It did not check the others.');
211
+ }
212
+ const verified = 0 === faults.length ? 'yes' : 'no';
213
+ lines.push('yes' === verified
214
+ ? `verified: yes — the smoke check passed on ${start.url}`
215
+ : `verified: no — ${String(faults.length)} fault(s) on ${start.url}`);
216
+ return { verified, lines };
217
+ }
218
+ export const MSG_NEXT_STEP = [
219
+ 'Next: connect your coding agent, so it can verify each change it makes:',
220
+ ' npx @reticlehq/server init',
221
+ ];
222
+ const STEP_PROJECT_DETECTED = 'project_detected';
223
+ const STEP_APP_CONNECTED = 'app_connected';
224
+ const EXIT_OK = 0;
225
+ const EXIT_FAIL = 1;
226
+ /** Find the app, check it, print the verdict. Returns the exit code: 0 only for "verified: yes". */
227
+ export async function runFirstVerdict(ports) {
228
+ const found = await ports.findApp();
229
+ if ('stop' in found) {
230
+ for (const line of found.lines) {
231
+ ports.out(line);
232
+ }
233
+ const step = OnboardingStop.NO_WEB_APP === found.stop ? STEP_PROJECT_DETECTED : STEP_APP_CONNECTED;
234
+ ports.stop({ step, stop: found.stop });
235
+ return EXIT_FAIL;
236
+ }
237
+ try {
238
+ ports.out(`Reticle is checking ${found.url}. It installs nothing in your app.`);
239
+ let facts;
240
+ try {
241
+ facts = await ports.smoke(found.url);
242
+ }
243
+ catch (error) {
244
+ facts = { failed: [error instanceof Error ? error.message : String(error)] };
245
+ }
246
+ if ('failed' in facts) {
247
+ for (const line of [
248
+ 'verified: unknown — Reticle could not check the page:',
249
+ ...facts.failed,
250
+ ]) {
251
+ ports.out(line);
252
+ }
253
+ ports.stop({ step: STEP_APP_CONNECTED, stop: OnboardingStop.APP_NOT_CONNECTED });
254
+ return EXIT_FAIL;
255
+ }
256
+ const verdict = judgeSmoke(facts);
257
+ for (const line of [...verdict.lines, '', ...MSG_NEXT_STEP]) {
258
+ ports.out(line);
259
+ }
260
+ return 'yes' === verdict.verified ? EXIT_OK : EXIT_FAIL;
261
+ }
262
+ finally {
263
+ found.release?.();
264
+ }
265
+ }
266
+ /** The `reason` on a stop this command records, so a reader can tell it from an `init` stop. */
267
+ export const FIRST_VERDICT_REASON = 'first_verdict';
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Run the CLI whose major matches the SDK the project actually installed.
3
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
4
+ * Every rule file `init` writes says `npx @reticlehq/server`, unpinned, which resolves the LATEST
5
+ * server. The agent's MCP entry names one version or an installed copy (see `serverInvocation` in
6
+ * init), and that copy is a release older or newer than the SDK too — while the project's SDK
6
7
  * stays locked at whatever `init` installed. A project wired on the previous major therefore got a
7
8
  * daemon from the next one, and every verdict came back `version_skew`; a global install from an
8
9
  * older release shadows npx the same way in the other direction. Nothing the project could do fixed
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Run the CLI whose major matches the SDK the project actually installed.
3
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
4
+ * Every rule file `init` writes says `npx @reticlehq/server`, unpinned, which resolves the LATEST
5
+ * server. The agent's MCP entry names one version or an installed copy (see `serverInvocation` in
6
+ * init), and that copy is a release older or newer than the SDK too — while the project's SDK
6
7
  * stays locked at whatever `init` installed. A project wired on the previous major therefore got a
7
8
  * daemon from the next one, and every verdict came back `version_skew`; a global install from an
8
9
  * older release shadows npx the same way in the other direction. Nothing the project could do fixed
@@ -21,7 +21,8 @@ import { probeDaemon, waitForDaemonBind } from '../../../surface/mcp/mcp-proxy.j
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';
24
- import { PortPresence, probePresence, describePresence, } from '../../daemon/binding/port-presence.js';
24
+ import { PortPresence, probePresence } from '../../daemon/binding/port-presence.js';
25
+ import { describePortState } from '../../daemon/port-state.js';
25
26
  import { daemonSpawnArgs } from '../daemon-start-options.js';
26
27
  import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
27
28
  import { requestShutdown, runKill } from '../cli-kill.js';
@@ -82,9 +83,9 @@ async function serveWithHonestExit(parsed) {
82
83
  log('reticle_daemon_start_refused', {
83
84
  port: parsed.port,
84
85
  presence,
85
- reason: describePresence(presence, parsed.port),
86
+ reason: describePortState(presence, parsed.port),
86
87
  });
87
- process.stderr.write(`${describePresence(presence, parsed.port)}\n`);
88
+ process.stderr.write(`${describePortState(presence, parsed.port)}\n`);
88
89
  process.exit(1);
89
90
  return;
90
91
  }
@@ -107,7 +108,7 @@ async function serveWithHonestExit(parsed) {
107
108
  log('reticle_daemon_start_failed_parent', {
108
109
  port: parsed.port,
109
110
  presence: settled,
110
- reason: describePresence(settled, parsed.port),
111
+ reason: describePortState(settled, parsed.port),
111
112
  log: logPath(parsed.port),
112
113
  stateDir: stateDirProblem(reticleStateHome()) === undefined ? 'writable' : 'unwritable',
113
114
  ...(startupCause === undefined ? {} : { cause: startupCause }),
@@ -119,7 +120,7 @@ async function serveWithHonestExit(parsed) {
119
120
  const dirProblem = stateDirProblem(reticleStateHome());
120
121
  process.stderr.write(dirProblem === undefined
121
122
  ? startupCause === undefined
122
- ? `the daemon did not come up on :${String(parsed.port)} — ${describePresence(settled, parsed.port)}\n` +
123
+ ? `the daemon did not come up on :${String(parsed.port)} — ${describePortState(settled, parsed.port)}\n` +
123
124
  `see ${logPath(parsed.port)}\n`
124
125
  : `the daemon did not come up on :${String(parsed.port)}.\n${startupCause}\n` +
125
126
  `see ${logPath(parsed.port)}\n`
@@ -9,14 +9,15 @@
9
9
  */
10
10
  import { log } from '../../log.js';
11
11
  import { isAlive, spawnDaemon, reticleStateHome } from '../daemon/daemon.js';
12
- import { PortPresence, probePresence, presenceIsUsable, describePresence, } from '../daemon/binding/port-presence.js';
12
+ import { PortPresence, probePresence, presenceIsUsable, } from '../daemon/binding/port-presence.js';
13
+ import { describePortState } from '../daemon/port-state.js';
13
14
  import { waitForDaemon, startMcpProxy, probeDaemon, proxyLog, setProxyLogPort, flushProxyExit, reportWakeRefused, } from '../../surface/mcp/mcp-proxy.js';
14
15
  import { installProxyResilience } from '../daemon/daemon-resilience.js';
15
16
  import { readProjectId, readProjectPort } from './ports/resolve/cli-port.js';
16
17
  import { resolveMcpPort, daemonProjectAt, relocationReason, } from '../daemon/daemon-resolve.js';
17
18
  import { daemonSpawnArgs } from './daemon-start-options.js';
18
19
  import { WakeAction, decideWake } from '../daemon/wake-decision.js';
19
- import { runKill } from './cli-kill.js';
20
+ import { runKill, stopWedgedDaemon } from './cli-kill.js';
20
21
  import { pickDaemonPortToBind } from '../daemon/binding/free-port.js';
21
22
  import { fetchStatus, summarizeStatus } from '../daemon/binding/daemon-status-probe.js';
22
23
  import { isNewerVersion } from '../update/update-nudge.js';
@@ -117,6 +118,9 @@ export async function handleMcp(opts) {
117
118
  reason: relocationReason(opts.port, to, projectId, reticleStateHome(), isAlive),
118
119
  });
119
120
  };
121
+ // A Reticle daemon stuck on the requested port is stopped, not moved away from: the app's SDK
122
+ // dials this port, and a daemon on another one gets no page (#1937).
123
+ await stopWedgedDaemon(opts.port);
120
124
  // `let`: a wake that finds the port taken resolves again, and the proxy follows (#1651, #1652).
121
125
  let port = await resolvePort();
122
126
  if (port !== opts.port) {
@@ -151,7 +155,12 @@ export async function handleMcp(opts) {
151
155
  // the bridge port answered "a daemon is here" — the proxy connected, the stream ended, and each
152
156
  // client request woke into the identical non-answer. Rejecting here is what puts the proxy
153
157
  // dormant with a reason, instead of pretending the wake succeeded.
154
- const presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
158
+ let presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
159
+ // A Reticle daemon that holds the port and does not answer: stop it and keep the port, as at
160
+ // boot. Relocating left the app dialling a port with no working daemon (#1937).
161
+ if (PortPresence.FOREIGN === presence && (await stopWedgedDaemon(port))) {
162
+ presence = await probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus });
163
+ }
155
164
  // Identity, not just presence. `resolveMcpPort` above already refuses to adopt a stranger's
156
165
  // daemon at boot; this is the same question asked on every WAKE, which is where it was missing.
157
166
  //
@@ -192,7 +201,7 @@ export async function handleMcp(opts) {
192
201
  throw new Error(PortPresence.DAEMON === presence
193
202
  ? `the daemon on port ${String(port)} belongs to a different project, so this one will ` +
194
203
  'not adopt it. Stop it, or start this project on its own port.'
195
- : describePresence(presence, port));
204
+ : describePortState(presence, port));
196
205
  }
197
206
  const scriptPath = process.argv[1];
198
207
  if (scriptPath === undefined) {
@@ -16,6 +16,8 @@
16
16
  export interface PortHolder {
17
17
  pid: number;
18
18
  command: string;
19
+ /** Its command line is a Reticle daemon's. Set only by `identifyPortHolder`. */
20
+ reticleDaemon?: boolean;
19
21
  }
20
22
  /**
21
23
  * Run a lookup and return its stdout, or null if it could not run.
@@ -111,14 +111,22 @@ ourPid) {
111
111
  */
112
112
  const ours = ourPid !== null && ourPid !== undefined
113
113
  ? ` Reticle's own records show pid ${String(ourPid)} for this port — if that process is ` +
114
- 'still around it is a wedged Reticle daemon, and `reticle stop` clears it.'
114
+ 'still around it is a Reticle daemon that did not answer in time. It may be busy. If it ' +
115
+ 'stays silent, `reticle stop` clears it.'
115
116
  : '';
116
117
  return `port ${String(port)} is held by a process Reticle could not identify — ${tail}${ours}`;
117
118
  }
119
+ if (true === holder.reticleDaemon && ourPid !== holder.pid) {
120
+ // Not the pid we recorded, so `reticle stop` cannot find it. `restart` finds the listener.
121
+ // It did not answer a short probe. A busy daemon does that too, and other agents can use it.
122
+ return (`port ${String(port)} is held by a Reticle daemon (pid ${String(holder.pid)}) that did not ` +
123
+ 'answer in time. It may be busy. Run `reticle status` again in a few seconds. If it stays ' +
124
+ 'silent, run `reticle restart`: it stops that process and starts a new daemon on this port.');
125
+ }
118
126
  if (ourPid !== null && ourPid !== undefined && ourPid === holder.pid) {
119
127
  return (`port ${String(port)} is held by YOUR Reticle daemon (pid ${String(holder.pid)}), and it is ` +
120
- 'not responding — running but wedged, so nothing can use it and nothing else can bind the ' +
121
- 'port. Stop it with `reticle stop`; the next tool call starts a fresh one.');
128
+ 'not responding: it did not answer in time. It may be busy. Run `reticle status` again in a ' +
129
+ 'few seconds. If it stays silent, run `reticle stop`; the next tool call starts a fresh one.');
122
130
  }
123
131
  return (`port ${String(port)} is held by pid ${String(holder.pid)} ("${holder.command}"), which is not ` +
124
132
  `a Reticle daemon — ${tail}`);
@@ -1,4 +1,5 @@
1
1
  import type { OnboardingStep } from '@reticlehq/core/telemetry';
2
+ import { type AgentRow, type WaitClock } from '../setup/agent-next-steps.js';
2
3
  /**
3
4
  * The terminal half of `reticle setup mcp`.
4
5
  *
@@ -15,7 +16,9 @@ import type { OnboardingStep } from '@reticlehq/core/telemetry';
15
16
  */
16
17
  export type StepReporter = (step: OnboardingStep) => void;
17
18
  /** How many coding agents on this machine have Reticle's tools after this run. */
18
- export declare function handleSetupMcp(reportStep: StepReporter, standalone?: boolean): number;
19
+ export declare function handleSetupMcp(reportStep: StepReporter, standalone?: boolean,
20
+ /** Wait for the agents to connect. The caller decides: a terminal, and not CI. */
21
+ waitForConnect?: boolean): number;
19
22
  /**
20
23
  * The one line the launchers read and strip: what they cannot know and the next steps depend on.
21
24
  *
@@ -43,3 +46,26 @@ export declare function handleSetupInstall(opts: {
43
46
  installSecs: number;
44
47
  mcp: boolean;
45
48
  }, reportStep: StepReporter): void;
49
+ /** Wait only where a person watches: a terminal, and not CI. */
50
+ export declare function shouldWaitForAgents(isTty: boolean | undefined, isCi: boolean): boolean;
51
+ /** What the wait reads, and the clock it runs on. Injected, so a test does not wait. */
52
+ export interface ConnectionWait {
53
+ readonly read: () => ReadonlySet<string>;
54
+ readonly clock: WaitClock;
55
+ }
56
+ /** Wait (bounded) for the registered agents to connect, then say which did and what is missing. */
57
+ export declare function reportConnections(rows: readonly AgentRow[], print: (line: string) => void, wait?: ConnectionWait): Promise<void>;
58
+ /**
59
+ * `reticle init`: the same block and wait as `reticle setup mcp`, after its restart notice. Print the
60
+ * "Your agents:" block now. Returns the wait, for the caller to run last: a no-op when
61
+ * nobody watches.
62
+ */
63
+ export declare function agentsBlock(rows: readonly AgentRow[], print: (line: string) => void, interactive: boolean, wait?: ConnectionWait): () => Promise<void>;
64
+ /** The agents on this machine and whether Reticle is in each config. Read only. */
65
+ export declare function readAgentRows(cwd: string): AgentRow[];
66
+ /**
67
+ * `reticle setup wait-agents`: the installer's last step. The installer sends `setup install` output
68
+ * to a file, so that run cannot wait; this one runs with the terminal attached. Waits only where a
69
+ * person watches.
70
+ */
71
+ export declare function handleSetupWaitAgents(interactive: boolean): Promise<void>;