@reticlehq/server 3.8.0 → 3.8.2

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 (115) hide show
  1. package/SKILL.md +2 -2
  2. package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
  3. package/dist/command/cli/answers/gate-hook-message.js +5 -1
  4. package/dist/command/cli/auth/cloud-session.d.ts +1 -19
  5. package/dist/command/cli/auth/device-flow.d.ts +3 -37
  6. package/dist/command/cli/cli-doctor.js +30 -23
  7. package/dist/command/cli/cli-flow-commands.js +7 -1
  8. package/dist/command/cli/cli-parse-serve.d.ts +24 -0
  9. package/dist/command/cli/cli-parse-serve.js +124 -0
  10. package/dist/command/cli/cli-parse.d.ts +1 -10
  11. package/dist/command/cli/cli-parse.js +62 -121
  12. package/dist/command/cli/cli-usage.js +5 -2
  13. package/dist/command/cli/cloud-link.d.ts +1 -20
  14. package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
  15. package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
  16. package/dist/command/cli/ports/port-holder.js +6 -1
  17. package/dist/command/cli/project-name.d.ts +1 -7
  18. package/dist/command/cli/project-name.js +1 -0
  19. package/dist/command/daemon/daemon.d.ts +6 -0
  20. package/dist/command/daemon/daemon.js +52 -2
  21. package/dist/command/daemon/win32-escape.d.ts +32 -0
  22. package/dist/command/daemon/win32-escape.js +119 -0
  23. package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
  24. package/dist/command/setup/bringup/dev-server-wait.js +5 -1
  25. package/dist/command/version/version-skew.d.ts +2 -0
  26. package/dist/command/version/version-skew.js +7 -3
  27. package/dist/features/crawl/crawl-tools.js +2 -2
  28. package/dist/features/exhaust/ledger.d.ts +15 -96
  29. package/dist/features/exhaust/ledger.js +3 -3
  30. package/dist/features/license/license.d.ts +2 -14
  31. package/dist/hooks/hook-commands.js +19 -9
  32. package/dist/judgement/capsule/capsule-store.d.ts +2 -39
  33. package/dist/language/flows/annotate-tools.js +1 -1
  34. package/dist/language/flows/fields/flow-secret-field.js +1 -1
  35. package/dist/language/flows/flow-expect-grammar.js +14 -6
  36. package/dist/language/flows/flow-lease.d.ts +27 -0
  37. package/dist/language/flows/flow-lease.js +59 -0
  38. package/dist/language/flows/flow-replay-run.d.ts +1 -87
  39. package/dist/language/flows/flow-replay-run.js +88 -283
  40. package/dist/language/flows/flow-replayable.d.ts +1 -1
  41. package/dist/language/flows/flow-start-path.d.ts +108 -0
  42. package/dist/language/flows/flow-start-path.js +313 -0
  43. package/dist/language/flows/flow-tools.d.ts +2 -23
  44. package/dist/language/flows/flow-tools.js +34 -66
  45. package/dist/language/flows/recording/flake.d.ts +3 -27
  46. package/dist/language/flows/recording/flake.js +1 -1
  47. package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
  48. package/dist/machine/git-tracks.js +1 -0
  49. package/dist/memory/cloud/cloud-sync.d.ts +2 -24
  50. package/dist/memory/intent/intent-shard.d.ts +41 -266
  51. package/dist/memory/journal/envelope-store.js +1 -1
  52. package/dist/memory/journal/envelope.d.ts +6 -154
  53. package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
  54. package/dist/memory/journal/on-disk/ambient-file.js +1 -1
  55. package/dist/memory/journal/self-observation.js +3 -1
  56. package/dist/memory/journal/session-journal.js +39 -20
  57. package/dist/portal/input/drive-error.d.ts +6 -0
  58. package/dist/portal/input/drive-error.js +9 -0
  59. package/dist/portal/input/real-input-press.d.ts +34 -0
  60. package/dist/portal/input/real-input-press.js +150 -0
  61. package/dist/portal/input/real-input.d.ts +6 -23
  62. package/dist/portal/input/real-input.js +95 -192
  63. package/dist/portal/input/viewport-tools.d.ts +19 -15
  64. package/dist/portal/pool/browser-pool.d.ts +5 -55
  65. package/dist/portal/pool/browser-pool.js +121 -100
  66. package/dist/portal/pool/pool-lease.d.ts +128 -0
  67. package/dist/portal/pool/pool-lease.js +49 -0
  68. package/dist/portal/production/production-activation.d.ts +5 -27
  69. package/dist/portal/session/control-envelope.d.ts +1 -1
  70. package/dist/portal/session/zero-install-first-call.d.ts +3 -0
  71. package/dist/portal/session/zero-install-first-call.js +33 -12
  72. package/dist/surface/mcp/mcp.d.ts +3 -3
  73. package/dist/surface/mcp/mcp.js +4 -6
  74. package/dist/surface/tools/act/act-danger.js +32 -17
  75. package/dist/surface/tools/act/durable.js +1 -1
  76. package/dist/surface/tools/act-sequence-tool.js +3 -3
  77. package/dist/surface/tools/act-tools.js +15 -16
  78. package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
  79. package/dist/surface/tools/coverage-tools.js +1 -1
  80. package/dist/surface/tools/dynamic-tools.d.ts +8 -0
  81. package/dist/surface/tools/dynamic-tools.js +36 -16
  82. package/dist/surface/tools/empty-session-list.d.ts +5 -29
  83. package/dist/surface/tools/feedback-tools.js +1 -1
  84. package/dist/surface/tools/gap-novelty.d.ts +1 -1
  85. package/dist/surface/tools/gaps/capability-absences.js +4 -3
  86. package/dist/surface/tools/harness-gate.d.ts +1 -1
  87. package/dist/surface/tools/harness-toolset.js +3 -4
  88. package/dist/surface/tools/lease-permissions.d.ts +1 -1
  89. package/dist/surface/tools/lease-readiness.d.ts +23 -1
  90. package/dist/surface/tools/lease-readiness.js +75 -9
  91. package/dist/surface/tools/lease-tools.d.ts +1 -52
  92. package/dist/surface/tools/lease-tools.js +79 -142
  93. package/dist/surface/tools/observe-tools.js +3 -1
  94. package/dist/surface/tools/read-tools.js +5 -5
  95. package/dist/surface/tools/real-input-attempt.js +70 -31
  96. package/dist/surface/tools/session-ports.d.ts +57 -0
  97. package/dist/surface/tools/session-ports.js +102 -0
  98. package/dist/surface/tools/tool-kit.d.ts +5 -3
  99. package/dist/surface/tools/tools.js +1 -1
  100. package/dist/telemetry/daemon-telemetry.d.ts +3 -1
  101. package/dist/telemetry/daemon-telemetry.js +9 -2
  102. package/dist/telemetry/telemetry.d.ts +3 -1
  103. package/dist/telemetry/telemetry.js +2 -0
  104. package/docs/agent-cheatsheet.md +12 -0
  105. package/docs/cli/gate.mdx +1 -1
  106. package/docs/cli/verify.mdx +8 -1
  107. package/docs/first-drive.md +7 -0
  108. package/docs/multi-agent-testing.md +2 -0
  109. package/docs/packages/react.mdx +14 -2
  110. package/docs/packages/vite-plugin.mdx +1 -0
  111. package/docs/predicates.mdx +6 -0
  112. package/docs/skill.md +3 -3
  113. package/docs/troubleshooting.mdx +10 -0
  114. package/docs/usage.md +1 -1
  115. package/package.json +8 -9
