bladebro 4.5.1 → 4.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bladebro",
3
- "version": "4.5.1",
3
+ "version": "4.5.2",
4
4
  "description": "Stealthy and efficient agentic browser driver for AI agents. Few tools, full control, real stealth, max token efficiency.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -37,9 +37,6 @@
37
37
  "peerDependenciesMeta": {
38
38
  "@earendil-works/pi-coding-agent": {
39
39
  "optional": true
40
- },
41
- "typebox": {
42
- "optional": true
43
40
  }
44
41
  },
45
42
  "bin": {
@@ -56,11 +53,11 @@
56
53
  "LICENSE"
57
54
  ],
58
55
  "optionalDependencies": {
59
- "bladebro-linux-x64": "4.5.1",
60
- "bladebro-linux-arm64": "4.5.1",
61
- "bladebro-darwin-x64": "4.5.1",
62
- "bladebro-darwin-arm64": "4.5.1",
63
- "bladebro-windows-x64": "4.5.1"
56
+ "bladebro-linux-x64": "4.5.2",
57
+ "bladebro-linux-arm64": "4.5.2",
58
+ "bladebro-darwin-x64": "4.5.2",
59
+ "bladebro-darwin-arm64": "4.5.2",
60
+ "bladebro-windows-x64": "4.5.2"
64
61
  },
