bladebro 3.0.19 → 3.0.21

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 (2) hide show
  1. package/package.json +5 -5
  2. package/pi-extension.ts +215 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bladebro",
3
- "version": "3.0.19",
3
+ "version": "3.0.21",
4
4
  "description": "Stealthy and efficient agentic browser driver for AI agents. Few tools, full control, real stealth, max token efficiency.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -44,10 +44,10 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "optionalDependencies": {
47
- "bladebro-linux-x64": "3.0.19",
48
- "bladebro-darwin-x64": "3.0.19",
49
- "bladebro-darwin-arm64": "3.0.19",
50
- "bladebro-windows-x64": "3.0.19"
47
+ "bladebro-linux-x64": "3.0.21",
48
+ "bladebro-darwin-x64": "3.0.21",
49
+ "bladebro-darwin-arm64": "3.0.21",
50
+ "bladebro-windows-x64": "3.0.21"
51
51
  },
52
52
  "engines": {
53
53
  "node": ">=14"
package/pi-extension.ts CHANGED
@@ -24,6 +24,7 @@
24
24
 
25
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
26
  import { Type } from "typebox";
27
+ import { Text } from "@earendil-works/pi-tui";
27
28
  import { spawn, type ChildProcess } from "node:child_process";
28
29
  import { existsSync } from "node:fs";
29
30
  import { dirname, join } from "node:path";
@@ -53,6 +54,185 @@ function resolveBinary(): string | null {
53
54
  }
54
55
  }
55
56
 
