@reticlehq/server 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/bridge.js +43 -3
  2. package/dist/bridge.test-harness.d.ts +5 -0
  3. package/dist/bridge.test-harness.js +12 -0
  4. package/dist/cli-parse.d.ts +14 -2
  5. package/dist/cli-parse.js +40 -12
  6. package/dist/cli-port.d.ts +3 -0
  7. package/dist/cli-port.js +21 -0
  8. package/dist/cli.js +9 -2
  9. package/dist/flows/decision.d.ts +0 -6
  10. package/dist/flows/decision.js +47 -6
  11. package/dist/flows/flow-tools.js +14 -6
  12. package/dist/init/capabilities.d.ts +17 -0
  13. package/dist/init/capabilities.js +51 -0
  14. package/dist/init/detect.d.ts +25 -0
  15. package/dist/init/detect.js +65 -3
  16. package/dist/init/next-patch.d.ts +41 -0
  17. package/dist/init/next-patch.js +106 -0
  18. package/dist/init/node-io.js +25 -0
  19. package/dist/init/patch-kind.d.ts +20 -0
  20. package/dist/init/patch-kind.js +10 -0
  21. package/dist/init/plan-framework.d.ts +35 -0
  22. package/dist/init/plan-framework.js +217 -0
  23. package/dist/init/plan.d.ts +51 -3
  24. package/dist/init/plan.js +125 -125
  25. package/dist/init/run.d.ts +17 -0
  26. package/dist/init/run.js +207 -16
  27. package/dist/init/slash-command.d.ts +18 -0
  28. package/dist/init/slash-command.js +52 -0
  29. package/dist/init/snippets.d.ts +44 -3
  30. package/dist/init/snippets.js +152 -6
  31. package/dist/init/vite-config.d.ts +4 -10
  32. package/dist/init/vite-config.js +15 -7
  33. package/dist/input/driven-redaction.d.ts +15 -0
  34. package/dist/input/driven-redaction.js +76 -0
  35. package/dist/input/network-detail.d.ts +9 -3
  36. package/dist/input/network-detail.js +35 -15
  37. package/dist/mcp-proxy.d.ts +35 -3
  38. package/dist/mcp-proxy.js +174 -27
  39. package/dist/session/session-manager.js +6 -0
  40. package/dist/session/session.d.ts +6 -0
  41. package/dist/session/session.js +7 -0
  42. package/dist/tools/read-tools.js +4 -0
  43. package/package.json +2 -2
package/dist/bridge.js CHANGED
@@ -1,9 +1,27 @@
1
1
  import * as http from 'node:http';
2
2
  import { WebSocketServer } from 'ws';
3
- import { EventType, HumanControlKind, RETICLE_WS_PATH, ReticleMessageSchema, LOOPBACK_HOST, MessageKind, ReticleEnv, RETICLE_PROTOCOL_VERSION, TRANSPORT_LIMITS, isLoopbackHostname, isOpaqueOrigin, OPAQUE_ORIGIN, isHighValueEvent, RATE_CAP_HIGH_VALUE_RESERVE_RATIO, } from '@reticlehq/core';
3
+ import { EventType, HumanControlKind, RETICLE_WS_PATH, ReticleMessageSchema, LOOPBACK_HOST, MessageKind, ReticleEnv, RETICLE_PROTOCOL_VERSION, TRANSPORT_LIMITS, isLocalPage, isLoopbackHostname, isOpaqueOrigin, OPAQUE_ORIGIN, isHighValueEvent, RATE_CAP_HIGH_VALUE_RESERVE_RATIO, } from '@reticlehq/core';
4
4
  import { Session, SessionManager } from './session/session.js';
5
5
  import { tokensMatch } from './token-auth.js';
6
6
  import { log } from './log.js';
7
+ /**
8
+ * Take the mutable half of a session's identity from a repeat HELLO.
9
+ *
10
+ * Lives here rather than on Session because this is where the protocol decision is made — the caller
11
+ * has already established that the hello belongs to this same session — and because these fields are
12
+ * the session's public surface, read by the tools on every listing.
13
+ *
14
+ * `hasCapabilities` is the one that matters: it is announced in the hello, which the SDK sends at
15
+ * connect(), while capabilities are registered AFTER connect by design (registerStore needs a live
16
+ * SDK to subscribe through). Without this refresh, an app that declared its entire testable surface
17
+ * reported `hasCapabilities: false` forever.
18
+ */
19
+ function refreshIdentity(session, hello) {
20
+ session.url = hello.url;
21
+ session.title = hello.title;
22
+ session.adapters = hello.adapters;
23
+ session.hasCapabilities = hello.hasCapabilities ?? false;
24
+ }
7
25
  /** WS close codes + reasons the bridge sends to the SDK (1008 = policy violation, 1013 = try again later). */
