@reticlehq/server 2.5.0 → 2.7.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 (182) hide show
  1. package/SKILL.md +226 -26
  2. package/dist/bridge/bridge.d.ts +18 -1
  3. package/dist/bridge/bridge.js +59 -6
  4. package/dist/bridge/bridge.test-harness.d.ts +8 -0
  5. package/dist/bridge/bridge.test-harness.js +9 -1
  6. package/dist/cli/chromium-hint.d.ts +40 -0
  7. package/dist/cli/chromium-hint.js +75 -0
  8. package/dist/cli/cli-doctor.d.ts +0 -5
  9. package/dist/cli/cli-doctor.js +74 -14
  10. package/dist/cli/cli-launch.d.ts +1 -0
  11. package/dist/cli/cli-launch.js +4 -1
  12. package/dist/cli/cli-parse-feedback.d.ts +31 -0
  13. package/dist/cli/cli-parse-feedback.js +56 -0
  14. package/dist/cli/cli-parse.d.ts +5 -9
  15. package/dist/cli/cli-parse.js +7 -23
  16. package/dist/cli/cli-port.js +20 -1
  17. package/dist/cli/doctor-daemon-line.d.ts +37 -0
  18. package/dist/cli/doctor-daemon-line.js +44 -0
  19. package/dist/cli/port-holder.d.ts +43 -0
  20. package/dist/cli/port-holder.js +99 -0
  21. package/dist/cli.js +170 -19
  22. package/dist/daemon/daemon-resilience.d.ts +24 -0
  23. package/dist/daemon/daemon-resilience.js +105 -17
  24. package/dist/daemon/daemon.js +26 -1
  25. package/dist/daemon/heartbeat.d.ts +115 -0
  26. package/dist/daemon/heartbeat.js +141 -0
  27. package/dist/daemon/idle-shutdown.d.ts +13 -5
  28. package/dist/daemon/idle-shutdown.js +14 -6
  29. package/dist/daemon/loopback-alias.d.ts +32 -0
  30. package/dist/daemon/loopback-alias.js +80 -0
  31. package/dist/daemon/port-presence.d.ts +76 -0
  32. package/dist/daemon/port-presence.js +93 -0
  33. package/dist/daemon/state-dir.d.ts +7 -0
  34. package/dist/daemon/state-dir.js +59 -0
  35. package/dist/daemon/unclean-predecessor.d.ts +23 -0
  36. package/dist/daemon/unclean-predecessor.js +27 -0
  37. package/dist/events/predicate-eval.d.ts +17 -3
  38. package/dist/events/predicate-eval.js +183 -96
  39. package/dist/events/predicate-parse.d.ts +23 -0
  40. package/dist/events/predicate-parse.js +68 -0
  41. package/dist/events/predicate.d.ts +5 -0
  42. package/dist/events/predicate.js +17 -3
  43. package/dist/events/ring-buffer.d.ts +8 -0
  44. package/dist/events/ring-buffer.js +35 -17
  45. package/dist/flows/flow-replay.d.ts +1 -1
  46. package/dist/flows/flow-replay.js +19 -2
  47. package/dist/flows/flow-step-runners.js +2 -3
  48. package/dist/flows/flow-tools.js +10 -14
  49. package/dist/flows/role-drift-reason.d.ts +20 -0
  50. package/dist/flows/role-drift-reason.js +39 -0
  51. package/dist/flows/suite-flakes.d.ts +33 -0
  52. package/dist/flows/suite-flakes.js +32 -0
  53. package/dist/honesty/blind-spots.d.ts +19 -0
  54. package/dist/honesty/blind-spots.js +33 -0
  55. package/dist/honesty/honesty.d.ts +11 -0
  56. package/dist/honesty/honesty.js +9 -1
  57. package/dist/honesty/verified.d.ts +10 -1
  58. package/dist/honesty/verified.js +42 -3
  59. package/dist/http-server.js +20 -1
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +36 -3
  62. package/dist/init/agent-rules.d.ts +18 -0
  63. package/dist/init/agent-rules.js +28 -5
  64. package/dist/init/cra.d.ts +13 -0
  65. package/dist/init/cra.js +16 -0
  66. package/dist/init/detect.d.ts +14 -0
  67. package/dist/init/detect.js +23 -0
  68. package/dist/init/mcp-clients.d.ts +81 -0
  69. package/dist/init/mcp-clients.js +248 -0
  70. package/dist/init/node-io.js +8 -2
  71. package/dist/init/non-js-project.d.ts +27 -0
  72. package/dist/init/non-js-project.js +60 -0
  73. package/dist/init/plan-framework.d.ts +9 -0
  74. package/dist/init/plan-framework.js +55 -3
  75. package/dist/init/plan.d.ts +14 -0
  76. package/dist/init/plan.js +93 -12
  77. package/dist/init/run.d.ts +18 -1
  78. package/dist/init/run.js +169 -23
  79. package/dist/init/snippets.d.ts +20 -1
  80. package/dist/init/snippets.js +71 -6
  81. package/dist/init/windows-quote.d.ts +38 -0
  82. package/dist/init/windows-quote.js +70 -0
  83. package/dist/mcp/mcp-outage.d.ts +1 -0
  84. package/dist/mcp/mcp-outage.js +8 -1
  85. package/dist/mcp/mcp-proxy.d.ts +11 -1
  86. package/dist/mcp/mcp-proxy.js +39 -9
  87. package/dist/mcp/mcp.js +64 -2
  88. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  89. package/dist/mcp/proxy-lifecycle.js +21 -0
  90. package/dist/pool/browser-pool.d.ts +9 -0
  91. package/dist/pool/browser-pool.js +38 -9
  92. package/dist/pool/playwright-launcher.js +10 -2
  93. package/dist/session/attachment-history.d.ts +46 -0
  94. package/dist/session/attachment-history.js +65 -0
  95. package/dist/session/live-control-tools.js +39 -2
  96. package/dist/session/live-control.d.ts +7 -9
  97. package/dist/session/live-control.js +34 -1
  98. package/dist/session/no-session-diagnosis.d.ts +11 -4
  99. package/dist/session/no-session-diagnosis.js +95 -14
  100. package/dist/session/no-session-watch.d.ts +3 -1
  101. package/dist/session/no-session-watch.js +10 -2
  102. package/dist/session/pending-commands.js +1 -0
  103. package/dist/session/review-store.d.ts +19 -0
  104. package/dist/session/review-store.js +34 -5
  105. package/dist/session/session-info.d.ts +17 -1
  106. package/dist/session/session-info.js +5 -1
  107. package/dist/session/session-manager.d.ts +12 -0
  108. package/dist/session/session-manager.js +83 -3
  109. package/dist/session/session.d.ts +9 -0
  110. package/dist/session/session.js +20 -0
  111. package/dist/status-payload.d.ts +13 -1
  112. package/dist/status-payload.js +6 -1
  113. package/dist/telemetry/app-instrumented.d.ts +37 -0
  114. package/dist/telemetry/app-instrumented.js +54 -0
  115. package/dist/telemetry/crash-cause.d.ts +47 -0
  116. package/dist/telemetry/crash-cause.js +104 -0
  117. package/dist/telemetry/daemon-telemetry.d.ts +25 -7
  118. package/dist/telemetry/daemon-telemetry.js +32 -13
  119. package/dist/telemetry/error-class.d.ts +26 -0
  120. package/dist/telemetry/error-class.js +52 -0
  121. package/dist/telemetry/feedback-cli.d.ts +7 -2
  122. package/dist/telemetry/feedback-cli.js +49 -5
  123. package/dist/telemetry/feedback-context.d.ts +1 -6
  124. package/dist/telemetry/feedback-context.js +64 -4
  125. package/dist/telemetry/git-facts.d.ts +7 -0
  126. package/dist/telemetry/git-facts.js +4 -4
  127. package/dist/telemetry/project-profile.js +2 -1
  128. package/dist/telemetry/session-metrics.d.ts +45 -3
  129. package/dist/telemetry/session-metrics.js +247 -9
  130. package/dist/telemetry/telemetry.d.ts +21 -1
  131. package/dist/telemetry/telemetry.js +34 -9
  132. package/dist/telemetry/verification-of.js +26 -1
  133. package/dist/tools/act-tools.js +63 -14
  134. package/dist/tools/assert-verdict.d.ts +7 -1
  135. package/dist/tools/assert-verdict.js +22 -3
  136. package/dist/tools/browser-tools.js +8 -2
  137. package/dist/tools/contract-tools.d.ts +0 -5
  138. package/dist/tools/contract-tools.js +26 -0
  139. package/dist/tools/dynamic-tools.js +6 -1
  140. package/dist/tools/error-recovery.d.ts +14 -0
  141. package/dist/tools/error-recovery.js +89 -8
  142. package/dist/tools/feedback-invite.d.ts +48 -0
  143. package/dist/tools/feedback-invite.js +71 -0
  144. package/dist/tools/invoke-tool.d.ts +0 -6
  145. package/dist/tools/invoke-tool.js +76 -7
  146. package/dist/tools/navigate-arrival.d.ts +26 -0
  147. package/dist/tools/navigate-arrival.js +60 -0
  148. package/dist/tools/navigate-result.d.ts +15 -1
  149. package/dist/tools/navigate-result.js +17 -10
  150. package/dist/tools/observe-tools.js +22 -4
  151. package/dist/tools/real-input-attempt.js +10 -5
  152. package/dist/tools/tool-kit.d.ts +60 -5
  153. package/dist/tools/tool-kit.js +55 -24
  154. package/dist/tools/tool-surface.js +13 -0
  155. package/dist/tools/tools.js +22 -0
  156. package/dist/visual/capture-cleanup.d.ts +16 -0
  157. package/dist/visual/capture-cleanup.js +39 -0
  158. package/dist/visual/visual-tools.d.ts +18 -0
  159. package/dist/visual/visual-tools.js +24 -5
  160. package/docs/README.md +55 -0
  161. package/docs/agent-cheatsheet.md +15 -3
  162. package/docs/benchmarks.md +1 -1
  163. package/docs/fixtures-dispatch-receiver.yml +76 -0
  164. package/docs/fixtures.md +17 -0
  165. package/docs/gate-plan.md +98 -0
  166. package/docs/gates.md +117 -0
  167. package/docs/local-registry.md +1 -1
  168. package/docs/matrix/2.5.0/claude-code-machine.json +19 -0
  169. package/docs/matrix/2.5.0/codex-machine.json +20 -0
  170. package/docs/matrix/2.5.0/cursor-machine.json +22 -0
  171. package/docs/matrix/2.5.0/gemini-machine.json +22 -0
  172. package/docs/matrix/2.5.0/opencode-machine.json +22 -0
  173. package/docs/matrix/2.5.0/vscode-machine.json +22 -0
  174. package/docs/matrix/2.5.0/windsurf-machine.json +22 -0
  175. package/docs/matrix/MATRIX.md +26 -0
  176. package/docs/matrix/README.md +59 -0
  177. package/docs/mint.json +1 -1
  178. package/docs/system-map.md +152 -0
  179. package/docs/telemetry-contract.md +15 -3
  180. package/docs/telemetry.md +16 -1
  181. package/docs/token-efficiency.md +2 -2
  182. package/package.json +2 -2
