@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
@@ -1,15 +1,12 @@
1
1
  import { resolveFlowUploads } from './fields/flow-upload-resolve.js';
2
2
  import { withLeaseKeyboard } from '../../surface/tools/real-input-attempt.js';
3
3
  import { learnFromRun } from '@reticlehq/engine/evidence/learned-guards.js';
4
- import { EventType, FLOW_SIGNAL_TIMEOUT_MS, FlowErrorCode, RecordedFlowSchema, ReplayStatus, ReticleCommand, RunKind, RunStatus, } from '@reticlehq/core';
4
+ import { EventType, FLOW_SIGNAL_TIMEOUT_MS, FlowErrorCode, RecordedFlowSchema, ReplayStatus, RunKind, RunStatus, } from '@reticlehq/core';
5
5
  import { haltedFrom } from './recording/replay-halt.js';
6
- import { establishedState, samePath, startPathBudgetMs } from './flow-journey.js';
7
- import { asRecord, asString } from '@reticlehq/core';
8
- import { navigablePath, routeOfEvent, routeOfUrl, } from '@reticlehq/engine/question/predicate/predicate-route.js';
9
- import { carryReticleIdentity } from '../../surface/tools/lease-tools.js';
6
+ import { establishedState } from './flow-journey.js';
7
+ import { asString } from '@reticlehq/core';
10
8
  import { partialStatus, replayFlow, replayWindow } from './flow-replay.js';
11
- import { anchorPrecondition, anchorQueryArgs, staleTargetResult } from './flow-step-runners.js';
12
- import { queryRefs } from './replay.js';
9
+ import { staleTargetResult } from './flow-step-runners.js';
13
10
  import { assertSuccess, dynamicTestids, successLabel, SUCCESS_STEP_TOOL } from './flow-success.js';
14
11
  import { buildDecision, unverifiableReason } from './decision.js';
15
12
  import { unsuppliedSecrets } from './fields/flow-secret-field.js';
@@ -36,14 +33,17 @@ import { replayStepShooter } from '../../features/visual/step-shots.js';
36
33
  import { flowsForSession } from './flow-store-for-session.js';
37
34
  import { projectForRoot } from '../../memory/project/project-for-root.js';
38
35
  import { CROSS_STEP_INDEX, crossStepOnly, declaredIn } from './flow-cross-step.js';
36
+ import { arriveAtStartPath, startPathMismatchHint, withStartPageWait } from './flow-start-path.js';
39
37
  export function latestRecordedFlow(events) {
40
38
  for (let i = events.length - 1; i >= 0; i--) {
41
39
  const event = events[i];
42
- if (event?.type !== EventType.FLOW_RECORDED)
40
+ if (event?.type !== EventType.FLOW_RECORDED) {
43
41
  continue;
42
+ }
44
43
  const parsed = RecordedFlowSchema.safeParse(event.data);
45
- if (parsed.success)
44
+ if (parsed.success) {
46
45
  return { name: parsed.data.name, flow: parsed.data.flow };
46
+ }
47
47
  }
48
48
  return undefined;
49
49
  }
@@ -81,43 +81,14 @@ recordRoot) {
81
81
  // it was worse here: a replay outcome that should have reached the dashboard silently did not,
82
82
  // because the daemon's own directory has no link file.
83
83
  const cloud = await resolveProjectCloud(deps.fs, recordRoot, homedir(), process.env);
84
- if (null === cloud.config || !cloud.policy.memory)
85
- return; // not attached / memory disabled → local only
84
+ if (null === cloud.config || !cloud.policy.memory) {
85
+ return;
86
+ } // not attached / memory disabled → local only
86
87
  const result = await syncRunRecordToCloud({ kind: RunKind.FLOW_REPLAY, name, status: runStatus, at: deps.now(), durationMs }, projectId, cloud.config, cloudFetch);
87
88
  if (result.outcome !== SyncOutcome.SYNCED) {
88
89
  log('cloud-run-record-sync-failed', { flow: name, status: result.status, error: result.error });
89
90
  }
90
91
  }
