sitelooper 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +625 -0
  3. package/bin/sitelooper.js +6 -0
  4. package/dist/agent/llm.js +460 -0
  5. package/dist/agent/llm.js.map +1 -0
  6. package/dist/agent/loop.js +870 -0
  7. package/dist/agent/loop.js.map +1 -0
  8. package/dist/agent/prompt.js +40 -0
  9. package/dist/agent/prompt.js.map +1 -0
  10. package/dist/agent/report.js +545 -0
  11. package/dist/agent/report.js.map +1 -0
  12. package/dist/agent/tools.js +1147 -0
  13. package/dist/agent/tools.js.map +1 -0
  14. package/dist/cli.js +1692 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/daemon/browser.js +218 -0
  17. package/dist/daemon/browser.js.map +1 -0
  18. package/dist/daemon/codegen.js +241 -0
  19. package/dist/daemon/codegen.js.map +1 -0
  20. package/dist/daemon/dialogs.js +57 -0
  21. package/dist/daemon/dialogs.js.map +1 -0
  22. package/dist/daemon/diff.js +198 -0
  23. package/dist/daemon/diff.js.map +1 -0
  24. package/dist/daemon/fingerprint.js +98 -0
  25. package/dist/daemon/fingerprint.js.map +1 -0
  26. package/dist/daemon/inputs.js +134 -0
  27. package/dist/daemon/inputs.js.map +1 -0
  28. package/dist/daemon/recorder.js +1232 -0
  29. package/dist/daemon/recorder.js.map +1 -0
  30. package/dist/daemon/refs.js +194 -0
  31. package/dist/daemon/refs.js.map +1 -0
  32. package/dist/daemon/server.js +1724 -0
  33. package/dist/daemon/server.js.map +1 -0
  34. package/dist/daemon/state.js +239 -0
  35. package/dist/daemon/state.js.map +1 -0
  36. package/dist/doctor.js +90 -0
  37. package/dist/doctor.js.map +1 -0
  38. package/dist/shared/paths.js +80 -0
  39. package/dist/shared/paths.js.map +1 -0
  40. package/dist/shared/protocol.js +28 -0
  41. package/dist/shared/protocol.js.map +1 -0
  42. package/dist/shared/secrets.js +92 -0
  43. package/dist/shared/secrets.js.map +1 -0
  44. package/dist/shared/text.js +39 -0
  45. package/dist/shared/text.js.map +1 -0
  46. package/dist/skills/compile.js +1420 -0
  47. package/dist/skills/compile.js.map +1 -0
  48. package/dist/skills/components.js +456 -0
  49. package/dist/skills/components.js.map +1 -0
  50. package/dist/skills/flow.js +1041 -0
  51. package/dist/skills/flow.js.map +1 -0
  52. package/dist/skills/learn.js +406 -0
  53. package/dist/skills/learn.js.map +1 -0
  54. package/dist/skills/ledger.js +304 -0
  55. package/dist/skills/ledger.js.map +1 -0
  56. package/dist/skills/relabel.js +206 -0
  57. package/dist/skills/relabel.js.map +1 -0
  58. package/dist/skills/repair.js +570 -0
  59. package/dist/skills/repair.js.map +1 -0
  60. package/dist/skills/replay.js +1281 -0
  61. package/dist/skills/replay.js.map +1 -0
  62. package/dist/skills/store.js +147 -0
  63. package/dist/skills/store.js.map +1 -0
  64. package/dist/spec/check.js +428 -0
  65. package/dist/spec/check.js.map +1 -0
  66. package/dist/spec/diagnostics.js +58 -0
  67. package/dist/spec/diagnostics.js.map +1 -0
  68. package/dist/spec/emit.js +2084 -0
  69. package/dist/spec/emit.js.map +1 -0
  70. package/dist/spec/index.js +62 -0
  71. package/dist/spec/index.js.map +1 -0
  72. package/dist/spec/ir.js +216 -0
  73. package/dist/spec/ir.js.map +1 -0
  74. package/dist/spec/lift.js +162 -0
  75. package/dist/spec/lift.js.map +1 -0
  76. package/dist/spec/locators.js +270 -0
  77. package/dist/spec/locators.js.map +1 -0
  78. package/dist/spec/lower.js +124 -0
  79. package/dist/spec/lower.js.map +1 -0
  80. package/dist/spec/repair.js +657 -0
  81. package/dist/spec/repair.js.map +1 -0
  82. package/dist/spec/rerecord.js +169 -0
  83. package/dist/spec/rerecord.js.map +1 -0
  84. package/dist/spec/rethread.js +120 -0
  85. package/dist/spec/rethread.js.map +1 -0
  86. package/package.json +50 -0
  87. package/skills/sitelooper/SKILL.md +228 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Restspace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,625 @@
