@phi-code-admin/phi-code 0.86.0 → 0.88.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 (35) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/docs/adr/0001-phase-contract.md +57 -0
  3. package/docs/adr/0002-independent-review.md +47 -0
  4. package/docs/fork-policy.md +18 -0
  5. package/extensions/phi/agents.ts +7 -4
  6. package/extensions/phi/benchmark.ts +129 -49
  7. package/extensions/phi/browser.ts +10 -37
  8. package/extensions/phi/btw/btw.ts +1 -7
  9. package/extensions/phi/chrome/index.ts +1283 -741
  10. package/extensions/phi/commit.ts +9 -14
  11. package/extensions/phi/goal/index.ts +10 -33
  12. package/extensions/phi/init.ts +37 -47
  13. package/extensions/phi/keys.ts +2 -7
  14. package/extensions/phi/mcp/callback-server.ts +162 -165
  15. package/extensions/phi/mcp/config.ts +122 -136
  16. package/extensions/phi/mcp/errors.ts +18 -23
  17. package/extensions/phi/mcp/index.ts +322 -355
  18. package/extensions/phi/mcp/oauth-provider.ts +289 -289
  19. package/extensions/phi/mcp/server-manager.ts +390 -413
  20. package/extensions/phi/mcp/tool-bridge.ts +381 -415
  21. package/extensions/phi/memory.ts +2 -2
  22. package/extensions/phi/models.ts +27 -26
  23. package/extensions/phi/orchestrator.ts +451 -237
  24. package/extensions/phi/productivity.ts +4 -2
  25. package/extensions/phi/providers/agent-def.ts +1 -1
  26. package/extensions/phi/providers/alibaba.ts +56 -7
  27. package/extensions/phi/providers/context-window.ts +4 -1
  28. package/extensions/phi/providers/live-models.ts +5 -20
  29. package/extensions/phi/providers/orchestrator-helpers.ts +31 -5
  30. package/extensions/phi/providers/phase-machine.ts +283 -0
  31. package/extensions/phi/setup.ts +196 -169
  32. package/extensions/phi/skill-loader.ts +14 -17
  33. package/extensions/phi/smart-router.ts +6 -6
  34. package/extensions/phi/web-search.ts +90 -50
  35. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
- import type { ExtensionAPI, ExtensionContext } from "phi-code";
2
- import { Type } from "typebox";
3
1
  import { existsSync, readFileSync, statSync } from "node:fs";
4
2
  import { mkdir, writeFile } from "node:fs/promises";
5
3
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
6
4
  import { dirname, join, resolve } from "node:path";
5
+ import type { ExtensionAPI, ExtensionContext } from "phi-code";
6
+ import { Type } from "typebox";
7
7
 
8
8
  /**
9
9
  * Existing-profile Chrome bridge for pi.
@@ -77,7 +77,9 @@ function safeJson(value: unknown): string {
77
77
  const snapshotModeValues = ["auto", "interactive", "forms", "pageMap", "text", "changes", "full"] as const;
78
78
 
79
79
  function compactLine(value: unknown, max = 140): string {
80
- const text = String(value ?? "").replace(/\s+/g, " ").trim();
80
+ const text = String(value ?? "")
81
+ .replace(/\s+/g, " ")
82
+ .trim();
81
83
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
82
84
  }
83
85
 
@@ -93,18 +95,33 @@ function formatChromeSnapshot(snapshot: any): string {
93
95
  lines.push(`# Chrome snapshot${snapshot.mode ? ` (${snapshot.mode})` : ""}`);
94
96
  lines.push(`${snapshot.title || "(untitled)"}`);
95
97
  if (snapshot.url) lines.push(`${snapshot.url}`);
96
- if (snapshot.viewport) lines.push(`viewport=${snapshot.viewport.width}x${snapshot.viewport.height} scroll=${snapshot.viewport.scrollX || 0},${snapshot.viewport.scrollY || 0}`);
97
- if (snapshot.summary?.modal) lines.push(`modal: ${snapshot.summary.modal.uid} ${compactLine(snapshot.summary.modal.label)}`);
98
- if (snapshot.summary?.focused) lines.push(`focused: ${snapshot.summary.focused.uid} ${snapshot.summary.focused.role || ""} ${compactLine(snapshot.summary.focused.label)}`);
98
+ if (snapshot.viewport)
99
+ lines.push(
100
+ `viewport=${snapshot.viewport.width}x${snapshot.viewport.height} scroll=${snapshot.viewport.scrollX || 0},${snapshot.viewport.scrollY || 0}`,
101
+ );
102
+ if (snapshot.summary?.modal)
103
+ lines.push(`modal: ${snapshot.summary.modal.uid} ${compactLine(snapshot.summary.modal.label)}`);
104
+ if (snapshot.summary?.focused)
105
+ lines.push(
106
+ `focused: ${snapshot.summary.focused.uid} ${snapshot.summary.focused.role || ""} ${compactLine(snapshot.summary.focused.label)}`,
107
+ );
99
108
  if (Array.isArray(snapshot.summary?.hints) && snapshot.summary.hints.length) {
100
109
  lines.push("\n## Hints");
101
110
  for (const hint of snapshot.summary.hints.slice(0, 6)) lines.push(`- ${hint}`);
102
111
  }
103
112
  if (snapshot.diff && !snapshot.diff.firstSnapshot) {
104
113
  const changed = [
105
- ...(snapshot.diff.changes || []).map((c: any) => c.kind === "textChanged" ? "text changed" : `${c.kind}: ${compactLine(c.before, 50)} → ${compactLine(c.after, 50)}`),
106
- ...(snapshot.diff.added || []).slice(0, 4).map((e: any) => `added ${e.uid} ${e.role || ""} ${compactLine(e.label)}`),
107
- ...(snapshot.diff.updated || []).slice(0, 4).map((u: any) => `updated ${u.uid} ${compactLine(u.after?.label || u.before?.label)}`),
114
+ ...(snapshot.diff.changes || []).map((c: any) =>
115
+ c.kind === "textChanged"
116
+ ? "text changed"
117
+ : `${c.kind}: ${compactLine(c.before, 50)} → ${compactLine(c.after, 50)}`,
118
+ ),
119
+ ...(snapshot.diff.added || [])
120
+ .slice(0, 4)
121
+ .map((e: any) => `added ${e.uid} ${e.role || ""} ${compactLine(e.label)}`),
122
+ ...(snapshot.diff.updated || [])
123
+ .slice(0, 4)
124
+ .map((u: any) => `updated ${u.uid} ${compactLine(u.after?.label || u.before?.label)}`),
108
125
  ];
109
126
  if (changed.length) {
110
127
  lines.push("\n## Changed since last snapshot");
@@ -114,29 +131,49 @@ function formatChromeSnapshot(snapshot: any): string {
114
131
  if (Array.isArray(snapshot.matches) && snapshot.matches.length) {
115
132
  lines.push(`\n## Matches for "${snapshot.query}"`);
116
133
  for (const match of snapshot.matches.slice(0, 12)) {
117
- if (match.kind === "text") lines.push(`- ${match.uid} text ${compactLine(match.text)} @ ${rectText(match.rect)}`);
118
- else if (match.kind === "region") lines.push(`- ${match.uid} region ${compactLine(match.label)} headings=${(match.headings || []).map((h: string) => compactLine(h, 50)).join(" | ")}`);
119
- else lines.push(`- ${match.uid} ${match.role || match.tag || "element"}${match.disabled ? " disabled" : ""} ${compactLine(match.label || match.selector)} @ ${rectText(match.rect)}`);
134
+ if (match.kind === "text")
135
+ lines.push(`- ${match.uid} text ${compactLine(match.text)} @ ${rectText(match.rect)}`);
136
+ else if (match.kind === "region")
137
+ lines.push(
138
+ `- ${match.uid} region ${compactLine(match.label)} headings=${(match.headings || []).map((h: string) => compactLine(h, 50)).join(" | ")}`,
139
+ );
140
+ else
141
+ lines.push(
142
+ `- ${match.uid} ${match.role || match.tag || "element"}${match.disabled ? " disabled" : ""} ${compactLine(match.label || match.selector)} @ ${rectText(match.rect)}`,
143
+ );
120
144
  }
121
145
  }
122
146
  if (snapshot.mode === "pageMap" && snapshot.pageMap) {
123
147
  lines.push("\n## Page map");
124
148
  for (const region of (snapshot.pageMap.regions || []).slice(0, 18)) {
125
149
  lines.push(`- ${region.uid} ${region.kind}: ${compactLine(region.label)}`);
126
- for (const action of (region.actions || []).slice(0, 5)) lines.push(` - ${action.uid} ${action.role || ""}${action.disabled ? " disabled" : ""} ${compactLine(action.label)}`);
150
+ for (const action of (region.actions || []).slice(0, 5))
151
+ lines.push(
152
+ ` - ${action.uid} ${action.role || ""}${action.disabled ? " disabled" : ""} ${compactLine(action.label)}`,
153
+ );
127
154
  }
128
155
  if (snapshot.pageMap.headings?.length) {
129
156
  lines.push("\nHeadings:");
130
- for (const h of snapshot.pageMap.headings.slice(0, 20)) lines.push(`- ${h.uid} h${h.level || ""} ${compactLine(h.text)}`);
157
+ for (const h of snapshot.pageMap.headings.slice(0, 20))
158
+ lines.push(`- ${h.uid} h${h.level || ""} ${compactLine(h.text)}`);
131
159
  }
132
160
  }
133
161
  if (Array.isArray(snapshot.layout) && snapshot.layout.length && snapshot.mode !== "changes") {
134
162
  lines.push("\n## Layout / context");
135
163
  for (const section of snapshot.layout.slice(0, snapshot.mode === "pageMap" ? 18 : 8)) {
136
- const bits = [`${section.uid}`, section.role || section.tag, compactLine(section.label || section.text || "(unnamed section)", 110), `@ ${rectText(section.rect)}`];
164
+ const bits = [
165
+ `${section.uid}`,
166
+ section.role || section.tag,
167
+ compactLine(section.label || section.text || "(unnamed section)", 110),
168
+ `@ ${rectText(section.rect)}`,
169
+ ];
137
170
  lines.push(`- ${bits.filter(Boolean).join(" ")}`);
138
- const fieldLabels = (section.fields || []).slice(0, 4).map((f: any) => `${f.uid} ${compactLine(f.label || f.role, 40)}`);
139
- const actionLabels = (section.actions || []).slice(0, 5).map((a: any) => `${a.uid}${a.disabled ? " disabled" : ""} ${compactLine(a.label || a.role, 40)}`);
171
+ const fieldLabels = (section.fields || [])
172
+ .slice(0, 4)
173
+ .map((f: any) => `${f.uid} ${compactLine(f.label || f.role, 40)}`);
174
+ const actionLabels = (section.actions || [])
175
+ .slice(0, 5)
176
+ .map((a: any) => `${a.uid}${a.disabled ? " disabled" : ""} ${compactLine(a.label || a.role, 40)}`);
140
177
  if (fieldLabels.length) lines.push(` fields: ${fieldLabels.join("; ")}`);
141
178
  if (actionLabels.length) lines.push(` actions: ${actionLabels.join("; ")}`);
142
179
  }
@@ -146,28 +183,52 @@ function formatChromeSnapshot(snapshot: any): string {
146
183
  const submits = snapshot.forms?.submits || [];
147
184
  if (fields.length || submits.length) lines.push("\n## Forms");
148
185
  for (const field of fields.slice(0, snapshot.mode === "forms" ? 40 : 12)) {
149
- const bits = [field.uid, field.role || field.tag, field.required ? "required" : "", field.invalid ? "invalid" : "", field.disabled ? "disabled" : "", compactLine(field.label || field.selector, 90)];
186
+ const bits = [
187
+ field.uid,
188
+ field.role || field.tag,
189
+ field.required ? "required" : "",
190
+ field.invalid ? "invalid" : "",
191
+ field.disabled ? "disabled" : "",
192
+ compactLine(field.label || field.selector, 90),
193
+ ];
150
194
  if (field.value) bits.push(`value=${compactLine(field.value, 50)}`);
151
195
  else if (field.valueRedacted) bits.push("value=[redacted]");
152
196
  lines.push(`- ${bits.filter(Boolean).join(" ")} @ ${rectText(field.rect)}`);
153
197
  }
154
- for (const submit of submits.slice(0, 8)) lines.push(`- ${submit.uid} submit/action${submit.disabled ? " disabled" : ""} ${compactLine(submit.label || submit.selector)} @ ${rectText(submit.rect)}`);
198
+ for (const submit of submits.slice(0, 8))
199
+ lines.push(
200
+ `- ${submit.uid} submit/action${submit.disabled ? " disabled" : ""} ${compactLine(submit.label || submit.selector)} @ ${rectText(submit.rect)}`,
201
+ );
155
202
  }
156
203
  if (Array.isArray(snapshot.elements) && snapshot.mode !== "pageMap") {
157
204
  lines.push("\n## Visible actions");
158
205
  for (const el of snapshot.elements.slice(0, snapshot.mode === "interactive" ? 60 : 25)) {
159
- const flags = [el.disabled ? "disabled" : "", el.occluded ? `occluded-by-${el.occluded.tag}` : ""].filter(Boolean).join(",");
206
+ const flags = [el.disabled ? "disabled" : "", el.occluded ? `occluded-by-${el.occluded.tag}` : ""]
207
+ .filter(Boolean)
208
+ .join(",");
160
209
  const context = el.context?.label ? ` in ${el.context.uid} ${compactLine(el.context.label, 60)}` : "";
161
- lines.push(`- ${el.uid} ${el.role || el.tag}${flags ? ` [${flags}]` : ""} ${compactLine(el.label || el.selector)}${context} @ ${rectText(el.rect)}`);
210
+ lines.push(
211
+ `- ${el.uid} ${el.role || el.tag}${flags ? ` [${flags}]` : ""} ${compactLine(el.label || el.selector)}${context} @ ${rectText(el.rect)}`,
212
+ );
162
213
  }
163
- if (snapshot.elements.length > (snapshot.mode === "interactive" ? 60 : 25)) lines.push(`- … ${snapshot.elements.length - (snapshot.mode === "interactive" ? 60 : 25)} more; retry with maxElements or mode=interactive`);
214
+ if (snapshot.elements.length > (snapshot.mode === "interactive" ? 60 : 25))
215
+ lines.push(
216
+ `- … ${snapshot.elements.length - (snapshot.mode === "interactive" ? 60 : 25)} more; retry with maxElements or mode=interactive`,
217
+ );
164
218
  }
165
- if ((snapshot.mode === "text" || snapshot.mode === "auto") && Array.isArray(snapshot.textSnippets) && snapshot.textSnippets.length) {
219
+ if (
220
+ (snapshot.mode === "text" || snapshot.mode === "auto") &&
221
+ Array.isArray(snapshot.textSnippets) &&
222
+ snapshot.textSnippets.length
223
+ ) {
166
224
  lines.push("\n## Text snippets");
167
- for (const snip of snapshot.textSnippets.slice(0, snapshot.mode === "text" ? 40 : 14)) lines.push(`- ${snip.uid} ${compactLine(snip.text, snapshot.mode === "text" ? 240 : 160)}`);
225
+ for (const snip of snapshot.textSnippets.slice(0, snapshot.mode === "text" ? 40 : 14))
226
+ lines.push(`- ${snip.uid} ${compactLine(snip.text, snapshot.mode === "text" ? 240 : 160)}`);
168
227
  if (snapshot.textTruncated) lines.push("- … page text truncated; retry with mode=text or maxTextChars for more");
169
228
  }
170
- lines.push("\nTip: use chrome_snapshot({query:'...', mode:'interactive|forms|pageMap|text|changes|full'}) or nearUid to zoom in.");
229
+ lines.push(
230
+ "\nTip: use chrome_snapshot({query:'...', mode:'interactive|forms|pageMap|text|changes|full'}) or nearUid to zoom in.",
231
+ );
171
232
  return truncateText(lines.join("\n"));
172
233
  }
173
234
 
@@ -181,26 +242,41 @@ function formatChromeInspect(inspect: any): string {
181
242
  const t = inspect.target || {};
182
243
  const lines: string[] = [];
183
244
  lines.push(`# Chrome inspect ${t.uid || ""}`.trim());
184
- lines.push(`${t.role || t.tag || "element"}${t.disabled ? " disabled" : ""}${t.occluded ? ` occluded-by-${t.occluded.tag}` : ""} ${compactLine(t.label || t.selector)}`);
245
+ lines.push(
246
+ `${t.role || t.tag || "element"}${t.disabled ? " disabled" : ""}${t.occluded ? ` occluded-by-${t.occluded.tag}` : ""} ${compactLine(t.label || t.selector)}`,
247
+ );
185
248
  if (t.selector) lines.push(`selector: ${t.selector}`);
186
249
  if (t.rect) lines.push(`rect: ${rectText(t.rect)}`);
187
- if (inspect.clickSuggestion) lines.push(`suggested click: chrome_click({ uid: "${inspect.clickSuggestion.uid}" }) or x=${inspect.clickSuggestion.x}, y=${inspect.clickSuggestion.y}`);
250
+ if (inspect.clickSuggestion)
251
+ lines.push(
252
+ `suggested click: chrome_click({ uid: "${inspect.clickSuggestion.uid}" }) or x=${inspect.clickSuggestion.x}, y=${inspect.clickSuggestion.y}`,
253
+ );
188
254
  if (Array.isArray(inspect.nearbyText) && inspect.nearbyText.length) {
189
255
  lines.push("\n## Nearby text");
190
256
  for (const item of inspect.nearbyText.slice(0, 12)) lines.push(`- ${item.uid} ${compactLine(item.text, 180)}`);
191
257
  }
192
258
  if (inspect.formContext) {
193
259
  lines.push("\n## Form context");
194
- for (const field of (inspect.formContext.fields || []).slice(0, 20)) lines.push(`- ${field.uid} ${field.role || field.tag}${field.disabled ? " disabled" : ""} ${compactLine(field.label || field.selector)}${field.value ? ` value=${compactLine(field.value, 60)}` : field.valueRedacted ? " value=[redacted]" : ""}`);
195
- for (const action of (inspect.formContext.actions || []).slice(0, 10)) lines.push(`- ${action.uid} action${action.disabled ? " disabled" : ""} ${compactLine(action.label || action.selector)}`);
260
+ for (const field of (inspect.formContext.fields || []).slice(0, 20))
261
+ lines.push(
262
+ `- ${field.uid} ${field.role || field.tag}${field.disabled ? " disabled" : ""} ${compactLine(field.label || field.selector)}${field.value ? ` value=${compactLine(field.value, 60)}` : field.valueRedacted ? " value=[redacted]" : ""}`,
263
+ );
264
+ for (const action of (inspect.formContext.actions || []).slice(0, 10))
265
+ lines.push(
266
+ `- ${action.uid} action${action.disabled ? " disabled" : ""} ${compactLine(action.label || action.selector)}`,
267
+ );
196
268
  }
197
269
  if (Array.isArray(inspect.nearbyActions) && inspect.nearbyActions.length) {
198
270
  lines.push("\n## Nearby actions");
199
- for (const action of inspect.nearbyActions.slice(0, 18)) lines.push(`- ${action.uid} ${action.role || action.tag}${action.disabled ? " disabled" : ""} ${compactLine(action.label || action.selector)} @ ${rectText(action.rect)}`);
271
+ for (const action of inspect.nearbyActions.slice(0, 18))
272
+ lines.push(
273
+ `- ${action.uid} ${action.role || action.tag}${action.disabled ? " disabled" : ""} ${compactLine(action.label || action.selector)} @ ${rectText(action.rect)}`,
274
+ );
200
275
  }
201
276
  if (Array.isArray(inspect.ancestors) && inspect.ancestors.length) {
202
277
  lines.push("\n## Ancestors");
203
- for (const a of inspect.ancestors.slice(0, 6)) lines.push(`- ${a.uid} ${a.role || a.tag} ${compactLine(a.label || a.selector, 120)}`);
278
+ for (const a of inspect.ancestors.slice(0, 6))
279
+ lines.push(`- ${a.uid} ${a.role || a.tag} ${compactLine(a.label || a.selector, 120)}`);
204
280
  }
205
281
  return truncateText(lines.join("\n"));
206
282
  }
@@ -230,7 +306,11 @@ function browserExtensionPath(): string {
230
306
 
231
307
  function hostnameOf(url: string | undefined): string {
232
308
  if (!url) return "";
233
- try { return new URL(url).hostname; } catch { return ""; }
309
+ try {
310
+ return new URL(url).hostname;
311
+ } catch {
312
+ return "";
313
+ }
234
314
  }
235
315
 
236
316
  // Description of a click/type/fill result's significant fields so the agent doesn't have to
@@ -243,12 +323,13 @@ function summarizeActionResult(result: unknown): string | undefined {
243
323
  // Many real effects — class/aria/data-state toggles, JS-held state, canvas, async updates —
244
324
  // don't move it, so a false value is NOT proof the action did nothing. Surface it only as a
245
325
  // soft hint, and never present it as a failure on its own.
246
- if (r.pageMutated === false) parts.push("no coarse DOM change detected (may still have taken effect — verify with includeSnapshot)");
326
+ if (r.pageMutated === false)
327
+ parts.push("no coarse DOM change detected (may still have taken effect — verify with includeSnapshot)");
247
328
  if (r.defaultPrevented === true) parts.push("defaultPrevented=true");
248
329
  if (r.elementVisible === false) parts.push("element NOT visible");
249
330
  if (r.occludedBy) {
250
331
  const o = r.occludedBy as { tag?: string; id?: string };
251
- parts.push(`occluded by <${o.tag ?? "?"}${o.id ? "#" + o.id : ""}>`);
332
+ parts.push(`occluded by <${o.tag ?? "?"}${o.id ? `#${o.id}` : ""}>`);
252
333
  }
253
334
  if (r.valueMatches === false) parts.push("input value did not stick");
254
335
  if (r.autoplayHint) parts.push("autoplay-gated affordance");
@@ -272,7 +353,7 @@ function corsHeadersFor(request: IncomingMessage): Record<string, string> {
272
353
  "access-control-allow-methods": "GET,POST,OPTIONS",
273
354
  "access-control-allow-headers": "content-type",
274
355
  "access-control-expose-headers": "x-pi-chrome-version",
275
- "vary": "origin",
356
+ vary: "origin",
276
357
  };
277
358
  }
278
359
 
@@ -287,7 +368,12 @@ function isLocalProcessRequest(request: IncomingMessage): boolean {
287
368
  return !request.headers.origin && !request.headers["sec-fetch-site"];
288
369
  }
289
370
 
290
- function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {
371
+ function sendJson(
372
+ response: ServerResponse,
373
+ status: number,
374
+ body: unknown,
375
+ extraHeaders?: Record<string, string>,
376
+ ): void {
291
377
  response.writeHead(status, {
292
378
  "content-type": "application/json; charset=utf-8",
293
379
  "cache-control": "no-store",
@@ -393,12 +479,22 @@ class ChromeProfileBridge {
393
479
  this.mode = undefined;
394
480
  }
395
481
 
396
- send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
482
+ send(
483
+ action: string,
484
+ params: Record<string, unknown>,
485
+ timeoutMs = DEFAULT_TIMEOUT_MS,
486
+ signal?: AbortSignal,
487
+ ): Promise<unknown> {
397
488
  if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs, signal);
398
489
  return this.sendLocal(action, params, timeoutMs, signal);
399
490
  }
400
491
 
401
- private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
492
+ private sendLocal(
493
+ action: string,
494
+ params: Record<string, unknown>,
495
+ timeoutMs = DEFAULT_TIMEOUT_MS,
496
+ signal?: AbortSignal,
497
+ ): Promise<unknown> {
402
498
  const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
403
499
  const command = { id, action, params };
404
500
  return new Promise((resolveCommand, rejectCommand) => {
@@ -425,8 +521,14 @@ class ChromeProfileBridge {
425
521
  }, timeoutMs);
426
522
  this.pending.set(id, {
427
523
  command,
428
- resolve: (value) => { cleanupAbort(); resolveCommand(value); },
429
- reject: (err) => { cleanupAbort(); rejectCommand(err); },
524
+ resolve: (value) => {
525
+ cleanupAbort();
526
+ resolveCommand(value);
527
+ },
528
+ reject: (err) => {
529
+ cleanupAbort();
530
+ rejectCommand(err);
531
+ },
430
532
  timer,
431
533
  });
432
534
  if (signal) signal.addEventListener("abort", onAbort, { once: true });
@@ -444,12 +546,17 @@ class ChromeProfileBridge {
444
546
  return `Timed out after ${timeoutMs}ms: the Chrome extension received the command but never returned a result. The action may be long-running, or the result post failed. Run /chrome doctor; if it persists, reload 'Pi Chrome Connector' at chrome://extensions.`;
445
547
  }
446
548
  if (pollAgeMs === undefined || pollAgeMs > 60_000) {
447
- return `Timed out after ${timeoutMs}ms: the Chrome extension is not polling (last seen ${pollAgeMs === undefined ? "never" : Math.round(pollAgeMs / 1000) + "s ago"}). Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile and keep that Chrome window open.`;
549
+ return `Timed out after ${timeoutMs}ms: the Chrome extension is not polling (last seen ${pollAgeMs === undefined ? "never" : `${Math.round(pollAgeMs / 1000)}s ago`}). Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile and keep that Chrome window open.`;
448
550
  }
449
551
  return `Timed out after ${timeoutMs}ms: the Chrome extension is polling (last seen ${Math.round(pollAgeMs / 1000)}s ago) but did not pick up this command in time. Retry; if it persists, reload 'Pi Chrome Connector' at chrome://extensions.`;
450
552
  }
451
553
 
452
- private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
554
+ private async sendViaOwner(
555
+ action: string,
556
+ params: Record<string, unknown>,
557
+ timeoutMs: number,
558
+ signal?: AbortSignal,
559
+ ): Promise<unknown> {
453
560
  const controller = new AbortController();
454
561
  const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
455
562
  const forwardAbort = () => controller.abort();
@@ -464,13 +571,18 @@ class ChromeProfileBridge {
464
571
  body: JSON.stringify({ action, params, timeoutMs }),
465
572
  signal: controller.signal,
466
573
  });
467
- const payload = (await response.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string };
574
+ const payload = (await response.json().catch(() => ({}))) as {
575
+ ok?: boolean;
576
+ result?: unknown;
577
+ error?: string;
578
+ };
468
579
  if (response.status === 404) {
469
580
  throw new Error(
470
581
  "A running Pi session owns the Chrome bridge but is using an older pi-chrome without multi-session support. Restart that Pi session after `pi update`, then retry.",
471
582
  );
472
583
  }
473
- if (!response.ok || !payload.ok) throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`);
584
+ if (!response.ok || !payload.ok)
585
+ throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`);
474
586
  return payload.result;
475
587
  } catch (error) {
476
588
  if ((error as Error).name === "AbortError") {
@@ -662,7 +774,12 @@ const CHROME_TOOL_NAMES = [
662
774
  const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
663
775
 
664
776
  function StringEnum<T extends readonly [string, ...string[]]>(values: T) {
665
- return Type.Union(values.map((value) => Type.Literal(value)) as [ReturnType<typeof Type.Literal>, ...ReturnType<typeof Type.Literal>[]]);
777
+ return Type.Union(
778
+ values.map((value) => Type.Literal(value)) as [
779
+ ReturnType<typeof Type.Literal>,
780
+ ...ReturnType<typeof Type.Literal>[],
781
+ ],
782
+ );
666
783
  }
667
784
 
668
785
  export default function (pi: ExtensionAPI): void {
@@ -711,7 +828,8 @@ export default function (pi: ExtensionAPI): void {
711
828
  authExpiryTimer = undefined;
712
829
  };
713
830
 
714
- const activeToolNamesWithoutChrome = (): string[] => pi.getActiveTools().filter((name) => !CHROME_TOOL_NAME_SET.has(name));
831
+ const activeToolNamesWithoutChrome = (): string[] =>
832
+ pi.getActiveTools().filter((name) => !CHROME_TOOL_NAME_SET.has(name));
715
833
 
716
834
  const activateChromeTools = (): void => {
717
835
  registerChromeTools(pi);
@@ -761,7 +879,7 @@ export default function (pi: ExtensionAPI): void {
761
879
 
762
880
  const updateChromeStatus = (ctx: ExtensionContext): void => {
763
881
  if (chromeControlAuthorized()) {
764
- ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge");
882
+ ctx.ui.setStatus("chrome", `${ctx.ui.theme.fg("success", "●")} Chrome Bridge`);
765
883
  } else {
766
884
  ctx.ui.setStatus("chrome", undefined);
767
885
  }
@@ -770,25 +888,37 @@ export default function (pi: ExtensionAPI): void {
770
888
  const scheduleAuthExpiry = (ctx: ExtensionContext, until: number | "indefinite"): void => {
771
889
  clearAuthExpiryTimer();
772
890
  if (until === "indefinite") return;
773
- authExpiryTimer = setTimeout(() => {
774
- if (chromeAuthorizedUntil !== until) return;
775
- try {
776
- lockChromeControl();
777
- ctx.ui.notify("Chrome control authorization expired. Run /chrome authorize to allow chrome_* tools again.", "info");
778
- updateChromeStatus(ctx);
779
- } catch (error) {
780
- console.warn(`Failed to expire pi-chrome authorization cleanly: ${(error as Error).message}`);
781
- }
782
- }, Math.max(0, until - Date.now()));
891
+ authExpiryTimer = setTimeout(
892
+ () => {
893
+ if (chromeAuthorizedUntil !== until) return;
894
+ try {
895
+ lockChromeControl();
896
+ ctx.ui.notify(
897
+ "Chrome control authorization expired. Run /chrome authorize to allow chrome_* tools again.",
898
+ "info",
899
+ );
900
+ updateChromeStatus(ctx);
901
+ } catch (error) {
902
+ console.warn(`Failed to expire pi-chrome authorization cleanly: ${(error as Error).message}`);
903
+ }
904
+ },
905
+ Math.max(0, until - Date.now()),
906
+ );
783
907
  };
784
908
 
785
- const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => {
909
+ const authorizedBridgeSend = (
910
+ action: string,
911
+ params: Record<string, unknown>,
912
+ timeoutMs = DEFAULT_TIMEOUT_MS,
913
+ signal?: AbortSignal,
914
+ ): Promise<unknown> => {
786
915
  requireChromeControlAuthorized();
787
916
  // Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
788
917
  // auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull
789
918
  // the resolved (e.g. active) tab into the session group on the service-worker side. We skip
790
919
  // tab.* actions: tab.new/group group explicitly, and activate/close/ungroup/list must not.
791
- const shouldJoinGroup = action.startsWith("page.") && sessionCtx !== undefined && params.sessionGroupTitle === undefined;
920
+ const shouldJoinGroup =
921
+ action.startsWith("page.") && sessionCtx !== undefined && params.sessionGroupTitle === undefined;
792
922
  const wireParams = shouldJoinGroup
793
923
  ? { ...params, sessionGroupTitle: sessionGroupTitle(sessionCtx as ExtensionContext), joinSessionGroup: true }
794
924
  : params;
@@ -857,64 +987,86 @@ Usage rules:
857
987
 
858
988
  // Shared handlers, dispatched by the unified /chrome command below.
859
989
  const doctorHandler = async (ctx: ExtensionContext) => {
860
- ctx.ui.notify("Checking pi-chrome…", "info");
861
- const lines: string[] = [`pi-chrome v${PI_CHROME_VERSION}`];
862
- const status = bridge.status();
863
- const roleLabel = status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine";
864
- lines.push(`• This pi session is ${roleLabel}.`);
865
- let extensionAlive = false;
866
- let versionMismatch = false;
990
+ ctx.ui.notify("Checking pi-chrome…", "info");
991
+ const lines: string[] = [`pi-chrome v${PI_CHROME_VERSION}`];
992
+ const status = bridge.status();
993
+ const roleLabel =
994
+ status.mode === "client"
995
+ ? "sharing another pi session's connection"
996
+ : "running the Chrome connection for this machine";
997
+ lines.push(`• This pi session is ${roleLabel}.`);
998
+ let extensionAlive = false;
999
+ let versionMismatch = false;
1000
+ try {
1001
+ const started = Date.now();
1002
+ const version = (await bridge.send("tab.version", {}, 35_000)) as {
1003
+ extensionId?: string;
1004
+ extensionVersion?: string;
1005
+ bridgeUrl?: string;
1006
+ };
1007
+ const latencyMs = Date.now() - started;
1008
+ extensionAlive = true;
1009
+ if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
1010
+ versionMismatch = true;
1011
+ lines.push(
1012
+ `✗ The Chrome companion extension is on an old version (${version.extensionVersion}); this pi-chrome is ${PI_CHROME_VERSION}.`,
1013
+ ` Every Chrome action will run with the old code until you reload the extension.`,
1014
+ ` Fix: open chrome://extensions and click the refresh icon on 'Pi Chrome Connector'.`,
1015
+ ` (After this one-time fix, future updates reload automatically.)`,
1016
+ );
1017
+ } else {
1018
+ lines.push(
1019
+ `✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`,
1020
+ );
1021
+ }
1022
+ } catch (error) {
1023
+ const message = (error as Error).message;
1024
+ lines.push(`✗ Chrome isn't responding: ${message}`);
1025
+ if (message.includes("older pi-chrome without multi-session")) {
1026
+ lines.push(
1027
+ " Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome).",
1028
+ );
1029
+ } else {
1030
+ lines.push(
1031
+ " Fix: run /chrome onboard to install the Chrome companion extension, then keep that Chrome window open.",
1032
+ );
1033
+ }
1034
+ }
1035
+
1036
+ if (extensionAlive && !versionMismatch) {
1037
+ // Sanity-check that pi-chrome can actually run code in the active tab.
867
1038
  try {
868
- const started = Date.now();
869
- const version = (await bridge.send("tab.version", {}, 35_000)) as {
870
- extensionId?: string;
871
- extensionVersion?: string;
872
- bridgeUrl?: string;
873
- };
874
- const latencyMs = Date.now() - started;
875
- extensionAlive = true;
876
- if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
877
- versionMismatch = true;
1039
+ const value = await bridge.send(
1040
+ "page.evaluate",
1041
+ { expression: "1+1", awaitPromise: true, foreground: false },
1042
+ 10_000,
1043
+ );
1044
+ if (value === 2) lines.push(`✓ pi-chrome can run code in the active Chrome tab.`);
1045
+ else
878
1046
  lines.push(
879
- `✗ The Chrome companion extension is on an old version (${version.extensionVersion}); this pi-chrome is ${PI_CHROME_VERSION}.`,
880
- ` Every Chrome action will run with the old code until you reload the extension.`,
881
- ` Fix: open chrome://extensions and click the refresh icon on 'Pi Chrome Connector'.`,
882
- ` (After this one-time fix, future updates reload automatically.)`,
1047
+ `⚠ pi-chrome ran code in the active tab but got an unexpected result (${JSON.stringify(value)}). The current tab may be locked-down (a Chrome internal page or a strict site).`,
883
1048
  );
884
- } else {
885
- lines.push(`✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`);
886
- }
887
1049
  } catch (error) {
888
- const message = (error as Error).message;
889
- lines.push(`✗ Chrome isn't responding: ${message}`);
890
- if (message.includes("older pi-chrome without multi-session")) {
891
- lines.push(" Fix: quit and restart the pi session that first opened the Chrome connection (it was on an older pi-chrome).");
892
- } else {
893
- lines.push(" Fix: run /chrome onboard to install the Chrome companion extension, then keep that Chrome window open.");
894
- }
1050
+ lines.push(`✗ pi-chrome can't run code in the active tab: ${(error as Error).message}`);
895
1051
  }
896
1052
 
897
- if (extensionAlive && !versionMismatch) {
898
- // Sanity-check that pi-chrome can actually run code in the active tab.
899
- try {
900
- const value = await bridge.send("page.evaluate", { expression: "1+1", awaitPromise: true, foreground: false }, 10_000);
901
- if (value === 2) lines.push(`✓ pi-chrome can run code in the active Chrome tab.`);
902
- else lines.push(`⚠ pi-chrome ran code in the active tab but got an unexpected result (${JSON.stringify(value)}). The current tab may be locked-down (a Chrome internal page or a strict site).`);
903
- } catch (error) {
904
- lines.push(`✗ pi-chrome can't run code in the active tab: ${(error as Error).message}`);
905
- }
906
-
907
- // Surface obvious site-side automation flags so the user knows why a site might block pi.
908
- try {
909
- const probe = (await bridge.send("page.probe", { foreground: false }, 10_000)) as Record<string, unknown>;
910
- if (probe && probe.arithmetic === 2) lines.push(`✓ The active tab is ${hostnameOf(String(probe.location))} and accepts pi-chrome's commands.`);
911
- if (probe && probe.webdriver) lines.push(`⚠ Your Chrome is reporting itself as automated to websites. Some sites use this signal to block sign-ins or bot checks.`);
912
- } catch (error) {
913
- lines.push(`⚠ Couldn't inspect the active tab: ${(error as Error).message}`);
914
- }
915
- } else if (versionMismatch) {
916
- lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`);
1053
+ // Surface obvious site-side automation flags so the user knows why a site might block pi.
1054
+ try {
1055
+ const probe = (await bridge.send("page.probe", { foreground: false }, 10_000)) as Record<string, unknown>;
1056
+ if (probe && probe.arithmetic === 2)
1057
+ lines.push(
1058
+ `✓ The active tab is ${hostnameOf(String(probe.location))} and accepts pi-chrome's commands.`,
1059
+ );
1060
+ if (probe?.webdriver)
1061
+ lines.push(
1062
+ `⚠ Your Chrome is reporting itself as automated to websites. Some sites use this signal to block sign-ins or bot checks.`,
1063
+ );
1064
+ } catch (error) {
1065
+ lines.push(`⚠ Couldn't inspect the active tab: ${(error as Error).message}`);
917
1066
  }
1067
+ } else if (versionMismatch) {
1068
+ lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`);
1069
+ }
918
1070
 
919
1071
  ctx.ui.notify(lines.join("\n"), "info");
920
1072
  };
@@ -965,7 +1117,8 @@ Usage rules:
965
1117
 
966
1118
  const parseAuthorizeArg = (arg: string): { label: string; until: number | "indefinite" } | undefined => {
967
1119
  const normalized = arg.trim().toLowerCase() || "15m";
968
- if (normalized === "indefinite" || normalized === "forever") return { label: "indefinitely", until: "indefinite" };
1120
+ if (normalized === "indefinite" || normalized === "forever")
1121
+ return { label: "indefinitely", until: "indefinite" };
969
1122
  const minutes = normalized.endsWith("m") ? Number(normalized.slice(0, -1)) : Number(normalized);
970
1123
  if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
971
1124
  return { label: `${minutes} minutes`, until: Date.now() + minutes * 60_000 };
@@ -997,9 +1150,18 @@ Usage rules:
997
1150
  return;
998
1151
  }
999
1152
  if (process.platform === "darwin") {
1000
- await pi.exec("open", ["-a", "Google Chrome", "chrome://extensions"], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
1001
- await pi.exec("open", ["-R", extensionPath], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
1002
- await pi.exec("sh", ["-lc", `printf %s ${JSON.stringify(extensionPath)} | pbcopy`], { cwd: workspaceCwd(ctx), timeout: 5_000 }).catch(() => undefined);
1153
+ await pi
1154
+ .exec("open", ["-a", "Google Chrome", "chrome://extensions"], { cwd: workspaceCwd(ctx), timeout: 5_000 })
1155
+ .catch(() => undefined);
1156
+ await pi
1157
+ .exec("open", ["-R", extensionPath], { cwd: workspaceCwd(ctx), timeout: 5_000 })
1158
+ .catch(() => undefined);
1159
+ await pi
1160
+ .exec("sh", ["-lc", `printf %s ${JSON.stringify(extensionPath)} | pbcopy`], {
1161
+ cwd: workspaceCwd(ctx),
1162
+ timeout: 5_000,
1163
+ })
1164
+ .catch(() => undefined);
1003
1165
  }
1004
1166
  ctx.ui.notify(
1005
1167
  "Chrome and Finder should be open. The extension folder path is on your clipboard. After you click 'Load unpacked' and pick it, run /chrome doctor to confirm everything is connected.",
@@ -1014,7 +1176,9 @@ Usage rules:
1014
1176
  try {
1015
1177
  const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string };
1016
1178
  if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
1017
- parts.push(`⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`);
1179
+ parts.push(
1180
+ `⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`,
1181
+ );
1018
1182
  } else {
1019
1183
  parts.push(`✓ Chrome connected`);
1020
1184
  }
@@ -1041,9 +1205,12 @@ Usage rules:
1041
1205
  ]);
1042
1206
  if (!choice) return;
1043
1207
  switch (choice) {
1044
- case "15 minutes": return authorizeHandler(ctx, "15m");
1045
- case "30 minutes": return authorizeHandler(ctx, "30m");
1046
- case "Indefinite": return authorizeHandler(ctx, "indefinite");
1208
+ case "15 minutes":
1209
+ return authorizeHandler(ctx, "15m");
1210
+ case "30 minutes":
1211
+ return authorizeHandler(ctx, "30m");
1212
+ case "Indefinite":
1213
+ return authorizeHandler(ctx, "indefinite");
1047
1214
  case "Custom minutes": {
1048
1215
  const value = await ctx.ui.input("Authorize for how many minutes?", "45");
1049
1216
  if (!value) continue;
@@ -1060,8 +1227,10 @@ Usage rules:
1060
1227
  ]);
1061
1228
  if (!choice) return;
1062
1229
  switch (choice) {
1063
- case "Use Chrome in background": return backgroundHandler(ctx, "on");
1064
- case "Use Chrome in foreground": return backgroundHandler(ctx, "off");
1230
+ case "Use Chrome in background":
1231
+ return backgroundHandler(ctx, "on");
1232
+ case "Use Chrome in foreground":
1233
+ return backgroundHandler(ctx, "off");
1065
1234
  }
1066
1235
  };
1067
1236
 
@@ -1077,11 +1246,18 @@ Usage rules:
1077
1246
  ]);
1078
1247
  if (!choice) return;
1079
1248
  switch (choice) {
1080
- case "Authorize Chrome control…": await openAuthorizeMenu(ctx); continue;
1081
- case "Lock Chrome control": return revokeHandler(ctx);
1082
- case "Doctor / troubleshoot": return doctorHandler(ctx);
1083
- case "Background / watch mode…": await openBackgroundMenu(ctx); continue;
1084
- case "Install / onboard extension": return onboardHandler(ctx);
1249
+ case "Authorize Chrome control…":
1250
+ await openAuthorizeMenu(ctx);
1251
+ continue;
1252
+ case "Lock Chrome control":
1253
+ return revokeHandler(ctx);
1254
+ case "Doctor / troubleshoot":
1255
+ return doctorHandler(ctx);
1256
+ case "Background / watch mode…":
1257
+ await openBackgroundMenu(ctx);
1258
+ continue;
1259
+ case "Install / onboard extension":
1260
+ return onboardHandler(ctx);
1085
1261
  }
1086
1262
  }
1087
1263
  };
@@ -1105,24 +1281,60 @@ Usage rules:
1105
1281
  let candidates: Item[] = [];
1106
1282
  if (path.length === 0) {
1107
1283
  candidates = [
1108
- { fullValue: "authorize", label: "authorize", description: "Allow this Pi session to use chrome_* tools." },
1284
+ {
1285
+ fullValue: "authorize",
1286
+ label: "authorize",
1287
+ description: "Allow this Pi session to use chrome_* tools.",
1288
+ },
1109
1289
  { fullValue: "revoke", label: "revoke", description: "Lock Chrome control for this Pi session." },
1110
- { fullValue: "status", label: "status", description: "One-line summary: connection, auth, and background setting." },
1111
- { fullValue: "doctor", label: "doctor", description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't." },
1112
- { fullValue: "onboard", label: "onboard", description: "Install the Chrome companion extension (first-time setup)." },
1113
- { fullValue: "background", label: "background", description: "Run pi-chrome in the background without focusing Chrome?" },
1290
+ {
1291
+ fullValue: "status",
1292
+ label: "status",
1293
+ description: "One-line summary: connection, auth, and background setting.",
1294
+ },
1295
+ {
1296
+ fullValue: "doctor",
1297
+ label: "doctor",
1298
+ description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't.",
1299
+ },
1300
+ {
1301
+ fullValue: "onboard",
1302
+ label: "onboard",
1303
+ description: "Install the Chrome companion extension (first-time setup).",
1304
+ },
1305
+ {
1306
+ fullValue: "background",
1307
+ label: "background",
1308
+ description: "Run pi-chrome in the background without focusing Chrome?",
1309
+ },
1114
1310
  ];
1115
1311
  } else if (path[0] === "authorize" && path.length === 1) {
1116
1312
  candidates = [
1117
1313
  { fullValue: "authorize 15m", label: "15m", description: "Authorize Chrome control for 15 minutes." },
1118
1314
  { fullValue: "authorize 30m", label: "30m", description: "Authorize Chrome control for 30 minutes." },
1119
- { fullValue: "authorize indefinite", label: "indefinite", description: "Authorize Chrome control until revoked or Pi exits." },
1315
+ {
1316
+ fullValue: "authorize indefinite",
1317
+ label: "indefinite",
1318
+ description: "Authorize Chrome control until revoked or Pi exits.",
1319
+ },
1120
1320
  ];
1121
1321
  } else if (path[0] === "background" && path.length === 1) {
1122
1322
  candidates = [
1123
- { fullValue: "background on", label: "on", description: "Run in background. Chrome stays in the background. Your editor keeps focus. (default)" },
1124
- { fullValue: "background off", label: "off", description: "Bring Chrome to the front so you can watch." },
1125
- { fullValue: "background toggle", label: "toggle", description: "Flip whichever way it's currently set." },
1323
+ {
1324
+ fullValue: "background on",
1325
+ label: "on",
1326
+ description: "Run in background. Chrome stays in the background. Your editor keeps focus. (default)",
1327
+ },
1328
+ {
1329
+ fullValue: "background off",
1330
+ label: "off",
1331
+ description: "Bring Chrome to the front so you can watch.",
1332
+ },
1333
+ {
1334
+ fullValue: "background toggle",
1335
+ label: "toggle",
1336
+ description: "Flip whichever way it's currently set.",
1337
+ },
1126
1338
  { fullValue: "background status", label: "status", description: "Show the current setting." },
1127
1339
  ];
1128
1340
  }
@@ -1140,11 +1352,16 @@ Usage rules:
1140
1352
  const [head, ...rest] = tokens;
1141
1353
  const subArgs = rest.join(" ");
1142
1354
  switch (head) {
1143
- case "authorize": return authorizeHandler(ctx, subArgs);
1144
- case "revoke": return revokeHandler(ctx);
1145
- case "status": return statusHandler(ctx);
1146
- case "doctor": return doctorHandler(ctx);
1147
- case "onboard": return onboardHandler(ctx);
1355
+ case "authorize":
1356
+ return authorizeHandler(ctx, subArgs);
1357
+ case "revoke":
1358
+ return revokeHandler(ctx);
1359
+ case "status":
1360
+ return statusHandler(ctx);
1361
+ case "doctor":
1362
+ return doctorHandler(ctx);
1363
+ case "onboard":
1364
+ return onboardHandler(ctx);
1148
1365
  case "background":
1149
1366
  return backgroundHandler(ctx, subArgs);
1150
1367
  case "settings": {
@@ -1155,7 +1372,10 @@ Usage rules:
1155
1372
  return;
1156
1373
  }
1157
1374
  default:
1158
- ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome authorize | revoke | status | doctor | onboard | background.`, "warning");
1375
+ ctx.ui.notify(
1376
+ `Unknown subcommand '${head}'. Try: /chrome authorize | revoke | status | doctor | onboard | background.`,
1377
+ "warning",
1378
+ );
1159
1379
  }
1160
1380
  },
1161
1381
  });
@@ -1164,636 +1384,958 @@ Usage rules:
1164
1384
  if (chromeToolsRegistered) return;
1165
1385
  chromeToolsRegistered = true;
1166
1386
 
1167
- pi.registerTool({
1168
- name: "chrome_launch",
1169
- label: "Chrome Bridge Setup",
1170
- description:
1171
- "Start/check the local bridge used by the companion Chrome extension. This does not launch a separate Chrome profile; install the unpacked Chrome extension in your existing Chrome profile to connect.",
1172
- promptSnippet: "Show instructions for connecting Pi to the user's existing Chrome profile via the companion extension.",
1173
- parameters: Type.Object({
1174
- port: Type.Optional(Type.Number({ description: "Ignored. The bundled Chrome extension polls 127.0.0.1:17318." })),
1175
- url: Type.Optional(Type.String({ description: "Optional URL to open in the existing Chrome profile after the extension is connected." })),
1176
- userDataDir: Type.Optional(Type.String({ description: "Ignored. This bridge intentionally uses the user's existing Chrome profile through the companion extension." })),
1177
- useDefaultProfile: Type.Optional(Type.Boolean({ description: "Ignored; existing-profile access comes from the companion Chrome extension." })),
1178
- headless: Type.Optional(Type.Boolean({ description: "Ignored." })),
1179
- }),
1180
- async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1181
- if (params.url && bridge.connected) {
1182
- const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS, signal);
1183
- return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
1184
- }
1185
- return {
1186
- content: [
1187
- {
1188
- type: "text",
1189
- text:
1190
- `Chrome profile bridge is listening at ${bridge.url}.\n\n` +
1191
- `To connect your existing Chrome profile:\n` +
1192
- `1. Open chrome://extensions in the Chrome profile you normally use.\n` +
1193
- `2. Enable Developer mode.\n` +
1194
- `3. Click “Load unpacked”.\n` +
1195
- `4. Select: ${browserExtensionPath()}\n\n` +
1196
- `Status: ${bridge.connected ? "connected" : "waiting for extension"}.`,
1197
- },
1198
- ],
1199
- details: { status: bridge.status(), extensionPath: browserExtensionPath() },
1200
- };
1201
- },
1202
- });
1203
-
1204
- pi.registerTool({
1205
- name: "chrome_tab",
1206
- label: "Chrome Tab",
1207
- description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.",
1208
- promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
1209
- parameters: Type.Object({
1210
- action: StringEnum(tabActionValues),
1211
- url: Type.Optional(Type.String({ description: "URL for action=new." })),
1212
- targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
1213
- urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })),
1214
- titleIncludes: Type.Optional(Type.String({ description: "Match the target tab by title substring for activate/close/group/ungroup." })),
1215
- group: Type.Optional(Type.Boolean({ description: "action=new only: pass false to open an ungrouped tab. By default every Pi-opened tab joins this session's own tab group." })),
1216
- groupTitle: Type.Optional(Type.String({ description: "Tab group title for action=group/new. Defaults to this Pi session's group ('Pi Session: <name-or-id>'). Pass an empty string on action=new to opt out of grouping." })),
1217
- groupColor: Type.Optional(Type.String({ description: "Tab group color for action=group/new: grey, blue, red, yellow, green, pink, purple, cyan, or orange. Defaults to blue." })),
1218
- host: Type.Optional(Type.String()),
1219
- port: Type.Optional(Type.Number()),
1220
- }),
1221
- async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1222
- const forwarded = { ...params } as typeof params & { groupTitle?: string };
1223
- // Default every Pi-opened/explicitly-grouped tab into this session's own group,
1224
- // named after the session display name (falling back to the session id), unless
1225
- // the caller specified a group title or opted out with group:false.
1226
- if ((params.action === "new" || params.action === "group") && params.groupTitle === undefined && params.group !== false) {
1227
- forwarded.groupTitle = sessionGroupTitle(ctx);
1228
- }
1229
- const result = await authorizedBridgeSend(`tab.${params.action}`, forwarded, DEFAULT_TIMEOUT_MS, signal);
1230
- if (params.action === "list") {
1231
- const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number; group?: { title?: string } | null }>;
1232
- const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
1233
- return { content: [{ type: "text", text }], details: { tabs } };
1234
- }
1235
- return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
1236
- },
1237
- });
1387
+ pi.registerTool({
1388
+ name: "chrome_launch",
1389
+ label: "Chrome Bridge Setup",
1390
+ description:
1391
+ "Start/check the local bridge used by the companion Chrome extension. This does not launch a separate Chrome profile; install the unpacked Chrome extension in your existing Chrome profile to connect.",
1392
+ promptSnippet:
1393
+ "Show instructions for connecting Pi to the user's existing Chrome profile via the companion extension.",
1394
+ parameters: Type.Object({
1395
+ port: Type.Optional(
1396
+ Type.Number({ description: "Ignored. The bundled Chrome extension polls 127.0.0.1:17318." }),
1397
+ ),
1398
+ url: Type.Optional(
1399
+ Type.String({
1400
+ description: "Optional URL to open in the existing Chrome profile after the extension is connected.",
1401
+ }),
1402
+ ),
1403
+ userDataDir: Type.Optional(
1404
+ Type.String({
1405
+ description:
1406
+ "Ignored. This bridge intentionally uses the user's existing Chrome profile through the companion extension.",
1407
+ }),
1408
+ ),
1409
+ useDefaultProfile: Type.Optional(
1410
+ Type.Boolean({
1411
+ description: "Ignored; existing-profile access comes from the companion Chrome extension.",
1412
+ }),
1413
+ ),
1414
+ headless: Type.Optional(Type.Boolean({ description: "Ignored." })),
1415
+ }),
1416
+ async execute(_id, params, signal, _onUpdate, _ctx): Promise<ToolTextResult> {
1417
+ if (params.url && bridge.connected) {
1418
+ const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS, signal);
1419
+ return {
1420
+ content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }],
1421
+ details: { status: bridge.status(), result },
1422
+ };
1423
+ }
1424
+ return {
1425
+ content: [
1426
+ {
1427
+ type: "text",
1428
+ text:
1429
+ `Chrome profile bridge is listening at ${bridge.url}.\n\n` +
1430
+ `To connect your existing Chrome profile:\n` +
1431
+ `1. Open chrome://extensions in the Chrome profile you normally use.\n` +
1432
+ `2. Enable Developer mode.\n` +
1433
+ `3. Click “Load unpacked”.\n` +
1434
+ `4. Select: ${browserExtensionPath()}\n\n` +
1435
+ `Status: ${bridge.connected ? "connected" : "waiting for extension"}.`,
1436
+ },
1437
+ ],
1438
+ details: { status: bridge.status(), extensionPath: browserExtensionPath() },
1439
+ };
1440
+ },
1441
+ });
1238
1442
 
1239
- pi.registerTool({
1240
- name: "chrome_snapshot",
1241
- label: "Chrome Snapshot",
1242
- description:
1243
- "Inspect a page in the user's existing Chrome profile. Default output is a concise, agent-friendly observation with structural layout/context, stable uids, visible actions, form fields, page hints, and changes since the previous snapshot. Use mode/query/nearUid to zoom instead of dumping the whole page. Runs in the background by default; pass background=false to bring Chrome to the foreground so the user can watch.",
1244
- promptSnippet: "Observe the current Chrome page: concise summary, structural layout, visible actions, forms, page map, query matches, and stable uids.",
1245
- parameters: Type.Object({
1246
- targetId: Type.Optional(Type.String()),
1247
- urlIncludes: Type.Optional(Type.String()),
1248
- titleIncludes: Type.Optional(Type.String()),
1249
- maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS })),
1250
- mode: Type.Optional(StringEnum(snapshotModeValues)),
1251
- query: Type.Optional(Type.String({ description: "Find/rank elements, regions, and text matching this phrase, e.g. 'merge button', 'email error', 'approve PR'." })),
1252
- maxTextChars: Type.Optional(Type.Number({ description: "Max body text chars included in the underlying snapshot. Defaults are smaller for concise modes." })),
1253
- containingText: Type.Optional(Type.String({ description: "Only return elements whose label/text contains this string (case-insensitive). Useful when the page has many controls." })),
1254
- roleFilter: Type.Optional(Type.String({ description: "Only return elements matching this ARIA role or tag name (case-insensitive). e.g. 'button', 'link', 'textbox'." })),
1255
- nearUid: Type.Optional(Type.String({ description: "Sort elements by proximity to this snapshot uid. Useful for finding controls near a known anchor." })),
1256
- background: Type.Optional(
1257
- Type.Boolean({ description: "If true (the default), run silently in the background without focusing Chrome; pass false so Chrome focuses + the tab activates and the user can watch." }),
1258
- ),
1259
- host: Type.Optional(Type.String()),
1260
- port: Type.Optional(Type.Number()),
1261
- }),
1262
- async execute(_id, params, signal): Promise<ToolTextResult> {
1263
- const snapshot = await authorizedBridgeSend(
1264
- "page.snapshot",
1265
- withBackground({ ...params, maxElements: params.maxElements ?? MAX_ELEMENTS }),
1266
- DEFAULT_TIMEOUT_MS,
1267
- signal,
1268
- );
1269
- return { content: [{ type: "text", text: formatChromeSnapshot(snapshot) }], details: { snapshot } };
1270
- },
1271
- });
1443
+ pi.registerTool({
1444
+ name: "chrome_tab",
1445
+ label: "Chrome Tab",
1446
+ description:
1447
+ "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.",
1448
+ promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
1449
+ parameters: Type.Object({
1450
+ action: StringEnum(tabActionValues),
1451
+ url: Type.Optional(Type.String({ description: "URL for action=new." })),
1452
+ targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
1453
+ urlIncludes: Type.Optional(
1454
+ Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." }),
1455
+ ),
1456
+ titleIncludes: Type.Optional(
1457
+ Type.String({
1458
+ description: "Match the target tab by title substring for activate/close/group/ungroup.",
1459
+ }),
1460
+ ),
1461
+ group: Type.Optional(
1462
+ Type.Boolean({
1463
+ description:
1464
+ "action=new only: pass false to open an ungrouped tab. By default every Pi-opened tab joins this session's own tab group.",
1465
+ }),
1466
+ ),
1467
+ groupTitle: Type.Optional(
1468
+ Type.String({
1469
+ description:
1470
+ "Tab group title for action=group/new. Defaults to this Pi session's group ('Pi Session: <name-or-id>'). Pass an empty string on action=new to opt out of grouping.",
1471
+ }),
1472
+ ),
1473
+ groupColor: Type.Optional(
1474
+ Type.String({
1475
+ description:
1476
+ "Tab group color for action=group/new: grey, blue, red, yellow, green, pink, purple, cyan, or orange. Defaults to blue.",
1477
+ }),
1478
+ ),
1479
+ host: Type.Optional(Type.String()),
1480
+ port: Type.Optional(Type.Number()),
1481
+ }),
1482
+ async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1483
+ const forwarded = { ...params } as typeof params & { groupTitle?: string };
1484
+ // Default every Pi-opened/explicitly-grouped tab into this session's own group,
1485
+ // named after the session display name (falling back to the session id), unless
1486
+ // the caller specified a group title or opted out with group:false.
1487
+ if (
1488
+ (params.action === "new" || params.action === "group") &&
1489
+ params.groupTitle === undefined &&
1490
+ params.group !== false
1491
+ ) {
1492
+ forwarded.groupTitle = sessionGroupTitle(ctx);
1493
+ }
1494
+ const result = await authorizedBridgeSend(`tab.${params.action}`, forwarded, DEFAULT_TIMEOUT_MS, signal);
1495
+ if (params.action === "list") {
1496
+ const tabs = result as Array<{
1497
+ id: number;
1498
+ title: string;
1499
+ url: string;
1500
+ active: boolean;
1501
+ windowId: number;
1502
+ group?: { title?: string } | null;
1503
+ }>;
1504
+ const text =
1505
+ tabs
1506
+ .map(
1507
+ (tab) =>
1508
+ `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`,
1509
+ )
1510
+ .join("\n") || "No tabs.";
1511
+ return { content: [{ type: "text", text }], details: { tabs } };
1512
+ }
1513
+ return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
1514
+ },
1515
+ });
1272
1516
 
1273
- pi.registerTool({
1274
- name: "chrome_find",
1275
- label: "Chrome Find",
1276
- description:
1277
- "Find elements, page regions, or text on the current Chrome page by query. Returns ranked matches with stable uids and coordinates. This is a focused wrapper around chrome_snapshot({ query }).",
1278
- promptSnippet: "Find matching controls/text/regions in Chrome by natural-language query and return stable uids.",
1279
- parameters: Type.Object({
1280
- query: Type.String({ description: "What to find, e.g. 'merge button', 'email error', 'approve PR', 'search box'." }),
1281
- mode: Type.Optional(StringEnum(snapshotModeValues)),
1282
- maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS })),
1283
- targetId: Type.Optional(Type.String()),
1284
- urlIncludes: Type.Optional(Type.String()),
1285
- titleIncludes: Type.Optional(Type.String()),
1286
- background: Type.Optional(
1287
- Type.Boolean({ description: "If true (the default), run silently in the background without focusing Chrome; pass false so Chrome focuses + the tab activates and the user can watch." }),
1288
- ),
1289
- host: Type.Optional(Type.String()),
1290
- port: Type.Optional(Type.Number()),
1291
- }),
1292
- async execute(_id, params, signal): Promise<ToolTextResult> {
1293
- const snapshot = await authorizedBridgeSend(
1294
- "page.snapshot",
1295
- withBackground({ ...params, mode: params.mode || "auto", maxElements: params.maxElements ?? MAX_ELEMENTS }),
1296
- DEFAULT_TIMEOUT_MS,
1297
- signal,
1298
- );
1299
- return { content: [{ type: "text", text: formatChromeSnapshot(snapshot) }], details: { snapshot } };
1300
- },
1301
- });
1517
+ pi.registerTool({
1518
+ name: "chrome_snapshot",
1519
+ label: "Chrome Snapshot",
1520
+ description:
1521
+ "Inspect a page in the user's existing Chrome profile. Default output is a concise, agent-friendly observation with structural layout/context, stable uids, visible actions, form fields, page hints, and changes since the previous snapshot. Use mode/query/nearUid to zoom instead of dumping the whole page. Runs in the background by default; pass background=false to bring Chrome to the foreground so the user can watch.",
1522
+ promptSnippet:
1523
+ "Observe the current Chrome page: concise summary, structural layout, visible actions, forms, page map, query matches, and stable uids.",
1524
+ parameters: Type.Object({
1525
+ targetId: Type.Optional(Type.String()),
1526
+ urlIncludes: Type.Optional(Type.String()),
1527
+ titleIncludes: Type.Optional(Type.String()),
1528
+ maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS })),
1529
+ mode: Type.Optional(StringEnum(snapshotModeValues)),
1530
+ query: Type.Optional(
1531
+ Type.String({
1532
+ description:
1533
+ "Find/rank elements, regions, and text matching this phrase, e.g. 'merge button', 'email error', 'approve PR'.",
1534
+ }),
1535
+ ),
1536
+ maxTextChars: Type.Optional(
1537
+ Type.Number({
1538
+ description:
1539
+ "Max body text chars included in the underlying snapshot. Defaults are smaller for concise modes.",
1540
+ }),
1541
+ ),
1542
+ containingText: Type.Optional(
1543
+ Type.String({
1544
+ description:
1545
+ "Only return elements whose label/text contains this string (case-insensitive). Useful when the page has many controls.",
1546
+ }),
1547
+ ),
1548
+ roleFilter: Type.Optional(
1549
+ Type.String({
1550
+ description:
1551
+ "Only return elements matching this ARIA role or tag name (case-insensitive). e.g. 'button', 'link', 'textbox'.",
1552
+ }),
1553
+ ),
1554
+ nearUid: Type.Optional(
1555
+ Type.String({
1556
+ description:
1557
+ "Sort elements by proximity to this snapshot uid. Useful for finding controls near a known anchor.",
1558
+ }),
1559
+ ),
1560
+ background: Type.Optional(
1561
+ Type.Boolean({
1562
+ description:
1563
+ "If true (the default), run silently in the background without focusing Chrome; pass false so Chrome focuses + the tab activates and the user can watch.",
1564
+ }),
1565
+ ),
1566
+ host: Type.Optional(Type.String()),
1567
+ port: Type.Optional(Type.Number()),
1568
+ }),
1569
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1570
+ const snapshot = await authorizedBridgeSend(
1571
+ "page.snapshot",
1572
+ withBackground({ ...params, maxElements: params.maxElements ?? MAX_ELEMENTS }),
1573
+ DEFAULT_TIMEOUT_MS,
1574
+ signal,
1575
+ );
1576
+ return { content: [{ type: "text", text: formatChromeSnapshot(snapshot) }], details: { snapshot } };
1577
+ },
1578
+ });
1302
1579
 
1303
- pi.registerTool({
1304
- name: "chrome_inspect",
1305
- label: "Chrome Inspect Element",
1306
- description:
1307
- "Inspect one snapshot uid or selector deeply: nearby text, nearby actions, form context, ancestors, and suggested click target. Use after chrome_snapshot/chrome_find when you need context around one element.",
1308
- promptSnippet: "Inspect a Chrome snapshot uid deeply for nearby text, form context, and suggested actions.",
1309
- parameters: Type.Object({
1310
- uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot/chrome_find." })),
1311
- selector: Type.Optional(Type.String({ description: "CSS selector if uid is unavailable." })),
1312
- scrollIntoView: Type.Optional(Type.Boolean({ description: "If true, scroll the target into view before inspecting. Default false to avoid changing page state." })),
1313
- targetId: Type.Optional(Type.String()),
1314
- urlIncludes: Type.Optional(Type.String()),
1315
- titleIncludes: Type.Optional(Type.String()),
1316
- background: Type.Optional(
1317
- Type.Boolean({ description: "If true (the default), run silently in the background without focusing Chrome; pass false so Chrome focuses + the tab activates and the user can watch." }),
1318
- ),
1319
- host: Type.Optional(Type.String()),
1320
- port: Type.Optional(Type.Number()),
1321
- }),
1322
- async execute(_id, params, signal): Promise<ToolTextResult> {
1323
- try {
1324
- const inspect = await authorizedBridgeSend("page.inspect", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1325
- return { content: [{ type: "text", text: formatChromeInspect(inspect) }], details: { inspect } };
1326
- } catch (error) {
1327
- const message = error instanceof Error ? error.message : String(error);
1328
- if (!/Unknown action: page\.inspect/i.test(message)) throw error;
1329
- // Compatibility fallback for a loaded Chrome extension service worker that has not
1330
- // been reloaded since chrome_inspect was added. It is less rich than page.inspect,
1331
- // but still gives useful nearby candidates instead of failing the workflow.
1580
+ pi.registerTool({
1581
+ name: "chrome_find",
1582
+ label: "Chrome Find",
1583
+ description:
1584
+ "Find elements, page regions, or text on the current Chrome page by query. Returns ranked matches with stable uids and coordinates. This is a focused wrapper around chrome_snapshot({ query }).",
1585
+ promptSnippet:
1586
+ "Find matching controls/text/regions in Chrome by natural-language query and return stable uids.",
1587
+ parameters: Type.Object({
1588
+ query: Type.String({
1589
+ description: "What to find, e.g. 'merge button', 'email error', 'approve PR', 'search box'.",
1590
+ }),
1591
+ mode: Type.Optional(StringEnum(snapshotModeValues)),
1592
+ maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS })),
1593
+ targetId: Type.Optional(Type.String()),
1594
+ urlIncludes: Type.Optional(Type.String()),
1595
+ titleIncludes: Type.Optional(Type.String()),
1596
+ background: Type.Optional(
1597
+ Type.Boolean({
1598
+ description:
1599
+ "If true (the default), run silently in the background without focusing Chrome; pass false so Chrome focuses + the tab activates and the user can watch.",
1600
+ }),
1601
+ ),
1602
+ host: Type.Optional(Type.String()),
1603
+ port: Type.Optional(Type.Number()),
1604
+ }),
1605
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1332
1606
  const snapshot = await authorizedBridgeSend(
1333
1607
  "page.snapshot",
1334
1608
  withBackground({
1335
1609
  ...params,
1336
- mode: "interactive",
1337
- maxElements: MAX_ELEMENTS,
1338
- nearUid: params.uid,
1339
- query: params.selector,
1610
+ mode: params.mode || "auto",
1611
+ maxElements: params.maxElements ?? MAX_ELEMENTS,
1340
1612
  }),
1341
1613
  DEFAULT_TIMEOUT_MS,
1342
1614
  signal,
1343
1615
  );
1344
- const text = `chrome_inspect fallback: loaded Chrome extension does not yet support page.inspect; reload it at chrome://extensions for deep inspect.\n\n${formatChromeSnapshot(snapshot)}`;
1345
- return { content: [{ type: "text", text }], details: { snapshot, fallback: "page.snapshot" } };
1346
- }
1347
- },
1348
- });
1616
+ return { content: [{ type: "text", text: formatChromeSnapshot(snapshot) }], details: { snapshot } };
1617
+ },
1618
+ });
1349
1619
 
1350
- pi.registerTool({
1351
- name: "chrome_navigate",
1352
- label: "Chrome Navigate",
1353
- description:
1354
- "Navigate an existing Chrome tab to a URL via the companion extension. Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Optionally waits for load completion.",
1355
- promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
1356
- parameters: Type.Object({
1357
- url: Type.String(),
1358
- targetId: Type.Optional(Type.String()),
1359
- urlIncludes: Type.Optional(Type.String()),
1360
- titleIncludes: Type.Optional(Type.String()),
1361
- waitUntilLoad: Type.Optional(Type.Boolean({ default: true })),
1362
- timeoutMs: Type.Optional(Type.Number({ default: 15_000 })),
1363
- initScript: Type.Optional(Type.String({ description: "Optional JavaScript source to run in MAIN world at document_start of the next navigation. Useful for seeding localStorage, stubbing Date.now(), or defining navigator.webdriver=undefined. Requires the companion extension's webNavigation permission." })),
1364
- background: Type.Optional(
1365
- Type.Boolean({ description: "If true, navigate silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." }),
1366
- ),
1367
- host: Type.Optional(Type.String()),
1368
- port: Type.Optional(Type.Number()),
1369
- }),
1370
- async execute(_id, params, signal): Promise<ToolTextResult> {
1371
- const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000, signal);
1372
- return { content: [{ type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` }], details: { result: result as Json } };
1373
- },
1374
- });
1620
+ pi.registerTool({
1621
+ name: "chrome_inspect",
1622
+ label: "Chrome Inspect Element",
1623
+ description:
1624
+ "Inspect one snapshot uid or selector deeply: nearby text, nearby actions, form context, ancestors, and suggested click target. Use after chrome_snapshot/chrome_find when you need context around one element.",
1625
+ promptSnippet: "Inspect a Chrome snapshot uid deeply for nearby text, form context, and suggested actions.",
1626
+ parameters: Type.Object({
1627
+ uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot/chrome_find." })),
1628
+ selector: Type.Optional(Type.String({ description: "CSS selector if uid is unavailable." })),
1629
+ scrollIntoView: Type.Optional(
1630
+ Type.Boolean({
1631
+ description:
1632
+ "If true, scroll the target into view before inspecting. Default false to avoid changing page state.",
1633
+ }),
1634
+ ),
1635
+ targetId: Type.Optional(Type.String()),
1636
+ urlIncludes: Type.Optional(Type.String()),
1637
+ titleIncludes: Type.Optional(Type.String()),
1638
+ background: Type.Optional(
1639
+ Type.Boolean({
1640
+ description:
1641
+ "If true (the default), run silently in the background without focusing Chrome; pass false so Chrome focuses + the tab activates and the user can watch.",
1642
+ }),
1643
+ ),
1644
+ host: Type.Optional(Type.String()),
1645
+ port: Type.Optional(Type.Number()),
1646
+ }),
1647
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1648
+ try {
1649
+ const inspect = await authorizedBridgeSend(
1650
+ "page.inspect",
1651
+ withBackground(params),
1652
+ DEFAULT_TIMEOUT_MS,
1653
+ signal,
1654
+ );
1655
+ return { content: [{ type: "text", text: formatChromeInspect(inspect) }], details: { inspect } };
1656
+ } catch (error) {
1657
+ const message = error instanceof Error ? error.message : String(error);
1658
+ if (!/Unknown action: page\.inspect/i.test(message)) throw error;
1659
+ // Compatibility fallback for a loaded Chrome extension service worker that has not
1660
+ // been reloaded since chrome_inspect was added. It is less rich than page.inspect,
1661
+ // but still gives useful nearby candidates instead of failing the workflow.
1662
+ const snapshot = await authorizedBridgeSend(
1663
+ "page.snapshot",
1664
+ withBackground({
1665
+ ...params,
1666
+ mode: "interactive",
1667
+ maxElements: MAX_ELEMENTS,
1668
+ nearUid: params.uid,
1669
+ query: params.selector,
1670
+ }),
1671
+ DEFAULT_TIMEOUT_MS,
1672
+ signal,
1673
+ );
1674
+ const text = `chrome_inspect fallback: loaded Chrome extension does not yet support page.inspect; reload it at chrome://extensions for deep inspect.\n\n${formatChromeSnapshot(snapshot)}`;
1675
+ return { content: [{ type: "text", text }], details: { snapshot, fallback: "page.snapshot" } };
1676
+ }
1677
+ },
1678
+ });
1375
1679
 
1376
- pi.registerTool({
1377
- name: "chrome_evaluate",
1378
- label: "Chrome Evaluate",
1379
- description:
1380
- "Evaluate JavaScript in an existing Chrome tab through the companion extension. Runs in the page context and returns JSON-serializable values when possible. Runs in the background by default; pass background=false to focus Chrome and activate the tab.",
1381
- promptSnippet: "Evaluate JavaScript in the active Chrome tab through the companion extension.",
1382
- parameters: Type.Object({
1383
- expression: Type.String(),
1384
- awaitPromise: Type.Optional(Type.Boolean({ default: true })),
1385
- targetId: Type.Optional(Type.String()),
1386
- urlIncludes: Type.Optional(Type.String()),
1387
- titleIncludes: Type.Optional(Type.String()),
1388
- background: Type.Optional(
1389
- Type.Boolean({ description: "If true, evaluate silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." }),
1390
- ),
1391
- host: Type.Optional(Type.String()),
1392
- port: Type.Optional(Type.Number()),
1393
- }),
1394
- async execute(_id, params, signal): Promise<ToolTextResult> {
1395
- const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1396
- const text = value === undefined
1397
- ? "undefined"
1398
- : typeof value === "string"
1399
- ? value
1400
- : safeJson(value) ?? "undefined";
1401
- return { content: [{ type: "text", text: truncateText(text) }], details: { value: value as Json } };
1402
- },
1403
- });
1680
+ pi.registerTool({
1681
+ name: "chrome_navigate",
1682
+ label: "Chrome Navigate",
1683
+ description:
1684
+ "Navigate an existing Chrome tab to a URL via the companion extension. Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Optionally waits for load completion.",
1685
+ promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
1686
+ parameters: Type.Object({
1687
+ url: Type.String(),
1688
+ targetId: Type.Optional(Type.String()),
1689
+ urlIncludes: Type.Optional(Type.String()),
1690
+ titleIncludes: Type.Optional(Type.String()),
1691
+ waitUntilLoad: Type.Optional(Type.Boolean({ default: true })),
1692
+ timeoutMs: Type.Optional(Type.Number({ default: 15_000 })),
1693
+ initScript: Type.Optional(
1694
+ Type.String({
1695
+ description:
1696
+ "Optional JavaScript source to run in MAIN world at document_start of the next navigation. Useful for seeding localStorage, stubbing Date.now(), or defining navigator.webdriver=undefined. Requires the companion extension's webNavigation permission.",
1697
+ }),
1698
+ ),
1699
+ background: Type.Optional(
1700
+ Type.Boolean({
1701
+ description:
1702
+ "If true, navigate silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
1703
+ }),
1704
+ ),
1705
+ host: Type.Optional(Type.String()),
1706
+ port: Type.Optional(Type.Number()),
1707
+ }),
1708
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1709
+ const result = await authorizedBridgeSend(
1710
+ "page.navigate",
1711
+ withBackground(params),
1712
+ (params.timeoutMs ?? 15_000) + 2_000,
1713
+ signal,
1714
+ );
1715
+ return {
1716
+ content: [
1717
+ { type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` },
1718
+ ],
1719
+ details: { result: result as Json },
1720
+ };
1721
+ },
1722
+ });
1404
1723
 
1405
- pi.registerTool({
1406
- name: "chrome_click",
1407
- label: "Chrome Click",
1408
- description:
1409
- "Click a snapshot uid, CSS selector, or viewport coordinate using Chrome's real input layer. Pass includeSnapshot=true to return a fresh snapshot after the click.",
1410
- promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
1411
- parameters: Type.Object({
1412
- uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
1413
- selector: Type.Optional(Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." })),
1414
- x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
1415
- y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
1416
- domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
1417
- includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." })),
1418
- maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1419
- targetId: Type.Optional(Type.String()),
1420
- urlIncludes: Type.Optional(Type.String()),
1421
- titleIncludes: Type.Optional(Type.String()),
1422
- background: Type.Optional(
1423
- Type.Boolean({ description: "If true, click silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." }),
1424
- ),
1425
- host: Type.Optional(Type.String()),
1426
- port: Type.Optional(Type.Number()),
1427
- }),
1428
- async execute(_id, params, signal): Promise<ToolTextResult> {
1429
- const raw = await authorizedBridgeSend("page.click", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1430
- const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1431
- const summary = summarizeActionResult(result);
1432
- const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
1433
- const text = summary ? `Clicked ${target} — ${summary}` : `Clicked ${target}`;
1434
- return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }], details: { result: raw as Json } };
1435
- },
1436
- });
1724
+ pi.registerTool({
1725
+ name: "chrome_evaluate",
1726
+ label: "Chrome Evaluate",
1727
+ description:
1728
+ "Evaluate JavaScript in an existing Chrome tab through the companion extension. Runs in the page context and returns JSON-serializable values when possible. Runs in the background by default; pass background=false to focus Chrome and activate the tab.",
1729
+ promptSnippet: "Evaluate JavaScript in the active Chrome tab through the companion extension.",
1730
+ parameters: Type.Object({
1731
+ expression: Type.String(),
1732
+ awaitPromise: Type.Optional(Type.Boolean({ default: true })),
1733
+ targetId: Type.Optional(Type.String()),
1734
+ urlIncludes: Type.Optional(Type.String()),
1735
+ titleIncludes: Type.Optional(Type.String()),
1736
+ background: Type.Optional(
1737
+ Type.Boolean({
1738
+ description:
1739
+ "If true, evaluate silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
1740
+ }),
1741
+ ),
1742
+ host: Type.Optional(Type.String()),
1743
+ port: Type.Optional(Type.Number()),
1744
+ }),
1745
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1746
+ const value = await authorizedBridgeSend(
1747
+ "page.evaluate",
1748
+ withBackground(params),
1749
+ DEFAULT_TIMEOUT_MS,
1750
+ signal,
1751
+ );
1752
+ const text =
1753
+ value === undefined ? "undefined" : typeof value === "string" ? value : (safeJson(value) ?? "undefined");
1754
+ return { content: [{ type: "text", text: truncateText(text) }], details: { value: value as Json } };
1755
+ },
1756
+ });
1437
1757
 
1438
- pi.registerTool({
1439
- name: "chrome_type",
1440
- label: "Chrome Type",
1441
- description:
1442
- "Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. Pass includeSnapshot=true to return a fresh snapshot after typing.",
1443
- promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
1444
- parameters: Type.Object({
1445
- text: Type.String(),
1446
- uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1447
- selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
1448
- includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." })),
1449
- maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1450
- pressEnter: Type.Optional(Type.Boolean()),
1451
- targetId: Type.Optional(Type.String()),
1452
- urlIncludes: Type.Optional(Type.String()),
1453
- titleIncludes: Type.Optional(Type.String()),
1454
- background: Type.Optional(
1455
- Type.Boolean({ description: "If true, type silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." }),
1456
- ),
1457
- host: Type.Optional(Type.String()),
1458
- port: Type.Optional(Type.Number()),
1459
- }),
1460
- async execute(_id, params, signal): Promise<ToolTextResult> {
1461
- const raw = await authorizedBridgeSend("page.type", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1462
- const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1463
- const summary = summarizeActionResult(result);
1464
- const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
1465
- const base = `Typed ${params.text.length} character(s)${into}.`;
1466
- const text = summary ? `${base} (${summary})` : base;
1467
- return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }], details: { result: raw as Json } };
1468
- },
1469
- });
1758
+ pi.registerTool({
1759
+ name: "chrome_click",
1760
+ label: "Chrome Click",
1761
+ description:
1762
+ "Click a snapshot uid, CSS selector, or viewport coordinate using Chrome's real input layer. Pass includeSnapshot=true to return a fresh snapshot after the click.",
1763
+ promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
1764
+ parameters: Type.Object({
1765
+ uid: Type.Optional(
1766
+ Type.String({
1767
+ description:
1768
+ "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot.",
1769
+ }),
1770
+ ),
1771
+ selector: Type.Optional(
1772
+ Type.String({ description: "CSS selector to click. Prefer uid from chrome_snapshot when available." }),
1773
+ ),
1774
+ x: Type.Optional(Type.Number({ description: "Viewport x coordinate if uid/selector is omitted." })),
1775
+ y: Type.Optional(Type.Number({ description: "Viewport y coordinate if uid/selector is omitted." })),
1776
+ domFallback: Type.Optional(
1777
+ Type.Boolean({
1778
+ description:
1779
+ "If true (default), fall back to DOM-dispatched click if Chrome's CDP input path is blocked by another extension overlay or debugger failure.",
1780
+ }),
1781
+ ),
1782
+ includeSnapshot: Type.Optional(
1783
+ Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the click." }),
1784
+ ),
1785
+ maxElements: Type.Optional(
1786
+ Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." }),
1787
+ ),
1788
+ targetId: Type.Optional(Type.String()),
1789
+ urlIncludes: Type.Optional(Type.String()),
1790
+ titleIncludes: Type.Optional(Type.String()),
1791
+ background: Type.Optional(
1792
+ Type.Boolean({
1793
+ description:
1794
+ "If true, click silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
1795
+ }),
1796
+ ),
1797
+ host: Type.Optional(Type.String()),
1798
+ port: Type.Optional(Type.Number()),
1799
+ }),
1800
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1801
+ const raw = await authorizedBridgeSend("page.click", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1802
+ const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1803
+ const summary = summarizeActionResult(result);
1804
+ const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
1805
+ const text = summary ? `Clicked ${target} — ${summary}` : `Clicked ${target}`;
1806
+ return {
1807
+ content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }],
1808
+ details: { result: raw as Json },
1809
+ };
1810
+ },
1811
+ });
1470
1812
 
1471
- pi.registerTool({
1472
- name: "chrome_fill",
1473
- label: "Chrome Fill",
1474
- description:
1475
- "Set the full value of a text input, textarea, or contenteditable element using Chrome click/select/delete/type input. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
1476
- promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
1477
- parameters: Type.Object({
1478
- text: Type.String(),
1479
- uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1480
- selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
1481
- submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
1482
- domFallback: Type.Optional(Type.Boolean({ description: "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure." })),
1483
- includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." })),
1484
- maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1485
- targetId: Type.Optional(Type.String()),
1486
- urlIncludes: Type.Optional(Type.String()),
1487
- titleIncludes: Type.Optional(Type.String()),
1488
- background: Type.Optional(
1489
- Type.Boolean({ description: "If true, fill silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." }),
1490
- ),
1491
- host: Type.Optional(Type.String()),
1492
- port: Type.Optional(Type.Number()),
1493
- }),
1494
- async execute(_id, params, signal): Promise<ToolTextResult> {
1495
- const raw = await authorizedBridgeSend("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1496
- const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1497
- const summary = summarizeActionResult(result);
1498
- const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
1499
- const base = `Filled ${params.text.length} character(s)${into}.`;
1500
- const text = summary ? `${base} (${summary})` : base;
1501
- return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }], details: { result: raw as Json } };
1502
- },
1503
- });
1813
+ pi.registerTool({
1814
+ name: "chrome_type",
1815
+ label: "Chrome Type",
1816
+ description:
1817
+ "Focus an optional snapshot uid or CSS selector, then type text using Chrome's real keyboard input. Pass includeSnapshot=true to return a fresh snapshot after typing.",
1818
+ promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
1819
+ parameters: Type.Object({
1820
+ text: Type.String(),
1821
+ uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1822
+ selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
1823
+ includeSnapshot: Type.Optional(
1824
+ Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." }),
1825
+ ),
1826
+ maxElements: Type.Optional(
1827
+ Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." }),
1828
+ ),
1829
+ pressEnter: Type.Optional(Type.Boolean()),
1830
+ targetId: Type.Optional(Type.String()),
1831
+ urlIncludes: Type.Optional(Type.String()),
1832
+ titleIncludes: Type.Optional(Type.String()),
1833
+ background: Type.Optional(
1834
+ Type.Boolean({
1835
+ description:
1836
+ "If true, type silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
1837
+ }),
1838
+ ),
1839
+ host: Type.Optional(Type.String()),
1840
+ port: Type.Optional(Type.Number()),
1841
+ }),
1842
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1843
+ const raw = await authorizedBridgeSend("page.type", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1844
+ const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1845
+ const summary = summarizeActionResult(result);
1846
+ const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
1847
+ const base = `Typed ${params.text.length} character(s)${into}.`;
1848
+ const text = summary ? `${base} (${summary})` : base;
1849
+ return {
1850
+ content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }],
1851
+ details: { result: raw as Json },
1852
+ };
1853
+ },
1854
+ });
1504
1855
 
1505
- pi.registerTool({
1506
- name: "chrome_key",
1507
- label: "Chrome Key",
1508
- description:
1509
- "Send a keyboard key to an existing Chrome tab (Enter, Escape, Tab, Backspace, Delete, ArrowUp/Down/Left/Right, or one character). Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Pass includeSnapshot=true to verify after the keypress.",
1510
- promptSnippet: "Press keys in Chrome through the companion extension.",
1511
- parameters: Type.Object({
1512
- key: Type.String(),
1513
- modifiers: Type.Optional(Type.Object({
1514
- shiftKey: Type.Optional(Type.Boolean()),
1515
- ctrlKey: Type.Optional(Type.Boolean()),
1516
- altKey: Type.Optional(Type.Boolean()),
1517
- metaKey: Type.Optional(Type.Boolean()),
1518
- }, { description: "Modifier keys to hold while pressing the key (chord)." })),
1519
- includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." })),
1520
- maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
1521
- targetId: Type.Optional(Type.String()),
1522
- urlIncludes: Type.Optional(Type.String()),
1523
- titleIncludes: Type.Optional(Type.String()),
1524
- background: Type.Optional(
1525
- Type.Boolean({ description: "If true, send the key silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." }),
1526
- ),
1527
- host: Type.Optional(Type.String()),
1528
- port: Type.Optional(Type.Number()),
1529
- }),
1530
- async execute(_id, params, signal): Promise<ToolTextResult> {
1531
- const raw = await authorizedBridgeSend("page.key", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1532
- const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1533
- const summary = summarizeActionResult(result);
1534
- const base = `Pressed ${params.key}.`;
1535
- const text = summary ? `${base} (${summary})` : base;
1536
- return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }], details: { result: raw as Json } };
1537
- },
1538
- });
1856
+ pi.registerTool({
1857
+ name: "chrome_fill",
1858
+ label: "Chrome Fill",
1859
+ description:
1860
+ "Set the full value of a text input, textarea, or contenteditable element using Chrome click/select/delete/type input. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
1861
+ promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
1862
+ parameters: Type.Object({
1863
+ text: Type.String(),
1864
+ uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1865
+ selector: Type.Optional(Type.String({ description: "CSS selector to fill if uid is omitted." })),
1866
+ submit: Type.Optional(Type.Boolean({ description: "If true, press Enter after filling." })),
1867
+ domFallback: Type.Optional(
1868
+ Type.Boolean({
1869
+ description:
1870
+ "If true (default), fall back to DOM value-setting if Chrome's CDP input path is blocked by another extension overlay or debugger failure.",
1871
+ }),
1872
+ ),
1873
+ includeSnapshot: Type.Optional(
1874
+ Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after filling." }),
1875
+ ),
1876
+ maxElements: Type.Optional(
1877
+ Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." }),
1878
+ ),
1879
+ targetId: Type.Optional(Type.String()),
1880
+ urlIncludes: Type.Optional(Type.String()),
1881
+ titleIncludes: Type.Optional(Type.String()),
1882
+ background: Type.Optional(
1883
+ Type.Boolean({
1884
+ description:
1885
+ "If true, fill silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
1886
+ }),
1887
+ ),
1888
+ host: Type.Optional(Type.String()),
1889
+ port: Type.Optional(Type.Number()),
1890
+ }),
1891
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1892
+ const raw = await authorizedBridgeSend("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1893
+ const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1894
+ const summary = summarizeActionResult(result);
1895
+ const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
1896
+ const base = `Filled ${params.text.length} character(s)${into}.`;
1897
+ const text = summary ? `${base} (${summary})` : base;
1898
+ return {
1899
+ content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }],
1900
+ details: { result: raw as Json },
1901
+ };
1902
+ },
1903
+ });
1539
1904
 
1540
- pi.registerTool({
1541
- name: "chrome_wait_for",
1542
- label: "Chrome Wait For",
1543
- description: "Poll an existing Chrome tab until a selector exists or a JavaScript expression returns truthy.",
1544
- promptSnippet: "Wait for page state in Chrome before further automation.",
1545
- parameters: Type.Object({
1546
- kind: StringEnum(waitForValues),
1547
- value: Type.String({ description: "CSS selector when kind=selector; JavaScript expression when kind=expression." }),
1548
- timeoutMs: Type.Optional(Type.Number({ default: 10_000 })),
1549
- intervalMs: Type.Optional(Type.Number({ default: 250 })),
1550
- targetId: Type.Optional(Type.String()),
1551
- urlIncludes: Type.Optional(Type.String()),
1552
- titleIncludes: Type.Optional(Type.String()),
1553
- host: Type.Optional(Type.String()),
1554
- port: Type.Optional(Type.Number()),
1555
- }),
1556
- async execute(_id, params, signal): Promise<ToolTextResult> {
1557
- const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000, signal);
1558
- return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
1559
- },
1560
- });
1905
+ pi.registerTool({
1906
+ name: "chrome_key",
1907
+ label: "Chrome Key",
1908
+ description:
1909
+ "Send a keyboard key to an existing Chrome tab (Enter, Escape, Tab, Backspace, Delete, ArrowUp/Down/Left/Right, or one character). Runs in the background by default; pass background=false to focus Chrome and activate the tab so the user can watch. Pass includeSnapshot=true to verify after the keypress.",
1910
+ promptSnippet: "Press keys in Chrome through the companion extension.",
1911
+ parameters: Type.Object({
1912
+ key: Type.String(),
1913
+ modifiers: Type.Optional(
1914
+ Type.Object(
1915
+ {
1916
+ shiftKey: Type.Optional(Type.Boolean()),
1917
+ ctrlKey: Type.Optional(Type.Boolean()),
1918
+ altKey: Type.Optional(Type.Boolean()),
1919
+ metaKey: Type.Optional(Type.Boolean()),
1920
+ },
1921
+ { description: "Modifier keys to hold while pressing the key (chord)." },
1922
+ ),
1923
+ ),
1924
+ includeSnapshot: Type.Optional(
1925
+ Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after the keypress." }),
1926
+ ),
1927
+ maxElements: Type.Optional(
1928
+ Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." }),
1929
+ ),
1930
+ targetId: Type.Optional(Type.String()),
1931
+ urlIncludes: Type.Optional(Type.String()),
1932
+ titleIncludes: Type.Optional(Type.String()),
1933
+ background: Type.Optional(
1934
+ Type.Boolean({
1935
+ description:
1936
+ "If true, send the key silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
1937
+ }),
1938
+ ),
1939
+ host: Type.Optional(Type.String()),
1940
+ port: Type.Optional(Type.Number()),
1941
+ }),
1942
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1943
+ const raw = await authorizedBridgeSend("page.key", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1944
+ const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1945
+ const summary = summarizeActionResult(result);
1946
+ const base = `Pressed ${params.key}.`;
1947
+ const text = summary ? `${base} (${summary})` : base;
1948
+ return {
1949
+ content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }],
1950
+ details: { result: raw as Json },
1951
+ };
1952
+ },
1953
+ });
1561
1954
 
1562
- pi.registerTool({
1563
- name: "chrome_list_console_messages",
1564
- label: "Chrome Console Messages",
1565
- description:
1566
- "List console messages captured in the page by the companion extension. Capture starts after any chrome_snapshot, chrome_evaluate, chrome_list_console_messages, or chrome_list_network_requests call installs page instrumentation.",
1567
- promptSnippet: "List captured console messages from the active Chrome page.",
1568
- parameters: Type.Object({
1569
- clear: Type.Optional(Type.Boolean({ description: "Clear the captured console log after reading." })),
1570
- targetId: Type.Optional(Type.String()),
1571
- urlIncludes: Type.Optional(Type.String()),
1572
- titleIncludes: Type.Optional(Type.String()),
1573
- background: Type.Optional(Type.Boolean({ description: "If true, run silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." })),
1574
- host: Type.Optional(Type.String()),
1575
- port: Type.Optional(Type.Number()),
1576
- }),
1577
- async execute(_id, params, signal): Promise<ToolTextResult> {
1578
- const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1579
- return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
1580
- },
1581
- });
1955
+ pi.registerTool({
1956
+ name: "chrome_wait_for",
1957
+ label: "Chrome Wait For",
1958
+ description: "Poll an existing Chrome tab until a selector exists or a JavaScript expression returns truthy.",
1959
+ promptSnippet: "Wait for page state in Chrome before further automation.",
1960
+ parameters: Type.Object({
1961
+ kind: StringEnum(waitForValues),
1962
+ value: Type.String({
1963
+ description: "CSS selector when kind=selector; JavaScript expression when kind=expression.",
1964
+ }),
1965
+ timeoutMs: Type.Optional(Type.Number({ default: 10_000 })),
1966
+ intervalMs: Type.Optional(Type.Number({ default: 250 })),
1967
+ targetId: Type.Optional(Type.String()),
1968
+ urlIncludes: Type.Optional(Type.String()),
1969
+ titleIncludes: Type.Optional(Type.String()),
1970
+ host: Type.Optional(Type.String()),
1971
+ port: Type.Optional(Type.Number()),
1972
+ }),
1973
+ async execute(_id, params, signal): Promise<ToolTextResult> {
1974
+ const result = await authorizedBridgeSend(
1975
+ "page.waitFor",
1976
+ params,
1977
+ (params.timeoutMs ?? 10_000) + 2_000,
1978
+ signal,
1979
+ );
1980
+ return {
1981
+ content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }],
1982
+ details: { result: result as Json },
1983
+ };
1984
+ },
1985
+ });
1582
1986
 
1583
- pi.registerTool({
1584
- name: "chrome_list_network_requests",
1585
- label: "Chrome Network Requests",
1586
- description:
1587
- "List fetch/XMLHttpRequest activity captured in the page by the companion extension. Capture starts after instrumentation is installed by snapshot/evaluate/network/console tools; browser document/static asset requests are not captured. Use includePreservedRequests=true to keep requests from earlier same-tab navigations that were captured before navigation.",
1588
- promptSnippet: "List captured XHR/fetch requests from the active Chrome page before doing DOM-heavy debugging.",
1589
- parameters: Type.Object({
1590
- includePreservedRequests: Type.Optional(Type.Boolean({ description: "Include captured requests from earlier locations in the same tab/session." })),
1591
- clear: Type.Optional(Type.Boolean({ description: "Clear the captured request log after reading." })),
1592
- targetId: Type.Optional(Type.String()),
1593
- urlIncludes: Type.Optional(Type.String()),
1594
- titleIncludes: Type.Optional(Type.String()),
1595
- background: Type.Optional(Type.Boolean({ description: "If true, run silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." })),
1596
- host: Type.Optional(Type.String()),
1597
- port: Type.Optional(Type.Number()),
1598
- }),
1599
- async execute(_id, params, signal): Promise<ToolTextResult> {
1600
- const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1601
- return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
1602
- },
1603
- });
1987
+ pi.registerTool({
1988
+ name: "chrome_list_console_messages",
1989
+ label: "Chrome Console Messages",
1990
+ description:
1991
+ "List console messages captured in the page by the companion extension. Capture starts after any chrome_snapshot, chrome_evaluate, chrome_list_console_messages, or chrome_list_network_requests call installs page instrumentation.",
1992
+ promptSnippet: "List captured console messages from the active Chrome page.",
1993
+ parameters: Type.Object({
1994
+ clear: Type.Optional(Type.Boolean({ description: "Clear the captured console log after reading." })),
1995
+ targetId: Type.Optional(Type.String()),
1996
+ urlIncludes: Type.Optional(Type.String()),
1997
+ titleIncludes: Type.Optional(Type.String()),
1998
+ background: Type.Optional(
1999
+ Type.Boolean({
2000
+ description:
2001
+ "If true, run silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
2002
+ }),
2003
+ ),
2004
+ host: Type.Optional(Type.String()),
2005
+ port: Type.Optional(Type.Number()),
2006
+ }),
2007
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2008
+ const result = await authorizedBridgeSend(
2009
+ "page.console.list",
2010
+ withBackground(params),
2011
+ DEFAULT_TIMEOUT_MS,
2012
+ signal,
2013
+ );
2014
+ return {
2015
+ content: [{ type: "text", text: truncateText(safeJson(result)) }],
2016
+ details: { result: result as Json },
2017
+ };
2018
+ },
2019
+ });
1604
2020
 
1605
- pi.registerTool({
1606
- name: "chrome_get_network_request",
1607
- label: "Chrome Network Request",
1608
- description: "Retrieve one captured fetch/XMLHttpRequest entry, including response body when available, by requestId from chrome_list_network_requests.",
1609
- promptSnippet: "Fetch captured request details and response body by requestId.",
1610
- parameters: Type.Object({
1611
- requestId: Type.String({ description: "Request id returned by chrome_list_network_requests." }),
1612
- targetId: Type.Optional(Type.String()),
1613
- urlIncludes: Type.Optional(Type.String()),
1614
- titleIncludes: Type.Optional(Type.String()),
1615
- background: Type.Optional(Type.Boolean({ description: "If true, run silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch." })),
1616
- host: Type.Optional(Type.String()),
1617
- port: Type.Optional(Type.Number()),
1618
- }),
1619
- async execute(_id, params, signal): Promise<ToolTextResult> {
1620
- const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1621
- return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
1622
- },
1623
- });
2021
+ pi.registerTool({
2022
+ name: "chrome_list_network_requests",
2023
+ label: "Chrome Network Requests",
2024
+ description:
2025
+ "List fetch/XMLHttpRequest activity captured in the page by the companion extension. Capture starts after instrumentation is installed by snapshot/evaluate/network/console tools; browser document/static asset requests are not captured. Use includePreservedRequests=true to keep requests from earlier same-tab navigations that were captured before navigation.",
2026
+ promptSnippet:
2027
+ "List captured XHR/fetch requests from the active Chrome page before doing DOM-heavy debugging.",
2028
+ parameters: Type.Object({
2029
+ includePreservedRequests: Type.Optional(
2030
+ Type.Boolean({
2031
+ description: "Include captured requests from earlier locations in the same tab/session.",
2032
+ }),
2033
+ ),
2034
+ clear: Type.Optional(Type.Boolean({ description: "Clear the captured request log after reading." })),
2035
+ targetId: Type.Optional(Type.String()),
2036
+ urlIncludes: Type.Optional(Type.String()),
2037
+ titleIncludes: Type.Optional(Type.String()),
2038
+ background: Type.Optional(
2039
+ Type.Boolean({
2040
+ description:
2041
+ "If true, run silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
2042
+ }),
2043
+ ),
2044
+ host: Type.Optional(Type.String()),
2045
+ port: Type.Optional(Type.Number()),
2046
+ }),
2047
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2048
+ const result = await authorizedBridgeSend(
2049
+ "page.network.list",
2050
+ withBackground(params),
2051
+ DEFAULT_TIMEOUT_MS,
2052
+ signal,
2053
+ );
2054
+ return {
2055
+ content: [{ type: "text", text: truncateText(safeJson(result)) }],
2056
+ details: { result: result as Json },
2057
+ };
2058
+ },
2059
+ });
1624
2060
 
1625
- pi.registerTool({
1626
- name: "chrome_screenshot",
1627
- label: "Chrome Screenshot",
1628
- description:
1629
- "Capture a screenshot of an existing Chrome tab via the companion extension and save it to disk. Chrome's extension screenshot API requires the target tab to be the active tab in its window. Runs in the background by default (the tab is briefly activated within its window for the capture, then the previous active tab is restored); pass background=false to focus Chrome so the user can watch.",
1630
- promptSnippet: "Capture Chrome screenshots and save them under .pi/chrome-screenshots by default.",
1631
- parameters: Type.Object({
1632
- path: Type.Optional(Type.String({ description: "Output path. Defaults to .pi/chrome-screenshots/<timestamp>.<format>." })),
1633
- format: Type.Optional(StringEnum(imageFormatValues)),
1634
- quality: Type.Optional(Type.Number({ description: "JPEG quality 0-100." })),
1635
- fullPage: Type.Optional(Type.Boolean({ description: "Not supported by the extension bridge yet; viewport screenshots are captured." })),
1636
- targetId: Type.Optional(Type.String()),
1637
- urlIncludes: Type.Optional(Type.String()),
1638
- titleIncludes: Type.Optional(Type.String()),
1639
- background: Type.Optional(
1640
- Type.Boolean({ description: "If true (the default), capture silently without focusing the Chrome window (the target tab is briefly activated within its window for the capture, then restored); pass false to focus Chrome." }),
1641
- ),
1642
- host: Type.Optional(Type.String()),
1643
- port: Type.Optional(Type.Number()),
1644
- }),
1645
- async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
1646
- const format = params.format ?? "png";
1647
- const cwd = workspaceCwd(ctx);
1648
- const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
1649
- const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
1650
- const result = (await authorizedBridgeSend("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS, signal)) as {
1651
- dataUrl?: string;
1652
- tab?: unknown;
1653
- fullPage?: boolean;
1654
- dimensions?: { width: number; height: number; viewportHeight: number; dpr: number };
1655
- tiles?: Array<{ y: number; dataUrl: string }>;
1656
- };
1657
- await mkdir(dirname(outputPath), { recursive: true });
1658
- if (result.fullPage && result.tiles && result.dimensions) {
1659
- // Stitch via PNG if format is png; otherwise we fall back to writing tile files and a
1660
- // manifest. We avoid pulling in an image library by writing each tile next to the main
1661
- // path with a -tileN suffix and a stitched.json manifest.
1662
- const { width, height, viewportHeight, dpr } = result.dimensions;
1663
- const manifest: Array<{ path: string; y: number }> = [];
1664
- for (let i = 0; i < result.tiles.length; i++) {
1665
- const tile = result.tiles[i];
1666
- const tilePath = outputPath.replace(/(\.[^.]+)$/, `-tile${i}$1`);
1667
- const base64 = tile.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
1668
- await writeFile(tilePath, Buffer.from(base64, "base64"));
1669
- manifest.push({ path: tilePath, y: tile.y });
2061
+ pi.registerTool({
2062
+ name: "chrome_get_network_request",
2063
+ label: "Chrome Network Request",
2064
+ description:
2065
+ "Retrieve one captured fetch/XMLHttpRequest entry, including response body when available, by requestId from chrome_list_network_requests.",
2066
+ promptSnippet: "Fetch captured request details and response body by requestId.",
2067
+ parameters: Type.Object({
2068
+ requestId: Type.String({ description: "Request id returned by chrome_list_network_requests." }),
2069
+ targetId: Type.Optional(Type.String()),
2070
+ urlIncludes: Type.Optional(Type.String()),
2071
+ titleIncludes: Type.Optional(Type.String()),
2072
+ background: Type.Optional(
2073
+ Type.Boolean({
2074
+ description:
2075
+ "If true, run silently without focusing Chrome. Defaults to on (the session background setting); pass false to focus Chrome so the user can watch.",
2076
+ }),
2077
+ ),
2078
+ host: Type.Optional(Type.String()),
2079
+ port: Type.Optional(Type.Number()),
2080
+ }),
2081
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2082
+ const result = await authorizedBridgeSend(
2083
+ "page.network.get",
2084
+ withBackground(params),
2085
+ DEFAULT_TIMEOUT_MS,
2086
+ signal,
2087
+ );
2088
+ return {
2089
+ content: [{ type: "text", text: truncateText(safeJson(result)) }],
2090
+ details: { result: result as Json },
2091
+ };
2092
+ },
2093
+ });
2094
+
2095
+ pi.registerTool({
2096
+ name: "chrome_screenshot",
2097
+ label: "Chrome Screenshot",
2098
+ description:
2099
+ "Capture a screenshot of an existing Chrome tab via the companion extension and save it to disk. Chrome's extension screenshot API requires the target tab to be the active tab in its window. Runs in the background by default (the tab is briefly activated within its window for the capture, then the previous active tab is restored); pass background=false to focus Chrome so the user can watch.",
2100
+ promptSnippet: "Capture Chrome screenshots and save them under .pi/chrome-screenshots by default.",
2101
+ parameters: Type.Object({
2102
+ path: Type.Optional(
2103
+ Type.String({ description: "Output path. Defaults to .pi/chrome-screenshots/<timestamp>.<format>." }),
2104
+ ),
2105
+ format: Type.Optional(StringEnum(imageFormatValues)),
2106
+ quality: Type.Optional(Type.Number({ description: "JPEG quality 0-100." })),
2107
+ fullPage: Type.Optional(
2108
+ Type.Boolean({
2109
+ description: "Not supported by the extension bridge yet; viewport screenshots are captured.",
2110
+ }),
2111
+ ),
2112
+ targetId: Type.Optional(Type.String()),
2113
+ urlIncludes: Type.Optional(Type.String()),
2114
+ titleIncludes: Type.Optional(Type.String()),
2115
+ background: Type.Optional(
2116
+ Type.Boolean({
2117
+ description:
2118
+ "If true (the default), capture silently without focusing the Chrome window (the target tab is briefly activated within its window for the capture, then restored); pass false to focus Chrome.",
2119
+ }),
2120
+ ),
2121
+ host: Type.Optional(Type.String()),
2122
+ port: Type.Optional(Type.Number()),
2123
+ }),
2124
+ async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
2125
+ const format = params.format ?? "png";
2126
+ const cwd = workspaceCwd(ctx);
2127
+ const defaultPath = join(
2128
+ cwd,
2129
+ ".pi",
2130
+ "chrome-screenshots",
2131
+ `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`,
2132
+ );
2133
+ const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
2134
+ const result = (await authorizedBridgeSend(
2135
+ "page.screenshot",
2136
+ withBackground(params),
2137
+ params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS,
2138
+ signal,
2139
+ )) as {
2140
+ dataUrl?: string;
2141
+ tab?: unknown;
2142
+ fullPage?: boolean;
2143
+ dimensions?: { width: number; height: number; viewportHeight: number; dpr: number };
2144
+ tiles?: Array<{ y: number; dataUrl: string }>;
2145
+ };
2146
+ await mkdir(dirname(outputPath), { recursive: true });
2147
+ if (result.fullPage && result.tiles && result.dimensions) {
2148
+ // Stitch via PNG if format is png; otherwise we fall back to writing tile files and a
2149
+ // manifest. We avoid pulling in an image library by writing each tile next to the main
2150
+ // path with a -tileN suffix and a stitched.json manifest.
2151
+ const { width, height, viewportHeight, dpr } = result.dimensions;
2152
+ const manifest: Array<{ path: string; y: number }> = [];
2153
+ for (let i = 0; i < result.tiles.length; i++) {
2154
+ const tile = result.tiles[i];
2155
+ const tilePath = outputPath.replace(/(\.[^.]+)$/, `-tile${i}$1`);
2156
+ const base64 = tile.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
2157
+ await writeFile(tilePath, Buffer.from(base64, "base64"));
2158
+ manifest.push({ path: tilePath, y: tile.y });
2159
+ }
2160
+ await writeFile(
2161
+ `${outputPath}.json`,
2162
+ JSON.stringify({ width, height, viewportHeight, dpr, tiles: manifest }, null, 2),
2163
+ );
2164
+ return {
2165
+ content: [
2166
+ {
2167
+ type: "text",
2168
+ text: `Saved ${result.tiles.length} full-page tile(s) for ${width}×${height}px page. Manifest: ${outputPath}.json`,
2169
+ },
2170
+ ],
2171
+ details: {
2172
+ manifest: `${outputPath}.json`,
2173
+ tiles: manifest,
2174
+ dimensions: result.dimensions,
2175
+ tab: result.tab,
2176
+ } as unknown as Record<string, unknown>,
2177
+ };
1670
2178
  }
1671
- await writeFile(outputPath + ".json", JSON.stringify({ width, height, viewportHeight, dpr, tiles: manifest }, null, 2));
2179
+ if (!result.dataUrl) throw new Error("Screenshot returned no dataUrl");
2180
+ const base64 = result.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
2181
+ await writeFile(outputPath, Buffer.from(base64, "base64"));
1672
2182
  return {
1673
- content: [{ type: "text", text: `Saved ${result.tiles.length} full-page tile(s) for ${width}×${height}px page. Manifest: ${outputPath}.json` }],
1674
- details: { manifest: outputPath + ".json", tiles: manifest, dimensions: result.dimensions, tab: result.tab } as unknown as Record<string, unknown>,
2183
+ content: [{ type: "text", text: `Saved Chrome screenshot to ${outputPath}` }],
2184
+ details: { path: outputPath, format, tab: result.tab },
1675
2185
  };
1676
- }
1677
- if (!result.dataUrl) throw new Error("Screenshot returned no dataUrl");
1678
- const base64 = result.dataUrl.replace(/^data:image\/(?:png|jpeg);base64,/, "");
1679
- await writeFile(outputPath, Buffer.from(base64, "base64"));
1680
- return { content: [{ type: "text", text: `Saved Chrome screenshot to ${outputPath}` }], details: { path: outputPath, format, tab: result.tab } };
1681
- },
1682
- });
2186
+ },
2187
+ });
1683
2188
 
1684
- pi.registerTool({
1685
- name: "chrome_hover",
1686
- label: "Chrome Hover",
1687
- description: "Hover over an element by uid, selector, or x/y using Chrome pointer movement.",
1688
- promptSnippet: "Hover a Chrome element to trigger :hover / mouseover handlers.",
1689
- parameters: Type.Object({
1690
- uid: Type.Optional(Type.String()),
1691
- selector: Type.Optional(Type.String()),
1692
- x: Type.Optional(Type.Number()),
1693
- y: Type.Optional(Type.Number()),
1694
- targetId: Type.Optional(Type.String()),
1695
- urlIncludes: Type.Optional(Type.String()),
1696
- titleIncludes: Type.Optional(Type.String()),
1697
- background: Type.Optional(Type.Boolean()),
1698
- }),
1699
- async execute(_id, params, signal): Promise<ToolTextResult> {
1700
- const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1701
- return { content: [{ type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
1702
- },
1703
- });
2189
+ pi.registerTool({
2190
+ name: "chrome_hover",
2191
+ label: "Chrome Hover",
2192
+ description: "Hover over an element by uid, selector, or x/y using Chrome pointer movement.",
2193
+ promptSnippet: "Hover a Chrome element to trigger :hover / mouseover handlers.",
2194
+ parameters: Type.Object({
2195
+ uid: Type.Optional(Type.String()),
2196
+ selector: Type.Optional(Type.String()),
2197
+ x: Type.Optional(Type.Number()),
2198
+ y: Type.Optional(Type.Number()),
2199
+ targetId: Type.Optional(Type.String()),
2200
+ urlIncludes: Type.Optional(Type.String()),
2201
+ titleIncludes: Type.Optional(Type.String()),
2202
+ background: Type.Optional(Type.Boolean()),
2203
+ }),
2204
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2205
+ const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
2206
+ return {
2207
+ content: [
2208
+ { type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` },
2209
+ ],
2210
+ details: { result: result as Json },
2211
+ };
2212
+ },
2213
+ });
1704
2214
 
1705
- pi.registerTool({
1706
- name: "chrome_drag",
1707
- label: "Chrome Drag",
1708
- description: "Drag from one uid/selector/point to another using Chrome pointer input.",
1709
- promptSnippet: "Drag a Chrome element from one point to another.",
1710
- parameters: Type.Object({
1711
- fromUid: Type.Optional(Type.String()),
1712
- fromSelector: Type.Optional(Type.String()),
1713
- fromX: Type.Optional(Type.Number()),
1714
- fromY: Type.Optional(Type.Number()),
1715
- toUid: Type.Optional(Type.String()),
1716
- toSelector: Type.Optional(Type.String()),
1717
- toX: Type.Optional(Type.Number()),
1718
- toY: Type.Optional(Type.Number()),
1719
- steps: Type.Optional(Type.Number({ default: 12 })),
1720
- targetId: Type.Optional(Type.String()),
1721
- urlIncludes: Type.Optional(Type.String()),
1722
- titleIncludes: Type.Optional(Type.String()),
1723
- background: Type.Optional(Type.Boolean()),
1724
- }),
1725
- async execute(_id, params, signal): Promise<ToolTextResult> {
1726
- const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1727
- return { content: [{ type: "text", text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}` }], details: { result: result as Json } };
1728
- },
1729
- });
2215
+ pi.registerTool({
2216
+ name: "chrome_drag",
2217
+ label: "Chrome Drag",
2218
+ description: "Drag from one uid/selector/point to another using Chrome pointer input.",
2219
+ promptSnippet: "Drag a Chrome element from one point to another.",
2220
+ parameters: Type.Object({
2221
+ fromUid: Type.Optional(Type.String()),
2222
+ fromSelector: Type.Optional(Type.String()),
2223
+ fromX: Type.Optional(Type.Number()),
2224
+ fromY: Type.Optional(Type.Number()),
2225
+ toUid: Type.Optional(Type.String()),
2226
+ toSelector: Type.Optional(Type.String()),
2227
+ toX: Type.Optional(Type.Number()),
2228
+ toY: Type.Optional(Type.Number()),
2229
+ steps: Type.Optional(Type.Number({ default: 12 })),
2230
+ targetId: Type.Optional(Type.String()),
2231
+ urlIncludes: Type.Optional(Type.String()),
2232
+ titleIncludes: Type.Optional(Type.String()),
2233
+ background: Type.Optional(Type.Boolean()),
2234
+ }),
2235
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2236
+ const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
2237
+ return {
2238
+ content: [
2239
+ {
2240
+ type: "text",
2241
+ text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}`,
2242
+ },
2243
+ ],
2244
+ details: { result: result as Json },
2245
+ };
2246
+ },
2247
+ });
1730
2248
 
1731
- pi.registerTool({
1732
- name: "chrome_tap",
1733
- label: "Chrome Tap (Touch)",
1734
- description:
1735
- "Dispatch a real touchstart/touchend tap through Chrome's input layer. Use for sites that gate on TouchEvent rather than MouseEvent (mobile-first PWAs, swipe carousels). Chrome may show its debugging banner while attached.",
1736
- promptSnippet: "Tap (real touch) a Chrome element by snapshot uid, selector, or coordinate.",
1737
- parameters: Type.Object({
1738
- uid: Type.Optional(Type.String()),
1739
- selector: Type.Optional(Type.String()),
1740
- x: Type.Optional(Type.Number()),
1741
- y: Type.Optional(Type.Number()),
1742
- targetId: Type.Optional(Type.String()),
1743
- urlIncludes: Type.Optional(Type.String()),
1744
- titleIncludes: Type.Optional(Type.String()),
1745
- background: Type.Optional(Type.Boolean()),
1746
- }),
1747
- async execute(_id, params, signal): Promise<ToolTextResult> {
1748
- const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1749
- const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
1750
- return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
1751
- },
1752
- });
2249
+ pi.registerTool({
2250
+ name: "chrome_tap",
2251
+ label: "Chrome Tap (Touch)",
2252
+ description:
2253
+ "Dispatch a real touchstart/touchend tap through Chrome's input layer. Use for sites that gate on TouchEvent rather than MouseEvent (mobile-first PWAs, swipe carousels). Chrome may show its debugging banner while attached.",
2254
+ promptSnippet: "Tap (real touch) a Chrome element by snapshot uid, selector, or coordinate.",
2255
+ parameters: Type.Object({
2256
+ uid: Type.Optional(Type.String()),
2257
+ selector: Type.Optional(Type.String()),
2258
+ x: Type.Optional(Type.Number()),
2259
+ y: Type.Optional(Type.Number()),
2260
+ targetId: Type.Optional(Type.String()),
2261
+ urlIncludes: Type.Optional(Type.String()),
2262
+ titleIncludes: Type.Optional(Type.String()),
2263
+ background: Type.Optional(Type.Boolean()),
2264
+ }),
2265
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2266
+ const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
2267
+ const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
2268
+ return {
2269
+ content: [{ type: "text", text: `Tapped ${target} (touch)` }],
2270
+ details: { result: result as Json },
2271
+ };
2272
+ },
2273
+ });
1753
2274
 
1754
- pi.registerTool({
1755
- name: "chrome_scroll",
1756
- label: "Chrome Scroll",
1757
- description: "Scroll the page or a specific scrollable element by dispatching real wheel events with momentum-shaped deltas, then applying the scroll. Positive deltaY scrolls down. Pass uid/selector to scroll within a container, otherwise the document scrolls.",
1758
- promptSnippet: "Scroll a Chrome page or container via wheel events (not raw scrollTop).",
1759
- parameters: Type.Object({
1760
- uid: Type.Optional(Type.String()),
1761
- selector: Type.Optional(Type.String()),
1762
- deltaY: Type.Optional(Type.Number({ description: "Pixels to scroll vertically. Positive = down." })),
1763
- deltaX: Type.Optional(Type.Number({ description: "Pixels to scroll horizontally. Positive = right." })),
1764
- steps: Type.Optional(Type.Number({ description: "Number of wheel events to dispatch. Defaults to ceil(|deltaY|/100)." })),
1765
- targetId: Type.Optional(Type.String()),
1766
- urlIncludes: Type.Optional(Type.String()),
1767
- titleIncludes: Type.Optional(Type.String()),
1768
- background: Type.Optional(Type.Boolean()),
1769
- }),
1770
- async execute(_id, params, signal): Promise<ToolTextResult> {
1771
- const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
1772
- return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
1773
- },
1774
- });
2275
+ pi.registerTool({
2276
+ name: "chrome_scroll",
2277
+ label: "Chrome Scroll",
2278
+ description:
2279
+ "Scroll the page or a specific scrollable element by dispatching real wheel events with momentum-shaped deltas, then applying the scroll. Positive deltaY scrolls down. Pass uid/selector to scroll within a container, otherwise the document scrolls.",
2280
+ promptSnippet: "Scroll a Chrome page or container via wheel events (not raw scrollTop).",
2281
+ parameters: Type.Object({
2282
+ uid: Type.Optional(Type.String()),
2283
+ selector: Type.Optional(Type.String()),
2284
+ deltaY: Type.Optional(Type.Number({ description: "Pixels to scroll vertically. Positive = down." })),
2285
+ deltaX: Type.Optional(Type.Number({ description: "Pixels to scroll horizontally. Positive = right." })),
2286
+ steps: Type.Optional(
2287
+ Type.Number({ description: "Number of wheel events to dispatch. Defaults to ceil(|deltaY|/100)." }),
2288
+ ),
2289
+ targetId: Type.Optional(Type.String()),
2290
+ urlIncludes: Type.Optional(Type.String()),
2291
+ titleIncludes: Type.Optional(Type.String()),
2292
+ background: Type.Optional(Type.Boolean()),
2293
+ }),
2294
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2295
+ const result = await authorizedBridgeSend(
2296
+ "page.scroll",
2297
+ withBackground(params),
2298
+ DEFAULT_TIMEOUT_MS,
2299
+ signal,
2300
+ );
2301
+ return {
2302
+ content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }],
2303
+ details: { result: result as Json },
2304
+ };
2305
+ },
2306
+ });
1775
2307
 
1776
- pi.registerTool({
1777
- name: "chrome_upload_file",
1778
- label: "Chrome Upload File",
1779
- description: "Attach local files to an <input type=file> element using Chrome DevTools file-input control. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.",
1780
- promptSnippet: "Attach local files to a Chrome <input type=file> without opening the native file picker.",
1781
- parameters: Type.Object({
1782
- uid: Type.Optional(Type.String()),
1783
- selector: Type.Optional(Type.String()),
1784
- paths: Type.Array(Type.String(), { description: "Local absolute file paths to upload." }),
1785
- targetId: Type.Optional(Type.String()),
1786
- urlIncludes: Type.Optional(Type.String()),
1787
- titleIncludes: Type.Optional(Type.String()),
1788
- background: Type.Optional(Type.Boolean()),
1789
- }),
1790
- async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1791
- const cwd = workspaceCwd(ctx);
1792
- const paths = params.paths.map((p) => resolve(cwd, p));
1793
- const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS, signal);
1794
- return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
1795
- },
1796
- });
2308
+ pi.registerTool({
2309
+ name: "chrome_upload_file",
2310
+ label: "Chrome Upload File",
2311
+ description:
2312
+ "Attach local files to an <input type=file> element using Chrome DevTools file-input control. Does NOT open the native file picker; works with React/Vue/Angular controlled inputs.",
2313
+ promptSnippet: "Attach local files to a Chrome <input type=file> without opening the native file picker.",
2314
+ parameters: Type.Object({
2315
+ uid: Type.Optional(Type.String()),
2316
+ selector: Type.Optional(Type.String()),
2317
+ paths: Type.Array(Type.String(), { description: "Local absolute file paths to upload." }),
2318
+ targetId: Type.Optional(Type.String()),
2319
+ urlIncludes: Type.Optional(Type.String()),
2320
+ titleIncludes: Type.Optional(Type.String()),
2321
+ background: Type.Optional(Type.Boolean()),
2322
+ }),
2323
+ async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
2324
+ const cwd = workspaceCwd(ctx);
2325
+ const paths = params.paths.map((p) => resolve(cwd, p));
2326
+ const result = await authorizedBridgeSend(
2327
+ "page.upload",
2328
+ withBackground({ ...params, paths }),
2329
+ DEFAULT_TIMEOUT_MS,
2330
+ signal,
2331
+ );
2332
+ return {
2333
+ content: [
2334
+ { type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` },
2335
+ ],
2336
+ details: { result: result as Json },
2337
+ };
2338
+ },
2339
+ });
1797
2340
  }
1798
-
1799
2341
  }