65
62
  "engines": {
66
63
  "node": ">=14"
package/pi-extension.ts CHANGED
@@ -1,37 +1,25 @@
1
1
  /**
2
- * Bladebro pi extension — native pi agent integration.
2
+ * Bladebro pi extension — the bladebro MCP server, registered with pi.
3
3
  *
4
- * Spawns the bladebro binary as a stdio MCP subprocess, discovers tools
5
- * via `tools/list`, and registers them natively with pi via
6
- * `pi.registerTool()`. The agent gets 5 first-class tools (act, see,
7
- * state, run, vision) with no adapter, no config files, no proxy.
4
+ * This is the whole extension. It resolves the bladebro binary this package
5
+ * installed and hands it to pi as a stdio MCP server; pi's own MCP client does
6
+ * everything else — spawn, handshake, tool-call proxying, shutdown. The five
7
+ * tools arrive with the binary's full definitions (descriptions, schemas and
8
+ * the server's `initialize` instructions), so no meaning is copied here and
9
+ * there is nothing that can drift when the Rust side changes.
8
10
  *
9
- * Tool definitions come from the binary at startup — zero maintenance.
10
- * When the Rust tool defs change, the extension picks them up automatically.
11
+ * `exposure: "direct"` declares the tools to the model like any other pi tool,
12
+ * and the session waits for this server before the first prompt. The timeout
13
+ * covers the driver's long operations (run/batch/collect), not pi's 60s default.
11
14
  *
12
- * Lifecycle:
13
- * - session_start → spawn binary, MCP handshake, tools/list, register tools
14
- * - tool call → proxy to binary via tools/call, return result
15
- * - session_shutdown → kill binary (which kills Chrome)
16
- *
17
- * Chrome launches lazily inside the binary (first tools/call only).
18
- * The binary process itself starts in milliseconds.
19
- *
20
- * Auto-adapt: tool descriptions, schemas, new tools, removed tools,
21
- * changed parameters — all fetched from the binary at session start.
22
- * The extension never hardcodes tool definitions.
15
+ * No protocol code, no copied schemas, no rendering: a troubleshooting step
16
+ * belongs at the source — the binary — never in this shim.
23
17
  */
24
-
25
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
- import { Type } from "typebox";
27
- import { Text } from "@earendil-works/pi-tui";
28
- import { StringDecoder } from "node:string_decoder";
29
- import { spawn, type ChildProcess } from "node:child_process";
30
19
  import { existsSync } from "node:fs";
31
20
  import { dirname, join } from "node:path";
32
21
 
33
- // ── Binary resolution ─────────────────────────────────────────────────
34
-
22
+ /** npm platform packages → the binary each one carries. */
35
23
  const PLATFORM_MAP: Record<string, string> = {
36
24
  "linux-x64": "bladebro-linux-x64",
37
25
  "linux-arm64": "bladebro-linux-arm64",
@@ -40,482 +28,36 @@ const PLATFORM_MAP: Record<string, string> = {
40
28
  "win32-x64": "bladebro-windows-x64",
41
29
  };
42
30
 
31
+ /** Absolute path of this platform's binary, or null when it is not installed. */
43
32
  function resolveBinary(): string | null {
44
- const key = `${process.platform}-${process.arch}`;
45
- const pkgName = PLATFORM_MAP[key];
33
+ const pkgName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
46
34
  if (!pkgName) return null;
47
35
  try {
48
36
  const pkgJsonPath = require.resolve(`${pkgName}/package.json`);
49
37
  const pkgJson = require(pkgJsonPath);
50
- const binName = pkgJson.main || "bladebro";
51
- const binPath = join(dirname(pkgJsonPath), binName);
38
+ const binPath = join(dirname(pkgJsonPath), pkgJson.main || "bladebro");
52
39
  return existsSync(binPath) ? binPath : null;
53
40
  } catch {
54
41
  return null;
55
42
  }
56
43
  }
57
44
 
58
- // ── TUI rendering helpers ─────────────────────────────────────────────
59
-
60
- /** Extract text from MCP tool result content blocks. */
61
- function getResultText(result: any): string {
62
- if (!result?.content) return "";
63
- return result.content
64
- .filter((c: any) => c.type === "text")
65
- .map((c: any) => c.text)
66
- .join("\n");
67
- }
68
-
69
- /** Format the `act` tool call display. */
70
- function formatActCall(args: any, theme: any): string {
71
- const action = args.action || "?";
72
- const bold = (s: string) => theme.bold(s);
73
- const accent = (s: string) => theme.fg("accent", s);
74
- const muted = (s: string) => theme.fg("muted", s);
75
- const title = bold("act");
76
-
77
- switch (action) {
78
- case "navigate": {
79
- const url = args.url || "";
80
- return `${title} ${accent("navigate")} ${muted("→")} ${accent(url)}`;
81
- }
82
- case "click": {
83
- const target = args.ref || args.text || args.label || "";
84
- return `${title} ${accent("click")} ${muted(target)}`;
85
- }
86
- case "type": {
87
- const target = args.ref || args.label || "";
88
- const text = args.text || "";
89
- const press = args.press ? ` ${muted("+ " + args.press)}` : "";
90
- return `${title} ${accent("type")} ${muted(target ? `${target} ` : "")}${muted(`"${text}"`)}${press}`;
91
- }
92
- case "scroll": {
93
- const dy = args.dy || 0;
94
- const dx = args.dx || 0;
95
- const arrow = dy > 0 ? "↓" : dy < 0 ? "↑" : dx > 0 ? "→" : "←";
96
- const mag = Math.abs(dy || dx);
97
- return `${title} ${accent("scroll")} ${muted(`${arrow}${mag}`)}`;
98
- }
99
- case "fill": {
100
- const n = args.fields?.length || 0;
101
- const submit = args.submit ? ` ${muted("+ submit")}` : "";
102
- return `${title} ${accent("fill")} ${muted(`(${n} field${n !== 1 ? "s" : ""})`)}${submit}`;
103
- }
104
- case "batch": {
105
- const n = args.steps?.length || 0;
106
- return `${title} ${accent("batch")} ${muted(`(${n} step${n !== 1 ? "s" : ""})`)}`;
107
- }
108
- case "hover": {
109
- const target = args.ref || args.text || args.label || "";
110
- return `${title} ${accent("hover")} ${muted(target)}`;
111
- }
112
- case "press": {
113
- return `${title} ${accent("press")} ${muted(args.key || "")}`;
114
- }
115
- case "eval": {
116
- const js = (args.js || "").slice(0, 50);
117
- return `${title} ${accent("eval")} ${muted(`"${js}${js.length >= 50 ? "…" : ""}"`)}`;
118
- }
119
- case "wait": {
120
- const cond = args.condition || "settle";
121
- const t = args.timeout || 10;
122
- return `${title} ${accent("wait")} ${muted(`${cond} (${t}s)`)}`;
123
- }
124
- case "read": {
125
- return `${title} ${accent("read")} ${muted(args.ref || "")}`;
126
- }
127
- case "back": return `${title} ${accent("back")}`;
128
- case "forward": return `${title} ${accent("forward")}`;
129
- case "reload": return `${title} ${accent("reload")}`;
130
- case "select": {
131
- const target = args.ref || "";
132
- const opt = args.option || "";
133
- return `${title} ${accent("select")} ${muted(`${target} "${opt}"`)}`;
134
- }
135
- case "pdf": return `${title} ${accent("pdf")}`;
136
- case "download": return `${title} ${accent("download")}`;
137
- case "collect": return `${title} ${accent("collect")}`;
138
- case "clear": return `${title} ${accent("clear")} ${muted(args.ref || "")}`;
139
- case "upload": return `${title} ${accent("upload")} ${muted(args.ref || "")}`;
140
- default: return `${title} ${accent(action)}`;
141
- }
142
- }
143
-
144
- /** Format the `see` tool call display. */
145
- function formatSeeCall(args: any, theme: any): string {
146
- const bold = (s: string) => theme.bold(s);
147
- const accent = (s: string) => theme.fg("accent", s);
148
- const muted = (s: string) => theme.fg("muted", s);
149
- const title = bold("see");
150
-
151
- const mode = args.mode;
152
- if (mode === "content") return `${title} ${accent("content")}`;
153
- if (mode === "outline") return `${title} ${accent("outline")}`;
154
- if (mode === "model") return `${title} ${accent("model")}`;
155
-
156
- const find = args.find;
157
- if (find) return `${title} ${accent("find")} ${muted(`"${find}"`)}`;
158
-
159
- const extract = args.extract;
160
- if (extract) return `${title} ${accent(`extract=${extract}`)}`;
161
-
162
- const scope = args.scope;
163
- if (scope) return `${title} ${accent("scope")} ${muted(scope)}`;
164
-
165
- const filter = args.filter;
166
- if (filter) return `${title} ${accent("model")} ${muted(`[${filter}]`)}`;
167
-
168
- const logs = args.logs;
169
- if (logs) return `${title} ${accent("logs")} ${muted(logs)}`;
170
-
171
- return `${title} ${accent("model")}`;
172
- }
173
-
174
- /** Format the `state` tool call display. */
175
- function formatStateCall(args: any, theme: any): string {
176
- const bold = (s: string) => theme.bold(s);
177
- const accent = (s: string) => theme.fg("accent", s);
178
- const muted = (s: string) => theme.fg("muted", s);
179
- const title = bold("state");
180
-
181
- const op = args.op || "?";
182
- const extra = args.url || args.name || "";
183
- return `${title} ${accent(op)}${extra ? ` ${muted(extra)}` : ""}`;
184
- }
185
-
186
- /** Format the `run` tool call display. */
187
- function formatRunCall(args: any, theme: any): string {
188
- const bold = (s: string) => theme.bold(s);
189
- const title = bold("run");
190
- return title;
191
- }
192
-
193
- /** Format the `vision` tool call display. */
194
- function formatVisionCall(args: any, theme: any): string {
195
- const bold = (s: string) => theme.bold(s);
196
- const title = bold("vision");
197
- return title;
198
- }
199
-
200
- /** Format tool result for display. */
201
- function formatResult(text: string, expanded: boolean, isError: boolean, theme: any): string {
202
- const out = (s: string) => theme.fg("toolOutput", s);
203
- const warn = (s: string) => theme.fg("warning", s);
204
- const muted = (s: string) => theme.fg("muted", s);
205
- const accent = (s: string) => theme.fg("accent", s);
206
-
207
- if (!text) return muted("(no output)");
208
-
209
- const lines = text.split("\n");
210
-
211
- if (isError) {
212
- // Error: show all lines in warning color
213
- return lines.map((l) => warn(l)).join("\n");
214
- }
215
-
216
- // Truncate if not expanded
217
- const maxLines = expanded ? 9999 : 15;
218
- const visible = lines.slice(0, maxLines);
219
- const skipped = lines.length - visible.length;
220
-
221
- const formatted = visible.map((line) => {
222
- // Outcome lines → accent
223
- if (line.startsWith("outcome:")) return accent(line);
224
- // Warning lines (⚠) → warning color
225
- if (line.includes("\u{26a0}")) return warn(line);
226
- // Verdict lines with arrow → toolOutput
227
- return out(line);
228
- });
229
-
230
- if (skipped > 0) {
231
- formatted.push(muted(` … ${skipped} more line${skipped !== 1 ? "s" : ""} (expand to view)`));
232
- }
233
-
234
- return formatted.join("\n");
235
- }
236
-
237
- // ── Minimal MCP stdio client ──────────────────────────────────────────
238
-
239
- interface PendingReq {
240
- resolve: (v: any) => void;
241
- reject: (e: any) => void;
242
- timer: ReturnType<typeof setTimeout>;
243
- }
244
-
245
- class McpStdio {
246
- private proc: ChildProcess | null = null;
247
- private nextId = 1;
248
- private pending = new Map<number, PendingReq>();
249
- private buffer = "";
250
- private alive = false;
251
- private decoder = new StringDecoder("utf8");
252
-
253
- start(binaryPath: string): Promise<void> {
254
- return new Promise((resolve, reject) => {
255
- this.buffer = "";
256
- this.decoder = new StringDecoder("utf8");
257
- const proc = this.proc = spawn(binaryPath, ["mcp"], {
258
- stdio: ["pipe", "pipe", "pipe"],
259
- env: { ...process.env },
260
- });
261
-
262
- const onExit = () => {
263
- if (this.proc !== proc) return;
264
- this.alive = false;
265
- this.proc = null;
266
- for (const [, p] of this.pending) {
267
- clearTimeout(p.timer);
268
- p.reject(new Error("bladebro process exited"));
269
- }
270
- this.pending.clear();
271
- };
272
-
273
- proc.on("exit", onExit);
274
- proc.stdin!.on("error", (err) => {
275
- onExit();
276
- proc.kill();
277
- reject(err);
278
- });
279
- this.proc.on("error", (err) => {
280
- onExit();
281
- reject(err);
282
- });
283
-
284
- this.proc.stdout!.on("data", (data: Buffer) => this.onData(data));
285
- this.proc.stderr!.on("data", (data: Buffer) => {
286
- const text = data.toString();
287
- for (const line of text.split("\n")) {
288
- const trimmed = line.trim();
289
- if (!trimmed) continue;
290
- if (/error|panic|fatal|warn|fail/i.test(trimmed)) {
291
- process.stderr.write(line + "\n");
292
- }
293
- }
294
- });
295
-
296
- this.request("initialize", {
297
- protocolVersion: "2025-06-18",
298
- capabilities: {},
299
- clientInfo: { name: "pi-bladebro", version: "1.0.0" },
300
- }, 10000)
301
- .then(() => {
302
- this.notify("notifications/initialized", {});
303
- this.alive = true;
304
- resolve();
305
- })
306
- .catch((err) => {
307
- onExit();
308
- proc.kill();
309
- reject(err);
310
- });
311
- });
312
- }
313
-
314
- private onData(data: Buffer): void {
315
- this.buffer += this.decoder.write(data);
316
- if (Buffer.byteLength(this.buffer, "utf8") > 64 * 1024 * 1024) {
317
- for (const [, p] of this.pending) {
318
- clearTimeout(p.timer);
319
- p.reject(new Error("bladebro MCP response exceeds 64 MiB"));
320
- }
321
- this.pending.clear();
322
- this.proc?.kill();
323
- return;
324
- }
325
- let idx: number;
326
- while ((idx = this.buffer.indexOf("\n")) >= 0) {
327
- const line = this.buffer.slice(0, idx).trim();
328
- this.buffer = this.buffer.slice(idx + 1);
329
- if (line) this.onMessage(line);
330
- }
331
- }
332
-
333
- private onMessage(line: string): void {
334
- let msg: any;
335
- try { msg = JSON.parse(line); } catch {
336
- for (const [, p] of this.pending) {
337
- clearTimeout(p.timer);
338
- p.reject(new Error("Invalid MCP JSON from bladebro"));
339
- }
340
- this.pending.clear();
341
- this.proc?.kill();
342
- return;
343
- }
344
- if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
345
- this.proc?.kill();
346
- return;
347
- }
348
- if (msg.id !== undefined && this.pending.has(msg.id)) {
349
- const entry = this.pending.get(msg.id)!;
350
- this.pending.delete(msg.id);
351
- clearTimeout(entry.timer);
352
- if (msg.error) entry.reject(new Error(msg.error.message || "MCP request failed"));
353
- else if (!("result" in msg)) entry.reject(new Error("MCP response missing result"));
354
- else entry.resolve(msg.result);
355
- }
356
- }
357
-
358
- private request(method: string, params: any, timeoutMs = 180000): Promise<any> {
359
- return new Promise((resolve, reject) => {
360
- if (!this.proc || this.proc.exitCode !== null || this.proc.signalCode !== null || !this.proc.stdin?.writable) {
361
- reject(new Error("bladebro process is not running"));
362
- return;
363
- }
364
- const id = this.nextId++;
365
- const timer = setTimeout(() => {
366
- this.pending.delete(id);
367
- reject(new Error(`MCP timeout: ${method} (${timeoutMs}ms)`));
368
- }, timeoutMs);
369
- this.pending.set(id, { resolve, reject, timer });
370
- const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
371
- this.proc?.stdin?.write(msg + "\n");
372
- });
373
- }
374
-
375
- private notify(method: string, params: any): void {
376
- const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
377
- this.proc?.stdin?.write(msg + "\n");
378
- }
379
-
380
- async listTools(): Promise<any[]> {
381
- const result = await this.request("tools/list", {}, 10000);
382
- return result.tools || [];
383
- }
384
-
385
- async callTool(name: string, args: any): Promise<any> {
386
- return this.request("tools/call", { name, arguments: args });
387
- }
388
-
389
- isAlive(): boolean { return this.alive && this.proc !== null; }
390
-
391
- async stop(): Promise<void> {
392
- const proc = this.proc;
393
- if (!proc) return;
394
- this.alive = false;
395
- for (const [, p] of this.pending) {
396
- clearTimeout(p.timer);
397
- p.reject(new Error("bladebro session stopped"));
398
- }
399
- this.pending.clear();
400
- if (proc.exitCode === null && proc.signalCode === null) {
401
- await new Promise<void>((resolve) => {
402
- const t = setTimeout(() => { proc.kill("SIGKILL"); resolve(); }, 5000);
403
- proc.once("exit", () => { clearTimeout(t); resolve(); });
404
- proc.stdin?.end();
405
- proc.kill("SIGTERM");
406
- });
407
- }
408
- if (this.proc === proc) this.proc = null;
409
- }
410
- }
411
-
412
- // ── Extension ─────────────────────────────────────────────────────────
413
-
414
- const TOOL_FORMATTERS: Record<string, (args: any, theme: any) => string> = {
415
- act: formatActCall,
416
- see: formatSeeCall,
417
- state: formatStateCall,
418
- run: formatRunCall,
419
- vision: formatVisionCall,
420
- };
421
-
422
45
  export default function bladebroExtension(pi: ExtensionAPI) {
423
- let client: McpStdio | null = null;
424
- let binaryPath: string | null = null;
425
- let restartPromise: Promise<void> | null = null;
46
+ const key = `${process.platform}-${process.arch}`;
47
+ const binary = resolveBinary();
426
48
 
427
- async function ensureClient(): Promise<McpStdio> {
428
- if (client?.isAlive()) return client;
429
- if (restartPromise) {
430
- await restartPromise;
431
- return client!;
432
- }
433
- if (!binaryPath) throw new Error("Bladebro binary not available");
434
- const p = (async () => {
435
- const c = new McpStdio();
436
- await c.start(binaryPath!);
437
- client = c;
438
- })();
439
- restartPromise = p;
440
- try {
441
- await p;
442
- } finally {
443
- restartPromise = null;
444
- }
445
- return client!;
49
+ if (!binary || typeof pi.registerMcpServer !== "function") {
50
+ const why = !binary
51
+ ? `no binary for ${key} — run: npm install bladebro`
52
+ : "this pi build has no MCP registration API — update pi";
53
+ pi.on("session_start", (_event, ctx) => ctx.ui.notify(`Bladebro: ${why}`, "error"));
54
+ return;
446
55
  }
447
56
 
448
- pi.on("session_start", async (_event, ctx) => {
449
- binaryPath = resolveBinary();
450
- if (!binaryPath) {
451
- const key = `${process.platform}-${process.arch}`;
452
- ctx.ui.notify(
453
- `Bladebro: no binary for ${key}. Run: npm install bladebro`,
454
- "error",
455
- );
456
- return;
457
- }
458
-
459
- try {
460
- await ensureClient();
461
- } catch (err: any) {
462
- ctx.ui.notify(`Bladebro: failed to start: ${err.message}`, "error");
463
- client = null;
464
- return;
465
- }
466
-
467
- let tools: any[];
468
- try {
469
- tools = await client!.listTools();
470
- } catch (err: any) {
471
- ctx.ui.notify(`Bladebro: tools/list failed: ${err.message}`, "error");
472
- return;
473
- }
474
-
475
- for (const tool of tools) {
476
- const formatter = TOOL_FORMATTERS[tool.name];
477
-
478
- pi.registerTool({
479
- name: tool.name,
480
- label: tool.name,
481
- description: tool.description,
482
- parameters: Type.Unsafe(tool.inputSchema),
483
- renderShell: "default",
484
- renderCall(args: any, theme: any, context: any) {
485
- const text = new Text("", 0, 0);
486
- if (formatter) {
487
- text.setText(formatter(args, theme));
488
- } else {
489
- text.setText(theme.bold(tool.name));
490
- }
491
- return text;
492
- },
493
- renderResult(result: any, options: any, theme: any, context: any) {
494
- const text = context.lastComponent ?? new Text("", 0, 0);
495
- const body = getResultText(result);
496
- text.setText(formatResult(body, options.expanded, result.isError, theme));
497
- return text;
498
- },
499
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
500
- const c = await ensureClient();
501
- if (_signal?.aborted) throw new Error("Bladebro tool call cancelled before dispatch");
502
- const result = await c.callTool(tool.name, params);
503
- return {
504
- content: result.content || [],
505
- details: {},
506
- isError: result.isError || false,
507
- };
508
- },
509
- });
510
- }
511
-
512
- ctx.ui.notify(`Bladebro ready (${tools.length} tools)`, "info");
513
- });
514
-
515
- pi.on("session_shutdown", async () => {
516
- if (client) {
517
- await client.stop();
518
- client = null;
519
- }
57
+ pi.registerMcpServer("bladebro", {
58
+ command: binary,
59
+ args: ["mcp"],
60
+ exposure: "direct",
61
+ timeout: 180,
520
62
  });
521
63
  }
@@ -1,45 +1,12 @@
1
1
  ---
2
2
  name: bladebro
3
- description: Drive a real browser with the bladebro tools (act, see, state, run, vision) — navigating sites, clicking, typing, filling forms, logging in, reading pages, extracting lists/search results/products, and screenshotting. Use whenever a task needs a live web page rather than a fetch or an API.
3
+ description: Optional house rules for bladebro. Bladebro itself needs no skill — the package registers its MCP server with pi and the five tools arrive complete. Add your own preferences here if you want the agent to use bladebro in a particular way.
4
4
  ---
5
5
 
6
- # Bladebro — driving a real browser
6
+ # No skill added by the user.
7
7
 
8
- `act` interacts, `see` reads, `state` manages cookies/tabs/sessions, `run` runs step batches with branching, `vision` screenshots. One long-lived Chrome; refs (`e1`, `e2`, …) address elements.
8
+ Bladebro works without this file: the npm package registers the bladebro MCP server with pi, and the tools come with their full definitions — descriptions, schemas, and the driver's own `initialize` instructions. Nothing needs repeating here.
9
9
 
10
- ## The loop
10
+ This file exists so you can tell your agent to use bladebro a specific way — sites to prefer, extraction shapes, pacing, things to avoid. Whatever you write below is read as guidance.
11
11
 
12
- 1. `act { "action": "navigate", "url": "…" }` — returns refs + a content preview; often enough to act on without a separate read.
13
- 2. Address by text when you can: `act { "action": "click", "text": "Sign in" }` — no `see` needed. Labels work for form fields.
14
- 3. Refs are page-scoped and short-lived: after a navigation or tab switch use the fresh refs from THAT response. Within a page they self-heal.
15
- 4. Every action returns an observation (verdict + what changed). A `see` after acting is usually unnecessary — read the observation.
16
- 5. Collapse round-trips: `fill` for whole forms, `batch`/`run` for sequences (branching, loops, inline reads).
17
-
18
- ## Reading
19
-
20
- - List, search, product, profile pages: `see { "extract": "auto" }` FIRST — one call returns structured items.
21
- - Articles and docs: `see { "mode": "content" }` (clean markdown). Cheapest "what's here": `mode: "outline"`.
22
- - One value or a custom shape: `act { "action": "eval", "js": "…" }`.
23
- - Machine-readable output: add `format: "json"` — the result is pure JSON. `act eval` in text mode is one line, `result: <compact JSON>`; if the script returned a string containing JSON, a note says so (parse the value once more).
24
-
25
- ## Trust what the result tells you
26
-
27
- - `page_total` with `partial`/`paged` in an extract: the page claims more results than the DOM holds — follow the pagination, or `act { "action": "collect", "url": "…" }` to scroll and dedupe the whole feed.
28
- - `sponsored: true` items are paid placements — flagged per item and counted in the envelope, left in page order.
29
- - A note that the URL's range filter does not match the page's own prices means the site ignored its filter — treat those results as unfiltered.
30
- - `duplicates_removed` / `redirects_unwrapped` explain what was cleaned up; item URLs are canonical, not tracking interstitials.
31
- - Oversized payloads are written to an artifact file and read back with `see { "artifact": "…" }` (paged). Nothing is truncated silently — a `… (+N chars truncated)` marker always says so.
32
-
33
- ## Forms, sequences, dialogs
34
-
35
- - Whole forms in ONE call: `act { "action": "fill", "fields": [ { "label": "Email", "text": "…" } ], "submit": "Sign in" }`.
36
- - Multi-step or branching work: `run { "steps": [ … ] }` — `if`/`while`, inline `see`/`extract` steps, one round-trip.
37
- - A click that dispatches but changes nothing reads "clicked (no observable DOM change)" — it fired ONCE; verify state before re-clicking. "no-effect" means nothing was dispatched, so a retry is safe there.
38
- - Dialogs (confirm/prompt/alert): arm `act { "action": "dialog", "expect": "confirm" }` BEFORE the action that triggers it.
39
-
40
- ## When something goes wrong
41
-
42
- - Errors carry the current page state — read it instead of guessing, and never blind-retry a submit.
43
- - `trusted:false` = text landed through an untrusted fallback; treat it as not typed. `degraded:true` = the browser is unhealthy (retry or restart it) — not a page problem.
44
- - An unknown action name answers with the valid list and the correct step.
45
- - CAPTCHA / bot walls: the verdict says `blocked:` and the remediation ladder is in the ambient notes. Do not try to solve image grids.
12
+ <!-- Add your rules below this line. -->