package/dist/init/plan.js CHANGED
@@ -6,15 +6,18 @@
6
6
  import { Framework, PackageManager, UiLibrary, installCommand, installCommandParts, } from './detect.js';
7
7
  import { claudeAddCommand, mcpManual } from './mcp.js';
8
8
  import { mergeCursorConfig, CursorMergeStatus, cursorServerEntry } from './cursor.js';
9
+ import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, } from './mcp-clients.js';
9
10
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from './slash-command.js';
10
11
  import { mergeMarkedInstruction, cursorRuleFile, AgentRuleStatus, CLAUDE_MD_PATH, AGENTS_MD_PATH, CURSOR_RULE_PATH, } from './agent-rules.js';
11
- import { viteSteps, nextSteps, craSteps, svelteKitSteps, astroSteps, VITE_PLUGIN_DETAIL, } from './plan-framework.js';
12
+ import { viteSteps, nextSteps, craSteps, svelteKitSteps, nuxtSteps, astroSteps, VITE_PLUGIN_DETAIL, } from './plan-framework.js';
12
13
  import { htmlManual, reticleConfigContent, unverifiedUiLibraryNote } from './snippets.js';
13
14
  // An app dev installs exactly the audience-scoped browser-side dependencies — never the retired
14
15
  // `@reticlehq/core` umbrella (which dragged the Node MCP server + ws into every app). The kit is the