57
+ // ── TUI rendering helpers ─────────────────────────────────────────────
58
+
59
+ /** Extract text from MCP tool result content blocks. */
60
+ function getResultText(result: any): string {
61
+ if (!result?.content) return "";
62
+ return result.content
63
+ .filter((c: any) => c.type === "text")
64
+ .map((c: any) => c.text)
65
+ .join("\n");
66
+ }
67
+
68
+ /** Format the `act` tool call display. */
69
+ function formatActCall(args: any, theme: any): string {
70
+ const action = args.action || "?";
71
+ const bold = (s: string) => theme.bold(s);
72
+ const accent = (s: string) => theme.fg("accent", s);
73
+ const muted = (s: string) => theme.fg("muted", s);
74
+ const title = bold("act");
75
+
76
+ switch (action) {
77
+ case "navigate": {
78
+ const url = args.url || "";
79
+ return `${title} ${accent("navigate")} ${muted("→")} ${accent(url)}`;
80
+ }
81
+ case "click": {
82
+ const target = args.ref || args.text || args.label || "";
83
+ return `${title} ${accent("click")} ${muted(target)}`;
84
+ }
85
+ case "type": {
86
+ const target = args.ref || args.label || "";
87
+ const text = args.text || "";
88
+ const press = args.press ? ` ${muted("+ " + args.press)}` : "";
89
+ return `${title} ${accent("type")} ${muted(target ? `${target} ` : "")}${muted(`"${text}"`)}${press}`;
90
+ }
91
+ case "scroll": {
92
+ const dy = args.dy || 0;
93
+ const dx = args.dx || 0;
94
+ const arrow = dy > 0 ? "↓" : dy < 0 ? "↑" : dx > 0 ? "→" : "←";
95
+ const mag = Math.abs(dy || dx);
96
+ return `${title} ${accent("scroll")} ${muted(`${arrow}${mag}`)}`;
97
+ }
98
+ case "fill": {
99
+ const n = args.fields?.length || 0;
100
+ const submit = args.submit ? ` ${muted("+ submit")}` : "";
101
+ return `${title} ${accent("fill")} ${muted(`(${n} field${n !== 1 ? "s" : ""})`)}${submit}`;
102
+ }
103
+ case "batch": {
104
+ const n = args.steps?.length || 0;
105
+ return `${title} ${accent("batch")} ${muted(`(${n} step${n !== 1 ? "s" : ""})`)}`;
106
+ }
107
+ case "hover": {
108
+ const target = args.ref || args.text || args.label || "";
109
+ return `${title} ${accent("hover")} ${muted(target)}`;
110
+ }
111
+ case "press": {
112
+ return `${title} ${accent("press")} ${muted(args.key || "")}`;
113
+ }
114
+ case "eval": {
115
+ const js = (args.js || "").slice(0, 50);
116
+ return `${title} ${accent("eval")} ${muted(`"${js}${js.length >= 50 ? "…" : ""}"`)}`;
117
+ }
118
+ case "wait": {
119
+ const cond = args.condition || "settle";
120
+ const t = args.timeout || 10;
121
+ return `${title} ${accent("wait")} ${muted(`${cond} (${t}s)`)}`;
122
+ }
123
+ case "read": {
124
+ return `${title} ${accent("read")} ${muted(args.ref || "")}`;
125
+ }
126
+ case "back": return `${title} ${accent("back")}`;
127
+ case "forward": return `${title} ${accent("forward")}`;
128
+ case "reload": return `${title} ${accent("reload")}`;
129
+ case "select": {
130
+ const target = args.ref || "";
131
+ const opt = args.option || "";
132
+ return `${title} ${accent("select")} ${muted(`${target} "${opt}"`)}`;
133
+ }
134
+ case "pdf": return `${title} ${accent("pdf")}`;
135
+ case "download": return `${title} ${accent("download")}`;
136
+ case "collect": return `${title} ${accent("collect")}`;
137
+ case "clear": return `${title} ${accent("clear")} ${muted(args.ref || "")}`;
138
+ case "upload": return `${title} ${accent("upload")} ${muted(args.ref || "")}`;
139
+ default: return `${title} ${accent(action)}`;
140
+ }
141
+ }
142
+
143
+ /** Format the `see` tool call display. */
144
+ function formatSeeCall(args: any, theme: any): string {
145
+ const bold = (s: string) => theme.bold(s);
146
+ const accent = (s: string) => theme.fg("accent", s);
147
+ const muted = (s: string) => theme.fg("muted", s);
148
+ const title = bold("see");
149
+
150
+ const mode = args.mode;
151
+ if (mode === "content") return `${title} ${accent("content")}`;
152
+ if (mode === "outline") return `${title} ${accent("outline")}`;
153
+ if (mode === "model") return `${title} ${accent("model")}`;
154
+
155
+ const find = args.find;
156
+ if (find) return `${title} ${accent("find")} ${muted(`"${find}"`)}`;
157
+
158
+ const extract = args.extract;
159
+ if (extract) return `${title} ${accent(`extract=${extract}`)}`;
160
+
161
+ const scope = args.scope;
162
+ if (scope) return `${title} ${accent("scope")} ${muted(scope)}`;
163
+
164
+ const filter = args.filter;
165
+ if (filter) return `${title} ${accent("model")} ${muted(`[${filter}]`)}`;
166
+
167
+ const logs = args.logs;
168
+ if (logs) return `${title} ${accent("logs")} ${muted(logs)}`;
169
+
170
+ return `${title} ${accent("model")}`;
171
+ }
172
+
173
+ /** Format the `state` tool call display. */
174
+ function formatStateCall(args: any, theme: any): string {
175
+ const bold = (s: string) => theme.bold(s);
176
+ const accent = (s: string) => theme.fg("accent", s);
177
+ const muted = (s: string) => theme.fg("muted", s);
178
+ const title = bold("state");
179
+
180
+ const op = args.op || "?";
181
+ const extra = args.url || args.name || "";
182
+ return `${title} ${accent(op)}${extra ? ` ${muted(extra)}` : ""}`;
183
+ }
184
+
185
+ /** Format the `run` tool call display. */
186
+ function formatRunCall(args: any, theme: any): string {
187
+ const bold = (s: string) => theme.bold(s);
188
+ const title = bold("run");
189
+ return title;
190
+ }
191
+
192
+ /** Format the `vision` tool call display. */
193
+ function formatVisionCall(args: any, theme: any): string {
194
+ const bold = (s: string) => theme.bold(s);
195
+ const title = bold("vision");
196
+ return title;
197
+ }
198
+
199
+ /** Format tool result for display. */
200
+ function formatResult(text: string, expanded: boolean, isError: boolean, theme: any): string {
201
+ const out = (s: string) => theme.fg("toolOutput", s);
202
+ const warn = (s: string) => theme.fg("warning", s);
203
+ const muted = (s: string) => theme.fg("muted", s);
204
+ const accent = (s: string) => theme.fg("accent", s);
205
+
206
+ if (!text) return muted("(no output)");
207
+
208
+ const lines = text.split("\n");
209
+
210
+ if (isError) {
211
+ // Error: show all lines in warning color
212
+ return lines.map((l) => warn(l)).join("\n");
213
+ }
214
+
215
+ // Truncate if not expanded
216
+ const maxLines = expanded ? 9999 : 15;
217
+ const visible = lines.slice(0, maxLines);
218
+ const skipped = lines.length - visible.length;
219
+
220
+ const formatted = visible.map((line) => {
221
+ // Outcome lines → accent
222
+ if (line.startsWith("outcome:")) return accent(line);
223
+ // Warning lines (⚠) → warning color
224
+ if (line.includes("\u{26a0}")) return warn(line);
225
+ // Verdict lines with arrow → toolOutput
226
+ return out(line);
227
+ });
228
+
229
+ if (skipped > 0) {
230
+ formatted.push(muted(` … ${skipped} more line${skipped !== 1 ? "s" : ""} (expand to view)`));
231
+ }
232
+
233
+ return formatted.join("\n");
234
+ }
235
+
56
236
  // ── Minimal MCP stdio client ──────────────────────────────────────────