1
+ # sitelooper
2
+
3
+ **Drive a web app from natural language once; replay it afterwards with no model in the loop.**
4
+
5
+ sitelooper is a Playwright CLI with an LLM agent inside it. You give it one instruction at a
6
+ time - "sign in as ops@example.com, create a ticket titled 'k7 Bench' and report its id" - and it
7
+ works the live browser for you, then hands back one structured, verified result. Nothing about
8
+ selectors, waits, dialogs or quoting reaches you or the outer agent that is calling it.
9
+
10
+ Then, every instruction that succeeds is compiled into a **stored procedure**, and a whole session can be exported as a **flow**. The next time the same job runs,
11
+ sitelooper replays the procedure deterministically — no model call, no tokens — and calls the
12
+ model only for a step the app has changed underneath. On the benchmark below, a converged flow
13
+ replays a seven-step ticket workflow in 17 seconds for $0.00, verified against what the app's own
14
+ database says happened.
15
+
16
+ > Package and command are both `sitelooper`. State lives under `~/.sitelooper/`, env vars are
17
+ > `SITELOOPER_*`. The project was previously called `sleep-walker`, and `browser-pilot` before
18
+ > that; the old env-var prefixes and home directories still work as aliases, but the old command names do not.
19
+
20
+ ## Why agent-driven browser automation does not rerun, and what sitelooper does about it
21
+
22
+ Ask any browser agent to do a job and it will, mostly. Ask it to do the same job tomorrow and you'll
23
+ be paying the price again: the model re-reads every page, re-decides every click, and costs
24
+ $1–1.50 per run on a dense app. The obvious fix - have the agent write a script from what it did -
25
+ doesn't work. The reasons are structural, and can't be fixed with a better prompt:
26
+
27
+ - **The run's own values are baked in.** The record it created has an id, the url has a uid, the
28
+ title carries a run marker. A script quotes them literally, so on the next run it opens
29
+ yesterday's record — or, worse, works a *different* record to completion and reports success.
30
+ - **The page changes every time.** Ids in class names, positional selectors
31
+ (`tr:nth-of-type(3)`), a textbox named after the current minute, a heading that renders only
32
+ after a scroll. What the agent clicked was right once; the selector it left behind names a
33
+ position, not a thing.
34
+ - **The agent's waits were implicit.** Every observation turn was a pause the app needed. A
35
+ script has no turns, so it runs ahead of a list that refetches a second later.
36
+ - **Nobody checks the effect.** A click can "succeed" on the wrong element. A save can be refused
37
+ by a dialog the script never saw. Codegen replays report green while the database is untouched;
38
+ in this benchmark the strongest static script verified 14 of 48 objectives and confirmed an
39
+ empty sales order.
40
+
41
+ sitelooper's answer is to treat the recording as evidence to compile, not text to replay:
42
+
43
+ - **Durable locators with fallbacks.** Each action stores a chain of candidates — role and name,
44
+ label, test id, a structural path last — and records which ones actually resolved on each replay,
45
+ so a volatile candidate is retired by measurement, not by guesswork. A click on a table row is
46
+ retargeted to the record's own link, whose name is its identifier. Each chain ends with where
47
+ the element was: its box and the viewport. That box is the yardstick a positional guess is
48
+ measured against, and, when every name has failed, the element at that point is taken as a last
49
+ candidate only if it is the same kind of control. A locator, never a blind click.
50
+ - **Parameters, not literals.** Values you typed become slots. Values you *declared* (`var
51
+ runid=k7`) become `{{runid}}`. A value one step read back and a later step used becomes
52
+ `{{step.output}}`, threaded live between steps. A record id that first appeared in a url after a
53
+ save is recognised as minted by this run and re-read from the browser on replay. What cannot be
54
+ threaded is left blank and sent to recovery — never guessed.
55
+ - **Effect gates.** Every step records what changed on the page when it ran. On replay a step that
56
+ ran but did not produce its recorded effect — the new title never appeared as a heading, an alert
57
+ the recording never saw — stops the replay before the next step acts on the wrong state. An
58
+ identity guard refuses to run a procedure on a page showing a different record than the one it
59
+ was asked for.
60
+ - **Built for single-page apps.** The agent's observation turns were implicit waits; a replay has
61
+ none, so every step first lets the DOM go quiet, and a navigation is given time to hydrate before
62
+ its effects are checked. A click recorded to open a popup is skipped when that popup is already
63
+ showing, because on a React toggle the same click would close it. A click that changed nothing
64
+ at all while the recording shows an effect is retried once after the page settles. A fallback
65
+ locator that resolves to a link leaving the app's origin is never taken.
66
+ - **A ladder, not a cliff.** Per step: replay the pinned procedure with zero model calls; if it
67
+ cannot, recover on a cheap model with the partial replay in hand; escalate to the strong model
68
+ only if that reports blocked; halt with per-step state only if that fails too. A recovery that
69
+ validates is compiled and **re-pinned into the flow**, so a flow heals itself over runs.
70
+ - **Honest reports.** A replayed step reports only values it read back live or that came from your
71
+ parameters. A value the recording captured as a literal is struck, never echoed from memory.
72
+ - **Nothing app-specific in the tool.** No selectors, gestures or workflow assumptions for any app
73
+ live in sitelooper. App knowledge goes in a per-session briefing you supply; every mechanism
74
+ above is described in terms any web app satisfies. This is the design boundary that keeps a fix
75
+ for one app from being a hack for it.
76
+
77
+ ## Getting started
78
+
79
+ Requires Node 20+, an installed Chrome or Edge (or `SITELOOPER_EXECUTABLE`), and an API key for
80
+ one OpenAI-compatible provider.
81
+
82
+ ```sh
83
+ npm install -g sitelooper # or, from a checkout: npm install && npm link
84
+ export NOVITA_API_KEY=... # any preset: zhipu, novita, openrouter, openai (see Providers)
85
+ sitelooper config set provider novita
86
+ sitelooper doctor # node, browser, provider, key — no daemon needed
87
+ ```
88
+
89
+ Drive a page:
90
+
91
+ ```sh
92
+ sitelooper open https://demo.playwright.dev/todomvc
93
+ sitelooper do "Add two todos: 'write the report' and 'send it'. Tick the first one off, then report how many items the footer counter shows as left."
94
+ ```
95
+
96
+ `do` returns `{status, summary, evidence}`; the counter it reports was read back from the page.
97
+ Add `--verbose` to watch the agent, `--headed` to watch the browser.
98
+
99
+ Record a flow and replay it:
100
+
101
+ ```sh
102
+ # 1. record: one --learn session, the caller deciding each step as it goes
103
+ sitelooper --session run1 --learn open http://app.local/
104
+ sitelooper --session run1 var runid=k7 # what will differ next time → {{runid}}
105
+ sitelooper --session run1 do "sign in as ops@example.com / {{env:APP_PASSWORD}} and create a ticket titled 'k7 Bench'; report its id"
106
+ sitelooper --session run1 do "on that ticket add a part 'k7 Part A' cost 100 markup 25; report the price"
107
+ sitelooper --session run1 stop --save-flow ticket-flow
108
+
109
+ # 2. replay: no caller, new value, fresh app
110
+ sitelooper run ticket-flow --var runid=m3 --progress
111
+ # [OK] 01-signin (replay) ← pinned procedure, zero model calls
112
+ # [OK] 02-add (replay)
113
+ # ticket-flow: 2/2 steps, 8s — success
114
+ ```
115
+
116
+ ### Compile to a Playwright spec
117
+
118
+ `sitelooper compile <flow-name-or-path> [--out <dir>] [--force]` takes a saved flow whose steps
119
+ have converged into stored procedures and emits two files: an owned `<name>.flow.ts` that carries
120
+ the flow as a `FLOW` constant plus one generated `async` step function per `FlowStep` (durable
121
+ locators, expectations, and parameter threading compiled to literal Playwright calls — no
122
+ sitelooper process, daemon, or model call involved), and a `<name>.spec.ts` scaffold that imports
123
+ `runFlow` and is written once and never touched again — it's yours to add assertions to. Re-running
124
+ `compile` regenerates the `.flow.ts` file (with `repair` able to patch it against a live page later)
125
+ but leaves an existing `.spec.ts` alone unless you pass `--force`.
126
+
127
+ Be honest about what this loses relative to a live `run`: this is Tier 2, compile-time only,
128
+ generated from the locator evidence a session already recorded — it does not measure anything
129
+ against the live page at compile time. A step whose stored procedure has no converged locator chain
130
+ compiles to a `throw` with a `TODO` rather than a guess, and `compile` exits 2 when any step is not
131
+ compilable. Point candidates (a last-resort click by screen position) are not expressible as a
132
+ Playwright locator and are dropped with a comment. And unlike `run`, a compiled spec has no runtime
133
+ recovery: if a locator has drifted since it was recorded, the spec fails outright instead of the
134
+ agent reasoning its way to the moved control — you get speed and zero cost per run in exchange for
135
+ giving up the LLM safety net.
136
+
137
+ `sitelooper flow list | show <name>` and `sitelooper skills list | show <id>` show what was
138
+ kept; flows are plain JSON under `~/.sitelooper/flows/`. A `run` prints per-step tier (A = zero
139
+ model), turns spent and drift tickets, and `--json` returns all of it.
140
+
141
+ **Diagnostics.** `compile` and `repair` both print problems as **diagnostics, first** — before
142
+ counts, file lists or change lists — each as a `what` / `why` / `fix` block, and both carry the
143
+ same list under `diagnostics` in `--json`. The point is that a broken *recording* and a broken
144
+ *app* look completely different once said this way, instead of spreading across four surfaces as
145
+ partial, sometimes contradictory hints. A step pinned to a **demoted** skill is an error: nothing
146
+ is written, because the recording is what's wrong, not the app —
147
+
148
+ ```
149
+ $ SITELOOPER_SKILLS_DIR=fwod34-skills sitelooper compile fwod34.json --out out/
150
+ error 08-open: it is pinned to the demoted skill s_c86522 — the compiled spec inherits a procedure
151
+ whose last replays failed at the same step
152
+ why: s_c86522 is demoted: 1 of 4 replays succeeded; replay failed at step 1 on 3 of them; the
153
+ demotion was two consecutive failures at step 1; last used 2026-09-04T14:38:34.394Z.
154
+ fix: sitelooper rerecord fwod34.json 08-open
155
+
156
+ nothing written: the error(s) above are about the RECORDING, not the app — a compiled spec would
157
+ fail at a locator and read as drift.
158
+ re-record the step(s) with the fix command above, or pass --force to compile the demoted pin anyway.
159
+ ```
160
+
161
+ `--force` compiles it anyway (it already overwrites an existing `.spec.ts`; this is its second
162
+ meaning) — the emitted file then carries the same diagnostic as a comment above the step, and
163
+ appended to that step's own locator-failure message, so a red CI run points straight back here
164
+ instead of reading as ordinary drift.
165
+
166
+ **Goal-state steps.** A recording that mutates the app also reads its own outcome back, so a
167
+ step's compiled procedure carries a `goal`: the visible text ("Cancelled") that was NOT on the
168
+ page when the instruction began and IS once it succeeded — the positive counterpart of
169
+ `preconditions.requireText`. A goal is derived only when the instruction stayed on one page
170
+ template, because only then is the pre-state the same page the outcome was read from. Both the replay engine (before it ever tries the zero-model path)
171
+ and a compiled spec (a guard at the top of the step body) check identity AND every goal text
172
+ against the LIVE page before a mutating step acts (and only on the page template the goal was
173
+ read on, so a list row's "Cancelled" for some other order never counts); when both already hold, the step succeeds
174
+ having done nothing, publishing the same values its read-backs would have from the stored report
175
+ template:
176
+
177
+ ```
178
+ [flow fwod34] 08-open: already satisfied — page shows "Cancelled" for "S00021"; nothing to do
179
+ ```
180
+
181
+ `run`'s per-step line prints `satisfied` where it would otherwise print `replay` or `agent`. The
182
+ emitted spec's guard is the same check, inlined at compile time so no model is needed to run it:
183
+
184
+ ```ts
185
+ // goal: the page already showing "Cancelled" for this record means the step's work is done —
186
+ // the same check replay makes before it acts (goalSatisfied, src/skills/replay.ts).
187
+ if (await satisfied(page, [p.v1], ["Cancelled"])) {
188
+ console.log('[sitelooper satisfied] 08-open — page shows "Cancelled"; nothing to do');
189
+ outputs['08-open.order_status'] = 'Cancelled';
190
+ return;
191
+ }
192
+ ```
193
+
194
+ This is what turns a retry step harmless: fwod34's 08-open was recorded asking to cancel an order
195
+ 06-open had already cancelled; on replay 06-open's cancel lands cleanly and 08-open, told the same
196
+ thing, now finds the goal already showing instead of hunting for a Cancel button that is not
197
+ there. A false negative just runs the step as before — the check never skips work that has not
198
+ actually happened.
199
+
200
+ **Contradicted steps.** The same recording carries the fact that would have caught this at export
201
+ time, if 06-open's cancel had *reported* landing when it did not: a read-only step immediately
202
+ after a mutating one is a free check on that mutating step's own report. `buildFlow` compares
203
+ them directly — same label (or, failing that, any label that names a status/state field) — and
204
+ when neither value's first line contains the other, it is a real contradiction, not just a
205
+ fuller status bar next to a short one:
206
+
207
+ ```
208
+ warning 07-open: 07-open read a value that contradicts what the previous step reported
209
+ why: contradicted-step: 07-open read order_status "Sales Order" right after 06-open reported
210
+ "Cancelled"; 06-open's change may not have landed and a later step may be retrying it.
211
+ Re-record 06-open.
212
+ fix: sitelooper rerecord fwod34.json 06-open
213
+ ```
214
+
215
+ Like a `noop-step`, this is a record-time fact riding on `flow.warnings` that `compile`
216
+ re-surfaces as a `contradicted-step` diagnostic, `fix` pointing at re-recording the mutating step
217
+ — the one whose recording, not the app, needs another look.
218
+
219
+ **The loop.** Once compiled, the `.spec.ts` runs under plain `@playwright/test` — no sitelooper
220
+ process, no model, nothing but the two generated files and Playwright itself. Each locator call is
221
+ a `pick()` fallthrough over the candidates recorded at compile time, tried in recorded order; if
222
+ the primary misses and a later candidate resolves, that's drift, not failure — the test still
223
+ passes, but `pick()` prints a `[sitelooper drift] ...` line and appends it to the `.flow.ts`'s
224
+ exported `DRIFT` array, so a CI report or your own `.spec.ts` assertion can surface it without
225
+ grepping stderr.
226
+
227
+ When drift shows up (or the spec goes red outright), `sitelooper repair <name.flow.ts> --var k=v
228
+ [--converge n]` closes the loop: it lifts the owned file back to its IR, replays it against the
229
+ *live* app in an isolated temp store (never touching `~/.sitelooper`), and lets the recovery
230
+ ladder adapt it — a resolved fallback is promoted with a pure codemod, no model; a chain that's
231
+ gone dead gets one new locator proposed and verified by the model on the live page; a segment that
232
+ needs re-recording is reported, never attempted. It then prints a reviewer-readable change list
233
+ ("candidate promoted", "new locator", "chain reordered"), and only if `--converge n` further real
234
+ runs come back as clean tier-A replays with no drift does it rewrite the `.flow.ts` — the
235
+ `.spec.ts` is never touched. A record-creating flow needs a fresh identity each of those runs;
236
+ `{n}` in a `--var` value is replaced by the run number (`--var runid=fix-{n}` becomes `fix-0`,
237
+ `fix-1`, ...). That gives each run its own records but not its own *app* — everything the
238
+ previous run left behind is still there — so `--reset-cmd "<shell command>"` runs a command of
239
+ your choosing before run 1 and before every converge run (`--reset-cmd "curl -s -X POST
240
+ http://127.0.0.1:4180/__reset"`). It runs through a shell and a non-zero exit aborts the
241
+ repair: a converge pass over an app that was not reset is a verdict about nothing.
242
+
243
+ Repair also folds each run's evidence back into the chains as a pure codemod, no model: a
244
+ candidate that has never resolved and has now missed on two runs is sorted to the *back* of its
245
+ chain and reported as `candidate retired: <expr> — missed 2 run(s), never hit; now last`.
246
+ Evidence outranks kind, with one exception — a structural css path never floats over an
247
+ identity or handle candidate that has actually resolved. Once a candidate is retired this way
248
+ the fallthrough that names it stops counting against `--converge`: the spec now records that
249
+ fact, so re-observing it is not new drift. Without that rule one chronically volatile locator
250
+ keeps the gate from ever clearing.
251
+
252
+ One thing `repair` cannot see on its own: every gate above runs the *IR* through the daemon, so a
253
+ defect in the **emitter** — a locator that lowers fine for replay and transpiles to a Playwright
254
+ call that never resolves — passes convergence and still ships a red spec. (That is exactly what
255
+ happened on kanboard: "converged, 5/5, no changes", file written, spec failing deterministically
256
+ under plain Playwright.) `--check-spec` closes it: after the owned file is written, the sibling
257
+ `.spec.ts` is run **once** under plain `@playwright/test` — a minimal generated config, headless,
258
+ one worker, 60 s per test, `--var` values passed in as `process.env.<VAR>` the way the scaffold
259
+ reads them, the same `--reset-cmd` first — and the JSON report is turned into one line:
260
+
261
+ ```
262
+ spec check: passed in 8 s, 0 drift
263
+ spec check: FAILED at @step 01-open s_8d7c18/2 — Error: none of 1 recorded locators resolved:
264
+ getByTestId('field-nonsense-broken') — this is an emitter defect, not drift: the live replay
265
+ passed this step
266
+ ```
267
+
268
+ A failed check does **not** un-write the file — the repair may well have adapted the locator
269
+ correctly, and the diff is still yours to review — but the exit code becomes `4`. When
270
+ `@playwright/test` can't be resolved from the project the check says so and is skipped, never
271
+ failed. The same run is available on its own as `sitelooper check <name.flow.ts> --var k=v
272
+ [--reset-cmd "<cmd>"] [--json]`, which needs no daemon and no model; `--json` puts the whole
273
+ verdict under `specCheck`, in `repair`'s report too.
274
+
275
+ That "emitter defect" wording is only correct when the failing step is clean. When repair's own
276
+ convergence runs already flagged the anchor step with a `needs-rerecord` diagnostic (see below),
277
+ `--check-spec` says so instead:
278
+
279
+ ```
280
+ spec check: FAILED at @step 08-open — the step's recording is the problem, not the emitter:
281
+ 08-open only passes because the engine replays s_fcb896 (read-only) instead of its demoted pin
282
+ s_c86522; a compiled spec halts here
283
+ ```
284
+
285
+ **When the recording, not the app, is what's wrong.** Sometimes every converge run passes —
286
+ `repair` reports "9/9 tier A, no change" — only because the replay engine is quietly covering a
287
+ broken pin with some *other* learned skill (it refuses to re-pin a read-only skill over a
288
+ mutating one, `canAdoptPin`). A compiled spec has no such fallback, so it still halts. `repair`
289
+ now catches this: a step whose pin is demoted, or whose converge runs are covered end-to-end by a
290
+ skill other than its pin, gets a `needs-rerecord` diagnostic instead of counting as converged —
291
+ printed first, `wrote: null`, exit `1` — instead of a silent, misleading "no change".
292
+
293
+ Exit codes matter here: `2` means the file was hand-edited or otherwise refused outright (not a
294
+ sitelooper flow file, or a missing `--var`); `3` means the repair itself worked but the convergence
295
+ gate didn't hold; `4` means it converged and the file was written but the emitted `.spec.ts` failed
296
+ its `--check-spec` run; `1` covers both "the repair would have dropped an expectation" (refused — an
297
+ assertion that no longer holds is a test failure for a human, not drift) and "nothing could be
298
+ repaired without re-recording". The intended workflow is a pull request, not a background daemon:
299
+ CI runs the spec and fails loud on drift; a developer, or a scheduled agent picking up the failure,
300
+ runs `repair` and opens the diff for review.
301
+
302
+ ```
303
+ $ npx playwright test fwrd42.spec.ts
304
+ ✓ fwrd42 (6.0s)
305
+ $ npx playwright test fwrd42.spec.ts # after the app renamed a button
306
+ ✓ fwrd42 (6.1s)
307
+ [sitelooper drift] 02-add s_05e528/1 target: primary getByTestId('add-part') missed; used #2 getByRole('button', { name: 'Add part', exact: true })
308
+ $ sitelooper repair fwrd42.flow.ts --var runid=fix-{n} --converge 1 \
309
+ --reset-cmd "curl -s -X POST http://127.0.0.1:4180/__reset"
310
+ 02-add: candidate promoted: page.getByRole('button', { name: 'Add part', exact: true }) now primary (was #1)
311
+ candidate retired: page.getByText('{{v4}}', { exact: true }) — missed 2 run(s), never hit; now last — s_640d6e step 4 target
312
+ wrote fwrd42.flow.ts (14 change(s); the .spec.ts was not touched)
313
+ $ sitelooper repair fwrd42.flow.ts --var runid=fix-{n} --converge 1 --check-spec \
314
+ --reset-cmd "curl -s -X POST http://127.0.0.1:4180/__reset"
315
+ ...
316
+ wrote fwrd42.flow.ts (14 change(s); the .spec.ts was not touched)
317
+ spec check: passed in 8 s, 0 drift
318
+ ```
319
+
320
+ Be honest about what the loop still doesn't give back, even after `repair`: this stays Tier 2 —
321
+ point candidates (a last-resort click by screen position) are still unexpressible and dropped at
322
+ compile time; there's no live chain measurement, so the spec learns from a run only when `repair`
323
+ is invoked, never continuously; there's no loop cursor across records; and a moved control still
324
+ fails the *run that discovered it* before repair can act — recovery in a compiled spec is a
325
+ follow-up PR, never a live save.
326
+
327
+ **Re-recording one step.** When a diagnostic's `fix` says `sitelooper rerecord`, that step's
328
+ *recording* is the problem — not a locator, a candidate to promote, or the app — so `repair`'s
329
+ locator-level machinery can't help. `sitelooper rerecord <flow-name-or-path> <step-id>
330
+ [--instruction "<text>"] [--var k=v ...] [--runs n] [--reset-cmd "<cmd>"] [--json]` re-records just
331
+ that one step: it backs the flow file up as `<file>.bak-<stamp>.json`, throws away the step's pin,
332
+ params and recorded values (keeping its declared outputs), and replays the flow `--runs` times
333
+ (default 2) in learning mode so the agent records the step afresh and the store's own re-pin rule
334
+ decides whether to keep the new procedure. It only succeeds when the *last* run replays the step at
335
+ tier A on the new pin; each run is a real run against the app, so mint per-run values with `{n}` or
336
+ reset the app with `--reset-cmd`, same as `repair --converge`. Whatever the daemon says about the
337
+ step while a run is on it — above all a re-pin refusal such as `not re-pinning s_04d970 — slot(s)
338
+ v2 identify the record but carry no origin to rebind from` — is printed under the run's line whether
339
+ or not `--progress` is on, and quoted in the `needs-rerecord` diagnostic when nothing was pinned, so
340
+ a refused re-record says *why* rather than just "no procedure".
341
+
342
+ When the re-recorded step turns out to be a procedure another step of the same flow already pins
343
+ (08-open, re-recorded as a read-only check, is covered by 07-open's status check), the new pin
344
+ shares that skill and inherits the sibling step's bindings for any slot the store recorded no
345
+ origin for. Read-only skills may be shared between steps; a mutating skill is still exclusive to
346
+ one step, and a step whose instruction asks for a change never adopts a read.
347
+
348
+ fwod34's step 08-open is exactly the demoted-pin case above: its instruction — written by the
349
+ recording orchestrator — asks to cancel an order that step 06 had already cancelled, so the
350
+ skill's first action clicks a Cancel button that is never there on replay. `--instruction` replaces
351
+ the recorded ask with a read-only one, so re-recording produces a check instead of repeating the
352
+ mistake:
353
+
354
+ ```
355
+ $ sitelooper rerecord fwod34.json 08-open \
356
+ --instruction "Open the sales order {{v1}} and report its current status; do not change it."
357
+ re-recording fwod34 step 08-open (2 run(s))
358
+ unpinned s_c86522, with a new instruction; old recording kept at fwod34.json.bak-lz3x9k.json
359
+ run 1: 08-open agent (6 turns) re-pinned s_fcb9a1
360
+ fwod34: 6/6 step(s) success
361
+ run 2: 08-open replay tier A (s_fcb9a1)
362
+ fwod34: 6/6 step(s) success
363
+ 08-open: pinned s_fcb9a1 (active, 1 action(s))
364
+ fwod34.json updated — the previous recording is at fwod34.json.bak-lz3x9k.json
365
+ ```
366
+
367
+ **Sizing an instruction.** One `do` is one logical, verifiable step: a goal plus the check that it
368
+ worked. Several UI actions inside one instruction is normal — that is the point. Too big (several
369
+ unrelated goals) stalls on planning; too small (one click) pays an agent loop for what `peek` gives
370
+ free.
371
+
372
+ **Briefing.** Everything the DOM will not tell an agent about your app goes in a page of markdown
373
+ loaded with `brief <file.md>`: where things are, house conventions ("Apply only previews, Save
374
+ persists"), credentials as `{{env:NAME}}` markers, what not to touch.
375
+
376
+ ### The full command set
377
+
378
+ ```sh
379
+ sitelooper open <url> | brief <file.md> | note "<text>" | peek [--selector css] | screenshot [path]
380
+ sitelooper do "<instruction>" [--json] [--progress] [--max-turns N] [--timeout S] [--no-escalate]
381
+ sitelooper var <name>=<value> # declare a run variable (learning session)
382
+ sitelooper skills list | show <id> | rm <id> | repair --drift <run-drift.json>
383
+ sitelooper flow list | show <name>
384
+ sitelooper run <flow> [--var k=v ...] [--json] [--progress]
385
+ sitelooper script [out.spec.ts] # emit a plain Playwright spec from the recorded actions
386
+ sitelooper compile <flow-name-or-path> [--out <dir>] [--force] [--json]
387
+ # compile a converged flow to a standalone spec;
388
+ # diagnostics (what/why/fix) print first; a step
389
+ # pinned to a demoted skill refuses to write unless
390
+ # --force
391
+ sitelooper repair <name.flow.ts> [--var k=v ...] [--out <file>] [--converge <n>]
392
+ [--reset-cmd "<shell command>"] [--check-spec] [--dry-run]
393
+ [--model M] [--json]
394
+ # replay a compiled flow against the live app and
395
+ # fold the adaptation back into the owned .flow.ts;
396
+ # --reset-cmd runs before run 1 and every converge run;
397
+ # --check-spec then runs the emitted .spec.ts once
398
+ # under plain Playwright (exit 4 if it fails); a step
399
+ # whose recording, not the app, is broken gets a
400
+ # needs-rerecord diagnostic and exit 1 instead
401
+ sitelooper rerecord <flow-name-or-path> <step-id> [--instruction "<text>"] [--var k=v ...]
402
+ [--runs n] [--reset-cmd "<cmd>"] [--json]
403
+ # re-record ONE step whose pin is demoted or
404
+ # covered by another skill; unpins it, optionally
405
+ # replaces its instruction, and replays --runs
406
+ # times (default 2) in learning mode
407
+ sitelooper check <name.flow.ts> [--var k=v ...] [--reset-cmd "<cmd>"] [--json]
408
+ # run the emitted .spec.ts once under plain
409
+ # @playwright/test and report the verdict
410
+ sitelooper session list | stop [--all] [--save-flow <name>]
411
+ sitelooper doctor | config | config set <key> <value>
412
+ ```
413
+
414
+ Global flags: `--session <name>` (one daemon and browser per session), `--learn`, `--headed`,
415
+ `--record` (webm per tab), `--script`, `--verbose`, `--progress`, `--json`. Exit codes: `0`
416
+ succeeded, `1` failed or blocked, `2` infrastructure (no key, no browser, LLM unreachable).
417
+
418
+ ## Current matrix
419
+
420
+ Two questions decide whether the tool earns its place. **First contact**: given a goal it has
421
+ never seen, how does sitelooper compare with the incumbents? **Every run after that**: once the
422
+ flow is known, what does repeating it cost, and does it stay correct? Success is always the
423
+ app-side verifier's count (mutation log, JSON-RPC or HTTP API state), never an arm's self-report.
424
+ All cells are cloud runs on identical hardware, one box per target; full detail in
425
+ [bench/MATRIX-SUMMARY.md](bench/MATRIX-SUMMARY.md).
426
+
427
+ **Matrix 1 — first contact.** sitelooper: set 26 (2026-09-03, build e048128; glm-5.3
428
+ orchestrator, deepseek-v4-flash inner with glm-5.3 escalation). agent-browser: set 17, same era,
429
+ glm-5.3.
430
+
431
+ | target | sitelooper | agent-browser |
432
+ |---|---|---|
433
+ | repairdesk (in-repo SPA) | 7/7 · $0.07 · 1212s (set 28; set 26: 7/7 · $0.09 · 819s) | 6/6 · $0.19 · 67s |
434
+ | kanboard (PHP, drag-and-drop) | 6/6 · $0.21 · 1078s (set 28; set 26: 6/6 · $0.04 · 385s) | **2/6 (turn-cap)** · $0.77 · 118s |
435
+ | grafana (React SPA) | 6/6 · $0.14 · 1381s (set 28; set 26: 6/6 · $0.48 · 2037s) | 6/6 · $1.05 · 448s |
436
+ | odoo (dense CRUD) | 6/6 · $0.38 · 1451s (set 28d; set 26: 6/6 · $0.59 · 1651s) | 6/6 · $1.51 · 302s |
437
+ | atelyr (private React app, local) | 2/2 checkable · $0.76 · 2557s (set 28e; set 28: 6 reported, 2/2 checkable · $1.43 · 3043s) | — |
438
+
439
+ On first contact sitelooper is the slowest arm on every target, by design: it drives a cheap
440
+ inner model and spends the extra time recording verified locators, value provenance and effect
441
+ expectations. What that buys is the lowest cost on every target (2–19× cheaper), a 25/25 objective
442
+ record including the board that turn-capped agent-browser at 2/6, and the recording that makes
443
+ Matrix 2 exist.
444
+
445
+ **Matrix 2 — every run after the first.** The same four flows repeated: sitelooper replays (set
446
+ 24, two replays each) against re-running the agent, against a Playwright script the agent authored
447
+ from its own run, against literal codegen from the recording, and against **Tier 2 spec** — the
448
+ same recording compiled by `sitelooper compile` into a standalone `@playwright/test` spec with no
449
+ sitelooper runtime in the loop at all, then replayed under the real Playwright test runner
450
+ (`bench/spec-replay.mjs`).
451
+
452
+ | target | sitelooper replay (r1, r2) | agent re-run | authored script | codegen | Tier 2 spec |
453
+ |---|---|---|---|---|---|
454
+ | repairdesk | **7/7, 7/7** · $0.00, $0.00 · 25s, 25s (set 31, m4rd on d28346a; every step at tier A, zero model turns; set 30 on a7f0c6e: 55s, 55s before the late-navigation fix; set 28: 24s, 23s) | 6/6 · $0.19 · 67s every time | 1/6, 1/6 · $0 | 6/6, 6/6 · $0 | **6/6, 6/6** · $0.00 · 15s, 15s (set 31, m4rd; 0 drift; repair converged with 4 candidate promotions, spec check passed in 14s; repaired spec 6/6 in 14s) |
455
+ | kanboard | **4/4 checkable, same** · $0.00, $0.00 · 27s, 27s (set 31, m4kb on d28346a; all five steps at tier A, zero turns; two objectives are report-based and a zero-model replay writes no report; set 30: 45s, 45s; set 28: 23s, 23s) | 2/6 · $0.77 · 118s every time | 5/6, 5/6 · $0 | 4/4 (+2 n/a) · $0 | **4/4 checkable, same** · $0.00 · 15s, 14s (set 31, m4kb; 0 drift; repair converged with no change, spec check passed in 13s; repaired spec 4/4 in 14s) |
456
+ | grafana | **6/6, 6/6** · $0.00, $0.00 · 54s, 54s (set 31, m4gr on d28346a; every step at tier A, zero model turns; set 30: 79s, 79s; set 28: 47s, 47s) | 6/6 · $1.05 · 448s every time | 0/6, 0/6 · $0 | 0/6, 0/6 · $0 | **4/6, 4/6** · $0.00 · 33s, 33s (set 31, m4gr; 0 drift; objectives 1 and 6 unverifiable by design, the spec arm writes no finalText; repair converged with 6 changes: 3 promotions, 1 model-proposed heading locator, 2 never-hit point candidates retired; spec check passed in 33s; repaired spec 4/6 in 34s) |
457
+ | odoo | **6/6, 6/6** · $0.02, $0.01 · 787s, 556s (set 31, m4od2 on d28346a, flow fwod34r3; model-bound, so the click-wait fix barely shows: 06-open fell back to the model on both replays, 24 then 39 turns, because its pinned skill's precondition names the order LIST page while the flow arrives on the order FORM, a store defect in 06-open's recording; r1 also lost 16 turns to a one-off sign-in fallback; set 30: 258s, 568s; set 28d on fwod34: 664s, 243s) | 6/6 · $1.51 · 302s every time | 1/6, 1/6 · $0 | 0/6, 0/6 · $0 | **6/6, 6/6** · $0.00 · 79s, 79s (set 31, m4od2; 0 drift both runs; repair 9/9 ×3 at tier A with no change, spec check passed in 77s; repaired spec 6/6 in 78s; set 30: 77s, 77s) |
458
+ | atelyr | 12/12 flow steps · $0.13, $0.43 · 710s, 1002s (set 28e; 114 then 134 model turns; nine of twelve steps at zero turns on the second replay, the three re-pinned steps among them) | — | — | — | not yet run |
459
+
460
+ **Set 31 (d28346a), the current build.** The set 30 routine rerun on one fix. Set 30's zero-model
461
+ replays had run at roughly twice set 28's wall clock; bisected locally on the repairdesk recording to
462
+ 93ac1f7, where the recorder's "give a click's late navigation a moment" made every click, press and
463
+ select in learning mode wait 1.5s unless the url moved, and replays run in learning mode. d28346a
464
+ ends that wait as soon as the page has no request in flight. Same boxes, same recordings, same
465
+ scores, and the replay wall clocks came back: repairdesk 55s → 25s, kanboard 45s → 27s, grafana
466
+ 79s → 54s, all at zero model turns. Odoo did not move (258s, 568s → 787s, 556s) because its
467
+ replays are model-bound: 06-open falls back to the model on every run of fwod34r3 (its recorded
468
+ precondition names the order list page and the flow arrives on the order form), so the wall clock
469
+ is model turns, not click waits; the first set 31 odoo box also ran the wrong flow (fwod34, whose
470
+ demoted 08-open needs the model) because fwod34r3 was only on a results branch, so it was
471
+ published on this branch at 16f9a4b and the target relaunched as m4od2. Every compiled spec passed
472
+ both runs with 0 drift at the same scores as set 30, every repair converged with its spec check
473
+ passing, and every repaired spec passed again. No goal-state guard fired and no diagnostic
474
+ appeared: the published stores predate goals, so no skill carries one yet.
475
+
476
+ | target | sitelooper replay r1, r2 (set 30 → set 31) | verifier | compiled spec a, b | repair | repaired spec | agent-browser, every run |
477
+ |---|---|---|---|---|---|---|
478
+ | repairdesk | 55s → **25s, 25s** · 0 turns · $0 | 7/7, 7/7 | 6/6, 6/6 · 15s, 15s | converged, 4 promotions, check passed | 6/6 · 14s | 6/6 · $0.19 · 67s |
479
+ | kanboard | 45s → **27s, 27s** · 0 turns · $0 | 4/4 checkable ×2 | 4/4 ×2 · 15s, 14s | no change, check passed | 4/4 · 14s | 2/6 · $0.77 · 118s |
480
+ | grafana | 79s → **54s, 54s** · 0 turns · $0 | 6/6, 6/6 | 4/6 ×2 · 33s, 33s | converged, 6 changes, check passed | 4/6 · 34s | 6/6 · $1.05 · 448s |
481
+ | odoo | 258s, 568s → 787s, 556s · 40, 39 turns · $0.02, $0.01 | 6/6, 6/6 | 6/6, 6/6 · 79s, 79s | no change, check passed | 6/6 · 78s | 6/6 · $1.51 · 302s |
482
+
483
+ Read across a row: the replay is the daemon re-running the recording with no orchestrator, the
484
+ compiled spec is the same recording under plain Playwright with no sitelooper runtime and no model
485
+ at all, and agent-browser is what it costs to have an agent do the task again from scratch. On the
486
+ three targets whose replays need no model turns, the replay beats agent-browser by 2.7×, 4.4× and
487
+ 8.3× on wall clock at zero cost, and the compiled spec by 4.5×, 8.4× and 13.6×. Odoo's replay is
488
+ the one still paying for model turns, and its compiled spec runs the same flow in 79s.
489
+
490
+ **Tier 2 spec, status.** `bench/spec-replay.mjs` compiles a published flow + skill store
491
+ (`sitelooper compile <flow> --out <tmp>` with `SITELOOPER_SKILLS_DIR` pointing at the store) and
492
+ runs the emitted `<name>.spec.ts` under `npx playwright test`, scored by the same app-side
493
+ verifiers as every other arm (`<tag>-spec-result.json`, `arm: "spec"`). Repairdesk ran locally
494
+ (`fwrd42` store/flowrun, verified 6/6 with the clean-run mutation log); kanboard, grafana, and
495
+ odoo ran on the bench's cloud environment across sets 1-8, with results published to
496
+ `origin/results/sp<N><target>` branches (`sp3kb`, `sp7gr`, `sp8od`, `sp11od`). Kanboard's compiled
497
+ spec passed 4/4 checkable objectives on both runs and repair converged with 0 tickets. Grafana's
498
+ compiled spec ran 1/1 with 0 drift on both runs (verifier 4/6, the other two objectives
499
+ unverifiable by design since the spec arm writes no report) and repair converged in two rounds
500
+ to a spec that still passes and still scores 4/6. Odoo's compiled spec passes end to end on the
501
+ cloud (`sp11od`, f838bec): 1/1 with 0 drift and 6/6 on both runs in 78s each, repair 9/9 at tier
502
+ A on all three runs with nothing to change and its spec check passing, and the repaired spec
503
+ 1/1 and 6/6 again. That took eight sets of emitter fixes (`sp8od` verified 6/6 but the test
504
+ halted at 08-open) and then one `sitelooper rerecord` of 08-open, whose recording asked to cancel
505
+ an order 06-open had already cancelled: the step is now pinned to 07-open's validated read-only
506
+ status check, which the store lets two steps share.
507
+
508
+ Set 24 also caught two engine regressions of its own (kanboard's replays at 22 and 37 turns
509
+ where set 15 needed none; grafana's replays losing objective 1 and recovering one step at 19 and
510
+ 44 turns). Every cause was a testable engine rule — a clock-stamped textbox name in an
511
+ expectation, a trailing space in an identity marker, an expectation-only value promoted to a
512
+ required parameter, a heading that renders only on scroll — and all are fixed on build f727c89.
513
+ The clean A/B is to replay the same set-24 flows and stores on the fixed build (set 24b):
514
+
515
+ | target | set 24 replays (b9ccbca) | set 24b replays (f727c89) |
516
+ |---|---|---|
517
+ | kanboard | 22 and 37 turns · 272s, 555s | **0 and 0 turns · 56s, 56s** · 4/4 app-state objectives both |
518
+ | grafana | 4/6, 5/6 · 19 and 44 turns | **6/6, 6/6** · 29 and 44 turns · 151s, 272s on 08cf104, with the same recording's flow re-exported by the fixed engine (one export rule needed that) and paired with its replay-refined store |
519
+ | odoo (set 26 recording) | 6/6, 6/6 · 91 and 35 turns | **6/6 · 31 turns · 213s** on 6ad5cde with the same pairing; the rest is the app's own url state varying between runs |
520
+
521
+ The grafana row shows the shape of most of this work: the set-24 grafana
522
+ cell as recorded was 4/6 and 5/6, and each miss was a rule in the engine
523
+ (a read discounted as an echo of a recorded scroll; a flow that referenced
524
+ a typed value as another step's output). Fixing the rules and re-exporting
525
+ the same recording gives 6/6 on both replays. Fresh recordings since then
526
+ (fwgr24, fwgr25, fwgr26) each added a rule of the same kind — an accidental
527
+ "Discard changes?" dialog, a dialog opened and cancelled, transient status
528
+ and alert lines — until fwgr26 compiled clean and instead lost every replay to an
529
+ error page. Five runs were spent finding out why: the sign-in skill carried
530
+ a recorded stray click on a `target=_blank` link to grafana.com, the box has
531
+ no network, the new tab landed on a browser error page, and the daemon
532
+ adopted that tab as the page to work on. The replay now keeps its page
533
+ whatever tabs open, a tab that lands on an error page is closed, and a
534
+ fallback that resolves to a link leaving the recorded origin is never
535
+ taken. Full detail, including the
536
+ runs that did not work, is in [bench/MATRIX-SUMMARY.md](bench/MATRIX-SUMMARY.md).
537
+
538
+ Reading it: static scripts are free and mostly wrong; re-running the agent is reliable and costs
539
+ the full price forever; sitelooper's repeat cost trends to zero without the correctness trending
540
+ anywhere, and where it does not, the cause has so far always been a specific engine rule rather
541
+ than the app.
542
+
543
+ ## Reference
544
+
545
+ ### Providers
546
+
547
+ The LLM layer is a generic OpenAI-compatible adapter with presets; any endpoint works by setting
548
+ `baseUrl` and `model` directly.
549
+
550
+ | Preset | Base URL | Default model | Escalation model | Key env var |
551
+ |---|---|---|---|---|
552
+ | `zhipu` (default) | `https://api.z.ai/api/paas/v4` | `glm-5.2` | — | `GLM_API_KEY` / `ZHIPU_API_KEY` |
553
+ | `novita` | `https://api.novita.ai/openai` | `deepseek/deepseek-v4-flash` | `zai-org/glm-5.3` | `NOVITA_API_KEY` |
554
+ | `openrouter` | `https://openrouter.ai/api/v1` | `z-ai/glm-5.2` | — | `OPENROUTER_API_KEY` |
555
+ | `openai` | `https://api.openai.com/v1` | `gpt-5-mini` | — | `OPENAI_API_KEY` |
556
+
557
+ Every field resolves **flag > env > config file > preset**: `--provider`, `--model`,
558
+ `--base-url`, `--fallback-model`; `SITELOOPER_PROVIDER`, `SITELOOPER_MODEL`,
559
+ `SITELOOPER_FALLBACK_MODEL`, `SITELOOPER_BASE_URL`, `SITELOOPER_API_KEY`;
560
+ `sitelooper config set <provider|model|fallbackModel|baseUrl|apiKey> <value>` →
561
+ `~/.sitelooper/config.json`. Prefer env for the key. The benchmark stack is
562
+ `SITELOOPER_PROVIDER=openrouter`, model `deepseek/deepseek-v4-flash`, fallback `z-ai/glm-5.3`.
563
+
564
+ **Escalation on blocked.** An instruction the routine model reports as `blocked` is retried once
565
+ on the escalation model, on the same browser and history, told it is resuming so it re-checks
566
+ state before repeating anything that could double-apply. A verified `failure` is not retried, nor
567
+ is an operator stop. Both attempts are billed into the returned `turns` and `usage`; the report's
568
+ `escalation` object says whether the retry rescued it. `--no-escalate`, or a fallback model of
569
+ `none`, turns it off.
570
+
571
+ ### Configuration
572
+
573
+ | Env / flag | Default | |
574
+ |---|---|---|
575
+ | `SITELOOPER_CHANNEL` | `chrome` → `msedge` → bundled | browser channel |
576
+ | `SITELOOPER_EXECUTABLE` | — | explicit browser binary |
577
+ | `SITELOOPER_HEADED=1`, `--headed` | headless | visible window (first call of a session) |
578
+ | `SITELOOPER_HOME` | `~/.sitelooper` | sessions, skills, flows, config |
579
+ | `SITELOOPER_SKILLS=1`, `--learn` | off | learning mode; `SITELOOPER_SKILLS_DIR` relocates the store |
580
+ | `SITELOOPER_FLOWS_DIR` | `~/.sitelooper/flows` | flow files |
581
+ | `SITELOOPER_RECORD=1`, `--record` | off | webm per tab; paths printed by `stop` |
582
+ | `SITELOOPER_SCRIPT=1`, `--script` | off | record every action as a replayable Playwright step |
583
+ | `--max-turns` | 30 | agent turn cap per instruction |
584
+ | `--timeout` | 300 | wall-clock seconds per instruction |
585
+ | `--turn-timeout` | 90 | seconds for one LLM call before it is aborted and nudged |
586
+
587
+ ### What the outer agent sees
588
+
589
+ `do` prints a one-line result, or with `--json` the full
590
+ `{report: {status, summary, details?, evidence?}, turns, usage, model}`. On a turn or time cap
591
+ the result also carries `actions`, the ordered tool calls that ran, so a caller can verify state
592
+ before resuming rather than repeat a mutation. Nothing else lands in the caller's context: the
593
+ agent's snapshots, retries and tool chatter stay inside the daemon.
594
+
595
+ ### What it will not do
596
+
597
+ - **Canvas-rendered content** (charts, drawn grids, images) has no DOM to read or verify; the
598
+ agent reports blocked and says so.
599
+ - **Anti-bot evasion, CAPTCHA solving, crawling** are out of scope. sitelooper is for testing
600
+ and driving apps you operate or are authorised to test.
601
+ - **Vision**: the agent is text-only; it reads the accessibility tree and DOM. Screenshots are
602
+ for you.
603
+ - **Guessing credentials**: a rejected or missing credential is an immediate blocked report,
604
+ never a retry loop. `{{env:NAME}}` markers are how you supply them.
605
+
606
+ ### Claude Code skill
607
+
608
+ `skills/sitelooper/SKILL.md` is the canonical copy of the bundled skill:
609
+
610
+ ```sh
611
+ mkdir -p ~/.claude/skills/sitelooper
612
+ cp skills/sitelooper/SKILL.md ~/.claude/skills/sitelooper/SKILL.md
613
+ ```
614
+
615
+ ### Development
616
+
617
+ ```sh
618
+ npm run build # tsc -> dist/
619
+ npm test # unit tests
620
+ BP_BROWSER_TESTS=1 npx vitest run # + browser-backed replay and perturbation tests (needs Chrome/Edge)
621
+ ```
622
+
623
+ The recording-path regression gate (`test/rebuild.test.ts`) recompiles real published
624
+ recordings and pins what they compile to; it runs the built engine, so build before testing.
625
+ Benchmark procedure, arms, targets and the cloud runbook live under `bench/`.