15
16
  // framework adapter (it re-exports the browser sensor), paired with that framework's dev-only build
16
17
  // plugin for source mapping + connect injection.
17
18
  const RETICLE_REACT_KIT = '@reticlehq/react';
19
+ /** The framework-neutral sensor, for stacks the React adapter has nothing to attach to. */
20
+ const RETICLE_BROWSER_SDK = '@reticlehq/browser';
18
21
  const RETICLE_VITE_PLUGIN = '@reticlehq/vite-plugin';
19
22
  const RETICLE_NEXT_PLUGIN = '@reticlehq/next';
20
23
  /**
@@ -32,7 +35,7 @@ export function pinnedPackages(packages, version) {
32
35
  return packages.map((p) => `${p}@${version}`);
33
36
  }
34
37
  /** The dev-dependencies `reticle init` installs for a given framework — kit first, build plugin next. */
35
- function frameworkPackages(framework) {
38
+ export function frameworkPackages(framework) {
36
39
  switch (framework) {
37
40
  case Framework.NEXT:
38
41
  return [RETICLE_REACT_KIT, RETICLE_NEXT_PLUGIN];
@@ -40,6 +43,11 @@ function frameworkPackages(framework) {
40
43
  case Framework.SVELTEKIT:
41
44
  // SvelteKit builds on Vite; until a dedicated Svelte kit exists it uses the Vite build plugin.
42
45
  return [RETICLE_REACT_KIT, RETICLE_VITE_PLUGIN];
46
+ case Framework.NUXT:
47
+ // The framework-neutral sensor, NOT the React kit. Nuxt renders Vue, and installing a package
48
+ // named @reticlehq/react — with `react` in its peer dependencies — into a Vue codebase is the
49
+ // single thing most likely to make someone abandon the setup, whether or not it works.
50
+ return [RETICLE_BROWSER_SDK];
43
51
  case Framework.ASTRO:
44
52
  // Astro owns its own Vite instance and renders its own HTML, so there is no config for the
45
53
  // plugin to attach to — the kit alone, connected from a page <script> (see astroManual).
@@ -67,6 +75,7 @@ const CONNECT_STEP_TITLES = new Set([
67
75
  'Connect snippet',
68
76
  'Connect snippet (CRA)',
69
77
  'Connect snippet (Astro)',
78
+ 'Connect snippet (Nuxt)',
70
79
  'Reticle client hook',
71
80
  'Reticle connect module',
72
81
  'ReticleDev component',
@@ -142,6 +151,49 @@ function cursorMcpStep(input) {
142
151
  write: { path: input.cursorConfigPath, content: r.content },
143
152
  };
144
153
  }
154
+ /**
155
+ * One step per OTHER detected client.
156
+ *
157
+ * The shapes differ enough that a shared "write mcp.json" would be wrong for three of them — see
158
+ * mcp-clients.ts. What is shared is the DECISION: already-correct is left alone, a file we cannot
159
+ * parse is reported with a paste-able block rather than overwritten, and everything else is written.
160
+ */
161
+ function otherClientSteps(input) {
162
+ const steps = [];
163
+ for (const detected of input.detectedClients ?? []) {
164
+ const spec = clientSpec(detected.id);
165
+ const merged = mergeClientConfig(spec, detected.existing);
166
+ const title = `MCP server (${spec.label})`;
167
+ if (merged.status === ClientMergeStatus.ALREADY) {
168
+ steps.push({
169
+ title,
170
+ target: detected.configPath,
171
+ status: StepStatus.ALREADY,
172
+ detail: `reticle already registered with ${spec.label}`,
173
+ });
174
+ continue;
175
+ }
176
+ if (merged.status === ClientMergeStatus.MANUAL) {
177
+ // Either the file did not parse, or the format is one we refuse to edit blind (TOML). Both
178
+ // end the same way: say so, and hand over the exact block.
179
+ steps.push({
180
+ title,
181
+ target: detected.configPath,
182
+ status: StepStatus.MANUAL,
183
+ detail: `add this to ${detected.configPath} by hand:\n${clientSnippet(spec)}`,
184
+ });
185
+ continue;
186
+ }
187
+ steps.push({
188
+ title,
189
+ target: detected.configPath,
190
+ status: StepStatus.APPLY,
191
+ detail: `register reticle with ${spec.label}`,
192
+ write: { path: detected.configPath, content: merged.content },
193
+ });
194
+ }
195
+ return steps;
196
+ }
145
197
  /** One global registration per detected agent (Claude + Cursor). Falls back to a manual note. */
146
198
  function mcpSteps(input) {
147
199
  if (!input.options.mcp) {
@@ -157,7 +209,10 @@ function mcpSteps(input) {
157
209
  },
158
210
  ];
159
211
  }
160
- const steps = stepsForAgents(input, (a) => a.mcpStep);
212
+ // Claude and Cursor first (they have their own registration paths), then every other detected
213
+ // client. A machine with Cursor AND Windsurf gets both — registering only the first one found is
214
+ // how a user ends up with Reticle in the editor they were not using.
215
+ const steps = [...stepsForAgents(input, (a) => a.mcpStep), ...otherClientSteps(input)];
161
216
  if (steps.length > 0)
162
217
  return steps;
163
218
  // No supported agent detected — print the one-time global instructions. Reached when the `claude`
@@ -348,13 +403,30 @@ function agentRuleSteps(input) {
348
403
  * with nothing naming a version. Pinning turns that into this loud failure, which is the better
349
404
  * trade — but only if the message says what to do about it.
350
405
  */
351
- /** Said out loud when the exact-version install was refused and the unpinned one worked. */
352
- function unpinnedRetryNote(version) {
406
+ /**
407
+ * Said out loud when the exact-version install failed and the unpinned one worked.
408
+ *
409
+ * It used to assert a cause it cannot know. Every one of nine fixture apps got the same sentence —
410
+ * "the registry refused 2.5.0 (pnpm's minimumReleaseAge holds new releases back)" — when the actual
411
+ * cause on that run was that the version did not exist yet, and the remedy offered was a `pnpm
412
+ * config` command handed to a yarn 1 project that will never read it.
413
+ *
414
+ * This note is built at PLAN time, before anything runs, and `io.exec` returns a bare boolean, so
415
+ * the apply layer has no failure text to hand back either. The honest move is therefore to report
416
+ * the CONSEQUENCE (which is certain and is the part that bites) and offer the remedy that belongs to
417
+ * the manager actually in use — rather than name a cause that is one possibility among several.
418
+ */
419
+ function unpinnedRetryNote(version, pm) {
353
420
  const wanted = version === undefined ? 'the exact version' : version;
354
- return (`the registry refused ${wanted} (pnpm's minimumReleaseAge holds new releases back), so the ` +
355
- `newest ACCEPTED version was installed instead. That may not match the daemon — if the agent ` +
356
- `reports protocol errors, check \`versionSkew\` in reticle_sessions, then either wait out the ` +
357
- `window or allow these packages: pnpm config set minimumReleaseAgeExclude "@reticlehq/*"`);
421
+ // Kept verbatim for pnpm, where minimumReleaseAge is a real and common cause with a real remedy.
422
+ const remedy = pm === PackageManager.PNPM
423
+ ? ' One common cause on pnpm is minimumReleaseAge holding a new release back; if pnpm ' +
424
+ 'reported ERR_PNPM_NO_MATURE_MATCHING_VERSION, either wait out the window or allow these ' +
425
+ 'packages: pnpm config set minimumReleaseAgeExclude "@reticlehq/*"'
426
+ : '';
427
+ return (`the pinned install of ${wanted} failed, so the newest version the registry WOULD accept was ` +
428
+ `installed instead. That may not match the daemon — if the agent reports protocol errors, check ` +
429
+ `\`versionSkew\` in reticle_sessions.${remedy}`);
358
430
  }
359
431
  function installFailureHint(pm) {
360
432
  if (pm !== PackageManager.PNPM)
@@ -392,7 +464,7 @@ function installStep(input) {
392
464
  // release-age hold gets a working install instead of no install.
393
465
  retry: {
394
466
  ...installCommandParts(pm, frameworkPackages(input.detection.framework)),
395
- note: unpinnedRetryNote(input.options.sdkVersion),
467
+ note: unpinnedRetryNote(input.options.sdkVersion, pm),
396
468
  },
397
469
  };
398
470
  }
@@ -420,8 +492,14 @@ function reticleConfigStep(input) {
420
492
  */
421
493
  function uiLibraryStep(input) {
422
494
  const lib = input.detection.uiLibrary;
423
- if (lib === UiLibrary.REACT || input.detection.framework === Framework.SVELTEKIT)
495
+ // Nuxt and SvelteKit each carry their own unverified wording inside the recipe, so a second,
496
+ // more generic notice beside it would only argue with the specific one.
497
+ const framework = input.detection.framework;
498
+ if (lib === UiLibrary.REACT ||
499
+ framework === Framework.SVELTEKIT ||
500
+ framework === Framework.NUXT) {
424
501
  return [];
502
+ }
425
503
  if (lib === UiLibrary.UNKNOWN)
426
504
  return [];
427
505
  return [
@@ -454,6 +532,9 @@ export function buildPlan(input) {
454
532
  else if (input.detection.framework === Framework.CRA) {
455
533
  steps.push(...craSteps(input));
456
534
  }
535
+ else if (input.detection.framework === Framework.NUXT) {
536
+ steps.push(...nuxtSteps(input));
537
+ }
457
538
  else if (input.detection.framework === Framework.SVELTEKIT) {
458
539
  steps.push(...svelteKitSteps(input));
459
540
  // The Vite plugin as well as the client hook. `init` already INSTALLS @reticlehq/vite-plugin for
@@ -466,7 +547,7 @@ export function buildPlan(input) {
466
547
  title: 'Connect snippet',
467
548
  target: 'index.html',
468
549
  status: StepStatus.MANUAL,
469
- detail: htmlManual(input.options.port, input.options.projectId),
550
+ detail: htmlManual(input.options.port, input.options.projectId, input.pairingToken),
470
551
  });
471
552
  }
472
553
  return { framework: input.detection.framework, steps };
@@ -7,8 +7,15 @@
7
7
  * Resolve the lockfiles set used to pick the package manager. A lockfile in the project root wins;
8
8
  * otherwise we walk UP the directory tree (monorepos keep the lockfile at the workspace root, not in
9
9
  * each package) so `reticle init` in a sub-package suggests `pnpm add` instead of defaulting to `npm i`.
10
+ *
11
+ * The walk is skipped when the project has its own installed tree, because an INHERITED lockfile is
12
+ * weaker evidence than a `node_modules` sitting right there — the ancestor describes the workspace,
13
+ * the tree describes THIS package. Reported from the field: `init` in a `frontend/` app installed
14
+ * with npm emitted `pnpm add -D` off a repo-root `pnpm-lock.yaml`, pnpm was not on PATH, and the
15
+ * failed install took every downstream wiring step with it. A LOCAL lockfile still wins over the
16
+ * tree — it is a deliberate statement about this package, not an inheritance.
10
17
  */
11
- export declare function resolveLockfiles(rootFiles: ReadonlySet<string>, cwd: string, io: Pick<InitIo, 'exists'>): Set<string>;
18
+ export declare function resolveLockfiles(rootFiles: ReadonlySet<string>, cwd: string, io: Pick<InitIo, 'exists'>, nodeModulesMarkers?: ReadonlySet<string>): Set<string>;
12
19
  export interface InitOptions {
13
20
  cwd: string;
14
21
  port: number | undefined;
@@ -59,5 +66,15 @@ interface InitResult {
59
66
  * it knows it is in a workspace; this is the matching walk DOWN.
60
67
  */
61
68
  export declare function findWorkspaceApps(io: Pick<InitIo, 'exists' | 'readFile' | 'listDirs'>): string[];
69
+ /**
70
+ * The standing ask, printed at the end of every `init`.
71
+ *
72
+ * Addressed to the agent as much as the human: whichever of them just ran this command is the one
73
+ * holding the experience, and neither has a Reticle tool surface to file through at this point.
74
+ *
75
+ * Printed by the `runInit` wrapper, NOT by `report()` — see the comment there for why, and for how
76
+ * the workspace redirect is kept from asking twice.
77
+ */
78
+ export declare const FEEDBACK_HINT: string;
62
79
  export declare function runInit(options: InitOptions, io: InitIo): InitResult;
63
80
  export {};
package/dist/init/run.js CHANGED
@@ -4,16 +4,17 @@
4
4
  * access goes through `InitIo` so the orchestration is unit-testable with an in-memory IO.
5
5
  */
6
6
  import { dirname, join } from 'node:path';
7
+ import { noPackageJsonMessage } from './non-js-project.js';
7
8
  import { spanSync } from '../trace.js';
8
9
  import { readFileSync } from 'node:fs';
9
10
  import { homedir } from 'node:os';
10
- import { detect, Framework } from './detect.js';
11
+ import { detect, Framework, namesAPackageManager } from './detect.js';
11
12
  import { wasMcpRegistered } from './mcp-registered.js';
12
13
  import { pickAstroHost } from './astro-host.js';
13
14
  import { workspaceParents } from './workspace-apps.js';
14
15
  import { chooseWorkspaceApp } from './app-choice.js';
15
16
  import { isConnectStep } from './plan.js';
16
- import { CURSOR_RULE_PATH } from './agent-rules.js';
17
+ import { CLI, CURSOR_RULE_PATH } from './agent-rules.js';
17
18
  import { CRA_ENV_PATH } from './cra.js';
18
19
  /** CRA's bundled entry, in the order create-react-app itself generates them. */
19
20
  const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
@@ -40,11 +41,12 @@ function readPairingToken() {
40
41
  return '';
41
42
  }
42
43
  }
43
- import { DEPS_TARGET, MCP_TARGET, buildPlan, StepStatus, } from './plan.js';
44
+ import { DEPS_TARGET, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan.js';
44
45
  import { claudeAvailableProbe, claudeExistsProbe } from './mcp.js';
45
46
  import { reticleDevLocation } from './next-patch.js';
46
47
  import { scanTestids, storeHints } from './capabilities.js';
47
48
  import { CURSOR_DIR_RELPATH, CURSOR_MCP_RELPATH } from './cursor.js';
49
+ import { fileBackedClients, clientMarkerRelPath, ConfigScope, McpClient } from './mcp-clients.js';
48
50
  import { deriveProjectId, packageName } from './project-id.js';
49
51
  import { VITE_DEV_MODULE_PATH } from './snippets.js';
50
52
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './slash-command.js';
@@ -62,11 +64,20 @@ const LOCKFILE_NAMES = [
62
64
  * Resolve the lockfiles set used to pick the package manager. A lockfile in the project root wins;
63
65
  * otherwise we walk UP the directory tree (monorepos keep the lockfile at the workspace root, not in
64
66
  * each package) so `reticle init` in a sub-package suggests `pnpm add` instead of defaulting to `npm i`.
67
+ *
68
+ * The walk is skipped when the project has its own installed tree, because an INHERITED lockfile is
69
+ * weaker evidence than a `node_modules` sitting right there — the ancestor describes the workspace,
70
+ * the tree describes THIS package. Reported from the field: `init` in a `frontend/` app installed
71
+ * with npm emitted `pnpm add -D` off a repo-root `pnpm-lock.yaml`, pnpm was not on PATH, and the
72
+ * failed install took every downstream wiring step with it. A LOCAL lockfile still wins over the
73
+ * tree — it is a deliberate statement about this package, not an inheritance.
65
74
  */
66
- export function resolveLockfiles(rootFiles, cwd, io) {
75
+ export function resolveLockfiles(rootFiles, cwd, io, nodeModulesMarkers = new Set()) {
67
76
  const set = new Set(rootFiles);
68
77
  if (LOCKFILE_NAMES.some((name) => set.has(name)))
69
78
  return set; // local lockfile is authoritative
79
+ if (namesAPackageManager(nodeModulesMarkers))
80
+ return set;
70
81
  let dir = cwd;
71
82
  for (let depth = 0; depth < 50; depth++) {
72
83
  for (const name of LOCKFILE_NAMES) {
@@ -83,6 +94,7 @@ export function resolveLockfiles(rootFiles, cwd, io) {
83
94
  return set;
84
95
  }
85
96
  const PACKAGE_JSON = 'package.json';
97
+ const NODE_MODULES_DIR = 'node_modules';
86
98
  /**
87
99
  * Root-layout candidates, App Router only. `--src-dir` apps keep theirs under `src/app`, and the
88
100
  * ReticleDev component has to land NEXT TO the layout or the relative import it generates is dead.
@@ -197,13 +209,15 @@ function gatherPlanInput(options, io, pkgRaw) {
197
209
  // Stable identity derived from the app's package.json name + root, so it survives port changes.
198
210
  const projectId = deriveProjectId(packageName(pkg), options.cwd);
199
211
  const rootFiles = new Set(io.rootFiles());
212
+ const nodeModulesMarkers = new Set(io.listFiles('node_modules'));
200
213
  const detectInput = {
201
214
  pkg: 'object' === typeof pkg && pkg !== null ? pkg : {},
202
215
  configFiles: rootFiles,
203
- // Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager.
204
- lockfiles: resolveLockfiles(rootFiles, options.cwd, io),
216
+ // Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager —
217
+ // unless this package's own installed tree already answers it, which outranks an inherited one.
218
+ lockfiles: resolveLockfiles(rootFiles, options.cwd, io, nodeModulesMarkers),
205
219
  // An already-installed tree names its own manager, which matters when no lockfile is committed.
206
- nodeModulesMarkers: new Set(io.listFiles('node_modules')),
220
+ nodeModulesMarkers,
207
221
  };
208
222
  const detection = detect(detectInput);
209
223
  const vitePath = firstPresent(rootFiles, VITE_CONFIG_CANDIDATES);
@@ -225,6 +239,27 @@ function gatherPlanInput(options, io, pkgRaw) {
225
239
  // project-level .cursor/ is unambiguous evidence somebody uses it here.
226
240
  const cursorPresent = options.mcp && (io.exists(cursorDir) || io.exists(CURSOR_DIR_RELPATH));
227
241
  const cursorConfig = cursorPresent ? io.readFile(cursorConfigPath) : null;
242
+ // Every OTHER MCP client this machine shows evidence of. Conservative and one-directional: we
243
+ // write into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody
244
+ // who does not use them. Cursor and Claude are handled above by their own steps.
245
+ const detectedClients = options.mcp
246
+ ? fileBackedClients()
247
+ .filter((spec) => spec.id !== McpClient.CURSOR)
248
+ .map((spec) => {
249
+ const marker = clientMarkerRelPath(spec);
250
+ const absolute = spec.scope === ConfigScope.HOME ? join(io.homeDir(), spec.relPath) : spec.relPath;
251
+ const markerPath = spec.scope === ConfigScope.HOME ? join(io.homeDir(), marker) : marker;
252
+ if (!io.exists(markerPath))
253
+ return null;
254
+ return {
255
+ id: spec.id,
256
+ configPath: absolute,
257
+ // io.readFile passes absolute paths through unchanged, so one call covers both scopes.
258
+ existing: io.readFile(absolute),
259
+ };
260
+ })
261
+ .filter((entry) => entry !== null)
262
+ : [];
228
263
  const astroPath = firstPresent(rootFiles, ASTRO_CONFIG_CANDIDATES);
229
264
  const astroSource = null === astroPath ? null : io.readFile(astroPath);
230
265
  // Which file owns the DOCUMENT, not how many files sit in a directory. The old rule ("exactly one
@@ -253,6 +288,7 @@ function gatherPlanInput(options, io, pkgRaw) {
253
288
  claudeCli,
254
289
  mcpExists,
255
290
  cursorPresent,
291
+ detectedClients,
256
292
  cursorProjectPresent: io.exists(CURSOR_DIR_RELPATH),
257
293
  cursorConfig,
258
294
  cursorConfigPath,
@@ -354,21 +390,62 @@ export function findWorkspaceApps(io) {
354
390
  }
355
391
  return [...new Set(found)];
356
392
  }
357
- function restartHint(framework) {
393
+ /**
394
+ * The standing ask, printed at the end of every `init`.
395
+ *
396
+ * Addressed to the agent as much as the human: whichever of them just ran this command is the one
397
+ * holding the experience, and neither has a Reticle tool surface to file through at this point.
398
+ *
399
+ * Printed by the `runInit` wrapper, NOT by `report()` — see the comment there for why, and for how
400
+ * the workspace redirect is kept from asking twice.
401
+ */
402
+ export const FEEDBACK_HINT = 'Anything wrong, missing, or awkward — in this setup or in Reticle itself? Tell us; it is the ' +
403
+ 'one thing that decides what gets fixed:\n' +
404
+ ` ${CLI} feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened" (agents)\n` +
405
+ ` ${CLI} feedback "what worked, what didn't" (humans)`;
406
+ function devServerRestart(framework) {
358
407
  if (framework === Framework.NEXT)
359
- return 'Restart `next dev`, then ask your agent: "List Reticle sessions".';
408
+ return 'Restart `next dev`';
360
409
  if (framework === Framework.VITE)
361
- return 'Restart `vite`, then ask your agent: "List Reticle sessions".';
410
+ return 'Restart `vite`';
362
411
  if (framework === Framework.ASTRO)
363
- return 'Restart `astro dev`, then ask your agent: "List Reticle sessions".';
412
+ return 'Restart `astro dev`';
364
413
  if (framework === Framework.SVELTEKIT)
365
- return 'Restart your dev server (`npm run dev`), then ask your agent: "List Reticle sessions".';
366
- return 'Reload your app on localhost, then ask your agent: "List Reticle sessions".';
414
+ return 'Restart your dev server (`npm run dev`)';
415
+ return 'Reload your app on localhost';
416
+ }
417
+ /**
418
+ * The last two lines a user reads, and the order matters.
419
+ *
420
+ * This used to say only "Restart <dev server>, then ask your agent: List Reticle sessions" — and
421
+ * asking the agent was the one thing that could not work yet. `init` registers the MCP server, but
422
+ * an agent client reads its tool list when it STARTS and never re-reads it, so the session that
423
+ * just ran `init` has no `reticle_*` tools however clean the install was. The user follows the
424
+ * instruction, the agent answers "unknown tool", and the obvious conclusion is that the install
425
+ * failed.
426
+ *
427
+ * So the reload is named FIRST, before the sentence that depends on it. Omitted entirely when this
428
+ * run did not register MCP (`--no-mcp`), where it would be advice about something we did not do.
429
+ */
430
+ function restartHint(framework, mcpRegistered) {
431
+ const dev = `${devServerRestart(framework)}.`;
432
+ if (!mcpRegistered)
433
+ return `${dev} Then ask your agent: "List Reticle sessions".`;
434
+ return (`${dev}\n` +
435
+ "Then reload your agent's MCP tools — `/mcp` in Claude Code, or reload the window in " +
436
+ 'Cursor/VS Code.\n' +
437
+ 'The tools only appear after that: your agent read its tool list before Reticle existed.\n' +
438
+ 'Then ask it: "List Reticle sessions".');
367
439
  }
368
440
  const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
369
441
  'installed stops it booting. Run that install, then re-run `reticle init`.';
370
- function report(plan, dryRun, failed, skipped, degraded, io) {
442
+ function report(plan, dryRun, failed, skipped, degraded, io, projectDir) {
371
443
  io.print(dryRun ? 'reticle init (dry run — no files written)' : 'reticle init');
444
+ // Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
445
+ // field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
446
+ // in `frontend/`, init redirected into it, and the report's `.reticle.json` was true about a
447
+ // directory the reader was not standing in. One line makes every path in the report unambiguous.
448
+ io.print(` in ${projectDir}`);
372
449
  io.print('');
373
450
  let applied = 0;
374
451
  let manual = 0;
@@ -417,7 +494,7 @@ function report(plan, dryRun, failed, skipped, degraded, io) {
417
494
  '"no browser session connected" until then. Everything else is already in place.');
418
495
  io.print('');
419
496
  }
420
- io.print(restartHint(plan.framework));
497
+ io.print(restartHint(plan.framework, wasMcpRegistered(resolvedStatus(plan, MCP_TARGET, failed, skipped))));
421
498
  return { ok: !connectPending, applied, manual };
422
499
  }
423
500
  /**
@@ -429,6 +506,25 @@ function report(plan, dryRun, failed, skipped, degraded, io) {
429
506
  * MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
430
507
  * message; a half-wired app is a broken project.
431
508
  */
509
+ /**
510
+ * Are the SDK packages actually on disk, whatever the install step reported?
511
+ *
512
+ * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
513
+ * not there — that took a dev server down once, and installing Reticle must never be why an app
514
+ * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
515
+ * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
516
+ * init tells the user to install by hand, they do, they re-run init, the install step fails again
517
+ * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
518
+ * a Next 16 app where npm had already installed both packages successfully — leaving an init that
519
+ * could not be retried into working, which is the shape this guard was written to prevent.
520
+ *
521
+ * Reading node_modules answers the real question and costs one `exists` call per package.
522
+ */
523
+ function sdkPackagesPresent(framework, io) {
524
+ const packages = frameworkPackages(framework);
525
+ return (packages.length > 0 &&
526
+ packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
527
+ }
432
528
  function applyEffects(plan, io) {
433
529
  const failed = new Set();
434
530
  const skipped = new Set();
@@ -443,9 +539,24 @@ function applyEffects(plan, io) {
443
539
  }
444
540
  const write = s.write;
445
541
  if (write !== undefined) {
446
- spanSync('init.write', { target: s.target, path: write.path }, () => {
447
- io.writeFile(write.path, write.content);
542
+ // Confirm the EFFECT, not the intention. Reported from the field (#160): init printed
543
+ // `[✓] Reticle config → .reticle.json` and the file was not there afterward. Nothing checked —
544
+ // no arrangement of the filesystem (a read-only mount, a full disk, an antivirus quarantining
545
+ // a new dotfile) could have turned that tick into anything else. A checkmark that cannot fail
546
+ // is decoration, and this one is the first thing a new user reads. Same shape as #139.
547
+ const wrote = spanSync('init.write', { target: s.target, path: write.path }, () => {
548
+ try {
549
+ io.writeFile(write.path, write.content);
550
+ }
551
+ catch {
552
+ return false; // a throw is the loud version of the same failure
553
+ }
554
+ return io.exists(write.path);
448
555
  });
556
+ if (!wrote) {
557
+ failed.add(s.target);
558
+ continue; // do not run this step's exec against a file that is not there
559
+ }
449
560
  }
450
561
  // Bound once so the traced call cannot need a `?? ''` fallback — a default there would turn a
451
562
  // narrowing mistake into an empty command that silently "succeeds".
@@ -469,7 +580,9 @@ function applyEffects(plan, io) {
469
580
  continue;
470
581
  }
471
582
  failed.add(s.target);
472
- if (s.target === DEPS_TARGET)
583
+ // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
584
+ // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
585
+ if (s.target === DEPS_TARGET && !sdkPackagesPresent(plan.framework, io))
473
586
  installFailed = true;
474
587
  }
475
588
  }
@@ -534,17 +647,50 @@ function redirectToWorkspaceApp(options, io, pkgRaw) {
534
647
  return runInit({ ...options, cwd: join(options.cwd, target), redirected: true }, io.scoped(target));
535
648
  }
536
649
  export function runInit(options, io) {
650
+ const result = runInitSteps(options, io);
651
+ // The ask goes here, not in report(): report() is only the success-shaped path, and the exits that
652
+ // matter most are the ones that never reach it — no package.json, an ambiguous workspace — where
653
+ // setup died before anything ran and the person holding the report has the least to go on. This
654
+ // wrapper is the one point every exit passes through.
655
+ //
656
+ // `redirected` is what keeps it to ONE print: wiring an app in a monorepo re-enters runInit for the
657
+ // chosen directory, and the inner call must not ask again.
658
+ if (true !== options.redirected) {
659
+ io.print('');
660
+ io.print(FEEDBACK_HINT);
661
+ }
662
+ return result;
663
+ }
664
+ function runInitSteps(options, io) {
537
665
  const pkgRaw = io.readFile(PACKAGE_JSON);
666
+ // Look for the app BEFORE concluding there isn't one.
667
+ //
668
+ // A root with no package.json is not a dead end — it is the ordinary shape of a repo whose app
669
+ // lives one directory down (`frontend/`, `web/`, `client/`) with no manifest at the top. Bailing
670
+ // first made that repo un-instrumentable: `reticle init` said "No package.json found", and
671
+ // `--app frontend`, the flag that exists for exactly this, was never read because the bail came
672
+ // first. Reported twice by the same user, who tried the documented workaround and hit the same
673
+ // wall. Discovery already handles the case (it scans top-level directories, not just declared
674
+ // workspaces); it was simply unreachable.
675
+ //
676
+ // `'{}'` because the redirect only needs the manifest to ask "is THIS directory the app", and a
677
+ // directory with no package.json is definitively not.
678
+ const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? '{}');
679
+ if (redirectedEarly !== null)
680
+ return redirectedEarly;
538
681
  if (null === pkgRaw) {
539
- io.print('No package.json found. Run `reticle init` from your project root.');
682
+ io.print(
683
+ // Two genuinely different situations used to share one sentence: a JS developer in the wrong
684
+ // directory, and a project that is not JavaScript at all. The second reads the old wording as
685
+ // a path problem and goes looking for a directory that cannot exist — reported from a
686
+ // Streamlit app, where the search continued into hunting for a browser bundle to inject by
687
+ // hand before the real answer surfaced.
688
+ noPackageJsonMessage((file) => io.exists(join(options.cwd, file))));
540
689
  // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
541
690
  // from someone who never ran the command — the two failure modes with the most different fixes.
542
691
  reportInitOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
543
692
  return { ok: false, applied: 0, manual: 0 };
544
693
  }
545
- const redirected = redirectToWorkspaceApp(options, io, pkgRaw);
546
- if (redirected !== null)
547
- return redirected;
548
694
  // Init is the flow a user experiences the wait of personally, and the fixture gate measures it at
549
695
  // 1–6s per app with no explanation of the spread. These three spans split that number into detect
550
696
  // (filesystem probing), plan (pure), and apply (writes + package-manager and CLI subprocesses).
@@ -553,7 +699,7 @@ export function runInit(options, io) {
553
699
  ? { failed: new Set(), skipped: new Set(), degraded: new Map() }
554
700
  : spanSync('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io));
555
701
  const { failed, skipped, degraded } = effects;
556
- const result = report(plan, options.dryRun, failed, skipped, degraded, io);
702
+ const result = report(plan, options.dryRun, failed, skipped, degraded, io, options.cwd);
557
703
  // A dry run is a preview, not an outcome — reporting it would inflate both success and failure.
558
704
  if (!options.dryRun) {
559
705
  reportInitOutcome({
@@ -47,7 +47,7 @@ export declare const NEXT_LAYOUT_MANUAL = "Mount <ReticleDev /> in your root lay
47
47
  * where a bare `@reticlehq/react` import resolves. A bare import in a plain index.html does NOT resolve in
48
48
  * the browser, so we never tell a bundled app to do that (the old advice silently failed for CRA).
49
49
  */
50
- export declare function htmlManual(port: number | undefined, projectId?: string): string;
50
+ export declare function htmlManual(port: number | undefined, projectId?: string, pairingToken?: string): string;
51
51
  export declare const NEXT_RETICLE_DEV_PATH = "app/reticle-dev.tsx";
52
52
  export declare const SVELTEKIT_HOOKS_PATH = "src/hooks.client.ts";
53
53
  /**
@@ -68,6 +68,25 @@ export declare const UNVERIFIED_FRAMEWORK_NOTE = "Reticle has no SvelteKit app a
68
68
  * a client hook is the reliable path. SvelteKit runs src/hooks.client.ts on the client at startup.
69
69
  */
70
70
  export declare function svelteKitHooksFile(port: number | undefined, projectId?: string): string;
71
+ /** Where a Nuxt dev-only client plugin belongs. `.client` keeps it out of SSR; Nuxt auto-registers it. */
72
+ export declare const NUXT_PLUGIN_PATH = "app/plugins/reticle.client.ts";
73
+ /**
74
+ * The Nuxt recipe, written out in full because every trap in it is one somebody actually hit.
75
+ *
76
+ * Reported from the field, in the order they were hit: `init` classified a Nuxt 4 app as `html`, so
77
+ * it installed a package named `@reticlehq/react` (with `react` in its peer dependencies) into a Vue
78
+ * codebase — the reporter only continued after auditing our dist to confirm there are no React
79
+ * imports at runtime, which most people will not do. It then handed over a snippet guarded on
80
+ * `window.location.hostname === 'localhost'`, which fails twice over in Nuxt: `window` does not
81
+ * exist during SSR, and the dev host here was a hosts-file alias (required for the backend's
82
+ * white-label origin detection), so the guard was false and the connect never ran — no error, no log
83
+ * line, nothing to debug. And nothing said a running dev server does not pick up a new plugin.
84
+ *
85
+ * So: `import.meta.dev` (build-time, host-independent) instead of a hostname check, `.client.ts`
86
+ * instead of an SSR guard, the framework-neutral sensor instead of the React kit, the non-localhost
87
+ * flag named up front, and the restart said out loud.
88
+ */
89
+ export declare function nuxtManual(port: number | undefined, projectId?: string): string;
71
90
  /**
72
91
  * Root-level project config for Reticle. Written by `reticle init`; read by `reticle mcp` for the port
73
92
  * and by tooling for the stable projectId (the app's identity across port changes).