57
237
 
58
238
  interface PendingReq {
@@ -92,11 +272,16 @@ class McpStdio {
92
272
 
93
273
  this.proc.stdout!.on("data", (data: Buffer) => this.onData(data));
94
274
  this.proc.stderr!.on("data", (data: Buffer) => {
95
- // Route binary stderr to pi stderr for debugging.
96
- process.stderr.write(data);
275
+ const text = data.toString();
276
+ for (const line of text.split("\n")) {
277
+ const trimmed = line.trim();
278
+ if (!trimmed) continue;
279
+ if (/error|panic|fatal|warn|fail/i.test(trimmed)) {
280
+ process.stderr.write(line + "\n");
281
+ }
282
+ }
97
283
  });
98
284
 
99
- // MCP initialize handshake, then signal ready.
100
285
  this.request("initialize", {
101
286
  protocolVersion: "2025-06-18",
102
287
  capabilities: {},
@@ -167,8 +352,6 @@ class McpStdio {
167
352
  this.alive = false;
168
353
  try { this.proc.stdin?.end(); } catch {}
169
354
  this.proc.kill("SIGTERM");
170
- // Wait up to 5s for graceful exit (Chrome shutdown takes up to 3s),
171
- // then SIGKILL. The orphan reaper handles any leaked Chrome.
172
355
  await new Promise<void>((resolve) => {
173
356
  const t = setTimeout(() => {
174
357
  this.proc?.kill("SIGKILL");
@@ -182,12 +365,17 @@ class McpStdio {
182
365
 
183
366
  // ── Extension ─────────────────────────────────────────────────────────
184
367
 
368
+ const TOOL_FORMATTERS: Record<string, (args: any, theme: any) => string> = {
369
+ act: formatActCall,
370
+ see: formatSeeCall,
371
+ state: formatStateCall,
372
+ run: formatRunCall,
373
+ vision: formatVisionCall,
374
+ };
375
+
185
376
  export default function bladebroExtension(pi: ExtensionAPI) {
186
377
  let client: McpStdio | null = null;
187
378
  let binaryPath: string | null = null;
188
- // Restart lock: prevents concurrent restarts from spawning
189
- // multiple binaries. When two parallel tool calls both see
190
- // isAlive() === false, they share the same restart promise.
191
379
  let restartPromise: Promise<void> | null = null;
192
380
 
193
381
  async function ensureClient(): Promise<McpStdio> {
@@ -230,7 +418,6 @@ export default function bladebroExtension(pi: ExtensionAPI) {
230
418
  return;
231
419
  }
232
420
 
233
- // Discover tools from the binary and register them natively.
234
421
  let tools: any[];
235
422
  try {
236
423
  tools = await client!.listTools();
@@ -240,14 +427,30 @@ export default function bladebroExtension(pi: ExtensionAPI) {
240
427
  }
241
428
 
242
429
  for (const tool of tools) {
430
+ const formatter = TOOL_FORMATTERS[tool.name];
431
+
243
432
  pi.registerTool({
244
433
  name: tool.name,
245
434
  label: tool.name,
246
435
  description: tool.description,
247
436
  parameters: Type.Unsafe(tool.inputSchema),
437
+ renderShell: "default",
438
+ renderCall(args: any, theme: any, context: any) {
439
+ const text = new Text("", 0, 0);
440
+ if (formatter) {
441
+ text.setText(formatter(args, theme));
442
+ } else {
443
+ text.setText(theme.bold(tool.name));
444
+ }
445
+ return text;
446
+ },
447
+ renderResult(result: any, options: any, theme: any, context: any) {
448
+ const text = context.lastComponent ?? new Text("", 0, 0);
449
+ const body = getResultText(result);
450
+ text.setText(formatResult(body, options.expanded, result.isError, theme));
451
+ return text;
452
+ },
248
453
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
249
- // Ensure the binary is alive — restart if it died.
250
- // ensureClient handles the restart race condition.
251
454
  const c = await ensureClient();
252
455
  const result = await c.callTool(tool.name, params);
253
456
  return {
@@ -259,7 +462,7 @@ export default function bladebroExtension(pi: ExtensionAPI) {
259
462
  });
260
463
  }
261
464
 
262
- ctx.ui.notify(`Bladebro: ${tools.length} tools ready`, "info");
465
+ ctx.ui.notify(`Bladebro ready (${tools.length} tools)`, "info");
263
466
  });
264
467
 
265
468
  pi.on("session_shutdown", async () => {