8
26
  const WS_CLOSE = {
9
27
  TOO_MANY_HANDSHAKES: [1013, 'too many pending handshakes'],
@@ -266,7 +284,16 @@ export class Bridge {
266
284
  }
267
285
  if (parsed.kind === MessageKind.HELLO) {
268
286
  if (session !== undefined) {
269
- socket.close(...WS_CLOSE.HELLO_DUPLICATE);
287
+ // A repeat hello on the SAME socket for the SAME session is an identity refresh, not a
288
+ // violation: the SDK re-announces when the app registers capabilities, which happens after
289
+ // connect by design. Closing the socket on it made `hasCapabilities` permanently false for
290
+ // every app that declared anything. A hello bearing a DIFFERENT session id is still a
291
+ // protocol violation — that is the case this guard exists for.
292
+ if (parsed.sessionId !== session.id) {
293
+ socket.close(...WS_CLOSE.HELLO_DUPLICATE);
294
+ return;
295
+ }
296
+ refreshIdentity(session, parsed);
270
297
  return;
271
298
  }
272
299
  if (this.#token !== undefined && !tokensMatch(this.#token, parsed.token)) {
@@ -339,7 +366,20 @@ export class Bridge {
339
366
  // Without this branch `new URL` throws INSIDE the WS upgrade handler, crashing the process.
340
367
  if (isOpaqueOrigin(normalized))
341
368
  return this.#token !== undefined;
342
- return isLoopbackHostname(new URL(normalized).hostname);
369
+ // `isLocalPage`, not `isLoopbackHostname` — the SAME rule the SDK applies on the page side.
370
+ //
371
+ // They had drifted, and the drift made a whole platform unreachable. Tauri v2 uses an opaque
372
+ // `tauri://localhost` on macOS/Linux, but on WINDOWS the webview needs a real http origin and
373
+ // Tauri serves `http://tauri.localhost`. `isLocalPage` was taught that hostname; this check was
374
+ // not. So a Windows Tauri app passed its own gate, dialed the bridge, and every handshake came
375
+ // back 403 — Reticle could not connect to a Tauri app on Windows at all, which is precisely the
376
+ // platform the release notes admitted had never been executed.
377
+ //
378
+ // No weaker than before: `.localhost` is reserved for loopback by RFC 6761, so the name cannot
379
+ // resolve to a remote host, and this is the argument core already made when it accepted the same
380
+ // hostname for the page.
381
+ const url = new URL(normalized);
382
+ return isLocalPage(url.protocol, url.hostname);
343
383
  }
344
384
  #parse(text) {
345
385
  let json;
@@ -37,6 +37,11 @@ export declare class FakeBrowser {
37
37
  }[];
38
38
  constructor(port: number, sessionId: string, hasCapabilities?: boolean);
39
39
  open(): Promise<void>;
40
+ /** Re-send the HELLO on the live socket, the way the SDK does when capabilities register late. */
41
+ reannounce(overrides?: {
42
+ sessionId?: string;
43
+ hasCapabilities?: boolean;
44
+ }): void;
40
45
  emit(type: string, data: Record<string, unknown>, ref?: string): void;
41
46
  close(): void;
42
47
  }
@@ -59,6 +59,18 @@ export class FakeBrowser {
59
59
  });
60
60
  });
61
61
  }
