@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,214 @@
1
+ /**
2
+ * The safe repairs `reticle doctor --fix` can apply, in the order it applies them.
3
+ *
4
+ * Each one changes only Reticle's own state, and each reuses the code that already owns that state:
5
+ * `removePid` for the pid file, `stopWedgedDaemon` for a stuck daemon, the launch path's pinned
6
+ * install for Chromium, and `reticle setup mcp` for the agent registrations. The facts each one
7
+ * reads are injected, so the decision is tested without a home directory or a port.
8
+ */
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { claudeAvailableProbe, claudeStaleEntryNote, claudeUserEntryIsFloating, codexAvailableProbe, probeCli, } from '@reticlehq/init';
12
+ import { mcpLauncher } from '../setup/mcp-launcher.js';
13
+ import { isAlive, readPid, removePid } from '../daemon/daemon.js';
14
+ import { PortPresence, probePresence } from '../daemon/binding/port-presence.js';
15
+ import { fetchStatus } from '../daemon/binding/daemon-status-probe.js';
16
+ import { identifyPortHolder } from '../daemon/port-state.js';
17
+ import { probeDaemon } from '../../surface/mcp/mcp-proxy.js';
18
+ import { probeChromiumWithFallback, probeLaunchableChromium } from '../../launch-chromium.js';
19
+ import { setupMcp } from '../setup/setup-mcp.js';
20
+ import { handleSetupMcp } from './setup-mcp-cli.js';
21
+ import { stopWedgedDaemon } from './cli-kill.js';
22
+ import { captureLookup } from './ports/port-holder.js';
23
+ import { bundledPlaywrightVersion, chromiumInstallCommand, } from './doctor/browser/chromium-hint.js';
24
+ import { DoctorRow, doctorRow } from './doctor/doctor-rows.js';
25
+ import { launcherLine, registeredEntries } from './doctor/doctor-launcher-line.js';
26
+ const SETUP_MCP_COMMAND = 'reticle setup mcp';
27
+ /** A pid file whose process is gone. `removePid` itself never removes the file of a live process. */
28
+ export function stalePidRepair(deps) {
29
+ const needed = () => {
30
+ const pid = deps.readPid();
31
+ return pid !== null && !deps.isAlive(pid);
32
+ };
33
+ return {
34
+ row: DoctorRow.DAEMON,
35
+ what: 'pid file of a stopped daemon',
36
+ step: 'delete the pid file of the stopped daemon in ~/.reticle',
37
+ needed: () => Promise.resolve(needed()),
38
+ apply: () => {
39
+ deps.removePid();
40
+ return Promise.resolve();
41
+ },
42
+ };
43
+ }
44
+ /**
45
+ * A Reticle daemon that holds the port and does not answer. Only a holder whose command line is a
46
+ * Reticle daemon's qualifies; `stopWedgedDaemon` checks that again, after a grace period, before it
47
+ * sends a signal. A stranger and the agent's own `reticle mcp` proxy are never stopped.
48
+ */
49
+ export function wedgedDaemonRepair(port, deps) {
50
+ return {
51
+ row: DoctorRow.DAEMON,
52
+ what: 'a Reticle daemon that does not answer',
53
+ step: `reticle restart --port ${String(port)}`,
54
+ needed: async () => PortPresence.FOREIGN === (await deps.presence()) && true === deps.holder()?.reticleDaemon,
55
+ apply: async () => {
56
+ await deps.stop();
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * No browser that a launch can use. Only when Playwright's own build is missing or at the wrong
62
+ * revision and no installed Chrome or Edge stands in. A RETICLE_CHROMIUM_PATH the user set is never
63
+ * replaced, and an absent playwright package is not a download this can fix.
64
+ */
65
+ export function chromiumRepair(deps) {
66
+ return {
67
+ row: DoctorRow.CHROMIUM,
68
+ what: "Playwright's own Chromium",
69
+ step: chromiumInstallCommand(deps.playwrightVersion),
70
+ needed: async () => {
71
+ const probe = await deps.probe();
72
+ return (!probe.exists &&
73
+ probe.fallback === undefined &&
74
+ true !== probe.configured &&
75
+ probe.executablePath !== undefined);
76
+ },
77
+ apply: async () => {
78
+ await deps.install();
79
+ },
80
+ };
81
+ }
82
+ /** Agents found on this machine that do not have Reticle's MCP server registered. */
83
+ export function mcpRepair(deps) {
84
+ return {
85
+ row: DoctorRow.MCP,
86
+ what: 'agent registrations',
87
+ step: SETUP_MCP_COMMAND,
88
+ needed: () => Promise.resolve(deps.gaps().length > 0),
89
+ apply: () => {
90
+ deps.register();
91
+ return Promise.resolve();
92
+ },
93
+ };
94
+ }
95
+ /** How the launcher row starts its command, as `doctor-launcher-line.ts` writes it. */
96
+ const LAUNCHER_RUN = 'Run: ';
97
+ /**
98
+ * An agent config starts Reticle from an installed copy that no longer exists. `setup mcp`
99
+ * replaces Reticle's own entry in the JSON clients. It keeps an existing Claude Code entry, so for
100
+ * Claude Code the row still fails after the repair, and the step is the row's remove-then-add.
101
+ */
102
+ export function launcherRepair(deps) {
103
+ let step = SETUP_MCP_COMMAND;
104
+ return {
105
+ row: DoctorRow.LAUNCHER,
106
+ what: 'a registered launcher that does not exist or has no version',
107
+ // The step is read after `needed`, which reads the row again.
108
+ get step() {
109
+ return step;
110
+ },
111
+ needed: () => {
112
+ const line = deps.line();
113
+ if (line === undefined) {
114
+ return Promise.resolve(false);
115
+ }
116
+ const at = line.indexOf(LAUNCHER_RUN);
117
+ step = at < 0 ? SETUP_MCP_COMMAND : line.slice(at + LAUNCHER_RUN.length);
118
+ return Promise.resolve(true);
119
+ },
120
+ apply: () => {
121
+ deps.register();
122
+ return Promise.resolve();
123
+ },
124
+ };
125
+ }
126
+ /** The launcher row for the agent configs of this machine and folder. */
127
+ export function readLauncherLine(cwd) {
128
+ const io = {
129
+ exists: existsSync,
130
+ homeDir: homedir,
131
+ readFile: (p) => {
132
+ try {
133
+ return readFileSync(p, 'utf8');
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ },
139
+ };
140
+ return launcherLine(registeredEntries(io, cwd), existsSync, claudeUserEntryIsFloating(io) ? claudeStaleEntryNote(mcpLauncher()) : undefined);
141
+ }
142
+ /** The `mcp` doctor row. */
143
+ export function mcpRegistrationRow(registration) {
144
+ const { missing, present } = registration;
145
+ if (missing.length > 0) {
146
+ const also = 0 === present.length ? '' : ` (registered with ${present.join(', ')})`;
147
+ return doctorRow(DoctorRow.MCP, `✗ not registered with ${missing.join(', ')}; run \`${SETUP_MCP_COMMAND}\`${also}`);
148
+ }
149
+ if (0 === present.length) {
150
+ return doctorRow(DoctorRow.MCP, `no agent that this check knows was found; \`${SETUP_MCP_COMMAND}\` names the agents it knows`);
151
+ }
152
+ return doctorRow(DoctorRow.MCP, `✓ registered with ${present.join(', ')}`);
153
+ }
154
+ /**
155
+ * Ask `setupMcp` what it would do, and do none of it. Config writes and registration commands are
156
+ * recorded, not run; only the two `--version` probes run, because they say whether an agent is here.
157
+ * No telemetry: this is a check, not a registration.
158
+ */
159
+ export function readMcpRegistration() {
160
+ const probes = [claudeAvailableProbe(), codexAvailableProbe()];
161
+ const isProbe = (command, args) => probes.some((p) => p.command === command && p.args.join(' ') === args.join(' '));
162
+ const result = setupMcp({
163
+ exists: (p) => existsSync(p),
164
+ readFile: (p) => {
165
+ try {
166
+ return readFileSync(p, 'utf8');
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ },
172
+ writeFile: () => { },
173
+ homeDir: () => homedir(),
174
+ print: () => { },
175
+ runCli: (command, args) => (isProbe(command, args) ? probeCli(command, args) : true),
176
+ reportStep: () => { },
177
+ });
178
+ return { missing: result.registered, present: result.alreadyThere };
179
+ }
180
+ /** The repairs for this machine and port, in the order `--fix` applies them. */
181
+ export function machineRepairs(port, reportStep) {
182
+ return [
183
+ stalePidRepair({
184
+ readPid: () => readPid(port),
185
+ isAlive,
186
+ removePid: () => {
187
+ removePid(port);
188
+ },
189
+ }),
190
+ wedgedDaemonRepair(port, {
191
+ presence: () => probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }),
192
+ holder: () => identifyPortHolder(port, captureLookup),
193
+ stop: () => stopWedgedDaemon(port),
194
+ }),
195
+ chromiumRepair({
196
+ probe: () => probeChromiumWithFallback(),
197
+ install: () => probeLaunchableChromium(),
198
+ playwrightVersion: bundledPlaywrightVersion(),
199
+ }),
200
+ // Before `mcp`: its `setup mcp` also replaces a stale launcher, and this one says so.
201
+ launcherRepair({
202
+ line: () => readLauncherLine(process.cwd()),
203
+ register: () => {
204
+ handleSetupMcp(reportStep, false);
205
+ },
206
+ }),
207
+ mcpRepair({
208
+ gaps: () => readMcpRegistration().missing,
209
+ register: () => {
210
+ handleSetupMcp(reportStep, false);
211
+ },
212
+ }),
213
+ ];
214
+ }
@@ -8,11 +8,10 @@
8
8
  */
9
9
  import { start } from '../../../index.js';
10
10
  import { log } from '../../../log.js';
11
- import { probePresence, describePresence } from '../../daemon/binding/port-presence.js';
11
+ import { probePresence } from '../../daemon/binding/port-presence.js';
12
12
  import { probeDaemon } from '../../../surface/mcp/mcp-proxy.js';
13
- import { readPid } from '../../daemon/daemon.js';
14
13
  import { fetchStatus } from '../../daemon/binding/daemon-status-probe.js';
15
- import { captureLookup, findPortHolder } from '../ports/port-holder.js';
14
+ import { describePortState } from '../../daemon/port-state.js';
16
15
  import { DriveMode, NO_ATTACHED_APP, attachedApp, decideDriveMode, describeAttached, driveForeignHolder, driveHeadlessOnAttach, driveRaceLost, requestDriveSession, } from './drive-attach.js';
17
16
  /** `projectId` is the one in this directory's `.reticle.json`, read by the caller. */
18
17
  export function handleDrive(parsed, projectId) {
@@ -34,10 +33,7 @@ async function driveWithHonestConflict(parsed, projectId) {
34
33
  const presence = await probePresence(parsed.port, { tcpOpen: probeDaemon, status: fetchStatus });
35
34
  const mode = decideDriveMode(presence);
36
35
  if (DriveMode.REFUSE === mode) {
37
- const reason = driveForeignHolder(describePresence(presence, parsed.port, {
38
- ourPid: readPid(parsed.port),
39
- holderPid: findPortHolder(parsed.port, captureLookup)?.pid,
40
- }));
36
+ const reason = driveForeignHolder(describePortState(presence, parsed.port));
41
37
  log('reticle_drive_port_conflict', { port: parsed.port, presence, reason });
42
38
  process.stderr.write(`${reason}\n`);
43
39
  process.exit(1);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Four checks for the first seconds of `init`, `connect`, `setup` and `verify`: the Node.js version,
3
+ * a browser to drive, `npx.cmd` on the Windows PATH, and the bridge port.
4
+ *
5
+ * Each failed check prints one line: the cause and its one fix. A passed check prints nothing. All
6
+ * are warnings: the command goes on, so a check that is wrong can never block a path that works.
7
+ * The paths that cannot go on still refuse later, in their own words (the lease preflight, the
8
+ * init runtime phase).
9
+ *
10
+ * `reticle doctor --fix` would repair the two that a program can repair: install the Chromium, and
11
+ * move init to a free port. That is a separate change.
12
+ */
13
+ import { PortPresence } from '../daemon/binding/port-presence.js';
14
+ import { type ChromiumProbe } from './doctor/browser/chromium-hint.js';
15
+ /** What the checks read, injected so each one runs against a fake machine in tests. */
16
+ export interface FirstUseFacts {
17
+ /** `process.versions.node`. */
18
+ nodeVersion: string;
19
+ /** The `engines.node` range of this package. */
20
+ nodeRange: string | undefined;
21
+ platform: NodeJS.Platform;
22
+ env: NodeJS.ProcessEnv;
23
+ /** The folder of the running `node` executable. */
24
+ nodeDir: string;
25
+ exists: (path: string) => boolean;
26
+ chromium: () => Promise<ChromiumProbe>;
27
+ /** The bridge port to check. No port: no port check. */
28
+ port?: number;
29
+ portPresence?: () => Promise<PortPresence>;
30
+ describePort?: (presence: PortPresence, port: number) => string;
31
+ }
32
+ /** The line for a Node.js older than the `engines` floor, or undefined. */
33
+ export declare function nodeTooOldLine(version: string, range: string | undefined): string | undefined;
34
+ /** The line for a machine with no browser Reticle can launch, or undefined. */
35
+ export declare function chromiumMissingLine(probe: ChromiumProbe): string | undefined;
36
+ /** The line for a Windows PATH with no `npx.cmd` on it, or undefined. */
37
+ export declare function windowsNpxLine(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, nodeDir: string, exists: (path: string) => boolean): string | undefined;
38
+ /** The warning lines, in a fixed order. Empty when every check passes. */
39
+ export declare function firstUseWarnings(facts: FirstUseFacts): Promise<string[]>;
40
+ /** Write the warnings for this machine to stderr. `port` adds the bridge port check. */
41
+ export declare function writeFirstUseWarnings(port?: number): Promise<void>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Four checks for the first seconds of `init`, `connect`, `setup` and `verify`: the Node.js version,
3
+ * a browser to drive, `npx.cmd` on the Windows PATH, and the bridge port.
4
+ *
5
+ * Each failed check prints one line: the cause and its one fix. A passed check prints nothing. All
6
+ * are warnings: the command goes on, so a check that is wrong can never block a path that works.
7
+ * The paths that cannot go on still refuse later, in their own words (the lease preflight, the
8
+ * init runtime phase).
9
+ *
10
+ * `reticle doctor --fix` would repair the two that a program can repair: install the Chromium, and
11
+ * move init to a free port. That is a separate change.
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { dirname, win32 } from 'node:path';
15
+ import { ReticleEnv } from '@reticlehq/core';
16
+ import { PortPresence, probePresence } from '../daemon/binding/port-presence.js';
17
+ import { describePortState } from '../daemon/port-state.js';
18
+ import { fetchStatus } from '../daemon/binding/daemon-status-probe.js';
19
+ import { probeDaemon } from '../../surface/mcp/mcp-proxy.js';
20
+ import { probeChromiumWithFallback } from '../../launch-chromium.js';
21
+ import { compareVersions } from '../version/version-skew.js';
22
+ import { SERVER_NODE_RANGE } from '../version/identity/server-version.js';
23
+ import { ChromiumPathProblem, configuredChromiumRefusal, } from './doctor/browser/chromium-hint.js';
24
+ const WARNING_PREFIX = '⚠ ';
25
+ const NODE_DOWNLOAD_URL = 'https://nodejs.org';
26
+ /** Only a plain floor (`>=20.11`) is read. Any other range is not checked. */
27
+ const NODE_FLOOR = /^>=\s*(\d+(?:\.\d+)*)$/;
28
+ const NPX_WINDOWS = 'npx.cmd';
29
+ const PATH_KEY = 'path';
30
+ const WINDOWS_PATH_SEPARATOR = ';';
31
+ const WINDOWS = 'win32';
32
+ /** The line for a Node.js older than the `engines` floor, or undefined. */
33
+ export function nodeTooOldLine(version, range) {
34
+ const floor = range === undefined ? undefined : NODE_FLOOR.exec(range.trim())?.[1];
35
+ if (floor === undefined) {
36
+ return undefined;
37
+ }
38
+ const order = compareVersions(version, floor);
39
+ if (order === undefined || order >= 0) {
40
+ return undefined;
41
+ }
42
+ return (`Reticle needs Node.js ${floor} or later, and this is Node.js ${version}. ` +
43
+ `Install the current Node.js LTS from ${NODE_DOWNLOAD_URL}, then run the command again.`);
44
+ }
45
+ /** The line for a machine with no browser Reticle can launch, or undefined. */
46
+ export function chromiumMissingLine(probe) {
47
+ if (probe.exists || probe.fallback !== undefined) {
48
+ return undefined;
49
+ }
50
+ if (true === probe.configured && probe.executablePath !== undefined) {
51
+ return configuredChromiumRefusal(probe.executablePath, probe.problem ?? ChromiumPathProblem.MISSING);
52
+ }
53
+ // No playwright at all is a broken install, not a blocked download. `reticle doctor` names it.
54
+ if (probe.executablePath === undefined) {
55
+ return undefined;
56
+ }
57
+ return ('No Chromium, Chrome or Edge is on this machine, so the first browser session downloads ' +
58
+ 'Chromium (about 150 MiB). If your network blocks that download, install Google Chrome, or set ' +
59
+ `${ReticleEnv.CHROMIUM_PATH} to a Chromium executable.`);
60
+ }
61
+ /** The line for a Windows PATH with no `npx.cmd` on it, or undefined. */
62
+ export function windowsNpxLine(platform, env, nodeDir, exists) {
63
+ if (WINDOWS !== platform) {
64
+ return undefined;
65
+ }
66
+ // Windows reads environment names without letter case: `Path` and `PATH` are one variable.
67
+ const key = Object.keys(env).find((name) => PATH_KEY === name.toLowerCase());
68
+ const path = key === undefined ? '' : (env[key] ?? '');
69
+ const onPath = path
70
+ .split(WINDOWS_PATH_SEPARATOR)
71
+ .map((dir) => dir.trim().replace(/^"|"$/g, ''))
72
+ .some((dir) => dir.length > 0 && exists(win32.join(dir, NPX_WINDOWS)));
73
+ if (onPath) {
74
+ return undefined;
75
+ }
76
+ return (`${NPX_WINDOWS} is not on PATH, so your agent cannot start the Reticle MCP server with npx. ` +
77
+ `Add ${nodeDir} to PATH, then open a new terminal.`);
78
+ }
79
+ /** Run a probe; a probe that fails gives no line. */
80
+ async function quietly(probe) {
81
+ try {
82
+ return await probe();
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ /** The warning lines, in a fixed order. Empty when every check passes. */
89
+ export async function firstUseWarnings(facts) {
90
+ const lines = [
91
+ nodeTooOldLine(facts.nodeVersion, facts.nodeRange),
92
+ windowsNpxLine(facts.platform, facts.env, facts.nodeDir, facts.exists),
93
+ ];
94
+ const probe = await quietly(facts.chromium);
95
+ lines.push(probe === undefined ? undefined : chromiumMissingLine(probe));
96
+ const { port, portPresence, describePort } = facts;
97
+ if (port !== undefined && portPresence !== undefined && describePort !== undefined) {
98
+ const presence = await quietly(portPresence);
99
+ lines.push(PortPresence.FOREIGN === presence ? describePort(presence, port) : undefined);
100
+ }
101
+ return lines.flatMap((line) => (line === undefined ? [] : [`${WARNING_PREFIX}${line}`]));
102
+ }
103
+ /** This machine's facts. */
104
+ function thisMachine(port) {
105
+ return {
106
+ nodeVersion: process.versions.node,
107
+ nodeRange: SERVER_NODE_RANGE,
108
+ platform: process.platform,
109
+ env: process.env,
110
+ nodeDir: dirname(process.execPath),
111
+ exists: existsSync,
112
+ chromium: () => probeChromiumWithFallback(),
113
+ ...(port === undefined
114
+ ? {}
115
+ : {
116
+ port,
117
+ portPresence: () => probePresence(port, { tcpOpen: probeDaemon, status: fetchStatus }),
118
+ describePort: describePortState,
119
+ }),
120
+ };
121
+ }
122
+ /** Write the warnings for this machine to stderr. `port` adds the bridge port check. */
123
+ export async function writeFirstUseWarnings(port) {
124
+ for (const line of await firstUseWarnings(thisMachine(port))) {
125
+ process.stderr.write(`${line}\n`);
126
+ }
127
+ }
@@ -0,0 +1,3 @@
1
+ import { type FirstVerdictPorts } from './first-verdict.js';
2
+ /** Bare `reticle` in an unwired folder. Returns the exit code. `stop` records an onboarding stop. */
3
+ export declare function cmdFirstVerdict(port: number, headed: boolean, stop: FirstVerdictPorts['stop']): Promise<number>;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The first verdict, wired to this machine: the dev server, the daemon and the Reticle browser.
3
+ * Every decision is in `first-verdict.ts`; this file only gathers facts and runs effects.
4
+ */
5
+ import { join } from 'node:path';
6
+ import { EventType, PredicateKind, QueryBy, ReticleTool, SnapshotMode, asRecord, } from '@reticlehq/core';
7
+ import { OnboardingStop } from '@reticlehq/core/telemetry';
8
+ import { RETICLE_VERIFY_DEFAULT_PORT } from '../../index.js';
9
+ import { DEV_SERVER_PORTS } from './ports/resolve/cli-port.js';
10
+ import { WELL_KNOWN_RETICLE_PORTS } from './ports/sibling-ports.js';
11
+ import { probeDevServerStates } from '../../portal/session/dev-server/dev-server-probe.js';
12
+ import { detectDevCommandInProject, } from '../../portal/session/dev-server/dev-command.js';
13
+ import { probePage } from '../setup/node-effects.js';
14
+ import { startDevServerAndWait } from '../setup/start-dev-server.js';
15
+ import { defaultPairingTokenDir, readOrCreatePairingTokenSync, } from '../../portal/bridge/pairing-token.js';
16
+ import { acquireLease, connectOverSse, endpointFor, releaseLease, verdictOf, } from './adhoc-verdict.js';
17
+ import { ensureTryDaemon } from './try-command.js';
18
+ import { writeFirstUseWarnings } from './first-use-checks.js';
19
+ import { AppChoice, MSG_NO_APP, chooseApp, linksToOpen, pageFactsOf, runFirstVerdict, } from './first-verdict.js';
20
+ /** How long a dev server this command started gets to serve its first page. */
21
+ const DEV_START_BUDGET_MS = 120_000;
22
+ /** How long a page gets to come back after a navigation, and to go quiet after it. */
23
+ const PAGE_ARRIVAL_MS = 15_000;
24
+ const PAGE_SETTLE_MS = 5_000;
25
+ const CALL_SLACK_MS = 5_000;
26
+ /** The most events read for one page, and the most links read off the start page. */
27
+ const EVENTS_READ = 500;
28
+ const LINKS_READ = 100;
29
+ const HREF = 'href';
30
+ /** The tool-call fields that carry each answer, for `verdictOf`. */
31
+ const NAV_FIELD = 'ok';
32
+ const EVENTS_FIELD = 'events';
33
+ const ELEMENTS_FIELD = 'elements';
34
+ const STATUS_FIELD = 'status';
35
+ /** `reticle_look` answers the find and the page read that the merged surface no longer names alone. */
36
+ const LOOK_FIND = 'find';
37
+ const LOOK_PAGE = 'page';
38
+ /** The bridge, the bench daemon and the verify endpoint. None of them is an app. */
39
+ const RETICLE_PORTS = new Set([
40
+ ...WELL_KNOWN_RETICLE_PORTS,
41
+ RETICLE_VERIFY_DEFAULT_PORT,
42
+ ]);
43
+ /** Only these events decide the smoke check. */
44
+ const SMOKE_EVENTS = [EventType.NET_REQUEST, EventType.CONSOLE_ERROR, EventType.ERROR_UNCAUGHT];
45
+ /** Start the folder's dev command and wait until it serves a page, or say why it did not. */
46
+ async function startDevServer(command, cwd) {
47
+ const dir = command.directory === undefined ? cwd : join(cwd, command.directory);
48
+ process.stdout.write(`Starting your dev server: ${command.command}\n`);
49
+ const started = await startDevServerAndWait(command.command, dir, DEV_START_BUDGET_MS);
50
+ return 'failed' in started
51
+ ? { lines: [started.failed], stop: OnboardingStop.DEV_SERVER_FAILED }
52
+ : { url: started.url, release: started.stop };
53
+ }
54
+ /** The app in `cwd`, or the one dev server that runs, started when it has to be. */
55
+ async function findApp(cwd) {
56
+ const devCommand = detectDevCommandInProject(cwd);
57
+ const pinned = devCommand?.port;
58
+ const pinnedServing = pinned !== undefined && (await probePage(`http://localhost:${String(pinned)}`)).served;
59
+ // Only outside an app folder: in one, another server is never the app to check.
60
+ const running = devCommand === undefined
61
+ ? (await probeDevServerStates([...DEV_SERVER_PORTS].filter((p) => !RETICLE_PORTS.has(p))))
62
+ .serving
63
+ : [];
64
+ const choice = chooseApp({
65
+ ...(devCommand === undefined ? {} : { devCommand }),
66
+ pinnedServing,
67
+ running,
68
+ });
69
+ if (AppChoice.USE === choice.action) {
70
+ return { url: choice.url };
71
+ }
72
+ if (AppChoice.NONE === choice.action) {
73
+ return choice;
74
+ }
75
+ // `chooseApp` starts a server only for a folder with a dev command.
76
+ return devCommand === undefined
77
+ ? { lines: MSG_NO_APP, stop: OnboardingStop.NO_WEB_APP }
78
+ : startDevServer(devCommand, cwd);
79
+ }
80
+ /** One call through `reticle_run`, the route every tool profile has. */
81
+ function run(caller, tool, args, timeoutMs) {
82
+ return caller.call(ReticleTool.RUN, { tool, args }, timeoutMs);
83
+ }
84
+ /** Wait for the page to go quiet, then read what its document did. */
85
+ async function readPage(caller, sessionId, url, nav) {
86
+ // A page that never goes quiet is still judged on what it did: the wait only bounds the read.
87
+ await run(caller, ReticleTool.WAIT_FOR, { predicate: { kind: PredicateKind.SETTLED }, timeout_ms: PAGE_SETTLE_MS, sessionId }, PAGE_SETTLE_MS + CALL_SLACK_MS).catch(() => undefined);
88
+ const observed = verdictOf(await run(caller, ReticleTool.OBSERVE, {
89
+ sessionId,
90
+ since: 0,
91
+ filters: SMOKE_EVENTS,
92
+ max_events: EVENTS_READ,
93
+ }), EVENTS_FIELD);
94
+ const events = observed?.[EVENTS_FIELD];
95
+ return pageFactsOf(url, Array.isArray(events) ? events : [], nav);
96
+ }
97
+ /** Go to `url` (or reload), and say whether the page came back and where. */
98
+ async function go(caller, sessionId, url) {
99
+ const args = url === undefined
100
+ ? { reload: true, timeout_ms: PAGE_ARRIVAL_MS, sessionId }
101
+ : { url, timeout_ms: PAGE_ARRIVAL_MS, sessionId };
102
+ const result = asRecord(verdictOf(await caller.call(ReticleTool.NAVIGATE, args, PAGE_ARRIVAL_MS + CALL_SLACK_MS), NAV_FIELD));
103
+ const landedOn = result['landedOn'];
104
+ const next = result['sessionId'];
105
+ return {
106
+ arrived: true === result['confirmed'],
107
+ ...('string' === typeof landedOn ? { landedOn } : {}),
108
+ sessionId: 'string' === typeof next ? next : sessionId,
109
+ };
110
+ }
111
+ /** The path of the page on screen, from the page's own status. */
112
+ async function currentPath(caller, sessionId) {
113
+ const answer = verdictOf(await run(caller, ReticleTool.LOOK, {
114
+ action: LOOK_PAGE,
115
+ mode: SnapshotMode.STATUS,
116
+ sessionId,
117
+ }).catch(() => undefined), STATUS_FIELD);
118
+ const route = asRecord(answer?.[STATUS_FIELD])['route'];
119
+ return 'string' === typeof route ? route : undefined;
120
+ }
121
+ /** The smoke check, through the daemon on `port`, in a Reticle browser with the zero-install reader. */
122
+ async function smokeThroughDaemon(port, url, headed) {
123
+ const unavailable = await ensureTryDaemon(port);
124
+ if (unavailable !== undefined) {
125
+ return { failed: [unavailable] };
126
+ }
127
+ const caller = await connectOverSse(endpointFor(port, readOrCreatePairingTokenSync(defaultPairingTokenDir())));
128
+ try {
129
+ const opened = await acquireLease(caller, url, headed);
130
+ if ('failed' in opened) {
131
+ return opened;
132
+ }
133
+ try {
134
+ // Reload once: the reader then runs from the start of the document, so it sees what the
135
+ // first load did (a console error during start-up) and not only what came after.
136
+ const back = await go(caller, opened.leased, undefined);
137
+ const path = await currentPath(caller, back.sessionId);
138
+ // The path only: the lease puts its own parameters on the url, and the page is not a link to open.
139
+ const here = path === undefined ? url : new URL(new URL(path, url).pathname, url).href;
140
+ const startNav = {
141
+ arrived: back.arrived,
142
+ ...(new URL(here).pathname === new URL(url).pathname ? {} : { landedOn: here }),
143
+ };
144
+ const start = await readPage(caller, back.sessionId, url, startNav);
145
+ const found = verdictOf(await run(caller, ReticleTool.LOOK, {
146
+ action: LOOK_FIND,
147
+ by: QueryBy.ROLE,
148
+ value: 'link',
149
+ attrs: [HREF],
150
+ limit: LINKS_READ,
151
+ sessionId: back.sessionId,
152
+ }), ELEMENTS_FIELD);
153
+ const elements = Array.isArray(found?.[ELEMENTS_FIELD]) ? found[ELEMENTS_FIELD] : [];
154
+ const links = elements.flatMap((item) => {
155
+ const element = asRecord(item);
156
+ const href = asRecord(element['attrs'])[HREF];
157
+ const name = element['name'];
158
+ return 'string' === typeof href
159
+ ? [{ name: 'string' === typeof name ? name : '', href }]
160
+ : [];
161
+ });
162
+ const picked = linksToOpen(here, links);
163
+ const visited = [];
164
+ let sessionId = back.sessionId;
165
+ for (const target of picked.open) {
166
+ const nav = await go(caller, sessionId, target);
167
+ sessionId = nav.sessionId;
168
+ visited.push(await readPage(caller, sessionId, target, nav));
169
+ }
170
+ return { start, links: visited, linksFound: picked.found };
171
+ }
172
+ finally {
173
+ await releaseLease(caller, opened.leased);
174
+ }
175
+ }
176
+ finally {
177
+ await caller.close().catch(() => undefined);
178
+ }
179
+ }
180
+ /** Bare `reticle` in an unwired folder. Returns the exit code. `stop` records an onboarding stop. */
181
+ export async function cmdFirstVerdict(port, headed, stop) {
182
+ await writeFirstUseWarnings();
183
+ return runFirstVerdict({
184
+ findApp: () => findApp(process.cwd()),
185
+ smoke: (url) => smokeThroughDaemon(port, url, headed),
186
+ out: (line) => process.stdout.write(`${line}\n`),
187
+ stop,
188
+ });
189
+ }