@reticlehq/server 2.6.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 (82) hide show
  1. package/SKILL.md +201 -18
  2. package/dist/bridge/bridge.d.ts +6 -1
  3. package/dist/bridge/bridge.js +27 -4
  4. package/dist/cli/chromium-hint.d.ts +40 -0
  5. package/dist/cli/chromium-hint.js +75 -0
  6. package/dist/cli/cli-doctor.js +3 -11
  7. package/dist/cli/cli-launch.d.ts +1 -0
  8. package/dist/cli/cli-launch.js +4 -1
  9. package/dist/cli/cli-port.js +20 -1
  10. package/dist/cli/doctor-daemon-line.js +10 -1
  11. package/dist/cli.js +40 -9
  12. package/dist/daemon/daemon-resilience.js +2 -2
  13. package/dist/daemon/loopback-alias.d.ts +32 -0
  14. package/dist/daemon/loopback-alias.js +80 -0
  15. package/dist/events/predicate-eval.d.ts +7 -3
  16. package/dist/events/predicate-eval.js +159 -92
  17. package/dist/events/predicate-parse.d.ts +1 -1
  18. package/dist/events/predicate-parse.js +18 -4
  19. package/dist/events/ring-buffer.d.ts +8 -0
  20. package/dist/events/ring-buffer.js +34 -1
  21. package/dist/honesty/honesty.d.ts +11 -0
  22. package/dist/honesty/honesty.js +9 -1
  23. package/dist/honesty/verified.js +26 -3
  24. package/dist/http-server.js +11 -1
  25. package/dist/index.js +26 -2
  26. package/dist/init/agent-rules.js +6 -1
  27. package/dist/init/detect.d.ts +8 -0
  28. package/dist/init/detect.js +15 -0
  29. package/dist/init/non-js-project.d.ts +27 -0
  30. package/dist/init/non-js-project.js +60 -0
  31. package/dist/init/plan-framework.d.ts +9 -0
  32. package/dist/init/plan-framework.js +19 -1
  33. package/dist/init/plan.js +20 -3
  34. package/dist/init/run.js +38 -9
  35. package/dist/init/snippets.d.ts +20 -1
  36. package/dist/init/snippets.js +71 -6
  37. package/dist/mcp/mcp-proxy.js +10 -3
  38. package/dist/mcp/mcp.js +35 -0
  39. package/dist/mcp/proxy-lifecycle.d.ts +19 -0
  40. package/dist/mcp/proxy-lifecycle.js +21 -0
  41. package/dist/pool/playwright-launcher.js +10 -2
  42. package/dist/session/live-control-tools.js +39 -2
  43. package/dist/session/live-control.d.ts +7 -9
  44. package/dist/session/live-control.js +34 -1
  45. package/dist/session/no-session-diagnosis.d.ts +4 -4
  46. package/dist/session/no-session-diagnosis.js +60 -29
  47. package/dist/session/review-store.d.ts +19 -0
  48. package/dist/session/review-store.js +34 -5
  49. package/dist/session/session-manager.d.ts +9 -0
  50. package/dist/session/session-manager.js +11 -0
  51. package/dist/session/session.d.ts +8 -0
  52. package/dist/session/session.js +12 -0
  53. package/dist/status-payload.d.ts +13 -1
  54. package/dist/status-payload.js +6 -1
  55. package/dist/telemetry/app-instrumented.d.ts +37 -0
  56. package/dist/telemetry/app-instrumented.js +54 -0
  57. package/dist/telemetry/daemon-telemetry.d.ts +4 -4
  58. package/dist/telemetry/daemon-telemetry.js +8 -4
  59. package/dist/telemetry/session-metrics.d.ts +3 -3
  60. package/dist/telemetry/session-metrics.js +4 -4
  61. package/dist/telemetry/telemetry.d.ts +3 -1
  62. package/dist/telemetry/telemetry.js +3 -1
  63. package/dist/telemetry/verification-of.js +26 -1
  64. package/dist/tools/act-tools.js +32 -10
  65. package/dist/tools/assert-verdict.js +7 -0
  66. package/dist/tools/error-recovery.js +6 -1
  67. package/dist/tools/invoke-tool.js +11 -10
  68. package/dist/tools/tool-kit.d.ts +52 -5
  69. package/dist/tools/tool-kit.js +55 -24
  70. package/dist/tools/tool-surface.js +9 -9
  71. package/dist/tools/tools.js +22 -0
  72. package/dist/visual/capture-cleanup.d.ts +16 -0
  73. package/dist/visual/capture-cleanup.js +39 -0
  74. package/dist/visual/visual-tools.d.ts +18 -0
  75. package/dist/visual/visual-tools.js +24 -5
  76. package/docs/agent-cheatsheet.md +14 -2
  77. package/docs/gate-plan.md +1 -1
  78. package/docs/gates.md +1 -1
  79. package/docs/telemetry-contract.md +13 -1
  80. package/docs/telemetry.md +9 -1
  81. package/docs/token-efficiency.md +2 -2
  82. package/package.json +2 -2
