@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.
- package/dist/bridge.js +43 -3
- package/dist/bridge.test-harness.d.ts +5 -0
- package/dist/bridge.test-harness.js +12 -0
- package/dist/cli-parse.d.ts +14 -2
- package/dist/cli-parse.js +40 -12
- package/dist/cli-port.d.ts +3 -0
- package/dist/cli-port.js +21 -0
- package/dist/cli.js +9 -2
- package/dist/flows/decision.d.ts +0 -6
- package/dist/flows/decision.js +47 -6
- package/dist/flows/flow-tools.js +14 -6
- package/dist/init/capabilities.d.ts +17 -0
- package/dist/init/capabilities.js +51 -0
- package/dist/init/detect.d.ts +25 -0
- package/dist/init/detect.js +65 -3
- package/dist/init/next-patch.d.ts +41 -0
- package/dist/init/next-patch.js +106 -0
- package/dist/init/node-io.js +25 -0
- package/dist/init/patch-kind.d.ts +20 -0
- package/dist/init/patch-kind.js +10 -0
- package/dist/init/plan-framework.d.ts +35 -0
- package/dist/init/plan-framework.js +217 -0
- package/dist/init/plan.d.ts +51 -3
- package/dist/init/plan.js +125 -125
- package/dist/init/run.d.ts +17 -0
- package/dist/init/run.js +207 -16
- package/dist/init/slash-command.d.ts +18 -0
- package/dist/init/slash-command.js +52 -0
- package/dist/init/snippets.d.ts +44 -3
- package/dist/init/snippets.js +152 -6
- package/dist/init/vite-config.d.ts +4 -10
- package/dist/init/vite-config.js +15 -7
- package/dist/input/driven-redaction.d.ts +15 -0
- package/dist/input/driven-redaction.js +76 -0
- package/dist/input/network-detail.d.ts +9 -3
- package/dist/input/network-detail.js +35 -15
- package/dist/mcp-proxy.d.ts +35 -3
- package/dist/mcp-proxy.js +174 -27
- package/dist/session/session-manager.js +6 -0
- package/dist/session/session.d.ts +6 -0
- package/dist/session/session.js +7 -0
- package/dist/tools/read-tools.js +4 -0
- 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
|
|
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
|
-
|
|
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,
|
package/dist/cli-parse.d.ts
CHANGED
|
@@ -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>] [--
|
|
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
|
-
|
|
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>] [--
|
|
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> [--
|
|
19
|
-
reticle mcp [--port N] [--drive <url>] [--
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
package/dist/cli-port.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/dist/flows/decision.d.ts
CHANGED
|
@@ -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;
|
package/dist/flows/decision.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}
|
package/dist/flows/flow-tools.js
CHANGED
|
@@ -345,12 +345,18 @@ export const FLOW_TOOLS = [
|
|
|
345
345
|
await lease.release().catch(() => undefined);
|
|
346
346
|
}
|
|
347
347
|
});
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/init/detect.d.ts
CHANGED
|
@@ -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;
|