91
- /**
92
- * The route the tab is on now: the last observed route.change, falling back to the pathname of the
93
- * session's own URL. The fallback matters — a tab that hard-loaded its page emits no route event
94
- * (the route observer only sees pushState/replaceState/popstate), but the HELLO url still names
95
- * where it sits, and without it a wrong-page replay reported plain drift with no hint at all.
96
- */
97
- function currentPathOf(session) {
98
- const routes = session.eventsSince(0).filter((e) => e.type === EventType.ROUTE_CHANGE);
99
- const last = routes.at(-1);
100
- // pathname + search + hash, because `startPath` is compared against this and must stay NAVIGABLE.
101
- //
102
- // Reading the pathname alone made both sides `/` on a hash router — always "same path", so the
103
- // hint never fired however far the tab had drifted, on the router desktop renderers use by
104
- // default. Leaving the SEARCH out was the mirror defect: `startPath` keeps its query, so a flow
105
- // starting at `/admin/events/7?tab=wrap` never matched a tab sitting on exactly that, re-navigated
106
- // on every replay, and the re-navigation killed the session mid-flow.
107
- //
108
- // Comparing without the query instead would have been worse in the direction that matters. The
109
- // query usually decides what the page renders, so a tab on `?tab=summary` would read as "already
110
- // at `?tab=wrap`" and the replay would start on the wrong page with nothing saying so. Both sides
111
- // carry it, which keeps a real difference visible and stops the false one.
112
- // Reticle's own params dropped, as the recording drops them from `startPath` (#1411).
113
- const observed = last === undefined ? undefined : routeOfEvent(last);
114
- if (observed !== undefined)
115
- return navigablePath(observed);
116
- if (session.url === undefined)
117
- return undefined;
118
- const fromUrl = routeOfUrl(session.url);
119
- return fromUrl === undefined ? undefined : navigablePath(fromUrl);
120
- }
121
92
  /**
122
93
  * Ask the project what it already knows about this flow.
123
94
  *
@@ -132,13 +103,15 @@ function currentPathOf(session) {
132
103
  */
133
104
  async function consultProjectMemory(deps, flow, root) {
134
105
  const subject = consultSubjectFor(flow);
135
- if (subject === undefined)
106
+ if (subject === undefined) {
136
107
  return undefined;
108
+ }
137
109
  try {
138
110
  // The APP's root, not the daemon's (wherever it was launched): its link file is this project's.
139
111
  const cloud = await resolveProjectCloud(deps.fs, root, homedir(), process.env);
140
- if (null === cloud.config || !cloud.policy.memory)
112
+ if (null === cloud.config || !cloud.policy.memory) {
141
113
  return undefined;
114
+ }
142
115
  // Scoped to the linked project. The key covers a whole workspace, so a read that named no
143
116
  // project could be answered with ANOTHER repo's knowledge and this replay would carry it into a
144
117
  // verdict as if it were this app's — see `memory-scope.ts` for the whole shape.
@@ -151,13 +124,15 @@ async function consultProjectMemory(deps, flow, root) {
151
124
  // `res.json` is a METHOD: read as a property it silently made "the project knows nothing".
152
125
  return 200 === res.status ? await res.json() : undefined;
153
126
  });
154
- if (body === undefined)
127
+ if (body === undefined) {
155
128
  return undefined;
129
+ }
156
130
  // `undefined`: the response could not be shown to be this project's, so nothing is attached —
157
131
  // a verdict carrying another repo's "established knowledge" is worse than none. See memory-scope.
158
132
  const entries = scopedMemoryEntries(body, cloud.projectId);
159
- if (entries === undefined)
133
+ if (entries === undefined) {
160
134
  return undefined;
135
+ }
161
136
  const picked = selectConsulted(entries);
162
137
  return 0 === picked.length ? undefined : picked;
163
138
  }
@@ -166,210 +141,6 @@ async function consultProjectMemory(deps, flow, root) {
166
141
  return undefined;
167
142
  }
168
143
  }