@@ -19,7 +19,7 @@
19
19
  * the base64 4/3 inflation factor applied, so the encoded payload always fits in one WebSocket
20
20
  * frame.
21
21
  */
22
- import { ActionType, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
22
+ import { ActionType, clickInputProblem, DriveErrorCode, ElementState, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, InputMode, InputModeReason, ReticleCommand, TRANSPORT_LIMITS, } from '@reticlehq/core';
23
23
  import { boxCenter, DriveError, isRealInputAction, unspellablePressReason, } from '../../portal/input/real-input.js';
24
24
  import { assertDragNotDestructive, assertNotDestructive } from './act/act-danger.js';
25
25
  import { assertNativeAllowed } from '../../portal/production/production-drive.js';
@@ -101,10 +101,12 @@ const UPLOAD_MAX_BYTES = Math.floor((TRANSPORT_LIMITS.MAX_MESSAGE_BYTES / (4 / 3
101
101
  * through unchanged. `home` is injected so unit tests do not depend on the machine's real home.
102
102
  */
103
103
  function expandUserPath(rawPath, home) {
104
- if ('~' === rawPath)
104
+ if ('~' === rawPath) {
105
105
  return home;
106
- if (rawPath.startsWith('~/') || rawPath.startsWith('~\\'))
106
+ }
107
+ if (rawPath.startsWith('~/') || rawPath.startsWith('~\\')) {
107
108
  return join(home, rawPath.slice(2));
109
+ }
108
110
  return rawPath;
109
111
  }
110
112
  /** True when `real` is inside `root` after both have been realpath'd / resolved. */
@@ -132,15 +134,18 @@ async function readUploadRoots(projectRoot, fs, home) {
132
134
  catch {
133
135
  return [];
134
136
  }
135
- if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed))
137
+ if ('object' !== typeof parsed || null === parsed || Array.isArray(parsed)) {
136
138
  return [];
139
+ }
137
140
  const listed = parsed['uploadRoots'];
138
- if (!Array.isArray(listed))
141
+ if (!Array.isArray(listed)) {
139
142
  return [];
143
+ }
140
144
  const roots = [];
141
145
  for (const entry of listed) {
142
- if ('string' !== typeof entry || 0 === entry.trim().length)
146
+ if ('string' !== typeof entry || 0 === entry.trim().length) {
143
147
  continue;
148
+ }
144
149
  roots.push(resolve(expandUserPath(entry.trim(), home)));
145
150
  }
146
151
  return roots;
@@ -191,11 +196,13 @@ async function resolveUploadPath(rawPath, projectRoot, fs, uploadRoots, home) {
191
196
  export async function rewriteUploadArgs(deps, action, innerArgs,
192
197
  /** Injected for tests; production uses the process home directory. */
193
198
  home = homedir()) {
194
- if (action !== ActionType.UPLOAD)
199
+ if (action !== ActionType.UPLOAD) {
195
200
  return innerArgs;
201
+ }
196
202
  const rawPath = asString(innerArgs['path']);
197
- if (rawPath === undefined)
198
- return innerArgs; // no path → browser handles as inline upload
203
+ if (rawPath === undefined) {
204
+ return innerArgs;
205
+ } // no path → browser handles as inline upload
199
206
  // Project root is one level above .reticle/
200
207
  const projectRoot = resolve(join(deps.reticleRoot, '..'));
201
208
  const uploadRoots = await readUploadRoots(projectRoot, deps.fs, home);
@@ -236,14 +243,17 @@ function synthetic(reason) {
236
243
  function performArgsOf(inner) {
237
244
  const performArgs = {};
238
245
  const value = asString(inner['value']);
239
- if (value !== undefined)
246
+ if (value !== undefined) {
240
247
  performArgs.value = value;
248
+ }
241
249
  const text = asString(inner['text']);
242
- if (text !== undefined)
250
+ if (text !== undefined) {
243
251
  performArgs.text = text;
252
+ }
244
253
  const key = asString(inner['key']);
245
- if (key !== undefined)
254
+ if (key !== undefined) {
246
255
  performArgs.key = key;
256
+ }
247
257
  const modifiers = inner['modifiers'];
248
258
  if (Array.isArray(modifiers)) {
249
259
  performArgs.modifiers = modifiers.filter((m) => 'string' === typeof m);
@@ -251,9 +261,14 @@ function performArgsOf(inner) {
251
261
  // A held key is the whole point of a hold-to-confirm control, and the synthetic path has honoured
252
262
  // `holdMs` all along. Not forwarding it made the real keyboard the WEAKER of the two paths: it
253
263
  // drove the key instantly and reported a hold that never held.
264
+ const button = asString(inner['button']);
265
+ if (button !== undefined) {
266
+ performArgs.button = button;
267
+ }
254
268
  const holdMs = asNumber(inner['holdMs']);
255
- if (holdMs !== undefined)
269
+ if (holdMs !== undefined) {
256
270
  performArgs.holdMs = holdMs;
271
+ }
257
272
  // `keys` and `code` are read but not forwarded: both are routed away by the caller — a multi-key
258
273
  // sequence and an explicit physical code have no driver spelling — so carrying them into the
259
274
  // provider would only invite it to guess at a gesture the caller did not ask for.
@@ -285,11 +300,13 @@ const ANCHOR_FIELDS = [
285
300
  */
286
301
  async function pressOnLease(deps, session, ref, inner) {
287
302
  const pool = deps.pool;
288
- if (pool === undefined || !pool.hasKeyboard(session.id))
303
+ if (pool === undefined || !pool.hasKeyboard(session.id)) {
289
304
  return undefined;
305
+ }
290
306
  const unspellable = unspellablePressReason('', inner);
291
- if (unspellable !== undefined)
307
+ if (unspellable !== undefined) {
292
308
  return synthetic(unspellable);
309
+ }
293
310
  // What the focused element is called, from the focus act's own report, so the saved step can
294
311
  // find it again: the real keyboard reports no element of its own.
295
312
  let anchor = {};
@@ -307,8 +324,9 @@ async function pressOnLease(deps, session, ref, inner) {
307
324
  }
308
325
  }
309
326
  const performed = await pool.pressLease(session.id, performArgsOf(inner));
310
- if (performed?.performed !== true)
327
+ if (performed?.performed !== true) {
311
328
  return undefined;
329
+ }
312
330
  return {
313
331
  result: {
314
332
  ...anchor,
@@ -382,15 +400,17 @@ async function hoverForReal(deps, session, ref, inner, provider) {
382
400
  if (box === undefined) {
383
401
  throw new Error(HOVER_NEEDS_POINTER_MSG);
384
402
  }
385
- if (!inView)
403
+ if (!inView) {
386
404
  throw new Error(HOVER_OFF_VIEWPORT_MSG);
405
+ }
387
406
  if (providerReady && provider !== undefined) {
388
407
  try {
389
408
  const performed = await provider.perform(session.url, ActionType.HOVER, box, {});
390
409
  // Hover always has a resolved box, so there is always a center to report: the provider's own
391
410
  // reading, or the box's if it reported none. Both are the same point, not a fabricated one.
392
- if (performed.performed)
411
+ if (performed.performed) {
393
412
  return hoverSucceeded(performed.center ?? boxCenter(box), scrolled);
413
+ }
394
414
  }
395
415
  catch {
396
416
  // A provider error used to fall back to synthetic dispatch. For hover that is a lie.
@@ -428,8 +448,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
428
448
  if (provider === undefined) {
429
449
  if (action === ActionType.PRESS) {
430
450
  const leased = await pressOnLease(deps, session, ref, inner);
431
- if (leased !== undefined)
451
+ if (leased !== undefined) {
432
452
  return leased;
453
+ }
433
454
  }
434
455
  // Silent by default: with no provider EVERY action is synthetic, and a reason on all of them is
435
456
  // noise on the most-used tool in the product. But an agent that passed native:true asked a
@@ -441,11 +462,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
441
462
  // one was asked for, instead of a different gesture being sent. See `unspellablePressReason`.
442
463
  if (action === ActionType.PRESS) {
443
464
  const unspellable = unspellablePressReason(ref, inner);
444
- if (unspellable !== undefined)
465
+ if (unspellable !== undefined) {
445
466
  return synthetic(unspellable);
467
+ }
446
468
  }
447
- if (!isRealInputAction(action))
448
- return synthetic(InputModeReason.NOT_POINTER); // fill/type stay synthetic
469
+ if (!isRealInputAction(action)) {
470
+ return synthetic(InputModeReason.NOT_POINTER);
471
+ } // fill/type stay synthetic
449
472
  // "Don't click, run the code": a click/dblclick runs the occlusion-honest SYNTHETIC path by default
450
473
  // even with a provider configured — no coordinate gesture to be intercepted by the HUD or missed
451
474
  // off-screen. Opt into a trusted native click with args.native:true (file pickers, clipboard,
@@ -453,6 +476,13 @@ export async function tryRealInput(deps, session, ref, action, args) {
453
476
  if ((action === ActionType.CLICK || action === ActionType.DBLCLICK) && !askedForNative) {
454
477
  return synthetic(InputModeReason.SYNTHETIC_CLICK_PREFERRED);
455
478
  }
479
+ // The page refuses the same arguments on the synthetic path. A native click must not drop one.
480
+ if (action === ActionType.CLICK || action === ActionType.DBLCLICK) {
481
+ const problem = clickInputProblem(action, inner);
482
+ if (problem !== undefined) {
483
+ throw new Error(`cannot ${action}: ${problem}`);
484
+ }
485
+ }
456
486
  // Under version skew CDP is unusable while DOM tools still work (#688). Surface the skew sentence
457
487
  // rather than "page not correlated" / a silent provider-error fallback — those send the agent
458
488
  // hunting a dead context that is not dead.
@@ -466,15 +496,17 @@ export async function tryRealInput(deps, session, ref, action, args) {
466
496
  if (action !== ActionType.PRESS && session.versionSkew !== undefined) {
467
497
  throw new Error(session.versionSkew);
468
498
  }
469
- if (!(await provider.isAvailableFor(session.url)))
499
+ if (!(await provider.isAvailableFor(session.url))) {
470
500
  return synthetic(InputModeReason.PAGE_NOT_CORRELATED);
501
+ }
471
502
  const performArgs = performArgsOf(inner);
472
503
  // A document key has no element of its own: skip INSPECT and the box entirely.
473
504
  if (action === ActionType.PRESS) {
474
505
  try {
475
506
  const performed = await provider.perform(session.url, action, PRESS_HAS_NO_BOX, performArgs);
476
- if (!performed.performed)
507
+ if (!performed.performed) {
477
508
  return synthetic(InputModeReason.PROVIDER_DECLINED);
509
+ }
478
510
  // No `center`: a key addresses focus, not a point. The placeholder's zero happened nowhere.
479
511
  //
480
512
  // `heldMs` IS forwarded, and it is the whole reason this object is not just the three fields
@@ -521,13 +553,15 @@ export async function tryRealInput(deps, session, ref, action, args) {
521
553
  let box = asBox(inspected);
522
554
  // A box outside the viewport after the reveal is a gesture that would land on nothing. The
523
555
  // synthetic path scrolls and hit-tests on its own, so it is the honest answer.
524
- if (box === undefined || !source.inView)
556
+ if (box === undefined || !source.inView) {
525
557
  return synthetic(InputModeReason.ELEMENT_NOT_LOCATABLE);
558
+ }
526
559
  let toBox;
527
560
  if (action === ActionType.DRAG) {
528
561
  const toRef = asString(inner['toRef']);
529
- if (toRef === undefined)
562
+ if (toRef === undefined) {
530
563
  return synthetic(InputModeReason.DRAG_TARGET_UNRESOLVED);
564
+ }
531
565
  const target = await inspectRevealed(deps, session, toRef);
532
566
  const targetInspected = target.inspected;
533
567
  // A drag is judged on BOTH ends: dropping onto "Trash" is destructive however innocent the
@@ -549,12 +583,14 @@ export async function tryRealInput(deps, session, ref, action, args) {
549
583
  }
550
584
  }
551
585
  }
552
- if (toBox !== undefined)
586
+ if (toBox !== undefined) {
553
587
  performArgs.toBox = toBox;
588
+ }
554
589
  try {
555
590
  const performed = await provider.perform(session.url, action, box, performArgs);
556
- if (!performed.performed)
591
+ if (!performed.performed) {
557
592
  return synthetic(InputModeReason.PROVIDER_DECLINED);
593
+ }
558
594
  return {
559
595
  result: {
560
596
  performed: true,
@@ -566,8 +602,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
566
602
  };
567
603
  }
568
604
  catch {
569
- if (session.versionSkew !== undefined)
605
+ if (session.versionSkew !== undefined) {
570
606
  throw new Error(session.versionSkew);
607
+ }
571
608
  return {
572
609
  result: undefined,
573
610
  settled: false,
@@ -585,8 +622,9 @@ export async function tryRealInput(deps, session, ref, action, args) {
585
622
  * A Proxy, not a copy: the session keeps private state its own methods read.
586
623
  */
587
624
  export function withLeaseKeyboard(deps, session) {
588
- if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true)
625
+ if (deps.realInput !== undefined || deps.pool?.hasKeyboard(session.id) !== true) {
589
626
  return session;
627
+ }
590
628
  const command = async (name, args = {}, timeoutMs) => {
591
629
  if (ReticleCommand.ACT === name && ActionType.PRESS === args['action']) {
592
630
  const ref = asString(args['ref']) ?? '';
@@ -600,8 +638,9 @@ export function withLeaseKeyboard(deps, session) {
600
638
  };
601
639
  return new Proxy(session, {
602
640
  get(target, key) {
603
- if ('command' === key)
641
+ if ('command' === key) {
604
642
  return command;
643
+ }
605
644
  const value = Reflect.get(target, key, target);
606
645
  return 'function' === typeof value
607
646
  ? value.bind(target)
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Provider capabilities handed to features as ports: a suite fixture to seed from, save/restore,
3
+ * slowing the page and breaking it. Each is `undefined` when the provider cannot do it. They sit on
4
+ * the tool side because a feature reaching into the input layer directly would depend on a driver.
5
+ */
6
+ import type { RealInputProvider } from '../../portal/input/real-input.js';
7
+ import { type StorageFixturePort } from '../../portal/input/storage-fixture.js';
8
+ import { type NetworkMutationPort } from '../../portal/input/network-mutation.js';
9
+ import type { Perturbation, SeedStorage } from '@reticlehq/core';
10
+ /**
11
+ * The state a suite should boot its flows from: whatever the agent is already sitting in.
12
+ *
13
+ * This is the join the fixture work was building toward. A suite's flows each start from cold, so
14
+ * fifty of them prove the login works fifty times — and the flow that LOGS OUT leaves every flow
15
+ * after it signed out, which no navigation repairs, because the problem is not where the subject is
16
+ * but what it holds. A seed is applied to an isolated context BEFORE the first navigation, which is
17
+ * exactly and only when a fixture can work.
18
+ *
19
+ * Best-effort, deliberately. A fixture is an OPTIMISATION over running from cold, and running from
20
+ * cold is correct: a suite that refused to start because a cookie jar could not be read would have
21
+ * traded a slow answer for no answer. Every "no" here — no provider, no storage support, nothing
22
+ * driven, a browser that threw, nothing worth seeding — produces the same honest `undefined`, and
23
+ * the suite runs exactly as it did before any of this existed.
24
+ */
25
+ export declare function suiteFixtureSeed(realInput: RealInputProvider | undefined, appUrl: string): Promise<SeedStorage | undefined>;
26
+ /**
27
+ * A way to make this session's page SLOW on purpose, or nothing.
28
+ *
29
+ * The third of these seams, for the same reason as the other two: a feature reaching into the input
30
+ * layer directly is a feature depending on a driver, and the boundary guard asks about it.
31
+ *
32
+ * Installs delays and NOTHING else. A rule that carried a status or a body would substitute a
33
+ * response the server never sent, and an app raced against invented data produces invented races —
34
+ * a fabricated defect handed to somebody at 3am, which is worse than finding nothing.
35
+ */
36
+ export declare function sessionPerturbationPort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<{
37
+ slow(rules: readonly Perturbation[]): Promise<void>;
38
+ clear(): Promise<void>;
39
+ } | undefined>;
40
+ /**
41
+ * Save/restore for this session's page, or nothing.
42
+ *
43
+ * The same job as `suiteFixtureSeed` next door, stopping one step earlier: that one captures and
44
+ * converts, this hands back the PORT so a caller can re-apply what it captured later. It lives here
45
+ * for the reason written above — a feature reaching into the input layer directly is a feature
46
+ * depending on a driver, and the boundary guard asks about it. It asked.
47
+ */
48
+ export declare function suiteFixturePort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<StorageFixturePort | undefined>;
49
+ /**
50
+ * A way to break this session's page, or nothing.
51
+ *
52
+ * Sits beside `suiteFixtureSeed` because it is the same shape of job: turning a provider capability
53
+ * into something a feature can use, on the one side of the graph that is allowed to know about both.
54
+ * A flow tool reaching into the input layer directly would be a feature depending on a driver, which
55
+ * is the reach the boundary guard exists to ask about.
56
+ */
57
+ export declare function sessionMutationPort(realInput: RealInputProvider | undefined, appUrl: string | undefined): Promise<NetworkMutationPort | undefined>;
@@ -0,0 +1,102 @@
1
+ import { fixturePortFor, seedFromStorageState, } from '../../portal/input/storage-fixture.js';
2
+ import { mutationPortFor } from '../../portal/input/network-mutation.js';
3
+ /**
4
+ * The state a suite should boot its flows from: whatever the agent is already sitting in.
5
+ *
6
+ * This is the join the fixture work was building toward. A suite's flows each start from cold, so
7
+ * fifty of them prove the login works fifty times — and the flow that LOGS OUT leaves every flow
8
+ * after it signed out, which no navigation repairs, because the problem is not where the subject is
9
+ * but what it holds. A seed is applied to an isolated context BEFORE the first navigation, which is
10
+ * exactly and only when a fixture can work.
11
+ *
12
+ * Best-effort, deliberately. A fixture is an OPTIMISATION over running from cold, and running from
13
+ * cold is correct: a suite that refused to start because a cookie jar could not be read would have
14
+ * traded a slow answer for no answer. Every "no" here — no provider, no storage support, nothing
15
+ * driven, a browser that threw, nothing worth seeding — produces the same honest `undefined`, and
16
+ * the suite runs exactly as it did before any of this existed.
17
+ */
18
+ export async function suiteFixtureSeed(realInput, appUrl) {
19
+ if (realInput === undefined) {
20
+ return undefined;
21
+ }
22
+ try {
23
+ const port = await fixturePortFor(realInput, appUrl);
24
+ if (port === undefined) {
25
+ return undefined;
26
+ }
27
+ return seedFromStorageState(await port.capture(), appUrl);
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ /**
34
+ * A way to make this session's page SLOW on purpose, or nothing.
35
+ *
36
+ * The third of these seams, for the same reason as the other two: a feature reaching into the input
37
+ * layer directly is a feature depending on a driver, and the boundary guard asks about it.
38
+ *
39
+ * Installs delays and NOTHING else. A rule that carried a status or a body would substitute a
40
+ * response the server never sent, and an app raced against invented data produces invented races —
41
+ * a fabricated defect handed to somebody at 3am, which is worse than finding nothing.
42
+ */
43
+ export async function sessionPerturbationPort(realInput, appUrl) {
44
+ if (realInput === undefined || appUrl === undefined) {
45
+ return undefined;
46
+ }
47
+ const setMocks = realInput.setMocks?.bind(realInput);
48
+ if (setMocks === undefined) {
49
+ return undefined;
50
+ }
51
+ if (!(await realInput.isAvailableFor(appUrl))) {
52
+ return undefined;
53
+ }
54
+ return {
55
+ slow: async (rules) => {
56
+ await setMocks(appUrl, [...rules]);
57
+ },
58
+ /** Clearing is the undo. A page left slowed is damage the next run inherits. */
59
+ clear: async () => {
60
+ await setMocks(appUrl, []);
61
+ },
62
+ };
63
+ }
64
+ /**
65
+ * Save/restore for this session's page, or nothing.
66
+ *
67
+ * The same job as `suiteFixtureSeed` next door, stopping one step earlier: that one captures and
68
+ * converts, this hands back the PORT so a caller can re-apply what it captured later. It lives here
69
+ * for the reason written above — a feature reaching into the input layer directly is a feature
70
+ * depending on a driver, and the boundary guard asks about it. It asked.
71
+ */
72
+ export async function suiteFixturePort(realInput, appUrl) {
73
+ if (realInput === undefined || appUrl === undefined) {
74
+ return undefined;
75
+ }
76
+ try {
77
+ return await fixturePortFor(realInput, appUrl);
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ /**
84
+ * A way to break this session's page, or nothing.
85
+ *
86
+ * Sits beside `suiteFixtureSeed` because it is the same shape of job: turning a provider capability
87
+ * into something a feature can use, on the one side of the graph that is allowed to know about both.
88
+ * A flow tool reaching into the input layer directly would be a feature depending on a driver, which
89
+ * is the reach the boundary guard exists to ask about.
90
+ */
91
+ export async function sessionMutationPort(realInput, appUrl) {
92
+ if (realInput === undefined || appUrl === undefined) {
93
+ return undefined;
94
+ }
95
+ try {
96
+ return await mutationPortFor(realInput, appUrl);
97
+ }
98
+ catch {
99
+ // A provider that cannot answer is the same answer as a provider that cannot break anything.
100
+ return undefined;
101
+ }
102
+ }
@@ -94,6 +94,8 @@ export interface ToolDeps<Ext = unknown> {
94
94
  url: string;
95
95
  notice: string;
96
96
  } | undefined>;
97
+ /** The one dev server running in the caller's project folder, for a lease that names no url. */
98
+ projectAppUrl?: () => Promise<string | undefined>;
97
99
  /** cross-run outcome memory (.reticle/project.json). */
98
100
  project: ProjectStore;
99
101
  /** optional native-input provider. undefined ⇒ everything stays synthetic. */
@@ -170,13 +172,13 @@ export interface ToolDeps<Ext = unknown> {
170
172
  export interface ToolDef<Ext = unknown> {
171
173
  name: string;
172
174
  description: string;
173
- inputSchema: z.ZodRawShape;
175
+ inputSchema: Record<string, z.ZodType>;
174
176
  /**
175
177
  * JSON Schema-compatible output schema for this tool. When present, the MCP server advertises it
176
178
  * in the tools/list response so schema-aware clients (like @reticlehq/cli) can validate outputs and
177
179
  * compose tool calls safely. Also drives TOON encoding for snapshot/query results.
178
180
  */
179
- outputSchema?: z.ZodRawShape;
181
+ outputSchema?: Record<string, z.ZodType>;
180
182
  /**
181
183
  * One concrete, valid call — the shape, not the prose.
182
184
  *
@@ -369,7 +371,7 @@ export declare const SCREENSHOT_DOC = " `screenshot.path` is a picture of the pa
369
371
  * Derived from `EnvelopeKey` rather than re-listed: this shape and the splice sites disagreed for a
370
372
  * whole release, and every key that went missing was a channel nobody could see was missing.
371
373
  */
372
- export declare const sessionEnvelopeShape: z.ZodRawShape;
374
+ export declare const sessionEnvelopeShape: Record<string, z.ZodType>;
373
375
  /** Unwrap a browser command result or throw its error so the agent sees a clean failure. */
374
376
  export declare function commandOrThrow(deps: ToolDeps, sessionId: string | undefined, name: ReticleCommand, args: Record<string, unknown>): Promise<unknown>;
375
377
  /** Full DOM snapshot → normalized tree lines + route, for tools that diff or scan the page. */
@@ -324,7 +324,7 @@ export const RAW_TOOLS = withCompactEnvelope([
324
324
  states: z.array(z.string()),
325
325
  visible: z.boolean(),
326
326
  attrs: z
327
- .record(z.string())
327
+ .record(z.string(), z.string())
328
328
  .optional()
329
329
  .describe('Present only for attributes requested via `attrs` and found on the element.'),
330
330
  source: z
@@ -5,7 +5,7 @@
5
5
  * It lives apart from `cli.ts` because it has its own timers and its own failure rule — nothing here
6
6
  * may ever be able to fail a daemon start — and because `cli.ts` is a dispatcher.
7
7
  */
8
- import { type SessionSummary } from '@reticlehq/core/telemetry';
8
+ import { DaemonSpawnMethod, type SessionSummary } from '@reticlehq/core/telemetry';
9
9
  /**
10
10
  * How often a running daemon rolls up its window.
11
11
  *
@@ -29,6 +29,8 @@ export declare const SESSION_FLUSH_MS: number;
29
29
  * costs nothing on a daemon that never serves a tool, because only a NON-EMPTY window emits.
30
30
  */
31
31
  export declare const FIRST_FLUSH_MS: number;
32
+ /** How the parent started this daemon, from the env it set. Anything else reads as absent. */
33
+ export declare function daemonSpawnMethod(env?: Readonly<Record<string, string | undefined>>): DaemonSpawnMethod | undefined;
32
34
  /** Stops the timers this installed. Called from the daemon's shutdown path. */
33
35
  interface DaemonTelemetry {
34
36
  /**
@@ -5,7 +5,8 @@
5
5
  * It lives apart from `cli.ts` because it has its own timers and its own failure rule — nothing here
6
6
  * may ever be able to fail a daemon start — and because `cli.ts` is a dispatcher.
7
7
  */
8
- import { TelemetryEventKind } from '@reticlehq/core/telemetry';
8
+ import { DaemonSpawnMethod, TelemetryEventKind, } from '@reticlehq/core/telemetry';
9
+ import { ReticleEnv } from '@reticlehq/core';
9
10
  import { getTelemetry } from './telemetry.js';
10
11
  import { getSessionMetrics } from './session-metrics.js';
11
12
  import { profileProject } from './project-profile.js';
@@ -78,6 +79,11 @@ function withNudgeState(summary) {
78
79
  ...(nudge.offered === undefined ? {} : { updateOffered: nudge.offered }),
79
80
  };
80
81
  }
82
+ /** How the parent started this daemon, from the env it set. Anything else reads as absent. */
83
+ export function daemonSpawnMethod(env = process.env) {
84
+ const value = env[ReticleEnv.SPAWN_METHOD];
85
+ return Object.values(DaemonSpawnMethod).find((method) => method === value);
86
+ }
81
87
  /**
82
88
  * Report the daemon as started, profile the project once, and keep the session counters flushing.
83
89
  * Every timer is `unref`'d so telemetry can never be the reason a daemon refuses to exit.
@@ -91,7 +97,8 @@ export function installDaemonTelemetry(cwd, now = () => Date.now(),
91
97
  */
92
98
  port) {
93
99
  const metrics = getSessionMetrics(now);
94
- void getTelemetry().emit(TelemetryEventKind.DAEMON_STARTED);
100
+ const spawnMethod = daemonSpawnMethod();
101
+ void getTelemetry().emit(TelemetryEventKind.DAEMON_STARTED, spawnMethod === undefined ? undefined : { spawnMethod });
95
102
  // Reports a past send could not deliver. Off the boot path, and never able to throw into it.
96
103
  void drainFeedbackOutbox().catch(() => undefined);
97
104
  // Ask npm whether a newer Reticle exists, so the agent can tell the human. Nothing checked before
@@ -1,4 +1,4 @@
1
- import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type OnboardingStep, type InstallSource, type McpConnection, type McpOutage, type DaemonDeath, type ProxyExit, type WakeRefusal, type ToolRefusal, type AppInstrumentation, type HarnessRun, type HudEvent, type PlatformLink, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core/telemetry';
1
+ import { TelemetryEventKind, ProjectIdSource, type Crash, type Feedback, type Identity, type BugFound, type InitOutcome, type OnboardingStep, type InstallSource, type McpConnection, type McpOutage, type DaemonDeath, type ProxyExit, type WakeRefusal, type DaemonSpawnMethod, type ToolRefusal, type AppInstrumentation, type HarnessRun, type HudEvent, type PlatformLink, type ProjectProfile, type SessionSummary, type TelemetryActor, type Verification, type VersionChange } from '@reticlehq/core/telemetry';
2
2
  /**
3
3
  * Presence of this file marks the MACHINE as the Reticle team's own: every event carries
4
4
  * `internal: true`. The persistent twin of `RETICLE_INTERNAL=1`, for a dev machine where setting a
@@ -113,6 +113,8 @@ export interface TelemetryExtra {
113
113
  daemonDeath?: DaemonDeath;
114
114
  /** `wake_refused`: what held the port. */
115
115
  wakeRefusal?: WakeRefusal;
116
+ /** `daemon_started`: how the parent started the daemon. */
117
+ spawnMethod?: DaemonSpawnMethod;
116
118
  /**
117
119
  * `platform_linked`: the workspace just linked. Overrides the envelope's own reading, which looks
118
120
  * at the daemon's working directory and not at the project the HUD linked.
@@ -470,6 +470,8 @@ export const createTelemetry = (opts) => {
470
470
  ...(extra?.proxyExit !== undefined ? { proxyExit: extra.proxyExit } : {}),
471
471
  ...(extra?.daemonDeath !== undefined ? { daemonDeath: extra.daemonDeath } : {}),
472
472
  ...(extra?.wakeRefusal !== undefined ? { wakeRefusal: extra.wakeRefusal } : {}),
473
+ // A SCALAR: the event build and the wire schema, no `blocks` entry.
474
+ ...(extra?.spawnMethod !== undefined ? { spawnMethod: extra.spawnMethod } : {}),
473
475
  };
474
476
  // The wire schema's caps are only real if something applies them. An event that fails is
475
477
  // DROPPED, never sent and never repaired: the payload is exactly what the caps exist to stop.
@@ -48,6 +48,7 @@ Two consequences worth knowing:
48
48
 
49
49
  - **You can assert what a field contains.** Do not read the value with `reticle_look { action: "find" }` and compare it in prose: that produces no verdict and does not count as verification.
50
50
  - **`{ role, text }` checks the text,** so it does not match every button with that role.
51
+ - **A `text` predicate reads rendered text, not a field's value.** Text typed into an `<input>` or a `<textarea>` is not on the page as text, so `{ kind: "text", contains }` does not see it. Assert it with `value` on an `element` predicate, as above.
51
52
 
52
53
  Fields that nothing can check are refused rather than ignored: `by` without a `value`, and `label`, `placeholder`, `testid` or `component` when a higher-precedence field already selected the element. `alt` is the one that can still be checked beside another locator: on an image it is compared with the image's `alt` attribute (so `alt: ""` passes on a decorative image but not on one missing its alt), and on anything that is not an image it is refused, with a hint to use `name`. An element query is a first-match dispatch, not a conjunction.
53
54
 
@@ -164,6 +165,17 @@ Recent additions, each of which answers a question agents were previously asking
164
165
  2. `reticle_run({ tool: "reticle_domain", args: {} })` learns the app BEFORE testing: the saved flows, what each asserts, and the **gaps** (declared signals/testids that no flow verifies, i.e. untested intent). Tells you what to test and where the real risk is without crawling the whole app. Falls back to `reticle_run({ tool: "reticle_capabilities", args: {} })` for the raw testable surface (`testids`, `signals`, `stores`, `flows`).
165
166
  3. Run the loop: **look → act → observe → assert**, cross-checking the 4 layers on anything that matters.
166
167
 
168
+ ## No MCP tools in this host
169
+
170
+ A cloud agent or a worker can have a shell and no MCP tools. Use the CLI. `verify --expect` asks a running daemon and never starts one, so start it first:
171
+
172
+ ```bash
173
+ npx @reticlehq/server serve --headless
174
+ npx @reticlehq/server verify http://localhost:5173/ --expect '{"kind":"text","contains":"Welcome"}'
175
+ ```
176
+
177
+ It exits `0` only on `verified: "yes"`. `--expect-file <path>` reads the predicate from a file, and `--timeout N` sets how long it waits (default `4000` ms). To move a connected tab to another page first, run `npx @reticlehq/server open <url> --navigate`. For an action that `--expect` cannot do, use the [HTTP MCP transport](/http-transport).
178
+
167
179
  ## Token note
168
180
 
169
181
  - **Keep observation cheap.** Prefer `reticle_look { action: "find" }` / scoped or `interactive` `reticle_look { action: "page" }` / `reticle_assert` over dumping the full tree. A full verify loop is ~100 tokens; see [Token efficiency](/token-efficiency) (~73× leaner than full-tree snapshots).
package/docs/cli/gate.mdx CHANGED
@@ -49,7 +49,7 @@ One `reticle_gate` line. Real capture from this repository, with the flow lists
49
49
  }
50
50
  ```
51
51
 
52
- `downgraded`, `deletedCoverage`, `unflowed`, `coverageRegressed`, `unexecuted`, `coverageStale` and `coverageAccepted` appear only when they are non-empty, and none was present in that run. The coverage categories need a coverage ledger, which a project gets from `reticle_verify { action: "crawl", exhaustive: true }` or `reticle_verify { action: "coverage", app: true }`; without one they never fire. The CLI prints this as a single line of JSON; it is pretty-printed here to be readable.
52
+ `downgraded`, `deletedCoverage`, `unflowed`, `coverageRegressed`, `unexecuted`, `coverageStale` and `coverageAccepted` appear only when they are non-empty, `flowsWithoutSources` only when it is `true`, and none was present in that run. The coverage categories need a coverage ledger, which a project gets from `reticle_verify { action: "crawl", exhaustive: true }` or `reticle_verify { action: "coverage", app: true }`; without one they never fire. The CLI prints this as a single line of JSON; it is pretty-printed here to be readable.
53
53
 
54
54
  ## Exit codes
55
55
 
@@ -7,7 +7,7 @@ icon: circle-check
7
7
  `reticle verify` is the CI command: no model in the loop, no MCP. On a free port it boots the engine in drive mode, waits for the in-page SDK to dial back, replays every saved flow, renders the verdict, and exits. When a Reticle daemon already owns the port, it asks that daemon to replay the flows instead.
8
8
 
9
9
  ```bash
10
- npx @reticlehq/server verify <url> [--headed] [--timeout N] [--storage-state <file>]
10
+ npx @reticlehq/server verify <url> [--headed] [--timeout N] [--storage-state <file>] [--expect <json> | --expect-file <path>]
11
11
  ```
12
12
 
13
13
  It uses the same runner and the same verdict as the MCP and HTTP paths, so a platform agent that can only run a shell command gets a byte-identical artifact.
@@ -28,6 +28,13 @@ It uses the same runner and the same verdict as the MCP and HTTP paths, so a pla
28
28
  | `--explore` / `--persona "<who does what>"` | boolean / string | off | No saved flows yet? The Harness drives the app and records them. `--persona` implies `--explore`. Needs a linked project (`reticle connect`, or `RETICLE_API_KEY` in CI). It runs at any instrumentation score |
29
29
  | `--confirm-dangerous` | boolean | off | Let this run press the controls the replay guard holds as destructive, such as a "Place order" or "Delete" button in a saved flow. Without it such a step is refused, so a checkout flow cannot pass. It covers this run only and is never saved into a flow. |
30
30
 
31
+ On Windows PowerShell, write the predicate to a file and pass `--expect-file` so the JSON never goes through shell quoting:
32
+
33
+ ```powershell
34
+ '{"kind":"text","contains":"Saved"}' | Set-Content expect.json
35
+ npx @reticlehq/server verify http://localhost:3000 --expect-file expect.json
36
+ ```
37
+
31
38
  ## What it prints
32
39
 
33
40
  The rendered run report on stdout. On failure it names the cause on stderr instead, and there are two refusals worth knowing:
@@ -72,6 +72,13 @@ node server/dist/command/cli.js verify http://localhost:4310/ --port 4400 \
72
72
  --expect '{"kind":"text","contains":"definitely not on this page"}'
73
73
  ```
74
74
 
75
+ On Windows PowerShell, `npx.cmd` re-parses `--expect '{...}'` and strips the inner quotes. Prefer `--expect-file`:
76
+
77
+ ```powershell
78
+ '{"kind":"text","contains":"Saved"}' | Set-Content expect.json
79
+ npx @reticlehq/server verify http://localhost:3000 --expect-file expect.json
80
+ ```
81
+
75
82
  ## 3. Drive it properly, with the tools
76
83
 
77
84
  If your editor has the Reticle MCP server, the same app is now drivable. The four calls worth knowing, in the order you actually use them:
@@ -36,6 +36,8 @@ reticle_run { tool: "reticle_lease", args: { action: "release", sessionId } }
36
36
 
37
37
  `reticle_run { tool: "reticle_lease", args: { action: "acquire" } }` opens a fresh isolated headless context against your **already-running** app, stamps the lease identity into the URL so the app's own SDK registers under a sessionId you can target, and waits until that tab has connected (`ready: true`) before returning, so the sessionId is usable immediately. Release when the flow finishes.
38
38
 
39
+ **Lease the exact host that the dev server binds.** `http://localhost:3000` and `http://127.0.0.1:3000` are two origins, and they are not the same address. A dev server that binds only `::1` (Vite can do this for `localhost`) refuses a lease on `127.0.0.1`, and a server that binds only `127.0.0.1` does not answer on `::1`. The two origins also have their own cookies and storage, and a lease is reused for each origin. Copy the url that the dev server prints, host and port included.
40
+
39
41
  ## Permissions
40
42
 
41
43
  A lease starts with every permission undecided. Pass `permissions` to grant some on the app's origin before the first navigation, so the first render already sees them: