bladebro 3.0.20 → 3.0.22

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 +207 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bladebro",
3
- "version": "3.0.20",
3
+ "version": "3.0.22",
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.20",
48
- "bladebro-darwin-x64": "3.0.20",
49
- "bladebro-darwin-arm64": "3.0.20",
50
- "bladebro-windows-x64": "3.0.20"
47
+ "bladebro-linux-x64": "3.0.22",
48
+ "bladebro-darwin-x64": "3.0.22",
49
+ "bladebro-darwin-arm64": "3.0.22",
50
+ "bladebro-windows-x64": "3.0.22"
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,9 +272,6 @@ 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
- // Forward only errors/warnings to pi stderr. Info lines like
96
- // "[bladebro] MCP server ready" are suppressed to keep the
97
- // agent TUI clean on startup.
98
275
  const text = data.toString();
99
276
  for (const line of text.split("\n")) {
100
277
  const trimmed = line.trim();
@@ -105,7 +282,6 @@ class McpStdio {
105
282
  }
106
283
  });
107
284
 
108
- // MCP initialize handshake, then signal ready.
109
285
  this.request("initialize", {
110
286
  protocolVersion: "2025-06-18",
111
287
  capabilities: {},
@@ -176,8 +352,6 @@ class McpStdio {
176
352
  this.alive = false;
177
353
  try { this.proc.stdin?.end(); } catch {}
178
354
  this.proc.kill("SIGTERM");
179
- // Wait up to 5s for graceful exit (Chrome shutdown takes up to 3s),
180
- // then SIGKILL. The orphan reaper handles any leaked Chrome.
181
355
  await new Promise<void>((resolve) => {
182
356
  const t = setTimeout(() => {
183
357
  this.proc?.kill("SIGKILL");
@@ -191,12 +365,17 @@ class McpStdio {
191
365
 
192
366
  // ── Extension ─────────────────────────────────────────────────────────
193
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
+
194
376
  export default function bladebroExtension(pi: ExtensionAPI) {
195
377
  let client: McpStdio | null = null;
196
378
  let binaryPath: string | null = null;
197
- // Restart lock: prevents concurrent restarts from spawning
198
- // multiple binaries. When two parallel tool calls both see
199
- // isAlive() === false, they share the same restart promise.
200
379
  let restartPromise: Promise<void> | null = null;
201
380
 
202
381
  async function ensureClient(): Promise<McpStdio> {
@@ -239,7 +418,6 @@ export default function bladebroExtension(pi: ExtensionAPI) {
239
418
  return;
240
419
  }
241
420
 
242
- // Discover tools from the binary and register them natively.
243
421
  let tools: any[];
244
422
  try {
245
423
  tools = await client!.listTools();
@@ -249,14 +427,30 @@ export default function bladebroExtension(pi: ExtensionAPI) {
249
427
  }
250
428
 
251
429
  for (const tool of tools) {
430
+ const formatter = TOOL_FORMATTERS[tool.name];
431
+
252
432
  pi.registerTool({
253
433
  name: tool.name,
254
434
  label: tool.name,
255
435
  description: tool.description,
256
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
+ },
257
453
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
258
- // Ensure the binary is alive — restart if it died.
259
- // ensureClient handles the restart race condition.
260
454
  const c = await ensureClient();
261
455
  const result = await c.callTool(tool.name, params);
262
456
  return {
@@ -268,7 +462,7 @@ export default function bladebroExtension(pi: ExtensionAPI) {
268
462
  });
269
463
  }
270
464
 
271
- ctx.ui.notify(`Bladebro: ${tools.length} tools ready`, "info");
465
+ ctx.ui.notify(`Bladebro ready (${tools.length} tools)`, "info");
272
466
  });
273
467
 
274
468
  pi.on("session_shutdown", async () => {