169
- /**
170
- * When a flow records the page its journey started on (`startPath`) and the tab is currently on a
171
- * different route, step 1 drifts for a reason that has nothing to do with the app regressing — the
172
- * anchor simply isn't on this page yet. Detect that so the decision says "navigate there first"
173
- * instead of a mystifying "a step no longer matches". Returns undefined when the routes agree or the
174
- * current route is unobservable — never a false alarm. Replay normally never gets here on a
175
- * wrong page (arriveAtStartPath navigates before step 1); this is the fallback when navigation
176
- * was refused. A page that never reconnected is reported as unverifiable before step 1.
177
- */
178
- export function startPathMismatchHint(flow, session) {
179
- const startPath = flow.startPath;
180
- if (startPath === undefined || 0 === startPath.length)
181
- return undefined;
182
- const current = currentPathOf(session);
183
- if (current === undefined || samePath(current, startPath))
184
- return undefined;
185
- return `this flow's journey starts on ${startPath} but the tab is on ${current} — navigate there (reticle_navigate { url: "${startPath}" }), then replay`;
186
- }
187
- /** How often replay looks for the SDK on the start page; how long is `startPathBudgetMs`. */
188
- const START_PATH_POLL_MS = 100;
189
- const REAL_ARRIVAL_CLOCK = {
190
- now: () => Date.now(),
191
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
192
- };
193
- /**
194
- * The session `oldId` denotes right now — itself, or the tombstone successor `resolve` rebinds to
195
- * after a full-document navigation. A different instance proves the SDK reconnected even when the
196
- * app redirected to another route. Keyed to the navigated tab's own identity on purpose: matching
197
- * "any session at the target page" could hand replay an unrelated tab already sitting there.
198
- */
199
- function arrivedSuccessor(sessions, oldId, target,
200
- /** The handle that issued the navigation. */
201
- issuedBy, isReload) {
202
- try {
203
- const candidate = sessions.resolve(oldId);
204
- // A reload can return the dying handle on the same path. Compare instances, not ids: a leased
205
- // tab can reconnect under the same id. A redirect counts only on a new instance.
206
- if (isReload && candidate === issuedBy)
207
- return undefined;
208
- const path = currentPathOf(candidate);
209
- if (path === undefined)
210
- return undefined;
211
- const matches = samePath(path, target);
212
- // A still-connected old tab on the wrong route is not a redirected successor.
213
- return matches || candidate !== issuedBy ? { session: candidate, matches } : undefined;
214
- }
215
- catch {
216
- return undefined;
217
- }
218
- }
219
- /**
220
- * Can step 1 start from where the tab already is?
221
- *
222
- * A route mismatch is NOT on its own a reason to navigate. The condition worth a page load is that
223
- * the first anchor is not reachable from here — and a flow whose first anchor lives somewhere
224
- * persistent (a sidebar, a header, a nav rail) is reachable from every route.
225
- *
226
- * Navigating anyway is expensive in the one direction that matters: a full page load tears the
227
- * session down and brings the app back COLD, which for a flow recorded after signing in is the
228
- * login screen. Step 1 then reports its anchor missing and blames a file that is completely fine.
229
- * A best-effort step that can leave the caller WORSE off than skipping it is not best-effort.
230
- *
231
- * Unresolvable either way (no anchor we can query, a failed query) reads as "cannot tell", and the
232
- * navigation goes ahead — the pre-existing behaviour, and the safe direction for a check whose whole
233
- * job is to avoid making things worse.
234
- */
235
- async function firstStepResolvesHere(session, flow) {
236
- const first = flow.steps?.[0];
237
- if (first === undefined)
238
- return false;
239
- const args = anchorQueryArgs(first.anchor);
240
- if (null === args)
241
- return false;
242
- try {
243
- return 0 < queryRefs(await session.command(ReticleCommand.QUERY, args)).length;
244
- }
245
- catch {
246
- return false;
247
- }
248
- }
249
- /**
250
- * Replay's half of the FlowFile contract: put the tab on the flow's `startPath` before step 1, and
251
- * RELOAD it if it is already there.
252
- *
253
- * The reload is the state contract. Replaying "add a task" twice against a tab nobody reset gives
254
- * the second run a world the recording never saw, and its assertions then describe the first run
255
- * rather than the app. Our own `replay-determinism.mjs` has always refreshed before every run — the
256
- * determinism we measured was never the determinism a user got.
257
- *
258
- * `requires` is the declared opt-out, and its first caller. A flow that says it starts from state
259
- * another flow established is the one flow a page load would destroy, so it is never reset — here
260
- * or from the wrong page. Silence is not an opt-out: a flow that depends on leftovers without
261
- * saying so is a flow whose green means nothing on a fresh machine, and CI is a fresh machine.
262
- *
263
- * A full-page load tears down the session socket, so the navigation must happen here — before any
264
- * step runs — and the replay continues on the session the SDK reconnects as (found via the same
265
- * tombstone rebind that lets `resolve(oldId)` answer after any navigation). Best-effort by design:
266
- * when the flow carries no startPath, the current route is unobservable, or navigation is refused,
267
- * replay proceeds on the connected session. A missing successor stops before step 1; a redirect
268
- * continues on the live successor with a route-specific hint.
269
- */
270
- export async function arriveAtStartPath(sessions, session, flow, timeoutMs = startPathBudgetMs(flow), clock = REAL_ARRIVAL_CLOCK) {
271
- const target = flow.startPath;
272
- if (target === undefined || 0 === target.length)
273
- return {};
274
- const current = currentPathOf(session);
275
- if (current === undefined)
276
- return {};
277
- // The declared opt-out, both directions. See above.
278
- if (0 < establishedState(flow).length)
279
- return {};
280
- const here = samePath(current, target);
281
- // A route mismatch that step 1 can start from anyway is not worth a page load: navigating away
282
- // from a persistent anchor could only hurt, and did. Arriving is the goal; resetting a tab that is
283
- // ALREADY here is a different decision, taken above, and this must not override it.
284
- if (!here && (await firstStepResolvesHere(session, flow)))
285
- return {};
286
- let destination;
287
- try {
288
- // startPath is a pathname (a host belongs to the machine, not the journey) — resolve it
289
- // against the tab's own URL to get something the browser can be sent to. Already HERE, the
290
- // reset reloads the page the tab is on: `samePath` ignored a query the flow did not record, and
291
- // navigating to `target` would strip it, so the replay would test a different page than the one
292
- // it just agreed it was on.
293
- destination = new URL(here ? current : target, session.url).toString();
294
- }
295
- catch {
296
- return {}; // no usable base URL — nowhere to navigate from
297
- }
298
- // Measured either side of the reset, and only when the reset is a reload: reachable before and
299
- // gone after is the reload's doing, not the flow file's. Without this, an app holding its session
300
- // in memory comes back signed out and step 1 blames a component that is completely fine.
301
- const resolvedBefore = here && (await firstStepResolvesHere(session, flow));
302
- const outcome = await navigateAndAwait(sessions, session, destination, target, timeoutMs, clock, here);
303
- if ('lost' === outcome.kind) {
304
- return {
305
- lost: `replay ${here ? 'reloaded' : 'navigated to'} ${target} before step 1 and no page was connected ` +
306
- `after ${String(timeoutMs)}ms, so the session it was driving is gone and nothing ran. A slow ` +
307
- `or backgrounded tab can take longer to come back: bring it to the front and replay`,
308
- };
309
- }
310
- if ('refused' === outcome.kind)
311
- return {};
312
- if ('redirected' === outcome.kind) {
313
- const path = currentPathOf(outcome.session) ?? 'another route';
314
- return {
315
- session: outcome.session,
316
- redirected: `replay ${here ? 'reloaded' : 'navigated to'} ${target} before step 1, but the tab reconnected on ${path}. Check the redirect or sign in before replaying`,
317
- };
318
- }
319
- const arrived = outcome.session;
320
- if (!resolvedBefore || (await firstStepResolvesHere(arrived, flow)))
321
- return { session: arrived };
322
- return {
323
- session: arrived,
324
- resetCost: resetCostHint(flow, target),
325
- };
326
- }
327
- /**
328
- * Why step 1 failed after the reset, and the one line that opts the flow out.
329
- *
330
- * No tool writes `requires`, so naming the field was advice nobody could act on without guessing its
331
- * shape. The first anchor IS the precondition: the element step 1 needs, present before it runs.
332
- */
333
- function resetCostHint(flow, target) {
334
- const first = flow.steps?.[0];
335
- const needs = first === undefined ? undefined : anchorPrecondition(first.anchor);
336
- const optOut = needs === undefined
337
- ? 'Declare what it needs (`requires`) in the flow file to opt out of the reset'
338
- : `Add \`"requires":${JSON.stringify([needs])}\` to the flow file to opt out of the reset`;
339
- return (`replay reloaded ${target} before step 1 and the first anchor did not come back — this flow ` +
340
- `starts from state a page load discards. ${optOut}, or record it from a cold page`);
341
- }
342
- export async function navigateAndAwait(sessions, session, destination, expectedPath, timeoutMs, clock = REAL_ARRIVAL_CLOCK,
343
- /** True when the destination is the page we are already on — see `arrivedSuccessor`. */
344
- isReload = false) {
345
- // A leased tab is addressed by URL params, so navigating without them would strand the lease.
346
- const url = carryReticleIdentity(session.url, destination);
347
- try {
348
- const outcome = await session.command(ReticleCommand.NAVIGATE, { url });
349
- if (!outcome.ok || true !== asRecord(outcome.result)['ok'])
350
- return { kind: 'refused' };
351
- }
352
- catch (error) {
353
- // A navigation that unloads the page rejects the very command that asked for it — the transport
354
- // dies with the document. Reading that as "the navigate failed" skipped the arrival poll below
355
- // and left replay driving a handle that could never answer again, which is how a flow with a
356
- // startPath died at step 1 on an app that had loaded perfectly. Anything else is a real refusal.
357
- if (!isDocumentGoneError(error))
358
- return { kind: 'refused' };
359
- }
360
- const deadline = clock.now() + timeoutMs;
361
- for (;;) {
362
- const arrived = arrivedSuccessor(sessions, session.id, expectedPath, session, isReload);
363
- if (true === arrived?.matches)
364
- return { kind: 'arrived', session: arrived.session };
365
- // A live redirect is a different result from no SDK reconnection at all.
366
- if (clock.now() >= deadline)
367
- return arrived === undefined
368
- ? { kind: 'lost' }
369
- : { kind: 'redirected', session: arrived.session };
370
- await clock.sleep(START_PATH_POLL_MS);
371
- }
372
- }
373
144
  /**
374
145
  * Replay one named flow end to end: load → re-resolve+run each step → assert the success oracle →
375
146
  * status + decision. Shared by reticle_flow_replay (single flow) and reticle_flow_verify (whole suite) so
@@ -389,16 +160,19 @@ async function loadInvokedFlows(deps, flow, target) {
389
160
  const seen = new Set([flow.name]);
390
161
  while (queue.length > 0) {
391
162
  const current = queue.shift();
392
- if (current === undefined)
163
+ if (current === undefined) {
393
164
  continue;
165
+ }
394
166
  for (const step of flattenSteps(current.steps)) {
395
167
  const name = step.invoke;
396
- if (name === undefined || seen.has(name))
168
+ if (name === undefined || seen.has(name)) {
397
169
  continue;
170
+ }
398
171
  seen.add(name);
399
172
  const sub = await flowsForSession(deps, target).flows.load(name, target.projectId);
400
- if (!sub.ok)
173
+ if (!sub.ok) {
401
174
  continue;
175
+ }
402
176
  out.set(name, sub.value);
403
177
  queue.push(sub.value);
404
178
  }
@@ -425,8 +199,9 @@ async function firstUnmetPrecondition(session, flow, since) {
425
199
  drift = await assertStepExpect(session, claim, waitForPredicate, 0, since);
426
200
  }
427
201
  catch (error) {
428
- if (!isDocumentGoneError(error))
202
+ if (!isDocumentGoneError(error)) {
429
203
  throw error;
204
+ }
430
205
  return `the page went away while this flow's preconditions were being checked, so nothing ran and nothing was proved. Replay again once the page is back.`;
431
206
  }
432
207
  if (drift !== undefined) {
@@ -505,16 +280,21 @@ observe = (s) => s) {
505
280
  // connected session — and the hint below turns the wrong-page drift into an actionable next move.
506
281
  // Continuing at a step means the page is already there; going to the start would undo it.
507
282
  const { to, at: continueAt } = replayWindow(args);
508
- const arrival = continueAt === undefined ? await arriveAtStartPath(deps.sessions, connected, loaded.value) : {};
283
+ const pool = deps.pool;
284
+ // Only a pool lease can say its page is still loading; any other tab keeps the step budget.
285
+ const stillLoading = pool === undefined ? undefined : () => pool.leaseLoading(connected.id);
286
+ const arrival = continueAt === undefined
287
+ ? await arriveAtStartPath(deps.sessions, connected, loaded.value, undefined, undefined, stillLoading)
288
+ : {};
509
289
  // Continuing on `connected` after the tab left timed every step out on a dead handle (#1129).
510
290
  if (arrival.lost !== undefined) {
511
291
  const reason = arrival.lost;
512
- return {
292
+ return withStartPageWait({
513
293
  name: loaded.value.name,
514
294
  status: ReplayStatus.UNVERIFIABLE,
515
295
  steps: [],
516
296
  unverifiable: { reason },
517
- };
297
+ }, arrival);
518
298
  }
519
299
  const session = arrival.session ?? connected;
520
300
  // The reset's own cost outranks the wrong-page hint: if the reload is why step 1 cannot start,
@@ -563,7 +343,7 @@ observe = (s) => s) {
563
343
  const missing = unsuppliedSecrets(replayable, process.env);
564
344
  if (missing.length > 0) {
565
345
  const names = missing.map((each) => each.envKey).join(', ');
566
- return {
346
+ return withStartPageWait({
567
347
  name: replayable.name,
568
348
  status: ReplayStatus.UNVERIFIABLE,
569
349
  steps: [],
@@ -573,22 +353,22 @@ observe = (s) => s) {
573
353
  `the value typed would be the placeholder itself, and any failure after it would be ` +
574
354
  `about this and not about the application.`,
575
355
  },
576
- };
356
+ }, arrival);
577
357
  }
578
358
  const from = resumeArg(args['from']);
579
359
  const badResume = unresolvedResume(from, replayable.steps);
580
360
  if (badResume !== undefined) {
581
361
  const error = { code: FlowErrorCode.STEP_NOT_FOUND, message: badResume };
582
- return { name: replayable.name, status: ReplayStatus.ERROR, steps: [], error };
362
+ return withStartPageWait({ name: replayable.name, status: ReplayStatus.ERROR, steps: [], error }, arrival);
583
363
  }
584
364
  const unmet = await firstUnmetPrecondition(session, replayable, replayFloor);
585
365
  if (unmet !== undefined) {
586
- return {
366
+ return withStartPageWait({
587
367
  name: replayable.name,
588
368
  status: ReplayStatus.UNVERIFIABLE,
589
369
  steps: [],
590
370
  unverifiable: { reason: unmet },
591
- };
371
+ }, arrival);
592
372
  }
593
373
  let steps;
594
374
  try {
@@ -614,13 +394,15 @@ observe = (s) => s) {
614
394
  });
615
395
  }
616
396
  catch (error) {
617
- if (!(error instanceof DocumentLostDuringReplay))
397
+ if (!(error instanceof DocumentLostDuringReplay)) {
618
398
  throw error;
619
- return lostDocumentResult(name, error);
399
+ }
400
+ return withStartPageWait(lostDocumentResult(name, error), arrival);
620
401
  }
621
402
  // Only part of the journey was driven here: its steps were checked, nothing about the whole flow.
622
- if (0 < (continueAt ?? 0) || (to !== undefined && to < loaded.value.steps.length))
623
- return { name, status: partialStatus(steps), steps };
403
+ if (0 < (continueAt ?? 0) || (to !== undefined && to < loaded.value.steps.length)) {
404
+ return withStartPageWait({ name, status: partialStatus(steps), steps }, arrival);
405
+ }
624
406
  // Computed HERE, before the synthetic success row is appended below: once that row is pushed,
625
407
  // `steps.length` no longer counts only the flow's own steps and the arithmetic is wrong.
626
408
  const halted = haltedFrom(steps, loaded.value.steps.length);
@@ -659,11 +441,21 @@ observe = (s) => s) {
659
441
  steps.push(row);
660
442
  }
661
443
  const staleTarget = staleTargetResult(name, steps);
662
- if (staleTarget !== undefined)
663
- return staleTarget;
444
+ if (staleTarget !== undefined) {
445
+ return withStartPageWait(staleTarget, arrival);
446
+ }
664
447
  const driftSteps = steps.filter((s) => s.drift !== undefined).length;
665
448
  const allOk = steps.every((s) => s.ok);
666
- const status = driftSteps > 0 ? ReplayStatus.DRIFT : allOk ? ReplayStatus.OK : ReplayStatus.ERROR;
449
+ let status;
450
+ if (driftSteps > 0) {
451
+ status = ReplayStatus.DRIFT;
452
+ }
453
+ else if (allOk) {
454
+ status = ReplayStatus.OK;
455
+ }
456
+ else {
457
+ status = ReplayStatus.ERROR;
458
+ }
667
459
  await recordReplayRun(deps, name, status, driftSteps, deps.now() - startedAt, projectId, replayRoot);
668
460
  // Anti-reward-hacking baseline: record what this flow asserted ONLY when it passed clean. A
669
461
  // failing run must never become the baseline a later weakening is measured against.
@@ -710,32 +502,41 @@ observe = (s) => s) {
710
502
  };
711
503
  errored.decision = buildDecision(errored, loaded.value, intentSaid, invokedFlows);
712
504
  applyStartPathHint(errored, startPathHint);
713
- if (deviation !== undefined)
505
+ if (deviation !== undefined) {
714
506
  errored.deviation = deviation;
715
- if (knows !== undefined)
507
+ }
508
+ if (knows !== undefined) {
716
509
  errored.knows = knows;
717
- if (crossStep.length > 0)
510
+ }
511
+ if (crossStep.length > 0) {
718
512
  errored.crossStep = crossStep;
719
- return errored;
513
+ }
514
+ return withStartPageWait(errored, arrival);
720
515
  }
721
516
  const result = { name, status, steps };
722
- if (halted !== undefined)
517
+ if (halted !== undefined) {
723
518
  result.halted = halted;
724
- if (knows !== undefined)
519
+ }
520
+ if (knows !== undefined) {
725
521
  result.knows = knows;
522
+ }
726
523
  // A green that cannot go red is not a pass: the same judgement `reticle_flow_verify` makes.
727
524
  const cannotFail = status === ReplayStatus.OK ? unverifiableReason(loaded.value, invokedFlows) : undefined;
728
- if (cannotFail !== undefined)
525
+ if (cannotFail !== undefined) {
729
526
  result.unverifiable = { reason: cannotFail };
527
+ }
730
528
  // One check of the session that ran it, so its end does not say "nothing was checked".
731
529
  noteReplayCheck(session, name, status, cannotFail !== undefined);
732
- if (status !== ReplayStatus.OK)
530
+ if (status !== ReplayStatus.OK) {
733
531
  result.decision = buildDecision(result, loaded.value, intentSaid, invokedFlows);
532
+ }
734
533
  applyStartPathHint(result, startPathHint);
735
- if (deviation !== undefined)
534
+ if (deviation !== undefined) {
736
535
  result.deviation = deviation;
737
- if (crossStep.length > 0)
536
+ }
537
+ if (crossStep.length > 0) {
738
538
  result.crossStep = crossStep;
539
+ }
739
540
  /*
740
541
  * What this run taught the flow.
741
542
  *
@@ -772,10 +573,12 @@ observe = (s) => s) {
772
573
  observed: steps.some((step) => step.digest !== undefined),
773
574
  });
774
575
  result.learned = learning.guards;
775
- if (learning.promoted.length > 0)
576
+ if (learning.promoted.length > 0) {
776
577
  result.promoted = learning.promoted;
777
- if (learning.regressed.length > 0)
578
+ }
579
+ if (learning.regressed.length > 0) {
778
580
  result.regressed = learning.regressed;
581
+ }
779
582
  /*
780
583
  * Write it back, or none of the above compounds.
781
584
  *
@@ -788,7 +591,7 @@ observe = (s) => s) {
788
591
  * lesson is smaller than losing the run that produced it. Skipped entirely when nothing moved,
789
592
  * so a clean replay of a flow with nothing to learn does not rewrite a file for no reason.
790
593
  */
