@phi-code-admin/phi-code 0.83.0 → 0.84.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.
- package/CHANGELOG.md +25 -0
- package/extensions/phi/btw/LICENSE +21 -0
- package/extensions/phi/btw/btw-ui.ts +238 -0
- package/extensions/phi/btw/btw.ts +363 -0
- package/extensions/phi/btw/index.ts +17 -0
- package/extensions/phi/btw/prompts/btw-system.txt +9 -0
- package/extensions/phi/chrome/LICENSE +21 -0
- package/extensions/phi/chrome/browser-extension/manifest.json +26 -0
- package/extensions/phi/chrome/browser-extension/service_worker.js +2388 -0
- package/extensions/phi/chrome/browser-extension/snapshot_injected.js +677 -0
- package/extensions/phi/chrome/index.ts +1799 -0
- package/extensions/phi/goal/LICENSE +21 -0
- package/extensions/phi/goal/index.ts +784 -0
- package/extensions/phi/todo/LICENSE +21 -0
- package/extensions/phi/todo/config.ts +14 -0
- package/extensions/phi/todo/index.ts +113 -0
- package/extensions/phi/todo/locales/de.json +17 -0
- package/extensions/phi/todo/locales/en.json +15 -0
- package/extensions/phi/todo/locales/es.json +17 -0
- package/extensions/phi/todo/locales/fr.json +17 -0
- package/extensions/phi/todo/locales/pt-BR.json +17 -0
- package/extensions/phi/todo/locales/pt.json +17 -0
- package/extensions/phi/todo/locales/ru.json +17 -0
- package/extensions/phi/todo/locales/uk.json +17 -0
- package/extensions/phi/todo/rpiv-config/config.ts +223 -0
- package/extensions/phi/todo/rpiv-config/index.ts +12 -0
- package/extensions/phi/todo/state/i18n-bridge.ts +65 -0
- package/extensions/phi/todo/state/invariants.ts +20 -0
- package/extensions/phi/todo/state/replay.ts +38 -0
- package/extensions/phi/todo/state/selectors.ts +107 -0
- package/extensions/phi/todo/state/state-reducer.ts +187 -0
- package/extensions/phi/todo/state/state.ts +18 -0
- package/extensions/phi/todo/state/store.ts +54 -0
- package/extensions/phi/todo/state/task-graph.ts +57 -0
- package/extensions/phi/todo/todo-overlay.ts +194 -0
- package/extensions/phi/todo/todo.ts +146 -0
- package/extensions/phi/todo/tool/response-envelope.ts +94 -0
- package/extensions/phi/todo/tool/types.ts +128 -0
- package/extensions/phi/todo/view/format.ts +162 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "phi-code";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Existing-profile Chrome bridge for pi.
|
|
10
|
+
*
|
|
11
|
+
* This is intentionally not a remote-debugging-port integration. Chrome blocks default-profile
|
|
12
|
+
* remote debugging in many normal launches, so pi-chrome uses a companion extension from the
|
|
13
|
+
* browser-extension folder bundled next to this Pi extension.
|
|
14
|
+
*
|
|
15
|
+
* The companion extension runs inside the user's real Chrome profile and polls this local
|
|
16
|
+
* pi extension for commands. That gives pi access to the user's existing tabs/authenticated
|
|
17
|
+
* profile, subject to the browser extension permissions the user grants.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
21
|
+
|
|
22
|
+
type ToolTextResult = {
|
|
23
|
+
content: Array<{ type: "text"; text: string }>;
|
|
24
|
+
details: Record<string, unknown>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type BridgeCommand = {
|
|
28
|
+
id: string;
|
|
29
|
+
action: string;
|
|
30
|
+
params: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type PendingCommand = {
|
|
34
|
+
command: BridgeCommand;
|
|
35
|
+
resolve: (value: unknown) => void;
|
|
36
|
+
reject: (error: Error) => void;
|
|
37
|
+
timer: NodeJS.Timeout;
|
|
38
|
+
deliveredAt?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type BridgeResult = {
|
|
42
|
+
id: string;
|
|
43
|
+
ok: boolean;
|
|
44
|
+
result?: unknown;
|
|
45
|
+
error?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const PI_CHROME_PKG_PATH = resolve(__dirname, "..", "..", "package.json");
|
|
49
|
+
function readPiChromeVersion(): string {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(PI_CHROME_PKG_PATH, "utf8")) as { version?: string };
|
|
52
|
+
if (pkg.version) return pkg.version;
|
|
53
|
+
} catch {}
|
|
54
|
+
return "0.0.0-dev";
|
|
55
|
+
}
|
|
56
|
+
const PI_CHROME_VERSION = readPiChromeVersion();
|
|
57
|
+
const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__";
|
|
58
|
+
// Authorization is kept on globalThis (separate from the singleton flag, which is cleared on
|
|
59
|
+
// reload) so a /reload — which tears down and re-evaluates the module — does not silently drop
|
|
60
|
+
// an active /chrome authorize grant.
|
|
61
|
+
const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__";
|
|
62
|
+
const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
|
|
63
|
+
const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
|
|
64
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
65
|
+
const MAX_TEXT_CHARS = 30_000;
|
|
66
|
+
const MAX_ELEMENTS = 80;
|
|
67
|
+
|
|
68
|
+
function truncateText(text: string, maxChars = MAX_TEXT_CHARS): string {
|
|
69
|
+
if (text.length <= maxChars) return text;
|
|
70
|
+
return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeJson(value: unknown): string {
|
|
74
|
+
return JSON.stringify(value, null, 2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapshotModeValues = ["auto", "interactive", "forms", "pageMap", "text", "changes", "full"] as const;
|
|
78
|
+
|
|
79
|
+
function compactLine(value: unknown, max = 140): string {
|
|
80
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
81
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rectText(rect: any): string {
|
|
85
|
+
if (!rect) return "?";
|
|
86
|
+
return `${rect.x},${rect.y} ${rect.width}x${rect.height}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatChromeSnapshot(snapshot: any): string {
|
|
90
|
+
if (!snapshot || typeof snapshot !== "object") return safeJson(snapshot);
|
|
91
|
+
if (snapshot.mode === "full") return truncateText(safeJson(snapshot));
|
|
92
|
+
const lines: string[] = [];
|
|
93
|
+
lines.push(`# Chrome snapshot${snapshot.mode ? ` (${snapshot.mode})` : ""}`);
|
|
94
|
+
lines.push(`${snapshot.title || "(untitled)"}`);
|
|
95
|
+
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)}`);
|
|
99
|
+
if (Array.isArray(snapshot.summary?.hints) && snapshot.summary.hints.length) {
|
|
100
|
+
lines.push("\n## Hints");
|
|
101
|
+
for (const hint of snapshot.summary.hints.slice(0, 6)) lines.push(`- ${hint}`);
|
|
102
|
+
}
|
|
103
|
+
if (snapshot.diff && !snapshot.diff.firstSnapshot) {
|
|
104
|
+
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)}`),
|
|
108
|
+
];
|
|
109
|
+
if (changed.length) {
|
|
110
|
+
lines.push("\n## Changed since last snapshot");
|
|
111
|
+
for (const item of changed.slice(0, 10)) lines.push(`- ${item}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(snapshot.matches) && snapshot.matches.length) {
|
|
115
|
+
lines.push(`\n## Matches for "${snapshot.query}"`);
|
|
116
|
+
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)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (snapshot.mode === "pageMap" && snapshot.pageMap) {
|
|
123
|
+
lines.push("\n## Page map");
|
|
124
|
+
for (const region of (snapshot.pageMap.regions || []).slice(0, 18)) {
|
|
125
|
+
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)}`);
|
|
127
|
+
}
|
|
128
|
+
if (snapshot.pageMap.headings?.length) {
|
|
129
|
+
lines.push("\nHeadings:");
|
|
130
|
+
for (const h of snapshot.pageMap.headings.slice(0, 20)) lines.push(`- ${h.uid} h${h.level || ""} ${compactLine(h.text)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(snapshot.layout) && snapshot.layout.length && snapshot.mode !== "changes") {
|
|
134
|
+
lines.push("\n## Layout / context");
|
|
135
|
+
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)}`];
|
|
137
|
+
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)}`);
|
|
140
|
+
if (fieldLabels.length) lines.push(` fields: ${fieldLabels.join("; ")}`);
|
|
141
|
+
if (actionLabels.length) lines.push(` actions: ${actionLabels.join("; ")}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if ((snapshot.mode === "forms" || snapshot.forms?.fields?.length) && snapshot.mode !== "pageMap") {
|
|
145
|
+
const fields = snapshot.forms?.fields || [];
|
|
146
|
+
const submits = snapshot.forms?.submits || [];
|
|
147
|
+
if (fields.length || submits.length) lines.push("\n## Forms");
|
|
148
|
+
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)];
|
|
150
|
+
if (field.value) bits.push(`value=${compactLine(field.value, 50)}`);
|
|
151
|
+
else if (field.valueRedacted) bits.push("value=[redacted]");
|
|
152
|
+
lines.push(`- ${bits.filter(Boolean).join(" ")} @ ${rectText(field.rect)}`);
|
|
153
|
+
}
|
|
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)}`);
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(snapshot.elements) && snapshot.mode !== "pageMap") {
|
|
157
|
+
lines.push("\n## Visible actions");
|
|
158
|
+
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(",");
|
|
160
|
+
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)}`);
|
|
162
|
+
}
|
|
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`);
|
|
164
|
+
}
|
|
165
|
+
if ((snapshot.mode === "text" || snapshot.mode === "auto") && Array.isArray(snapshot.textSnippets) && snapshot.textSnippets.length) {
|
|
166
|
+
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)}`);
|
|
168
|
+
if (snapshot.textTruncated) lines.push("- … page text truncated; retry with mode=text or maxTextChars for more");
|
|
169
|
+
}
|
|
170
|
+
lines.push("\nTip: use chrome_snapshot({query:'...', mode:'interactive|forms|pageMap|text|changes|full'}) or nearUid to zoom in.");
|
|
171
|
+
return truncateText(lines.join("\n"));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function formatIncludedSnapshotText(raw: unknown, text: string): string {
|
|
175
|
+
const snapshot = raw && typeof raw === "object" ? (raw as { snapshot?: unknown }).snapshot : undefined;
|
|
176
|
+
return snapshot ? `${text}\n\n${formatChromeSnapshot(snapshot)}` : text;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function formatChromeInspect(inspect: any): string {
|
|
180
|
+
if (!inspect || typeof inspect !== "object") return safeJson(inspect);
|
|
181
|
+
const t = inspect.target || {};
|
|
182
|
+
const lines: string[] = [];
|
|
183
|
+
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)}`);
|
|
185
|
+
if (t.selector) lines.push(`selector: ${t.selector}`);
|
|
186
|
+
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}`);
|
|
188
|
+
if (Array.isArray(inspect.nearbyText) && inspect.nearbyText.length) {
|
|
189
|
+
lines.push("\n## Nearby text");
|
|
190
|
+
for (const item of inspect.nearbyText.slice(0, 12)) lines.push(`- ${item.uid} ${compactLine(item.text, 180)}`);
|
|
191
|
+
}
|
|
192
|
+
if (inspect.formContext) {
|
|
193
|
+
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)}`);
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(inspect.nearbyActions) && inspect.nearbyActions.length) {
|
|
198
|
+
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)}`);
|
|
200
|
+
}
|
|
201
|
+
if (Array.isArray(inspect.ancestors) && inspect.ancestors.length) {
|
|
202
|
+
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)}`);
|
|
204
|
+
}
|
|
205
|
+
return truncateText(lines.join("\n"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function extensionRoot(): string {
|
|
209
|
+
// Resolve relative to this extension file, not ctx.cwd. ctx.cwd can temporarily be
|
|
210
|
+
// an attachment/clipboard path when Pi is handling pasted images.
|
|
211
|
+
if (typeof __dirname === "string") return __dirname;
|
|
212
|
+
return process.cwd();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function workspaceCwd(ctx: ExtensionContext): string {
|
|
216
|
+
for (const candidate of [ctx.cwd, process.cwd()]) {
|
|
217
|
+
if (!candidate) continue;
|
|
218
|
+
try {
|
|
219
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate;
|
|
220
|
+
} catch {
|
|
221
|
+
// try next candidate
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return process.cwd();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function browserExtensionPath(): string {
|
|
228
|
+
return join(extensionRoot(), "browser-extension");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hostnameOf(url: string | undefined): string {
|
|
232
|
+
if (!url) return "";
|
|
233
|
+
try { return new URL(url).hostname; } catch { return ""; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Description of a click/type/fill result's significant fields so the agent doesn't have to
|
|
237
|
+
// guess whether the action actually changed the page.
|
|
238
|
+
function summarizeActionResult(result: unknown): string | undefined {
|
|
239
|
+
if (!result || typeof result !== "object") return undefined;
|
|
240
|
+
const r = result as Record<string, unknown>;
|
|
241
|
+
const parts: string[] = [];
|
|
242
|
+
// NOTE: pageMutated is a coarse heuristic (a hash over body text + input values + node count).
|
|
243
|
+
// Many real effects — class/aria/data-state toggles, JS-held state, canvas, async updates —
|
|
244
|
+
// don't move it, so a false value is NOT proof the action did nothing. Surface it only as a
|
|
245
|
+
// 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)");
|
|
247
|
+
if (r.defaultPrevented === true) parts.push("defaultPrevented=true");
|
|
248
|
+
if (r.elementVisible === false) parts.push("element NOT visible");
|
|
249
|
+
if (r.occludedBy) {
|
|
250
|
+
const o = r.occludedBy as { tag?: string; id?: string };
|
|
251
|
+
parts.push(`occluded by <${o.tag ?? "?"}${o.id ? "#" + o.id : ""}>`);
|
|
252
|
+
}
|
|
253
|
+
if (r.valueMatches === false) parts.push("input value did not stick");
|
|
254
|
+
if (r.autoplayHint) parts.push("autoplay-gated affordance");
|
|
255
|
+
return parts.length ? parts.join("; ") : undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function readRequestBody(request: IncomingMessage): Promise<string> {
|
|
259
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
260
|
+
const chunks: Buffer[] = [];
|
|
261
|
+
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
262
|
+
request.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
|
|
263
|
+
request.on("error", rejectBody);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function corsHeadersFor(request: IncomingMessage): Record<string, string> {
|
|
268
|
+
const origin = String(request.headers.origin ?? "");
|
|
269
|
+
if (!origin.startsWith("chrome-extension://")) return {};
|
|
270
|
+
return {
|
|
271
|
+
"access-control-allow-origin": origin,
|
|
272
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
273
|
+
"access-control-allow-headers": "content-type",
|
|
274
|
+
"access-control-expose-headers": "x-pi-chrome-version",
|
|
275
|
+
"vary": "origin",
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isBrowserOriginAllowed(request: IncomingMessage): boolean {
|
|
280
|
+
const origin = String(request.headers.origin ?? "");
|
|
281
|
+
if (origin) return origin.startsWith("chrome-extension://");
|
|
282
|
+
const secFetchSite = String(request.headers["sec-fetch-site"] ?? "");
|
|
283
|
+
return !secFetchSite || secFetchSite === "none" || secFetchSite === "same-origin";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isLocalProcessRequest(request: IncomingMessage): boolean {
|
|
287
|
+
return !request.headers.origin && !request.headers["sec-fetch-site"];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {
|
|
291
|
+
response.writeHead(status, {
|
|
292
|
+
"content-type": "application/json; charset=utf-8",
|
|
293
|
+
"cache-control": "no-store",
|
|
294
|
+
...(extraHeaders ?? {}),
|
|
295
|
+
});
|
|
296
|
+
response.end(JSON.stringify(body));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
class ChromeProfileBridge {
|
|
300
|
+
private server: Server | undefined;
|
|
301
|
+
private pending = new Map<string, PendingCommand>();
|
|
302
|
+
private queue: BridgeCommand[] = [];
|
|
303
|
+
private waiters: Array<(command: BridgeCommand | undefined) => void> = [];
|
|
304
|
+
private lastSeenAt: number | undefined;
|
|
305
|
+
private clientName: string | undefined;
|
|
306
|
+
private mode: "server" | "client" | undefined;
|
|
307
|
+
|
|
308
|
+
constructor(
|
|
309
|
+
private readonly host: string,
|
|
310
|
+
private readonly port: number,
|
|
311
|
+
) {}
|
|
312
|
+
|
|
313
|
+
get url(): string {
|
|
314
|
+
return `http://${this.host}:${this.port}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
get connected(): boolean {
|
|
318
|
+
// MV3 service workers can pause between polls/alarms. Treat a recent poll as
|
|
319
|
+
// connected without sending a probe command; real chrome_* tool calls are
|
|
320
|
+
// the authoritative end-to-end health check.
|
|
321
|
+
return this.lastSeenAt !== undefined && Date.now() - this.lastSeenAt < 5 * 60_000;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
status(): Record<string, unknown> {
|
|
325
|
+
return {
|
|
326
|
+
url: this.url,
|
|
327
|
+
mode: this.mode ?? "starting",
|
|
328
|
+
connected: this.connected,
|
|
329
|
+
lastSeenAt: this.lastSeenAt,
|
|
330
|
+
clientName: this.clientName,
|
|
331
|
+
queuedCommands: this.queue.length,
|
|
332
|
+
pendingCommands: this.pending.size,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async start(): Promise<void> {
|
|
337
|
+
if (this.server || this.mode === "client") return;
|
|
338
|
+
await this.bindServerOrClient();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi
|
|
342
|
+
// session owns it and we run as a client that forwards commands to that owner.
|
|
343
|
+
private async bindServerOrClient(): Promise<void> {
|
|
344
|
+
const server = createServer((request, response) => {
|
|
345
|
+
void this.handle(request, response).catch((error) => {
|
|
346
|
+
sendJson(response, 500, { error: (error as Error).message });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
await new Promise<void>((resolveStart, rejectStart) => {
|
|
351
|
+
server.once("error", rejectStart);
|
|
352
|
+
server.listen(this.port, this.host, () => {
|
|
353
|
+
server.off("error", rejectStart);
|
|
354
|
+
resolveStart();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
this.server = server;
|
|
358
|
+
this.mode = "server";
|
|
359
|
+
} catch (error) {
|
|
360
|
+
server.close();
|
|
361
|
+
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
362
|
+
// Another Pi session already owns the bridge port. Use it as the shared
|
|
363
|
+
// machine-local broker so multiple Pi sessions can control Chrome at once.
|
|
364
|
+
this.mode = "client";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Client-mode self-heal: when the owning Pi session disappears, fetches to its port fail
|
|
369
|
+
// with `fetch failed` / ECONNREFUSED forever. Try to grab the now-free port and become the
|
|
370
|
+
// server ourselves so chrome_* tools recover without a manual restart.
|
|
371
|
+
private async tryPromoteToServer(): Promise<boolean> {
|
|
372
|
+
if (this.mode !== "client") return this.mode === "server";
|
|
373
|
+
this.mode = undefined;
|
|
374
|
+
await this.bindServerOrClient();
|
|
375
|
+
return this.mode === "server";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
stop(): void {
|
|
379
|
+
if (this.mode === "client") {
|
|
380
|
+
this.mode = undefined;
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const pending of this.pending.values()) {
|
|
384
|
+
clearTimeout(pending.timer);
|
|
385
|
+
pending.reject(new Error("Chrome profile bridge stopped"));
|
|
386
|
+
}
|
|
387
|
+
this.pending.clear();
|
|
388
|
+
this.queue = [];
|
|
389
|
+
for (const waiter of this.waiters) waiter(undefined);
|
|
390
|
+
this.waiters = [];
|
|
391
|
+
this.server?.close();
|
|
392
|
+
this.server = undefined;
|
|
393
|
+
this.mode = undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
|
|
397
|
+
if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs, signal);
|
|
398
|
+
return this.sendLocal(action, params, timeoutMs, signal);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
|
|
402
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
403
|
+
const command = { id, action, params };
|
|
404
|
+
return new Promise((resolveCommand, rejectCommand) => {
|
|
405
|
+
if (signal?.aborted) {
|
|
406
|
+
rejectCommand(new Error("Chrome command aborted"));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const cleanupAbort = () => {
|
|
410
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
411
|
+
};
|
|
412
|
+
const onAbort = () => {
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
this.pending.delete(id);
|
|
415
|
+
this.queue = this.queue.filter((queued) => queued.id !== id);
|
|
416
|
+
cleanupAbort();
|
|
417
|
+
rejectCommand(new Error("Chrome command aborted"));
|
|
418
|
+
};
|
|
419
|
+
const timer = setTimeout(() => {
|
|
420
|
+
const entry = this.pending.get(id);
|
|
421
|
+
this.pending.delete(id);
|
|
422
|
+
this.queue = this.queue.filter((queued) => queued.id !== id);
|
|
423
|
+
cleanupAbort();
|
|
424
|
+
rejectCommand(new Error(this.timeoutMessage(entry, timeoutMs)));
|
|
425
|
+
}, timeoutMs);
|
|
426
|
+
this.pending.set(id, {
|
|
427
|
+
command,
|
|
428
|
+
resolve: (value) => { cleanupAbort(); resolveCommand(value); },
|
|
429
|
+
reject: (err) => { cleanupAbort(); rejectCommand(err); },
|
|
430
|
+
timer,
|
|
431
|
+
});
|
|
432
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
433
|
+
this.enqueue(command);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Classify why a local command timed out so the agent isn't left guessing. The three
|
|
438
|
+
// distinct failure modes are: extension never polled (not installed / not running),
|
|
439
|
+
// extension polled but never picked up this command, and extension picked up the command
|
|
440
|
+
// but never posted a result back (long-running action or a failed /result post).
|
|
441
|
+
private timeoutMessage(entry: PendingCommand | undefined, timeoutMs: number): string {
|
|
442
|
+
const pollAgeMs = this.lastSeenAt === undefined ? undefined : Date.now() - this.lastSeenAt;
|
|
443
|
+
if (entry?.deliveredAt) {
|
|
444
|
+
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
|
+
}
|
|
446
|
+
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.`;
|
|
448
|
+
}
|
|
449
|
+
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
|
+
}
|
|
451
|
+
|
|
452
|
+
private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
|
453
|
+
const controller = new AbortController();
|
|
454
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
|
|
455
|
+
const forwardAbort = () => controller.abort();
|
|
456
|
+
if (signal) {
|
|
457
|
+
if (signal.aborted) controller.abort();
|
|
458
|
+
else signal.addEventListener("abort", forwardAbort, { once: true });
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
const response = await fetch(`${this.url}/command`, {
|
|
462
|
+
method: "POST",
|
|
463
|
+
headers: { "content-type": "application/json" },
|
|
464
|
+
body: JSON.stringify({ action, params, timeoutMs }),
|
|
465
|
+
signal: controller.signal,
|
|
466
|
+
});
|
|
467
|
+
const payload = (await response.json().catch(() => ({}))) as { ok?: boolean; result?: unknown; error?: string };
|
|
468
|
+
if (response.status === 404) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
"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
|
+
);
|
|
472
|
+
}
|
|
473
|
+
if (!response.ok || !payload.ok) throw new Error(payload.error ?? `Chrome bridge owner HTTP ${response.status}`);
|
|
474
|
+
return payload.result;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if ((error as Error).name === "AbortError") {
|
|
477
|
+
if (signal?.aborted) throw new Error("Chrome command aborted");
|
|
478
|
+
throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`);
|
|
479
|
+
}
|
|
480
|
+
// `fetch failed` / ECONNREFUSED means the Pi session that owned the bridge port is gone.
|
|
481
|
+
// Try to take over the port ourselves and re-run the command locally instead of staying
|
|
482
|
+
// stuck as a client pointed at a dead owner.
|
|
483
|
+
if (this.isOwnerUnreachable(error)) {
|
|
484
|
+
const promoted = await this.tryPromoteToServer().catch(() => false);
|
|
485
|
+
if (promoted) return this.sendLocal(action, params, timeoutMs, signal);
|
|
486
|
+
throw new Error(
|
|
487
|
+
"The Pi session that owned the Chrome bridge is unreachable and this session could not take over the bridge port. Restart this Pi session, or run /chrome doctor.",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
throw error;
|
|
491
|
+
} finally {
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private isOwnerUnreachable(error: unknown): boolean {
|
|
498
|
+
const message = (error as Error)?.message ?? "";
|
|
499
|
+
const code = (error as NodeJS.ErrnoException)?.code ?? "";
|
|
500
|
+
const cause = (error as { cause?: NodeJS.ErrnoException })?.cause;
|
|
501
|
+
const causeCode = cause?.code ?? "";
|
|
502
|
+
return (
|
|
503
|
+
/fetch failed|ECONNREFUSED|ECONNRESET|other side closed|socket hang up/i.test(message) ||
|
|
504
|
+
code === "ECONNREFUSED" ||
|
|
505
|
+
causeCode === "ECONNREFUSED" ||
|
|
506
|
+
causeCode === "ECONNRESET"
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private enqueue(command: BridgeCommand): void {
|
|
511
|
+
const waiter = this.waiters.shift();
|
|
512
|
+
if (waiter) waiter(command);
|
|
513
|
+
else this.queue.push(command);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
517
|
+
const url = new URL(request.url ?? "/", this.url);
|
|
518
|
+
const corsHeaders = corsHeadersFor(request);
|
|
519
|
+
if (request.method === "OPTIONS") {
|
|
520
|
+
if (!isBrowserOriginAllowed(request)) {
|
|
521
|
+
sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
sendJson(response, 200, { ok: true }, corsHeaders);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (request.method === "GET" && url.pathname === "/status") {
|
|
528
|
+
sendJson(response, 200, this.status());
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (request.method === "POST" && url.pathname === "/command") {
|
|
532
|
+
if (!isLocalProcessRequest(request)) {
|
|
533
|
+
sendJson(response, 403, { ok: false, error: "Chrome commands are accepted only from local Pi processes" });
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const body = JSON.parse(await readRequestBody(request)) as {
|
|
537
|
+
action?: string;
|
|
538
|
+
params?: Record<string, unknown>;
|
|
539
|
+
timeoutMs?: number;
|
|
540
|
+
};
|
|
541
|
+
if (!body.action) {
|
|
542
|
+
sendJson(response, 400, { ok: false, error: "Missing command action" });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const result = await this.sendLocal(body.action, body.params ?? {}, body.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
547
|
+
sendJson(response, 200, { ok: true, result });
|
|
548
|
+
} catch (error) {
|
|
549
|
+
sendJson(response, 504, { ok: false, error: (error as Error).message });
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (request.method === "GET" && url.pathname === "/next") {
|
|
554
|
+
if (!isBrowserOriginAllowed(request)) {
|
|
555
|
+
sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
this.lastSeenAt = Date.now();
|
|
559
|
+
this.clientName = url.searchParams.get("name") ?? undefined;
|
|
560
|
+
let aborted = false;
|
|
561
|
+
let activeWaiter: ((command: BridgeCommand | undefined) => void) | undefined;
|
|
562
|
+
request.once("close", () => {
|
|
563
|
+
aborted = true;
|
|
564
|
+
if (activeWaiter) this.waiters = this.waiters.filter((entry) => entry !== activeWaiter);
|
|
565
|
+
});
|
|
566
|
+
let command = this.queue.shift();
|
|
567
|
+
if (!command) {
|
|
568
|
+
command = await this.waitForCommand(25_000, (waiter) => {
|
|
569
|
+
activeWaiter = waiter;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (aborted) {
|
|
573
|
+
// Long-poll connection died before we could deliver. Requeue any command we pulled
|
|
574
|
+
// so the next live /next picks it up instead of dropping it on the floor.
|
|
575
|
+
if (command) this.queue.unshift(command);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// Mark the command as delivered so a later timeout can distinguish "extension never
|
|
579
|
+
// picked it up" from "extension is running it / failed to post a result".
|
|
580
|
+
if (command) {
|
|
581
|
+
const entry = this.pending.get(command.id);
|
|
582
|
+
if (entry) entry.deliveredAt = Date.now();
|
|
583
|
+
}
|
|
584
|
+
// Re-read version on every /next so bumping package.json takes effect without pi restart.
|
|
585
|
+
const currentVersion = readPiChromeVersion();
|
|
586
|
+
sendJson(
|
|
587
|
+
response,
|
|
588
|
+
200,
|
|
589
|
+
command
|
|
590
|
+
? { type: "command", command, expectedExtensionVersion: currentVersion }
|
|
591
|
+
: { type: "none", expectedExtensionVersion: currentVersion },
|
|
592
|
+
{ ...corsHeaders, "x-pi-chrome-version": currentVersion },
|
|
593
|
+
);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (request.method === "POST" && url.pathname === "/result") {
|
|
597
|
+
if (!isBrowserOriginAllowed(request)) {
|
|
598
|
+
sendJson(response, 403, { ok: false, error: "browser origin not allowed" });
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
this.lastSeenAt = Date.now();
|
|
602
|
+
const result = JSON.parse(await readRequestBody(request)) as BridgeResult;
|
|
603
|
+
const pending = this.pending.get(result.id);
|
|
604
|
+
if (!pending) {
|
|
605
|
+
sendJson(response, 404, { ok: false, error: "unknown command id" }, corsHeaders);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
clearTimeout(pending.timer);
|
|
609
|
+
this.pending.delete(result.id);
|
|
610
|
+
if (result.ok) pending.resolve(result.result);
|
|
611
|
+
else pending.reject(new Error(result.error ?? "Chrome extension command failed"));
|
|
612
|
+
sendJson(response, 200, { ok: true }, corsHeaders);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
sendJson(response, 404, { error: "not found" });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private waitForCommand(
|
|
619
|
+
timeoutMs: number,
|
|
620
|
+
registerWaiter?: (waiter: (command: BridgeCommand | undefined) => void) => void,
|
|
621
|
+
): Promise<BridgeCommand | undefined> {
|
|
622
|
+
return new Promise((resolveWait) => {
|
|
623
|
+
let settled = false;
|
|
624
|
+
const waiter = (command: BridgeCommand | undefined) => {
|
|
625
|
+
if (settled) return;
|
|
626
|
+
settled = true;
|
|
627
|
+
clearTimeout(timer);
|
|
628
|
+
this.waiters = this.waiters.filter((entry) => entry !== waiter);
|
|
629
|
+
resolveWait(command);
|
|
630
|
+
};
|
|
631
|
+
const timer = setTimeout(() => waiter(undefined), timeoutMs);
|
|
632
|
+
this.waiters.push(waiter);
|
|
633
|
+
registerWaiter?.(waiter);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const tabActionValues = ["list", "new", "activate", "close", "group", "ungroup", "version"] as const;
|
|
639
|
+
const imageFormatValues = ["png", "jpeg"] as const;
|
|
640
|
+
const waitForValues = ["selector", "expression"] as const;
|
|
641
|
+
const CHROME_TOOL_NAMES = [
|
|
642
|
+
"chrome_launch",
|
|
643
|
+
"chrome_tab",
|
|
644
|
+
"chrome_snapshot",
|
|
645
|
+
"chrome_navigate",
|
|
646
|
+
"chrome_evaluate",
|
|
647
|
+
"chrome_click",
|
|
648
|
+
"chrome_type",
|
|
649
|
+
"chrome_fill",
|
|
650
|
+
"chrome_key",
|
|
651
|
+
"chrome_wait_for",
|
|
652
|
+
"chrome_list_console_messages",
|
|
653
|
+
"chrome_list_network_requests",
|
|
654
|
+
"chrome_get_network_request",
|
|
655
|
+
"chrome_screenshot",
|
|
656
|
+
"chrome_hover",
|
|
657
|
+
"chrome_drag",
|
|
658
|
+
"chrome_tap",
|
|
659
|
+
"chrome_scroll",
|
|
660
|
+
"chrome_upload_file",
|
|
661
|
+
] as const;
|
|
662
|
+
const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
|
|
663
|
+
|
|
664
|
+
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>[]]);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export default function (pi: ExtensionAPI): void {
|
|
669
|
+
const instanceToken = Symbol("pi-chrome-instance");
|
|
670
|
+
const currentRoot = extensionRoot();
|
|
671
|
+
const globalState = globalThis as typeof globalThis & {
|
|
672
|
+
[PI_CHROME_GLOBAL_KEY]?: { version: string; root: string; token?: symbol };
|
|
673
|
+
[PI_CHROME_AUTH_KEY]?: { until: number | "indefinite" };
|
|
674
|
+
};
|
|
675
|
+
const alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];
|
|
676
|
+
if (alreadyLoaded?.token || (alreadyLoaded && alreadyLoaded.root !== currentRoot)) {
|
|
677
|
+
console.warn(
|
|
678
|
+
`pi-chrome already loaded from ${alreadyLoaded.root} (v${alreadyLoaded.version}); skipping duplicate from ${currentRoot}.`,
|
|
679
|
+
);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// pi-chrome <=0.15.19 set the singleton flag but did not clear it on reload.
|
|
683
|
+
// If the stale flag points at this same extension root, replace it instead of
|
|
684
|
+
// skipping the freshly reloaded extension.
|
|
685
|
+
globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: currentRoot, token: instanceToken };
|
|
686
|
+
|
|
687
|
+
const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
|
|
688
|
+
let backgroundDefault = true;
|
|
689
|
+
let chromeAuthorizedUntil: number | "indefinite" | undefined;
|
|
690
|
+
// Restore an authorization that survived a /reload. Drop it if it already expired.
|
|
691
|
+
const persistedAuth = globalState[PI_CHROME_AUTH_KEY];
|
|
692
|
+
if (persistedAuth) {
|
|
693
|
+
if (persistedAuth.until === "indefinite" || persistedAuth.until > Date.now()) {
|
|
694
|
+
chromeAuthorizedUntil = persistedAuth.until;
|
|
695
|
+
} else {
|
|
696
|
+
delete globalState[PI_CHROME_AUTH_KEY];
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const persistAuth = (): void => {
|
|
700
|
+
if (chromeAuthorizedUntil === undefined) delete globalState[PI_CHROME_AUTH_KEY];
|
|
701
|
+
else globalState[PI_CHROME_AUTH_KEY] = { until: chromeAuthorizedUntil };
|
|
702
|
+
};
|
|
703
|
+
let chromeToolsRegistered = false;
|
|
704
|
+
let authExpiryTimer: NodeJS.Timeout | undefined;
|
|
705
|
+
// Remembered so bridge sends can tag tabs with this session's group even when ctx isn't handy.
|
|
706
|
+
let sessionCtx: ExtensionContext | undefined;
|
|
707
|
+
|
|
708
|
+
const clearAuthExpiryTimer = (): void => {
|
|
709
|
+
if (!authExpiryTimer) return;
|
|
710
|
+
clearTimeout(authExpiryTimer);
|
|
711
|
+
authExpiryTimer = undefined;
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const activeToolNamesWithoutChrome = (): string[] => pi.getActiveTools().filter((name) => !CHROME_TOOL_NAME_SET.has(name));
|
|
715
|
+
|
|
716
|
+
const activateChromeTools = (): void => {
|
|
717
|
+
registerChromeTools(pi);
|
|
718
|
+
pi.setActiveTools([...new Set([...pi.getActiveTools(), ...CHROME_TOOL_NAMES])]);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const deactivateChromeTools = (): void => {
|
|
722
|
+
pi.setActiveTools(activeToolNamesWithoutChrome());
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const lockChromeControl = (): void => {
|
|
726
|
+
clearAuthExpiryTimer();
|
|
727
|
+
chromeAuthorizedUntil = undefined;
|
|
728
|
+
persistAuth();
|
|
729
|
+
deactivateChromeTools();
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const authSummary = (): string => {
|
|
733
|
+
if (chromeAuthorizedUntil === "indefinite") return "authorized indefinitely";
|
|
734
|
+
if (typeof chromeAuthorizedUntil === "number") {
|
|
735
|
+
const remainingMs = chromeAuthorizedUntil - Date.now();
|
|
736
|
+
if (remainingMs > 0) return `authorized for ~${Math.ceil(remainingMs / 60_000)}m`;
|
|
737
|
+
}
|
|
738
|
+
return "locked";
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const chromeControlAuthorized = (): boolean => {
|
|
742
|
+
if (chromeAuthorizedUntil === "indefinite") return true;
|
|
743
|
+
if (typeof chromeAuthorizedUntil === "number" && chromeAuthorizedUntil > Date.now()) return true;
|
|
744
|
+
if (chromeAuthorizedUntil !== undefined) lockChromeControl();
|
|
745
|
+
return false;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const requireChromeControlAuthorized = (): void => {
|
|
749
|
+
if (!chromeControlAuthorized()) {
|
|
750
|
+
throw new Error("Chrome control locked. Ask the user to run /chrome authorize before using chrome_* tools.");
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
// Tab-group title for this Pi session: prefer the user-set display name, else the session id.
|
|
755
|
+
const sessionGroupTitle = (ctx: ExtensionContext): string => {
|
|
756
|
+
const sm = ctx.sessionManager;
|
|
757
|
+
const name = sm.getSessionName?.();
|
|
758
|
+
const id = sm.getSessionId?.();
|
|
759
|
+
return `Pi Session: ${name || id || "unknown"}`;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const updateChromeStatus = (ctx: ExtensionContext): void => {
|
|
763
|
+
if (chromeControlAuthorized()) {
|
|
764
|
+
ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge");
|
|
765
|
+
} else {
|
|
766
|
+
ctx.ui.setStatus("chrome", undefined);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const scheduleAuthExpiry = (ctx: ExtensionContext, until: number | "indefinite"): void => {
|
|
771
|
+
clearAuthExpiryTimer();
|
|
772
|
+
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()));
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => {
|
|
786
|
+
requireChromeControlAuthorized();
|
|
787
|
+
// Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
|
|
788
|
+
// auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull
|
|
789
|
+
// the resolved (e.g. active) tab into the session group on the service-worker side. We skip
|
|
790
|
+
// 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;
|
|
792
|
+
const wireParams = shouldJoinGroup
|
|
793
|
+
? { ...params, sessionGroupTitle: sessionGroupTitle(sessionCtx as ExtensionContext), joinSessionGroup: true }
|
|
794
|
+
: params;
|
|
795
|
+
return bridge.send(action, wireParams, timeoutMs, signal);
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
// Translate the public `background` parameter (default on = silent/background) into the
|
|
799
|
+
// service worker's wire-level `foreground` flag, accepting legacy `foreground` as a fallback.
|
|
800
|
+
const withBackground = <T extends Record<string, unknown>>(params: T): T => {
|
|
801
|
+
const typed = params as { background?: boolean; foreground?: boolean };
|
|
802
|
+
const explicit =
|
|
803
|
+
typed.background !== undefined
|
|
804
|
+
? typed.background
|
|
805
|
+
: typed.foreground !== undefined
|
|
806
|
+
? !typed.foreground
|
|
807
|
+
: undefined;
|
|
808
|
+
const background = explicit ?? backgroundDefault;
|
|
809
|
+
return { ...params, foreground: !background } as T;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
813
|
+
sessionCtx = ctx;
|
|
814
|
+
await bridge.start();
|
|
815
|
+
// Reestablish in-memory state after a /reload restored chromeAuthorizedUntil from globalThis.
|
|
816
|
+
if (chromeControlAuthorized()) {
|
|
817
|
+
activateChromeTools();
|
|
818
|
+
if (typeof chromeAuthorizedUntil === "number") scheduleAuthExpiry(ctx, chromeAuthorizedUntil);
|
|
819
|
+
}
|
|
820
|
+
updateChromeStatus(ctx);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
pi.on("session_shutdown", () => {
|
|
824
|
+
clearAuthExpiryTimer();
|
|
825
|
+
bridge.stop();
|
|
826
|
+
if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) {
|
|
827
|
+
delete globalState[PI_CHROME_GLOBAL_KEY];
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
pi.on("before_agent_start", (event) => {
|
|
832
|
+
if (!chromeToolsRegistered || !chromeControlAuthorized()) {
|
|
833
|
+
return { systemPrompt: event.systemPrompt };
|
|
834
|
+
}
|
|
835
|
+
const primer = `
|
|
836
|
+
<chrome-profile-bridge>
|
|
837
|
+
Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
|
|
838
|
+
|
|
839
|
+
Capability model (important):
|
|
840
|
+
- Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
|
|
841
|
+
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
|
|
842
|
+
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
|
|
843
|
+
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly.
|
|
844
|
+
|
|
845
|
+
Usage rules:
|
|
846
|
+
1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
|
|
847
|
+
2. \`chrome_snapshot\` before clicking/typing; pass \`uid\` over \`selector\`.
|
|
848
|
+
3. \`includeSnapshot=true\` on click/type/fill/key to verify in one round trip.
|
|
849
|
+
4. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
|
|
850
|
+
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
|
|
851
|
+
6. By default chrome_* tools run in the background without focusing Chrome; pass \`background=false\` or run /chrome background off when the user wants to watch Chrome work.
|
|
852
|
+
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
|
|
853
|
+
8. Run /chrome doctor when in doubt about connectivity or capabilities.
|
|
854
|
+
</chrome-profile-bridge>`;
|
|
855
|
+
return { systemPrompt: event.systemPrompt + primer };
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
// Shared handlers, dispatched by the unified /chrome command below.
|
|
859
|
+
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;
|
|
867
|
+
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;
|
|
878
|
+
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.)`,
|
|
883
|
+
);
|
|
884
|
+
} else {
|
|
885
|
+
lines.push(`✓ Chrome is connected (companion extension v${version.extensionVersion ?? "?"}, responded in ${latencyMs}ms).`);
|
|
886
|
+
}
|
|
887
|
+
} 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
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
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.`);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
// Run-in-background (Chrome focus) handler. No args = toggle. Explicit on/off/status.
|
|
923
|
+
const BACKGROUND_DESC: Record<string, string> = {
|
|
924
|
+
on: "pi-chrome runs in the background; Chrome won't pop up or steal focus.",
|
|
925
|
+
off: "Chrome pops to the front and switches tabs so you can watch what pi-chrome is doing.",
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const backgroundHandler = async (ctx: ExtensionContext, args: string) => {
|
|
929
|
+
const arg = (args || "").trim().toLowerCase();
|
|
930
|
+
const currentLabel = backgroundDefault ? "on" : "off";
|
|
931
|
+
|
|
932
|
+
if (arg === "status") {
|
|
933
|
+
ctx.ui.notify(`Run in background is ${currentLabel}. ${BACKGROUND_DESC[currentLabel]}`, "info");
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (arg === "on" || arg === "true" || arg === "1") backgroundDefault = true;
|
|
938
|
+
else if (arg === "off" || arg === "false" || arg === "0") backgroundDefault = false;
|
|
939
|
+
else if (arg === "toggle" || arg === "") backgroundDefault = !backgroundDefault;
|
|
940
|
+
else {
|
|
941
|
+
ctx.ui.notify(`Unknown background setting '${arg}'. Pick one of: on | off | toggle | status.`, "warning");
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const nextLabel = backgroundDefault ? "on" : "off";
|
|
946
|
+
ctx.ui.notify(`Run in background → ${nextLabel}. ${BACKGROUND_DESC[nextLabel]}`, "info");
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const authorizeFor = async (ctx: ExtensionContext, label: string, until: number | "indefinite") => {
|
|
950
|
+
const ok = await ctx.ui.confirm(
|
|
951
|
+
"Authorize pi-chrome control?",
|
|
952
|
+
`This Pi session will be allowed to inspect and control your existing Chrome profile for ${label}.\n\nChrome actions use your signed-in browser state and real input. Only approve if you trust the current agent/task.`,
|
|
953
|
+
);
|
|
954
|
+
if (!ok) {
|
|
955
|
+
ctx.ui.notify("Chrome control remains locked.", "info");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
chromeAuthorizedUntil = until;
|
|
959
|
+
persistAuth();
|
|
960
|
+
activateChromeTools();
|
|
961
|
+
scheduleAuthExpiry(ctx, until);
|
|
962
|
+
ctx.ui.notify(`Chrome control authorized for ${label}.`, "info");
|
|
963
|
+
updateChromeStatus(ctx);
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const parseAuthorizeArg = (arg: string): { label: string; until: number | "indefinite" } | undefined => {
|
|
967
|
+
const normalized = arg.trim().toLowerCase() || "15m";
|
|
968
|
+
if (normalized === "indefinite" || normalized === "forever") return { label: "indefinitely", until: "indefinite" };
|
|
969
|
+
const minutes = normalized.endsWith("m") ? Number(normalized.slice(0, -1)) : Number(normalized);
|
|
970
|
+
if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
|
|
971
|
+
return { label: `${minutes} minutes`, until: Date.now() + minutes * 60_000 };
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const authorizeHandler = async (ctx: ExtensionContext, args: string) => {
|
|
975
|
+
const grant = parseAuthorizeArg(args);
|
|
976
|
+
if (!grant) {
|
|
977
|
+
ctx.ui.notify("Unknown authorize duration. Use minutes (15m, 30m, 45) or indefinite.", "warning");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
return authorizeFor(ctx, grant.label, grant.until);
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const revokeHandler = (ctx: ExtensionContext) => {
|
|
984
|
+
lockChromeControl();
|
|
985
|
+
ctx.ui.notify("Chrome control locked. Run /chrome authorize to allow chrome_* tools again.", "info");
|
|
986
|
+
updateChromeStatus(ctx);
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
const onboardHandler = async (ctx: ExtensionContext) => {
|
|
990
|
+
const extensionPath = browserExtensionPath();
|
|
991
|
+
const proceed = await ctx.ui.confirm(
|
|
992
|
+
"Install the pi-chrome Chrome extension?",
|
|
993
|
+
`This opens Chrome's extensions page and reveals the folder pi-chrome needs you to load.\n\nWhen the windows open, in Chrome:\n 1. Turn on 'Developer mode' (top-right toggle).\n 2. Click 'Load unpacked' and choose the folder that just opened in Finder, or paste this path:\n ${extensionPath}\n\nPress Enter to continue, or Esc to cancel.`,
|
|
994
|
+
);
|
|
995
|
+
if (!proceed) {
|
|
996
|
+
ctx.ui.notify("Cancelled. You can run /chrome onboard again whenever you're ready.", "info");
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
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);
|
|
1003
|
+
}
|
|
1004
|
+
ctx.ui.notify(
|
|
1005
|
+
"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.",
|
|
1006
|
+
"info",
|
|
1007
|
+
);
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome
|
|
1011
|
+
// picker and as the body of /chrome status.
|
|
1012
|
+
const statusSummary = async (): Promise<string> => {
|
|
1013
|
+
const parts: string[] = [];
|
|
1014
|
+
try {
|
|
1015
|
+
const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string };
|
|
1016
|
+
if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
|
|
1017
|
+
parts.push(`⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`);
|
|
1018
|
+
} else {
|
|
1019
|
+
parts.push(`✓ Chrome connected`);
|
|
1020
|
+
}
|
|
1021
|
+
} catch {
|
|
1022
|
+
parts.push(`✗ Chrome not responding`);
|
|
1023
|
+
}
|
|
1024
|
+
parts.push(`auth: ${authSummary()}`);
|
|
1025
|
+
parts.push(`background: ${backgroundDefault ? "on" : "off"}`);
|
|
1026
|
+
return parts.join(" · ");
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
const statusHandler = async (ctx: ExtensionContext) => {
|
|
1030
|
+
ctx.ui.notify("Checking Chrome connection…", "info");
|
|
1031
|
+
ctx.ui.notify(await statusSummary(), "info");
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
const openAuthorizeMenu = async (ctx: ExtensionContext): Promise<void> => {
|
|
1035
|
+
while (true) {
|
|
1036
|
+
const choice = await ctx.ui.select("Authorize Chrome control", [
|
|
1037
|
+
"15 minutes",
|
|
1038
|
+
"30 minutes",
|
|
1039
|
+
"Indefinite",
|
|
1040
|
+
"Custom minutes",
|
|
1041
|
+
]);
|
|
1042
|
+
if (!choice) return;
|
|
1043
|
+
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");
|
|
1047
|
+
case "Custom minutes": {
|
|
1048
|
+
const value = await ctx.ui.input("Authorize for how many minutes?", "45");
|
|
1049
|
+
if (!value) continue;
|
|
1050
|
+
return authorizeHandler(ctx, value);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
const openBackgroundMenu = async (ctx: ExtensionContext): Promise<void> => {
|
|
1057
|
+
const choice = await ctx.ui.select("Background / watch mode", [
|
|
1058
|
+
"Use Chrome in background",
|
|
1059
|
+
"Use Chrome in foreground",
|
|
1060
|
+
]);
|
|
1061
|
+
if (!choice) return;
|
|
1062
|
+
switch (choice) {
|
|
1063
|
+
case "Use Chrome in background": return backgroundHandler(ctx, "on");
|
|
1064
|
+
case "Use Chrome in foreground": return backgroundHandler(ctx, "off");
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const openCommandMenu = async (ctx: ExtensionContext): Promise<void> => {
|
|
1069
|
+
while (true) {
|
|
1070
|
+
ctx.ui.notify("Checking Chrome connection…", "info");
|
|
1071
|
+
const choice = await ctx.ui.select(`pi-chrome\n${await statusSummary()}`, [
|
|
1072
|
+
"Authorize Chrome control…",
|
|
1073
|
+
"Lock Chrome control",
|
|
1074
|
+
"Doctor / troubleshoot",
|
|
1075
|
+
"Background / watch mode…",
|
|
1076
|
+
"Install / onboard extension",
|
|
1077
|
+
]);
|
|
1078
|
+
if (!choice) return;
|
|
1079
|
+
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);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
pi.registerCommand("chrome", {
|
|
1090
|
+
description:
|
|
1091
|
+
"All pi-chrome controls in one place.\n /chrome authorize [15m|30m|<minutes>|indefinite] — allow this Pi session to use chrome_* tools.\n /chrome revoke — lock Chrome control.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — whether pi-chrome runs without focusing Chrome.\nRun with no arguments for an interactive picker that shows current state.",
|
|
1092
|
+
getArgumentCompletions: (prefix) => {
|
|
1093
|
+
const raw = prefix;
|
|
1094
|
+
const trimmedRight = raw.replace(/\s+$/, "");
|
|
1095
|
+
const tokens = trimmedRight ? trimmedRight.split(/\s+/) : [];
|
|
1096
|
+
const endsWithSpace = raw.length > 0 && raw !== trimmedRight;
|
|
1097
|
+
// Path = completed tokens; partial = the token currently being typed (or "" if cursor sits right after a space).
|
|
1098
|
+
const partial = endsWithSpace ? "" : (tokens.pop() ?? "");
|
|
1099
|
+
const path = tokens.map((t) => t.toLowerCase());
|
|
1100
|
+
const partialLower = partial.toLowerCase();
|
|
1101
|
+
|
|
1102
|
+
// Build candidate set with FULL argument-text values so pi-tui's apply-completion
|
|
1103
|
+
// (which replaces the entire argument) lands correctly even for nested paths.
|
|
1104
|
+
type Item = { fullValue: string; label: string; description: string };
|
|
1105
|
+
let candidates: Item[] = [];
|
|
1106
|
+
if (path.length === 0) {
|
|
1107
|
+
candidates = [
|
|
1108
|
+
{ fullValue: "authorize", label: "authorize", description: "Allow this Pi session to use chrome_* tools." },
|
|
1109
|
+
{ 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?" },
|
|
1114
|
+
];
|
|
1115
|
+
} else if (path[0] === "authorize" && path.length === 1) {
|
|
1116
|
+
candidates = [
|
|
1117
|
+
{ fullValue: "authorize 15m", label: "15m", description: "Authorize Chrome control for 15 minutes." },
|
|
1118
|
+
{ 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." },
|
|
1120
|
+
];
|
|
1121
|
+
} else if (path[0] === "background" && path.length === 1) {
|
|
1122
|
+
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." },
|
|
1126
|
+
{ fullValue: "background status", label: "status", description: "Show the current setting." },
|
|
1127
|
+
];
|
|
1128
|
+
}
|
|
1129
|
+
if (candidates.length === 0) return null;
|
|
1130
|
+
const filtered = candidates.filter((c) => c.label.toLowerCase().startsWith(partialLower));
|
|
1131
|
+
if (filtered.length === 0) return null;
|
|
1132
|
+
return filtered.map((c) => ({ value: c.fullValue, label: c.label, description: c.description }));
|
|
1133
|
+
},
|
|
1134
|
+
handler: async (args, ctx) => {
|
|
1135
|
+
const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
|
|
1136
|
+
if (tokens.length === 0) {
|
|
1137
|
+
await openCommandMenu(ctx);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const [head, ...rest] = tokens;
|
|
1141
|
+
const subArgs = rest.join(" ");
|
|
1142
|
+
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);
|
|
1148
|
+
case "background":
|
|
1149
|
+
return backgroundHandler(ctx, subArgs);
|
|
1150
|
+
case "settings": {
|
|
1151
|
+
// Legacy nested form: /chrome settings background ...
|
|
1152
|
+
const [setting, ...settingArgs] = rest;
|
|
1153
|
+
if (setting === "background") return backgroundHandler(ctx, settingArgs.join(" "));
|
|
1154
|
+
ctx.ui.notify(`'/chrome settings' was removed. Use /chrome background directly.`, "warning");
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
default:
|
|
1158
|
+
ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome authorize | revoke | status | doctor | onboard | background.`, "warning");
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
function registerChromeTools(pi: ExtensionAPI): void {
|
|
1164
|
+
if (chromeToolsRegistered) return;
|
|
1165
|
+
chromeToolsRegistered = true;
|
|
1166
|
+
|
|
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
|
+
});
|
|
1238
|
+
|
|
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
|
+
});
|
|
1272
|
+
|
|
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
|
+
});
|
|
1302
|
+
|
|
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.
|
|
1332
|
+
const snapshot = await authorizedBridgeSend(
|
|
1333
|
+
"page.snapshot",
|
|
1334
|
+
withBackground({
|
|
1335
|
+
...params,
|
|
1336
|
+
mode: "interactive",
|
|
1337
|
+
maxElements: MAX_ELEMENTS,
|
|
1338
|
+
nearUid: params.uid,
|
|
1339
|
+
query: params.selector,
|
|
1340
|
+
}),
|
|
1341
|
+
DEFAULT_TIMEOUT_MS,
|
|
1342
|
+
signal,
|
|
1343
|
+
);
|
|
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
|
+
});
|
|
1349
|
+
|
|
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
|
+
});
|
|
1375
|
+
|
|
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
|
+
});
|
|
1404
|
+
|
|
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
|
+
});
|
|
1437
|
+
|
|
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
|
+
});
|
|
1470
|
+
|
|
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
|
+
});
|
|
1504
|
+
|
|
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
|
+
});
|
|
1539
|
+
|
|
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
|
+
});
|
|
1561
|
+
|
|
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
|
+
});
|
|
1582
|
+
|
|
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
|
+
});
|
|
1604
|
+
|
|
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
|
+
});
|
|
1624
|
+
|
|
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 });
|
|
1670
|
+
}
|
|
1671
|
+
await writeFile(outputPath + ".json", JSON.stringify({ width, height, viewportHeight, dpr, tiles: manifest }, null, 2));
|
|
1672
|
+
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>,
|
|
1675
|
+
};
|
|
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
|
+
});
|
|
1683
|
+
|
|
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
|
+
});
|
|
1704
|
+
|
|
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
|
+
});
|
|
1730
|
+
|
|
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
|
+
});
|
|
1753
|
+
|
|
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
|
+
});
|
|
1775
|
+
|
|
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
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
}
|