@@ -5,6 +5,7 @@
5
5
  * block. Harnesses gate on it (e.g. "grade ≥ net AND integrity clean"). This is pure composition +
6
6
  * assessment — all five components already exist individually; wiring it onto results is a later step.
7
7
  */
8
+ import type { CaptureLoss } from '@reticlehq/core';
8
9
  /** Assertion grade, strongest first — the tier the verdict actually proved. */
9
10
  export declare const HonestyGrade: {
10
11
  readonly SIGNAL: "signal";
@@ -23,6 +24,15 @@ interface HonestyInputs {
23
24
  coveragePartial?: boolean;
24
25
  truncated?: boolean;
25
26
  blindSpots?: readonly string[];
27
+ /**
28
+ * The machine-readable half of `issues` — WHICH kind of loss, from core's closed `CaptureLoss`.
29
+ *
30
+ * `issues` is prose built for an agent to read, and prose is not something a dashboard can group
31
+ * by: `unclean_capture` became a large share of all field verdicts with no way to ask which of its three
32
+ * causes produced them. Passed by the caller rather than derived here, because only the caller
33
+ * knows whether a blind-spot note came from the browser transport or from a boundary in the page.
34
+ */
35
+ losses?: readonly CaptureLoss[];
26
36
  }
27
37
  export interface HonestyBlock {
28
38
  grade: HonestyGrade;
@@ -40,6 +50,7 @@ export interface HonestyBlock {
40
50
  integrity: {
41
51
  clean: boolean;
42
52
  issues: string[];
53
+ losses?: CaptureLoss[];
43
54
  };
44
55
  }
45
56
  export declare function buildHonestyBlock(inputs: HonestyInputs): HonestyBlock;
@@ -46,7 +46,15 @@ export function buildHonestyBlock(inputs) {
46
46
  ? {}
47
47
  : { envelope: { samples, sufficient: samples >= MIN_ENVELOPE_SAMPLES } }),
48
48
  coverage: { ...(pct === undefined ? {} : { pct }), partial },
49
- integrity: { clean: 0 === issues.length, issues },
49
+ integrity: {
50
+ clean: 0 === issues.length,
51
+ issues,
52
+ // Only when something was actually lost. An empty array beside `clean: true` would be a field
53
+ // that is always present and always says nothing, which is how a signal gets tuned out.
54
+ ...(0 === issues.length || undefined === inputs.losses || 0 === inputs.losses.length
55
+ ? {}
56
+ : { losses: [...new Set(inputs.losses)] }),
57
+ },
50
58
  };
51
59
  }
52
60
  /** Whether a verdict's honesty block clears the harness's bar. Reasons list every failed criterion. */
@@ -1,4 +1,4 @@
1
- import { Verified, VerifiedReason } from '@reticlehq/core';
1
+ import { Verified, VerifiedReason, isAbsenceDerived } from '@reticlehq/core';
2
2
  import { HonestyGrade } from './honesty.js';