62
+ /** Re-send the HELLO on the live socket, the way the SDK does when capabilities register late. */
63
+ reannounce(overrides = {}) {
64
+ this.#send({
65
+ kind: MessageKind.HELLO,
66
+ protocolVersion: 1,
67
+ sessionId: overrides.sessionId ?? this.sessionId,
68
+ url: 'http://localhost:3000/checkout',
69
+ title: 'Checkout',
70
+ adapters: [],
71
+ hasCapabilities: overrides.hasCapabilities ?? this.hasCapabilities,
72
+ });
73
+ }
62
74
  emit(type, data, ref) {
63
75
  this.#send({
64
76
  kind: MessageKind.EVENT,
@@ -4,7 +4,7 @@
4
4
  * file-size cap and keep the parser pure + unit-testable. Re-exported from cli.ts so existing
5
5
  * imports are unchanged.
6
6
  */
7
- export declare const CLI_USAGE = "usage:\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] (wire Reticle into the project in this directory)\n reticle serve [--port N] [--drive <url>] [--headed] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet]\n reticle status [--port N]\n reticle doctor [--port N] (diagnose setup: Chromium, daemon, port \u2014 one command)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--headed] [--timeout N] [--storage-state <file>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headed] (foreground mode \u2014 for debugging)\n reticle mcp [--port N] [--drive <url>] [--headed] (MCP stdio proxy \u2014 auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics \u2014 status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't \u2014 prints exactly what it sends)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle Cloud \u2014 runs/flows recorded on the dashboard):\n reticle login --email <e> [--code <c>] [--org <n>] (sign in: mails a code, then exchanges it)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push (send local run artifacts to the dashboard)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)";
7
+ export declare const CLI_USAGE = "usage:\n reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] (wire Reticle into the project in this directory)\n reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]\n reticle stop [--port N] [--quiet]\n reticle status [--port N]\n reticle doctor [--port N] (diagnose setup: Chromium, daemon, port \u2014 one command)\n reticle open [url] [--port N] (show the app: reuse the connected tab, else open one)\n reticle verify <url> [--headed] [--timeout N] [--storage-state <file>] (one-shot: drive the URL, verify saved flows, exit 0=pass)\n reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)\n reticle gate [--since <ref>] [file...] (exit non-zero unless passing artifacts cover the affected flows)\n reticle watch [url] (on save, report which saved flows must re-verify)\n reticle drive <url> [--headless] (foreground mode \u2014 for debugging)\n reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy \u2014 auto-starts daemon if needed)\n reticle update (install the latest server version and restart)\n reticle rollback (restore the previous server version and restart)\n reticle license (show enterprise license status: active | eval | missing)\n reticle telemetry [status|enable|disable] (anonymous usage metrics \u2014 status shows what's sent + the policy)\n reticle feedback [--rating 1-5] [--bug] \"message\" (tell us what worked and what didn't \u2014 prints exactly what it sends)\n reticle identify --context company|side_project|open_source|learning [--company N] [--email E] [--forget]\n (OPT-IN: tell us who you are, e.g. for support or an enterprise trial)\n\nCloud (link this project to Reticle Cloud \u2014 runs/flows recorded on the dashboard):\n reticle login --email <e> [--code <c>] [--org <n>] (sign in: mails a code, then exchanges it)\n reticle link [--project <name|id>] (bind this repo: mints a scoped key, writes .reticle/cloud.json)\n reticle whoami (who am I signed in as, and is this repo attached?)\n reticle project <ls|create <name>> (list or create cloud projects)\n reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]\n reticle push (send local run artifacts to the dashboard)\n reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)\n\n'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay\nand the spec runner \u2014 batch work). --headed opts any of them in, --headless hides drive, CI hides it.";
8
8
  export declare const COMPANY_FLAG = "--company";
9
9
  export declare const EMAIL_FLAG = "--email";
10
10
  export declare const CONTEXT_FLAG = "--context";
@@ -33,6 +33,12 @@ export declare const UNKNOWN_COMMAND = "unknown";
33
33
  /** The subcommand name if we recognize it, else `unknown`. Bare `reticle` reports `help`. */
34
34
  export declare function knownCommand(arg: string | undefined): string;
35
35
  export declare const HEADED_FLAG = "--headed";
36
+ /**
37
+ * Force a hidden browser. The default is now HEADED: a run nobody can see is a run nobody trusts,
38
+ * and every "did it actually do anything?" question cost a round-trip. CI passes this (or just sets
39
+ * CI, which flips the default) because there is no display there to be headed on.
40
+ */
41
+ export declare const HEADLESS_FLAG = "--headless";
36
42
  export declare const PORT_FLAG = "--port";
37
43
  export declare const DRIVE_FLAG = "--drive";
38
44
  export declare const HTTP_FLAG = "--http";
@@ -137,4 +143,10 @@ export type CliResult = {
137
143
  kind: 'error';
138
144
  message: string;
139
145
  };
140
- export declare function parseCliArgs(argv: string[], defaultPort: number): CliResult;
146
+ /**
147
+ * @param defaultHeadless Whether a browser Reticle launches should be hidden when no flag says
148
+ * otherwise. Injected rather than read from the environment here, because this module is pure —
149
+ * `cli.ts` decides it from `CI`. The product default is FALSE: showing the run is what makes it
150
+ * trustworthy, and asking people to opt into seeing their own app was backwards.
151
+ */
152
+ export declare function parseCliArgs(argv: string[], defaultPort: number, defaultHeadless?: boolean): CliResult;
package/dist/cli-parse.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  export const CLI_USAGE = `usage:
8
8
  reticle init [--dry-run] [--port N] [--no-mcp] [--no-install] (wire Reticle into the project in this directory)
9
- reticle serve [--port N] [--drive <url>] [--headed] [--http] [--http-port N] [--http-token T]
9
+ reticle serve [--port N] [--drive <url>] [--headless] [--http] [--http-port N] [--http-token T]
10
10
  reticle stop [--port N] [--quiet]
11
11
  reticle status [--port N]
12
12
  reticle doctor [--port N] (diagnose setup: Chromium, daemon, port — one command)
@@ -15,8 +15,8 @@ export const CLI_USAGE = `usage:
15
15
  reticle affected [--since <ref>] [file...] (which saved flows must re-verify for the changed files)
16
16
  reticle gate [--since <ref>] [file...] (exit non-zero unless passing artifacts cover the affected flows)
17
17
  reticle watch [url] (on save, report which saved flows must re-verify)
18
- reticle drive <url> [--headed] (foreground mode — for debugging)
19
- reticle mcp [--port N] [--drive <url>] [--headed] (MCP stdio proxy — auto-starts daemon if needed)
18
+ reticle drive <url> [--headless] (foreground mode — for debugging)
19
+ reticle mcp [--port N] [--drive <url>] [--headless] (MCP stdio proxy — auto-starts daemon if needed)
20
20
  reticle update (install the latest server version and restart)
21
21
  reticle rollback (restore the previous server version and restart)
22
22
  reticle license (show enterprise license status: active | eval | missing)
@@ -32,7 +32,10 @@ Cloud (link this project to Reticle Cloud — runs/flows recorded on the dashboa
32
32
  reticle project <ls|create <name>> (list or create cloud projects)
33
33
  reticle config [--runs on|off] [--memory on|off] [--flows on|off] [--verify local|server]
34
34
  reticle push (send local run artifacts to the dashboard)
35
- reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)`;
35
+ reticle runs | regression | share <runId> (read cloud state; regression exits 3 if any flow broke)
36
+
37
+ 'drive' shows the browser; everything else is hidden (serve/mcp own the pool behind leases, replay
38
+ and the spec runner — batch work). --headed opts any of them in, --headless hides drive, CI hides it.`;
36
39
  const INIT_COMMAND = 'init';
37
40
  const SERVE_COMMAND = 'serve';
38
41
  const STOP_COMMAND = 'stop';
@@ -120,6 +123,12 @@ export function knownCommand(arg) {
120
123
  return KNOWN_COMMANDS.has(arg) ? arg : UNKNOWN_COMMAND;
121
124
  }
122
125
  export const HEADED_FLAG = '--headed';
126
+ /**
127
+ * Force a hidden browser. The default is now HEADED: a run nobody can see is a run nobody trusts,
128
+ * and every "did it actually do anything?" question cost a round-trip. CI passes this (or just sets
129
+ * CI, which flips the default) because there is no display there to be headed on.
130
+ */
131
+ export const HEADLESS_FLAG = '--headless';
123
132
  export const PORT_FLAG = '--port';
124
133
  export const DRIVE_FLAG = '--drive';
125
134
  const QUIET_FLAG = '--quiet';
@@ -132,7 +141,13 @@ export const HTTP_PORT_FLAG = '--http-port';
132
141
  export const HTTP_TOKEN_FLAG = '--http-token';
133
142
  const TIMEOUT_FLAG = '--timeout';
134
143
  const STORAGE_STATE_FLAG = '--storage-state';
135
- function parseServeFlags(args, defaultPort) {
144
+ /**
145
+ * `serve` / `mcp` / `_daemon` own the browser POOL, which backs automated work — leased contexts for
146
+ * parallel agents, flow replay, the spec runner. Those are batch: nobody is watching, and launching
147
+ * them headed changed timing enough to break four e2e specs. The headed default belongs to the
148
+ * INTERACTIVE command (`drive`), where a human asked to see the run. `--headed` still opts in here.
149
+ */
150
+ function parseServeFlags(args, defaultPort, _defaultHeadless) {
136
151
  let port = defaultPort;
137
152
  let driveUrl;
138
153
  let headless = true;
@@ -161,6 +176,9 @@ function parseServeFlags(args, defaultPort) {
161
176
  else if (arg === HEADED_FLAG) {
162
177
  headless = false;
163
178
  }
179
+ else if (arg === HEADLESS_FLAG) {
180
+ headless = true;
181
+ }
164
182
  else if (arg === HTTP_FLAG) {
165
183
  http = true;
166
184
  }
@@ -205,13 +223,16 @@ function parsePortFlag(args, defaultPort) {
205
223
  const parsed = parseInt(n, 10);
206
224
  return isNaN(parsed) ? defaultPort : parsed;
207
225
  }
208
- function parseDriveSuffix(args, port) {
209
- let headless = true;
226
+ function parseDriveSuffix(args, port, defaultHeadless) {
227
+ let headless = defaultHeadless;
210
228
  let driveUrl;
211
229
  for (const arg of args) {
212
230
  if (arg === HEADED_FLAG) {
213
231
  headless = false;
214
232
  }
233
+ else if (arg === HEADLESS_FLAG) {
234
+ headless = true;
235
+ }
215
236
  else if (arg.startsWith('--')) {
216
237
  return { kind: 'error', message: CLI_USAGE };
217
238
  }
@@ -336,7 +357,14 @@ function parseTargetArgs(rest) {
336
357
  }
337
358
  return since === undefined ? { files } : { files, since };
338
359
  }
339
- export function parseCliArgs(argv, defaultPort) {
360
+ /**
361
+ * @param defaultHeadless Whether a browser Reticle launches should be hidden when no flag says
362
+ * otherwise. Injected rather than read from the environment here, because this module is pure —
363
+ * `cli.ts` decides it from `CI`. The product default is FALSE: showing the run is what makes it
364
+ * trustworthy, and asking people to opt into seeing their own app was backwards.
365
+ */
366
+ export function parseCliArgs(argv, defaultPort, defaultHeadless = false) {
367
+ // Bare `reticle` is `serve` — a pool-owning command, so headless like the rest of that family.
340
368
  if (argv.length === 0)
341
369
  return { kind: 'serve', port: defaultPort, headless: true, http: false };
342
370
  const [cmd, ...rest] = argv;
@@ -356,7 +384,7 @@ export function parseCliArgs(argv, defaultPort) {
356
384
  return { kind: 'init', port: r.port, mcp: r.mcp, dryRun: r.dryRun, install: r.install };
357
385
  }
358
386
  case SERVE_COMMAND: {
359
- const r = parseServeFlags(rest, defaultPort);
387
+ const r = parseServeFlags(rest, defaultPort, defaultHeadless);
360
388
  if (r.kind === 'error')
361
389
  return r;
362
390
  return {
@@ -436,13 +464,13 @@ export function parseCliArgs(argv, defaultPort) {
436
464
  return url !== undefined ? { kind: 'open', port, url } : { kind: 'open', port };
437
465
  }
438
466
  case DRIVE_COMMAND: {
439
- const r = parseDriveSuffix(rest, defaultPort);
467
+ const r = parseDriveSuffix(rest, defaultPort, defaultHeadless);
440
468
  if (r.kind === 'error')
441
469
  return r;
442
470
  return { kind: 'drive', port: r.port, driveUrl: r.driveUrl, headless: r.headless };
443
471
  }
444
472
  case DAEMON_INNER_COMMAND: {
445
- const r = parseServeFlags(rest, defaultPort);
473
+ const r = parseServeFlags(rest, defaultPort, defaultHeadless);
446
474
  if (r.kind === 'error')
447
475
  return r;
448
476
  return {
@@ -503,7 +531,7 @@ export function parseCliArgs(argv, defaultPort) {
503
531
  case ROLLBACK_COMMAND:
504
532
  return { kind: 'rollback' };
505
533
  case MCP_COMMAND: {
506
- const r = parseServeFlags(rest, defaultPort);
534
+ const r = parseServeFlags(rest, defaultPort, defaultHeadless);
507
535
  if (r.kind === 'error')
508
536
  return r;
509
537
  return {
@@ -7,6 +7,9 @@
7
7
  * 3..reticle.json "port" field in the cwd ← per-project isolation
8
8
  * 4. RETICLE_DEFAULT_PORT (4400)
9
9
  */
10
+ export declare function isLikelyDevServerPort(port: number): boolean;
11
+ /** The warning printed when a project pins the bridge to something that looks like its dev server. */
12
+ export declare function devServerPortWarning(port: number): string;
10
13
  /**
11
14
  * Read the port stored in the project's .reticle.json (written by `reticle init`).
12
15
  * Returns undefined if the file is absent, unreadable, or has no valid numeric port.
package/dist/cli-port.js CHANGED
@@ -8,6 +8,27 @@
8
8
  * 4. RETICLE_DEFAULT_PORT (4400)
9
9
  */
10
10
  import { readFileSync } from 'node:fs';
11
+ /**
12
+ * Ports that belong to a DEV SERVER, not to Reticle's bridge. Next defaults to 3000, Vite to 5173,
13
+ * Astro to 4321, and so on.
14
+ *
15
+ * `.reticle.json`'s `port` is the bridge — the daemon ↔ SDK channel, default 4400 — and it has
16
+ * nothing to do with the port the app is served on. Setting it to the dev-server port makes the
17
+ * daemon try to bind the port the app already holds; what the user sees is either a bind failure or
18
+ * a daemon that never connects, neither of which mentions the actual mistake. The old setup skill
19
+ * asked "what port does your dev server run on?" a few lines before showing this field, which is how
20
+ * the confusion got manufactured in the first place.
21
+ */
22
+ const DEV_SERVER_PORTS = new Set([
23
+ 3000, 3001, 4200, 4321, 5000, 5173, 5174, 8000, 8080, 8100, 9000,
24
+ ]);
25
+ export function isLikelyDevServerPort(port) {
26
+ return DEV_SERVER_PORTS.has(port);
27
+ }
28
+ /** The warning printed when a project pins the bridge to something that looks like its dev server. */
29
+ export function devServerPortWarning(port) {
30
+ return `[reticle] .reticle.json sets "port": ${port}, which is a common DEV-SERVER port. That field is the Reticle BRIDGE port (default 4400), not the port your app is served on — they must be different, or the daemon fights your dev server for it. Remove "port" unless you are running several apps at once.`;
31
+ }
11
32
  /**
12
33
  * Read the port stored in the project's .reticle.json (written by `reticle init`).
13
34
  * Returns undefined if the file is absent, unreadable, or has no valid numeric port.
package/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ import { runInit } from './init/run.js';
24
24
  import { handleDoctor } from './cli-doctor.js';
25
25
  import { buildNodeIo } from './init/node-io.js';
26
26
  import { describeLicense } from './license/license.js';
27
- import { readProjectPort, readProjectId } from './cli-port.js';
27
+ import { isLikelyDevServerPort, devServerPortWarning, readProjectPort, readProjectId } from './cli-port.js';
28
28
  import { DAEMON_INNER_COMMAND, PORT_FLAG, DRIVE_FLAG, HEADED_FLAG, HTTP_FLAG, HTTP_PORT_FLAG, HTTP_TOKEN_FLAG, parseCliArgs, CLI_USAGE, } from './cli-parse.js';
29
29
  import { handleFeedback, handleIdentify, handleTelemetry } from './telemetry/feedback-cli.js';
30
30
  import { installDaemonTelemetry } from './telemetry/daemon-telemetry.js';
@@ -413,8 +413,15 @@ function main() {
413
413
  const portEnv = process.env[ReticleEnv.PORT];
414
414
  const envPort = portEnv !== undefined ? parseInt(portEnv, 10) : undefined;
415
415
  const projectPort = readProjectPort(process.cwd());
416
+ // Say so rather than letting the daemon fight the dev server for the port and fail with an
417
+ // EADDRINUSE that mentions neither file nor cause.
418
+ if (projectPort !== undefined && isLikelyDevServerPort(projectPort)) {
419
+ process.stderr.write(`${devServerPortWarning(projectPort)}\n`);
420
+ }
416
421
  const defaultPort = envPort ?? projectPort ?? RETICLE_DEFAULT_PORT;
417
- const parsed = parseCliArgs(argv, defaultPort);
422
+ // Headed by default; hidden only where there is no display to be headed on. A run nobody can see
423
+ // is a run nobody trusts, and every "did it actually do anything?" cost a human round-trip.
424
+ const parsed = parseCliArgs(argv, defaultPort, process.env['CI'] !== undefined);
418
425
  switch (parsed.kind) {
419
426
  case 'error':
420
427
  log('reticle_usage_error', { message: parsed.message });
@@ -1,11 +1,5 @@
1
1
  import { type FlowFile, type FlowReplayResult, type ReplayDecision, type SuiteVerdict } from '@reticlehq/core';
2
2
  export declare function buildDecision(result: FlowReplayResult, flow?: FlowFile): ReplayDecision;
3
- /**
4
- * Aggregate per-flow replays into one suite verdict — the autonomous loop's consolidated answer
5
- * after a build: did anything break, and what's the prioritized fix list. Pure: pass the already
6
- * decision-annotated replay results + their flows. Passing flows are counted; only failures carry
7
- * detail (token-cheap), each with the actionable decision so the agent fixes without re-querying.
8
- */
9
3
  export declare function buildSuiteVerdict(runs: ReadonlyArray<{
10
4
  replay: FlowReplayResult;
11
5
  flow?: FlowFile;
@@ -1,5 +1,5 @@
1
1
  import { AnchorKind, ReplayStatus, } from '@reticlehq/core';
2
- import { classifyFlowAssertions } from './flow-classify.js';
2
+ import { classifyFlowAssertions, FlowAssertionGrade } from './flow-classify.js';
3
3
  import { SUCCESS_STEP_TOOL } from './flow-success.js';
4
4
  /**
5
5
  * Turn a replay result into the autonomy DECISION envelope — the judgement a human reviewer used to
@@ -92,12 +92,39 @@ function suiteVerdictOf(status) {
92
92
  * decision-annotated replay results + their flows. Passing flows are counted; only failures carry
93
93
  * detail (token-cheap), each with the actionable decision so the agent fixes without re-querying.
94
94
  */
95
+ /**
96
+ * Why a green replay still verified nothing, or undefined when it genuinely did.
97
+ *
98
+ * A flow with no steps, or one that asserts no observable consequence, replays green no matter what
99
+ * the app does — the grader already knows this at save time and says so. Counting it as `passed`
100
+ * turned that warning into "all 1 flow pass", which is a false green in the exact feature sold as
101
+ * the regression suite. Classified only when the flow file is available; never guessed.
102
+ */
103
+ function unverifiableReason(flow) {
104
+ if (flow === undefined)
105
+ return undefined;
106
+ if (flow.steps.length === 0) {
107
+ return 'the flow has no steps — it replays green whatever the app does. Record it again, or add steps with reticle_annotate.';
108
+ }
109
+ const c = classifyFlowAssertions(flow);
110
+ if (c.grade !== FlowAssertionGrade.ASSERTION_FREE)
111
+ return undefined;
112
+ return (c.warning ??
113
+ 'the flow asserts no observable consequence, so it passes even when the feature is broken.');
114
+ }
95
115
  export function buildSuiteVerdict(runs) {
96
116
  const failures = [];
117
+ const unverifiable = [];
97
118
  let passed = 0;
98
119
  for (const { replay, flow } of runs) {
99
120
  if (replay.status === ReplayStatus.OK) {
100
- passed += 1;
121
+ const reason = unverifiableReason(flow);
122
+ // A green that cannot go red is not a pass. Counted apart, so `passed` stays a count of things
123
+ // actually verified rather than of replays that merely completed.
124
+ if (reason !== undefined)
125
+ unverifiable.push({ flow: replay.name, reason });
126
+ else
127
+ passed += 1;
101
128
  continue;
102
129
  }
103
130
  const decision = replay.decision ?? buildDecision(replay, flow);
@@ -111,9 +138,23 @@ export function buildSuiteVerdict(runs) {
111
138
  }
112
139
  const total = runs.length;
113
140
  const failed = failures.length;
114
- const status = failed === 0 ? 'pass' : 'fail';
141
+ // A real failure outranks an unverifiable flow: a broken flow is worse news than an empty one.
142
+ const status = failed > 0 ? 'fail' : unverifiable.length > 0 ? 'unverifiable' : 'pass';
143
+ const cannotFail = unverifiable.length === 0
144
+ ? ''
145
+ : ` — ${String(unverifiable.length)} verified nothing (${unverifiable.map((u) => u.flow).join(', ')})`;
115
146
  const summary = failed === 0
116
- ? `all ${total} flow${total === 1 ? '' : 's'} pass`
117
- : `${passed}/${total} flows pass — ${failed} need attention: ${failures.map((f) => f.flow).join(', ')}`;
118
- return { status, total, passed, failed, summary, failures };
147
+ ? unverifiable.length === 0
148
+ ? `all ${total} flow${total === 1 ? '' : 's'} pass`
149
+ : `${String(passed)}/${String(total)} flows verified${cannotFail}`
150
+ : `${passed}/${total} flows pass — ${failed} need attention: ${failures.map((f) => f.flow).join(', ')}${cannotFail}`;
151
+ return {
152
+ status,
153
+ total,
154
+ passed,
155
+ failed,
156
+ summary,
157
+ failures,
158
+ ...(unverifiable.length > 0 ? { unverifiable } : {}),
159
+ };
119
160
  }
@@ -345,12 +345,18 @@ export const FLOW_TOOLS = [
345
345
  await lease.release().catch(() => undefined);
346
346
  }
347
347
  });
348
- return buildSuiteVerdict(outcomes.map((o, i) => ({
349
- replay: o.ok && o.value !== undefined
350
- ? o.value
351
- : leaseFailureReplay(requested[i] ?? '', o.error),
352
- })));
348
+ const parallelRuns = await Promise.all(outcomes.map(async (o, i) => {
349
+ const name = requested[i] ?? '';
350
+ const replay = o.ok && o.value !== undefined ? o.value : leaseFailureReplay(name, o.error);
351
+ const loaded = await deps.flows.load(name, projectId).catch(() => null);
352
+ const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
353
+ return flow === undefined ? { replay } : { replay, flow };
354
+ }));
355
+ return buildSuiteVerdict(parallelRuns);
353
356
  }
357
+ // The flow FILE travels with each replay so the verdict can tell a green that verified
358
+ // something from a green that could never have gone red. Without it the suite reported "all 1
359
+ // flow pass" for a flow with no steps at all.
354
360
  const runs = [];
355
361
  const timed = [];
356
362
  // The flake ledger the CLI gate already keeps — see FlakeStore. It was only ever written by
@@ -362,7 +368,9 @@ export const FLOW_TOOLS = [
362
368
  for (const flowName of requested) {
363
369
  const start = deps.now();
364
370
  const replay = await replayNamedFlow(deps, { flowName, sessionId });
365
- runs.push({ replay });
371
+ const loaded = await deps.flows.load(flowName, projectId).catch(() => null);
372
+ const flow = loaded !== null && loaded.ok ? loaded.value : undefined;
373
+ runs.push(flow === undefined ? { replay } : { replay, flow });
366
374
  timed.push({ replay, durationMs: deps.now() - start });
367
375
  }
368
376
  // Accrue this run's outcomes, then report what the ledger knows. Best-effort on both halves: a
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What the app can be driven by: its `data-testid` values, and which state library holds its truth.
3
+ *
4
+ * `init` wired none of this, so every app came up with `hasCapabilities: false`, empty capabilities,
5
+ * and a `reticle_state` holding nothing but `__reticle_renders` — the state-truth read that SKILL.md
6
+ * calls the highest-value line was unavailable on every app out of the box. Measured across six.
7
+ *
8
+ * Testids are scanned rather than asked for. Stores are NAMED but not wired: detecting that an app
9
+ * depends on zustand is easy, knowing which module exports the store instance is not, and generating
10
+ * a wrong import would break the dev module that everything else now hangs off.
11
+ */
12
+ /** Cap the generated list. A capabilities block is a hint for an agent, not an inventory. */
13
+ export declare const MAX_TESTIDS = 60;
14
+ /** Every distinct `data-testid` literal in the given sources, in first-seen order. */
15
+ export declare function scanTestids(sources: readonly string[]): string[];
16
+ /** The store libraries this app depends on, as ready-to-uncomment registration lines. */
17
+ export declare function storeHints(deps: ReadonlySet<string>): string[];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What the app can be driven by: its `data-testid` values, and which state library holds its truth.
3
+ *
4
+ * `init` wired none of this, so every app came up with `hasCapabilities: false`, empty capabilities,
5
+ * and a `reticle_state` holding nothing but `__reticle_renders` — the state-truth read that SKILL.md
6
+ * calls the highest-value line was unavailable on every app out of the box. Measured across six.
7
+ *
8
+ * Testids are scanned rather than asked for. Stores are NAMED but not wired: detecting that an app
9
+ * depends on zustand is easy, knowing which module exports the store instance is not, and generating
10
+ * a wrong import would break the dev module that everything else now hangs off.
11
+ */
12
+ /** `data-testid="foo"` / `data-testid='foo'` / `data-testid={"foo"}` — the forms people actually write. */
13
+ const TESTID_ATTR = /data-testid\s*=\s*[{]?\s*["'`]([^"'`]+)["'`]/g;
14
+ /** Cap the generated list. A capabilities block is a hint for an agent, not an inventory. */
15
+ export const MAX_TESTIDS = 60;
16
+ /** Every distinct `data-testid` literal in the given sources, in first-seen order. */
17
+ export function scanTestids(sources) {
18
+ const found = new Set();
19
+ for (const src of sources) {
20
+ for (const m of src.matchAll(TESTID_ATTR)) {
21
+ const id = m[1];
22
+ if (id !== undefined && id.length > 0)
23
+ found.add(id);
24
+ if (found.size >= MAX_TESTIDS)
25
+ return [...found];
26
+ }
27
+ }
28
+ return [...found];
29
+ }
30
+ /**
31
+ * State libraries the browser SDK has an adapter for, in the order we would recommend registering
32
+ * them. TanStack Query is first on purpose: a stale cache served as fresh fires NO network request,
33
+ * so the network log shows silence and the DOM shows a plausible number — the cache is the only
34
+ * witness, which makes it the highest-value store in the list and the least obvious one to add.
35
+ */
36
+ const STORE_LIBRARIES = [
37
+ ['@tanstack/react-query', "registerStore('queries', tanstackQueryStore(queryClient))"],
38
+ ['zustand', "registerStore('app', useStore) // pass the store itself, not () => store.getState()"],
39
+ ['@reduxjs/toolkit', "registerStore('app', store)"],
40
+ ['redux', "registerStore('app', store)"],
41
+ ['jotai', "registerStore('app', jotaiStore(getDefaultStore(), { cart, user }))"],
42
+ ['valtio', "registerStore('app', valtioStore(state))"],
43
+ ['mobx', "registerStore('app', mobxStore(state))"],
44
+ ['xstate', "registerStore('machine', xstateStore(actor))"],
45
+ ['pinia', "registerStore('cart', piniaStore(useCartStore()))"],
46
+ ['svelte', "registerStore('cart', svelteStore(cartStore))"],
47
+ ];
48
+ /** The store libraries this app depends on, as ready-to-uncomment registration lines. */
49
+ export function storeHints(deps) {
50
+ return STORE_LIBRARIES.filter(([dep]) => deps.has(dep)).map(([, hint]) => hint);
51
+ }
@@ -6,6 +6,7 @@ export declare const Framework: {
6
6
  readonly NEXT: "next";
7
7
  readonly VITE: "vite";
8
8
  readonly SVELTEKIT: "sveltekit";
9
+ readonly ASTRO: "astro";
9
10
  readonly HTML: "html";
10
11
  };
11
12
  export type Framework = (typeof Framework)[keyof typeof Framework];
@@ -27,9 +28,31 @@ export interface DetectInput {
27
28
  configFiles: ReadonlySet<string>;
28
29
  /** Lockfile basenames present in the project root. */
29
30
  lockfiles: ReadonlySet<string>;
31
+ /** Marker basenames inside `node_modules` (`.modules.yaml`, `.yarn-state.yml`, …), if installed. */
32
+ nodeModulesMarkers?: ReadonlySet<string>;
30
33
  }
34
+ /**
35
+ * The UI library the app renders with. Detection used to key on "vite is in package.json" and stop
36
+ * there, so a Vue or Preact app got `@reticlehq/react` installed and an all-green report with no
37
+ * mention that the React adapter has nothing to attach to.
38
+ */
39
+ export declare const UiLibrary: {
40
+ readonly REACT: "react";
41
+ readonly PREACT: "preact";
42
+ readonly VUE: "vue";
43
+ readonly SVELTE: "svelte";
44
+ readonly UNKNOWN: "unknown";
45
+ };
46
+ export type UiLibrary = (typeof UiLibrary)[keyof typeof UiLibrary];
31
47
  export interface Detection {
32
48
  framework: Framework;
49
+ uiLibrary: UiLibrary;
50
+ /**
51
+ * Whether the project is TypeScript. Not cosmetic: generating a `.tsx` file into a JavaScript
52
+ * project makes Next auto-install TypeScript on the next `next dev`, which on Next 13 takes its
53
+ * require-hook down with it and the dev server never starts.
54
+ */
55
+ typescript: boolean;
33
56
  reactMajor: number | undefined;
34
57
  /** React 19 dropped _debugSource, so it needs the build-time source-map stamp. */
35
58
  needsSourceMapping: boolean;
@@ -37,6 +60,8 @@ export interface Detection {
37
60
  }
38
61
  /** Extract the leading major version from a semver range like "^19.0.0" or "19.1.1". */
39
62
  export declare function parseMajor(range: string | undefined): number | undefined;
63
+ /** Marker basenames present inside the project's `node_modules`, if it has one. */
64
+ export declare function packageManagerFromNodeModules(markers: ReadonlySet<string>): PackageManager | undefined;
40
65
  export declare function detect(input: DetectInput): Detection;
41
66
  interface InstallCommand {
42
67
  command: string;