791
- return result;
594
+ return withStartPageWait(result, arrival);
792
595
  }
793
596
  /**
794
597
  * The deviation report over a replay's route segments — compared against the learned envelope, which is
@@ -799,8 +602,9 @@ async function computeReplayDeviation(deps, session, floor,
799
602
  root) {
800
603
  try {
801
604
  const segments = computeSegments(session.eventsSince(floor));
802
- if (0 === segments.length)
605
+ if (0 === segments.length) {
803
606
  return undefined;
607
+ }
804
608
  return await reportAndAccumulate(new EnvelopeStore(deps.fs, root), segments);
805
609
  }
806
610
  catch {
@@ -809,8 +613,9 @@ root) {
809
613
  }
810
614
  /** Fold a start-page-mismatch hint onto a non-passing replay's decision (the actionable next move). */
811
615
  function applyStartPathHint(result, hint) {
812
- if (hint === undefined || result.decision === undefined)
616
+ if (hint === undefined || result.decision === undefined) {
813
617
  return;
618
+ }
814
619
  result.decision.suggestedFix = hint;
815
620
  result.decision.nextAction = hint;
816
621
  }
@@ -35,4 +35,4 @@ export declare function readyForReplay(flow: FlowFile): FlowFile;
35
35
  export declare function replayability(flow: FlowFile): Replayability;
36
36
  /** The suite's argument and field for this, worded once. */
37
37
  export declare const SKIP_WRITES_ARG: z.ZodOptional<z.ZodBoolean>;
38
- export declare const REPLAYABILITY_OUTPUT: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
38
+ export declare const REPLAYABILITY_OUTPUT: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;