3
3
  export function decideVerified(inputs) {
4
4
  const { pass, honesty, contradictions = [], settled, outcomePending, outcomeUnread } = inputs;
@@ -41,14 +41,37 @@ export function decideVerified(inputs) {
41
41
  // this product exists to catch is a green assertion sitting on top of a failed write. Measured on
42
42
  // the bench app — `ui-advanced-request-failed` arrived with verdict.pass true and every other
43
43
  // channel agreeing. Letting `pass` win there would report exactly the false green being detected.
44
- if (contradictions.length > 0) {
45
- const kinds = contradictions.map((c) => c.kind).join(', ');
44
+ // ...but ONLY for contradictions we positively OBSERVED. A kind inferred from the absence of
45
+ // evidence — the request had not settled, the response had not been applied yet — is a statement
46
+ // about when we stopped looking, not about whether the action worked, and the window closes the
47
+ // moment the predicate first passes. Reproduced on the bench app: `auth:granted` fired with
48
+ // matching data, state changed, the token was stored, the capture was clean and the grade was
49
+ // `signal`, and the verdict was still NO because one POST was in flight. That inverts the grade
50
+ // hierarchy — a timing observation beat a consequence observation — and it is the shape of half of
51
+ // every `no` verdict in the field. See ABSENCE_DERIVED_CONTRADICTIONS for why a false negative
52
+ // costs more than it looks: it makes an agent redo work that succeeded, or stop believing the
53
+ // verdict channel, which is the product.
54
+ const observed = contradictions.filter((c) => !isAbsenceDerived(c.kind));
55
+ if (observed.length > 0) {
56
+ const kinds = observed.map((c) => c.kind).join(', ');
46
57
  return {
47
58
  verified: Verified.NO,
48
59
  verifiedReason: VerifiedReason.CONTRADICTED,
49
60
  because: `channels disagree about this action (${kinds}) even though the assertion passed`,
50
61
  };
51
62
  }
63
+ // Absence-derived only: report it, do not assert failure. UNKNOWN is the honest answer — Reticle
64
+ // drove the app and could not yet tell. The finding still rides out in `contradictions`, so an
65
+ // agent that wants to wait and re-check has everything it needs.
66
+ if (contradictions.length > 0) {
67
+ const kinds = contradictions.map((c) => c.kind).join(', ');
68
+ return {
69
+ verified: Verified.UNKNOWN,
70
+ verifiedReason: VerifiedReason.UNSETTLED,
71
+ because: `the assertion held, but this window closed before the app finished (${kinds}) — ` +
72
+ `re-check, or assert a consequence that settles`,
73
+ };
74
+ }
52
75
  // Below the contradiction clause, and for the same reason a contradiction outranks a dirty
53
76
  // capture: evidence AGAINST beats absence of evidence, whichever absence it is. Both can hold at
54
77
  // once — assert `{ text: 'Saved' }` that was already on screen while the write 500s — and ordering
@@ -150,8 +150,18 @@ export function createSharedServer(options = {}) {
150
150
  transports.clear();
151
151
  await new Promise((resolve, reject) => {
152
152
  httpServer.close((err) => {
153
- if (err !== undefined && err !== null)
153
+ // A server that never listened is already closed, and saying so is not an error worth
154
+ // propagating. It matters because this close() runs on the CLEANUP path of a failed
155
+ // `--drive`: when the app URL is unreachable, the daemon tears down before `listen()` has
156
+ // been called, Node rejects with ERR_SERVER_NOT_RUNNING, and that rejection REPLACES the
157
+ // real cause. The user's app is down and the message they read is "Server is not running",
158
+ // which blames Reticle for their dev server — traced live, where swallowing this turned the
159
+ // same failure back into "net::ERR_CONNECTION_REFUSED at http://localhost:4312/".
160
+ if (err !== undefined &&
161
+ err !== null &&
162
+ 'ERR_SERVER_NOT_RUNNING' !== err.code) {
154
163
  reject(err);
164
+ }
155
165
  else
156
166
  resolve();
157
167
  });
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { originOf } from './session/session-manager.js';
5
5
  import { setBrowserMode, BrowserMode } from './telemetry/browser-mode.js';
6
6
  import { replayNamedFlow } from './flows/flow-tools.js';
7
7
  import { createSharedServer } from './http-server.js';
8
+ import { openLoopbackAlias } from './daemon/loopback-alias.js';
9
+ import { reportAppInstrumented } from './telemetry/app-instrumented.js';
8
10
  import { resolveBridgeSecurityWithAutoToken } from './bridge/bridge-security.js';
9
11
  import { Bridge } from './bridge/bridge.js';
10
12
  import { BaselineStore } from './project/baselines.js';
@@ -14,6 +16,7 @@ import { buildFlowChips } from './flows/flow-scope.js';
14
16
  import { ProjectStore } from './project/project-store.js';
15
17
  import { AnnotationStore } from './flows/annotation-store.js';
16
18
  import { createNodeFileSystem } from './project/fs-port.js';
19
+ import { cleanupCaptureDirectories } from './visual/capture-cleanup.js';
17
20
  import { ReticleRunner } from './runs/reticle-runner.js';
18
21
  import { createRunnerPort } from './runs/runner-port.js';
19
22
  import { RunStore } from './runs/run-store.js';
@@ -287,6 +290,7 @@ export async function start(options = {}) {
287
290
  ...(security.token !== undefined ? { token: security.token } : {}),
288
291
  close: async () => {
289
292
  reaper.stop();
293
+ await cleanupCaptureDirectories();
290
294
  leaseReaper?.stop();
291
295
  await pool?.shutdown();
292
296
  await owned?.dispose();
@@ -309,7 +313,9 @@ export async function startDaemon(options = {}) {
309
313
  // mirror rejection so a port collision can't surface as an unhandled promise rejection.
310
314
  void bridge.ready.catch(() => undefined);
311
315
  // `reticle status` GETs this for a live, at-a-glance view of connected tabs + their health.
312
- shared.attachStatus(() => statusPayload(bridge.sessions.count(), bridge.sessions.list()));
316
+ // The same diagnosis agents get on an empty `reticle_sessions`, so `reticle status` — the
317
+ // most-run command in the field — stops answering "sessionCount: 0" and nothing else.
318
+ shared.attachStatus(() => statusPayload(bridge.sessions.count(), bridge.sessions.list(), bridge.sessions.noSessionHint()));
313
319
  // Agent-independent presence: the daemon outlives any single agent, so when the LAST agent's MCP
314
320
  // connection drops (it stopped, or is waiting on the human), end every session and push a clear
315
321
  // "go to your terminal" notice to the panel — the human is on the browser and must not lose a typed
@@ -389,6 +395,16 @@ export async function startDaemon(options = {}) {
389
395
  // connecting session's project (a shared daemon serves many apps; each panel shows only its own
390
396
  // flows + legacy untagged ones). Each chip carries a `start` hint (the first step's testid anchor)
391
397
  // so the HUD shows a flow only on the page it can begin from — the panel re-scopes per route.
398
+ // The first instrumented app of this daemon run — the funnel step nothing could measure before.
399
+ // Inside the existing session-ready hook rather than beside it: this fires exactly when a page
400
+ // carrying the SDK has completed its handshake, which is the definition of "instrumented", and
401
+ // `reportAppInstrumented` is idempotent so later sessions cost a boolean check.
402
+ bridge.attachSessionReady(() => {
403
+ reportAppInstrumented({
404
+ initialized: readProjectId(process.cwd()) !== undefined,
405
+ agentAttached: agentConnected,
406
+ });
407
+ });
392
408
  bridge.attachSessionReady((session) => {
393
409
  flows
394
410
  .list(session.projectId)
@@ -417,7 +433,13 @@ export async function startDaemon(options = {}) {
417
433
  shared.httpServer.once('listening', onListening);
418
434
  shared.httpServer.listen(port, security.host ?? LOOPBACK_HOST);
419
435
  });
420
- log('mcp_daemon_started', { port });
436
+ // `localhost` is a name with two answers, and Windows/Chrome tries the IPv6 one first. Only alias
437
+ // when we are on the default loopback bind: an explicit RETICLE_HOST is a deliberate choice about
438
+ // reachability, and quietly adding a second listener to it would be the opposite of deliberate.
439
+ const loopbackAlias = security.host === undefined
440
+ ? await openLoopbackAlias(port)
441
+ : { opened: false, close: undefined };
442
+ log('mcp_daemon_started', { port, loopbackAlias: loopbackAlias.opened });
421
443
  return {
422
444
  bridge,
423
445
  ...(realInput !== undefined ? { realInput } : {}),
@@ -431,10 +453,12 @@ export async function startDaemon(options = {}) {
431
453
  agentAttached: () => agentConnected,
432
454
  close: async () => {
433
455
  reaper.stop();
456
+ await cleanupCaptureDirectories();
434
457
  const vh = verifyHttp;
435
458
  if (vh !== undefined)
436
459
  await new Promise((resolve) => vh.server.close(() => resolve()));
437
460
  leaseReaper.stop();
461
+ await loopbackAlias.close?.();
438
462
  await pool.shutdown();
439
463
  await owned?.dispose();
440
464
  await bridge.close();
@@ -44,10 +44,15 @@ const RULE_BODY = `## Verifying with Reticle
44
44
  This app is instrumented by **Reticle** — an in-app verification layer exposed as \`reticle_*\` MCP tools and the \`${CLI}\` CLI (run it through npx — Reticle's server is not installed into this project). Verifying is part of "done", not an optional extra.
45
45
 
46
46
  - **When:** after you build or change any user-facing feature, flow, or UI, verify it with Reticle **before** telling the user it is complete. Reading the code or passing unit tests is not enough — drive the real running app.
47
- - **How:** drive the actual flow (\`reticle_act\` / \`reticle_act_and_wait\`), then read the observable effect (\`reticle_assert\`, \`reticle_snapshot\`, \`reticle_state\`, \`reticle_network\`). Confirm the change produced the effect it was meant to, with no new console errors or failed requests.
47
+ - **How:** drive the flow with \`reticle_act_and_wait({ ref, action, until })\` — it names the consequence you expect BEFORE the action, which is the difference between a check and a rationalisation. Batch a multi-step flow (a login, a form) into one \`reticle_act_sequence\` rather than one round trip per field. Read the surrounding evidence with \`reticle_snapshot\`, \`reticle_state\`, \`reticle_network\`, \`reticle_console\`.
48
+ - **Only \`reticle_act_and_wait\` and \`reticle_assert\` produce a verdict.** \`reticle_act\` and everything else move or read the app and prove nothing, so a session that ends without one of those two has no result however many tools it used. In practice \`reticle_act\` is reached for far more often and proves nothing; \`act_and_wait\` is where the defects actually get caught.
49
+ - **\`verified: "unknown"\` is not a pass.** It means Reticle drove the app and could not tell what happened; \`verifiedReason\` says which of eleven clauses decided that and what to do about it. Report it as unknown — never as working.
50
+ - **If a tool result carries \`verify_next\`, do what it says next.** It means you have driven this page several times without asking for a verdict, so nothing you have done so far is proven.
48
51
  - **Covered flows:** run \`${CLI} gate\` — it reports which recorded flows the changed files affect and whether they still pass.
49
52
  - **Never weaken a check to make it green.** Downgrading, skipping, or deleting an assertion is a finding, not a fix.
50
53
  - **If Reticle can't run** (no daemon, or this is not a running web app), say so — do not skip verification silently.
54
+ - **If the \`reticle_*\` tools are missing entirely, they have not been loaded yet — not broken.** \`init\` registers the MCP server, but a client reads its tool list at startup and does not re-read it. Ask the human to reload their MCP tools (\`/mcp\` in Claude Code, or reload the window in Cursor/VS Code), wait, then carry on. \`npx ${RETICLE_NPM_PACKAGE} status\` works meanwhile and says whether the daemon is up and whether the app has connected.
55
+ - **Setup is not finished until one real flow has been driven and produced a verdict.** \`init\` exiting 0 means files were written; the tools appearing means your client can reach a daemon; a listed session means the app dialled in. None of those is evidence that anything works. If you are blocked before a verdict, say which step and what you tried — never report a clean install you did not verify.
51
56
  - **If a tool result carries \`version_skew\`, act on it before trusting anything else.** Two pieces of Reticle are on different wire contracts, so tools can misbehave in ways neither side reports — a green here is not evidence. The field says which pair disagrees and names the exact fix: run \`${CLI} stop\` and retry when the DAEMON is stale, or tell the human to install the matching SDK and restart their dev server when the PAGE is. Do that first, then redo whatever you were verifying.
52
57
  - **If a tool result carries \`update_available\`, say so.** Reticle checked npm and a newer version exists. Tell the human the version numbers and offer to run \`${CLI} update\` — between tasks, never mid-verification, because it restarts the daemon and drops the session. Finish what you are doing first.
53
58
 
@@ -4,6 +4,14 @@
4
4
  */
5
5
  export declare const Framework: {
6
6
  readonly NEXT: "next";
7
+ /**
8
+ * Nuxt owns its own Vite instance and has no `vite.config`, so it used to fall all the way through
9
+ * to HTML — and then be handed a React kit and a connect snippet guarded on
10
+ * `window.location.hostname === 'localhost'`, which cannot work in a Vue app (the guard throws
11
+ * during SSR, and never runs at all on a non-localhost dev host). Detected in its own right so it
12
+ * gets the package and the recipe that actually fit it.
13
+ */
14
+ readonly NUXT: "nuxt";
7
15
  readonly VITE: "vite";
8
16
  readonly SVELTEKIT: "sveltekit";
9
17
  readonly ASTRO: "astro";
@@ -4,6 +4,14 @@
4
4
  */
5
5
  export const Framework = {
6
6
  NEXT: 'next',
7
+ /**
8
+ * Nuxt owns its own Vite instance and has no `vite.config`, so it used to fall all the way through
9
+ * to HTML — and then be handed a React kit and a connect snippet guarded on
10
+ * `window.location.hostname === 'localhost'`, which cannot work in a Vue app (the guard throws
11
+ * during SSR, and never runs at all on a non-localhost dev host). Detected in its own right so it
12
+ * gets the package and the recipe that actually fit it.
13
+ */
14
+ NUXT: 'nuxt',
7
15
  VITE: 'vite',
8
16
  SVELTEKIT: 'sveltekit',
9
17
  ASTRO: 'astro',
@@ -32,6 +40,7 @@ export const UiLibrary = {
32
40
  const NEXT_CONFIGS = ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs'];
33
41
  const VITE_CONFIGS = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.mts'];
34
42
  const SVELTE_CONFIGS = ['svelte.config.js', 'svelte.config.ts', 'svelte.config.mjs'];
43
+ const NUXT_CONFIGS = ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs'];
35
44
  const ASTRO_CONFIGS = [
36
45
  'astro.config.mjs',
37
46
  'astro.config.js',
@@ -98,6 +107,12 @@ function detectFramework(input) {
98
107
  if (depVersion(pkg, 'next') !== undefined || hasAnyConfig(configFiles, NEXT_CONFIGS)) {
99
108
  return Framework.NEXT;
100
109
  }
110
+ // Nuxt before Vite: Nuxt apps can carry a `vite` dependency and even a vite.config, but the
111
+ // generic Vite path would wire a plugin into a config Nuxt does not read, and inject connect into
112
+ // an index.html Nuxt does not serve.
113
+ if (depVersion(pkg, 'nuxt') !== undefined || hasAnyConfig(configFiles, NUXT_CONFIGS)) {
114
+ return Framework.NUXT;
115
+ }
101
116
  // SvelteKit is Vite-based but renders through app.html, so the Vite plugin's index.html injection
102
117
  // never fires (verified) — it needs a manual client connect. Check BEFORE the generic Vite branch.
103
118
  if (depVersion(pkg, '@sveltejs/kit') !== undefined || hasAnyConfig(configFiles, SVELTE_CONFIGS)) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Telling a Python (or Ruby, or Go, or PHP) developer why Reticle cannot wire their app.
3
+ *
4
+ * `reticle init` answered "No package.json found here, and no app directory beneath it either",
5
+ * which reads as *you are in the wrong directory* — so an agent on a Streamlit app went looking for
6
+ * a directory that does not exist, then went looking for a standalone browser bundle to inject by
7
+ * hand, and only worked out the real answer several steps later.
8
+ *
9
+ * The real answer is not about directories at all: Reticle's SDK has to be imported by the app's own
10
+ * JS build, and a server-rendered Python app has no JS build to import it from. That is a property
11
+ * of the project, it is knowable from one look at the filesystem, and saying it plainly costs the
12
+ * reader nothing while the directory message costs them the whole investigation.
13
+ *
14
+ * Deliberately a hint, not a refusal: a Django or Rails app with a `frontend/` package.json is a
15
+ * perfectly ordinary Reticle install, and this only ever speaks when there is no package.json to be
16
+ * found anywhere.
17
+ */
18
+ /** The ecosystem this directory looks like, or undefined when nothing says. */
19
+ export declare function detectNonJsEcosystem(exists: (file: string) => boolean): string | undefined;
20
+ /**
21
+ * What `init` should say when there is no package.json.
22
+ *
23
+ * Two genuinely different situations behind one old sentence: a JS developer standing in the wrong
24
+ * directory, and a developer whose project is not JavaScript at all. The first needs a path; the
25
+ * second needs to know that no path exists and why.
26
+ */
27
+ export declare function noPackageJsonMessage(exists: (file: string) => boolean): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Telling a Python (or Ruby, or Go, or PHP) developer why Reticle cannot wire their app.
3
+ *
4
+ * `reticle init` answered "No package.json found here, and no app directory beneath it either",
5
+ * which reads as *you are in the wrong directory* — so an agent on a Streamlit app went looking for
6
+ * a directory that does not exist, then went looking for a standalone browser bundle to inject by
7
+ * hand, and only worked out the real answer several steps later.
8
+ *
9
+ * The real answer is not about directories at all: Reticle's SDK has to be imported by the app's own
10
+ * JS build, and a server-rendered Python app has no JS build to import it from. That is a property
11
+ * of the project, it is knowable from one look at the filesystem, and saying it plainly costs the
12
+ * reader nothing while the directory message costs them the whole investigation.
13
+ *
14
+ * Deliberately a hint, not a refusal: a Django or Rails app with a `frontend/` package.json is a
15
+ * perfectly ordinary Reticle install, and this only ever speaks when there is no package.json to be
16
+ * found anywhere.
17
+ */
18
+ /** Marker files that identify an ecosystem, and what to call it. */
19
+ const ECOSYSTEM_MARKERS = [
20
+ ['requirements.txt', 'Python'],
21
+ ['pyproject.toml', 'Python'],
22
+ ['Pipfile', 'Python'],
23
+ ['setup.py', 'Python'],
24
+ ['manage.py', 'Python (Django)'],
25
+ ['Gemfile', 'Ruby'],
26
+ ['go.mod', 'Go'],
27
+ ['composer.json', 'PHP'],
28
+ ['Cargo.toml', 'Rust'],
29
+ ['pom.xml', 'Java'],
30
+ ['build.gradle', 'Java'],
31
+ ['mix.exs', 'Elixir'],
32
+ ];
33
+ /** The ecosystem this directory looks like, or undefined when nothing says. */
34
+ export function detectNonJsEcosystem(exists) {
35
+ for (const [marker, name] of ECOSYSTEM_MARKERS) {
36
+ if (exists(marker))
37
+ return name;
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * What `init` should say when there is no package.json.
43
+ *
44
+ * Two genuinely different situations behind one old sentence: a JS developer standing in the wrong
45
+ * directory, and a developer whose project is not JavaScript at all. The first needs a path; the
46
+ * second needs to know that no path exists and why.
47
+ */
48
+ export function noPackageJsonMessage(exists) {
49
+ const ecosystem = detectNonJsEcosystem(exists);
50
+ if (ecosystem === undefined) {
51
+ return ('No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
52
+ "your app's directory, or from a repo root that contains it.");
53
+ }
54
+ return (`This looks like a ${ecosystem} project, and there is no package.json here or in any app ` +
55
+ 'directory beneath it. Reticle instruments a page by having its SDK imported into the app’s own ' +
56
+ 'JavaScript build — so a server-rendered app with no JS build has nowhere to load it from, and ' +
57
+ 'there is no directory you could run this from that would change that. ' +
58
+ 'If this project does have a JS front end (a `frontend/`, `client/` or `assets/` directory with ' +
59
+ 'its own package.json), run `reticle init` there, or point at it with `--app <dir>`.');
60
+ }
@@ -38,6 +38,15 @@ export declare function nextSteps(input: PlanInput): Step[];
38
38
  * bundler never processes for modules. Reported from a real cra-redux-saga app.
39
39
  */
40
40
  export declare function craSteps(input: PlanInput): Step[];
41
+ /**
42
+ * Nuxt: one manual step carrying the whole recipe, and no pretence of more.
43
+ *
44
+ * There is no Nuxt app in `apps/` and no CI gate for one, so an auto-written plugin would be a
45
+ * support claim nothing backs — the same reasoning SvelteKit already carries. What this DOES fix is
46
+ * everything that made the previous fall-through actively wrong: Nuxt was classified as `html`, so
47
+ * it was handed the React kit and a localhost-guarded snippet that cannot run in a Vue app.
48
+ */
49
+ export declare function nuxtSteps(input: PlanInput): Step[];
41
50
  export declare function svelteKitSteps(input: PlanInput): Step[];
42
51
  /**
43
52
  * Astro: the config define + build target, and the connect script in ONE layout.
@@ -8,7 +8,7 @@ import { patchNextConfig, patchRootLayout, patchPagesApp } from './next-patch.js
8
8
  import { patchAstroConfig, patchAstroLayout } from './astro-patch.js';
9
9
  import { CRA_DEV_MODULE_IMPORT, CRA_DEV_MODULE_PATH, CRA_ENV_PATH, CRA_TOKEN_PER_MACHINE_NOTICE, TOKEN_VAR, craDevModuleFile, craEnvPatch, craImportPatch, } from './cra.js';
10
10
  import { PatchKind } from './patch-kind.js';
11
- import { viteManual, NEXT_LAYOUT_MANUAL, NEXT_LAYOUT_PATH, viteDevModuleFile, VITE_DEV_MODULE_PATH, nextReticleDevFile, NEXT_RETICLE_DEV_PATH, nextConfigManual, svelteKitHooksFile, SVELTEKIT_HOOKS_PATH, UNVERIFIED_FRAMEWORK_NOTE, astroManual, } from './snippets.js';
11
+ import { viteManual, NEXT_LAYOUT_MANUAL, NEXT_LAYOUT_PATH, viteDevModuleFile, VITE_DEV_MODULE_PATH, nextReticleDevFile, NEXT_RETICLE_DEV_PATH, nextConfigManual, svelteKitHooksFile, SVELTEKIT_HOOKS_PATH, UNVERIFIED_FRAMEWORK_NOTE, astroManual, nuxtManual, NUXT_PLUGIN_PATH, } from './snippets.js';
12
12
  import { StepStatus } from './plan.js';
13
13
  /** What adding `reticle()` to a Vite config buys, which differs by framework. */
14
14
  export const VITE_PLUGIN_DETAIL = {
@@ -291,6 +291,24 @@ export function craSteps(input) {
291
291
  });
292
292
  return steps;
293
293
  }
294
+ /**
295
+ * Nuxt: one manual step carrying the whole recipe, and no pretence of more.
296
+ *
297
+ * There is no Nuxt app in `apps/` and no CI gate for one, so an auto-written plugin would be a
298
+ * support claim nothing backs — the same reasoning SvelteKit already carries. What this DOES fix is
299
+ * everything that made the previous fall-through actively wrong: Nuxt was classified as `html`, so
300
+ * it was handed the React kit and a localhost-guarded snippet that cannot run in a Vue app.
301
+ */
302
+ export function nuxtSteps(input) {
303
+ return [
304
+ {
305
+ title: 'Connect snippet (Nuxt)',
306
+ target: NUXT_PLUGIN_PATH,
307
+ status: StepStatus.MANUAL,
308
+ detail: nuxtManual(input.options.port, input.options.projectId),
309
+ },
310
+ ];
311
+ }
294
312
  export function svelteKitSteps(input) {
295
313
  const unverified = {
296
314
  title: 'SvelteKit is UNVERIFIED',
package/dist/init/plan.js CHANGED
@@ -9,13 +9,15 @@ import { mergeCursorConfig, CursorMergeStatus, cursorServerEntry } from './curso
9
9
  import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, } from './mcp-clients.js';
10
10
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from './slash-command.js';
11
11
  import { mergeMarkedInstruction, cursorRuleFile, AgentRuleStatus, CLAUDE_MD_PATH, AGENTS_MD_PATH, CURSOR_RULE_PATH, } from './agent-rules.js';
12
- 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';
13
13
  import { htmlManual, reticleConfigContent, unverifiedUiLibraryNote } from './snippets.js';
14
14
  // An app dev installs exactly the audience-scoped browser-side dependencies — never the retired
15
15
  // `@reticlehq/core` umbrella (which dragged the Node MCP server + ws into every app). The kit is the
16
16
  // framework adapter (it re-exports the browser sensor), paired with that framework's dev-only build
17
17
  // plugin for source mapping + connect injection.
18
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';
19
21
  const RETICLE_VITE_PLUGIN = '@reticlehq/vite-plugin';
20
22
  const RETICLE_NEXT_PLUGIN = '@reticlehq/next';
21
23
  /**
@@ -41,6 +43,11 @@ export function frameworkPackages(framework) {
41
43
  case Framework.SVELTEKIT:
42
44
  // SvelteKit builds on Vite; until a dedicated Svelte kit exists it uses the Vite build plugin.
43
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];
44
51
  case Framework.ASTRO:
45
52
  // Astro owns its own Vite instance and renders its own HTML, so there is no config for the
46
53
  // plugin to attach to — the kit alone, connected from a page <script> (see astroManual).
@@ -68,6 +75,7 @@ const CONNECT_STEP_TITLES = new Set([
68
75
  'Connect snippet',
69
76
  'Connect snippet (CRA)',
70
77
  'Connect snippet (Astro)',
78
+ 'Connect snippet (Nuxt)',
71
79
  'Reticle client hook',
72
80
  'Reticle connect module',
73
81
  'ReticleDev component',
@@ -484,8 +492,14 @@ function reticleConfigStep(input) {
484
492
  */
485
493
  function uiLibraryStep(input) {
486
494
  const lib = input.detection.uiLibrary;
487
- 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) {
488
501
  return [];
502
+ }
489
503
  if (lib === UiLibrary.UNKNOWN)
490
504
  return [];
491
505
  return [
@@ -518,6 +532,9 @@ export function buildPlan(input) {
518
532
  else if (input.detection.framework === Framework.CRA) {
519
533
  steps.push(...craSteps(input));
520
534
  }
535
+ else if (input.detection.framework === Framework.NUXT) {
536
+ steps.push(...nuxtSteps(input));
537
+ }
521
538
  else if (input.detection.framework === Framework.SVELTEKIT) {
522
539
  steps.push(...svelteKitSteps(input));
523
540
  // The Vite plugin as well as the client hook. `init` already INSTALLS @reticlehq/vite-plugin for
@@ -530,7 +547,7 @@ export function buildPlan(input) {
530
547
  title: 'Connect snippet',
531
548
  target: 'index.html',
532
549
  status: StepStatus.MANUAL,
533
- detail: htmlManual(input.options.port, input.options.projectId),
550
+ detail: htmlManual(input.options.port, input.options.projectId, input.pairingToken),
534
551
  });
535
552
  }
536
553
  return { framework: input.detection.framework, steps };
package/dist/init/run.js CHANGED
@@ -4,6 +4,7 @@
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';
@@ -402,16 +403,39 @@ export const FEEDBACK_HINT = 'Anything wrong, missing, or awkward — in this se
402
403
  'one thing that decides what gets fixed:\n' +
403
404
  ` ${CLI} feedback --agent --kind <bug|gap|ambiguity|feature_request|improvement> "what happened" (agents)\n` +
404
405
  ` ${CLI} feedback "what worked, what didn't" (humans)`;
405
- function restartHint(framework) {
406
+ function devServerRestart(framework) {
406
407
  if (framework === Framework.NEXT)
407
- return 'Restart `next dev`, then ask your agent: "List Reticle sessions".';
408
+ return 'Restart `next dev`';
408
409
  if (framework === Framework.VITE)
409
- return 'Restart `vite`, then ask your agent: "List Reticle sessions".';
410
+ return 'Restart `vite`';
410
411
  if (framework === Framework.ASTRO)
411
- return 'Restart `astro dev`, then ask your agent: "List Reticle sessions".';
412
+ return 'Restart `astro dev`';
412
413
  if (framework === Framework.SVELTEKIT)
413
- return 'Restart your dev server (`npm run dev`), then ask your agent: "List Reticle sessions".';
414
- 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".');
415
439
  }
416
440
  const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
417
441
  'installed stops it booting. Run that install, then re-run `reticle init`.';
@@ -470,7 +494,7 @@ function report(plan, dryRun, failed, skipped, degraded, io, projectDir) {
470
494
  '"no browser session connected" until then. Everything else is already in place.');
471
495
  io.print('');
472
496
  }
473
- io.print(restartHint(plan.framework));
497
+ io.print(restartHint(plan.framework, wasMcpRegistered(resolvedStatus(plan, MCP_TARGET, failed, skipped))));
474
498
  return { ok: !connectPending, applied, manual };
475
499
  }
476
500
  /**
@@ -655,8 +679,13 @@ function runInitSteps(options, io) {
655
679
  if (redirectedEarly !== null)
656
680
  return redirectedEarly;
657
681
  if (null === pkgRaw) {
658
- io.print('No package.json found here, and no app directory beneath it either. Run `reticle init` from ' +
659
- "your app's directory, or from a repo root that contains it.");
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))));
660
689
  // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
661
690
  // from someone who never ran the command — the two failure modes with the most different fixes.
662
691
  reportInitOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
@@ -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).