aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 08:37:24 -0400
committerGitHub <noreply@github.com>2026-05-16 08:37:24 -0400
commit7992115c6dd9884e1a1f2992db531342d2916fcd (patch)
treec37f274c112a202af86144fd6034a8a54d7933eb
parent0ce964f978a613e6dbe624a09210fd71b420546e (diff)
Make chrome_* tools interruptible via AbortSignal (#1)
Thread the harness AbortSignal through authorizedBridgeSend, the ChromeProfileBridge send/sendLocal/sendViaOwner paths, and every chrome_* tool's execute(). Pressing Esc now rejects the in-flight bridge call immediately (most visibly fixing chrome_wait_for, which would otherwise block for the full timeoutMs).
-rw-r--r--CHANGELOG.md4
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/index.ts119
-rw-r--r--package.json2
4 files changed, 80 insertions, 47 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a22e451..60db118 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
All notable user-facing changes to `pi-chrome`.
+## 0.15.20 — 2026-05-15
+
+- **Interruptible `chrome_*` tools.** All `chrome_*` tools now honor the agent harness `AbortSignal`, so pressing Esc aborts in-flight bridge calls (including the long-polling `chrome_wait_for`) immediately instead of waiting out the full `timeoutMs`.
+
## 0.15.19 — 2026-05-14
- **Simpler package description.** README hero and npm/pi.dev description now use the same concise authorization-focused sentence.
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index 3b6c1a9..0d33b58 100644
--- a/extensions/chrome-profile-bridge/browser-extension/manifest.json
+++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Pi Chrome Connector",
- "version": "0.15.19",
+ "version": "0.15.20",
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
"permissions": [
"tabs",
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index f96bb12..48b726f 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -237,32 +237,58 @@ class ChromeProfileBridge {
this.mode = undefined;
}
- send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
- if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs);
- return this.sendLocal(action, params, timeoutMs);
+ send(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
+ if (this.mode === "client") return this.sendViaOwner(action, params, timeoutMs, signal);
+ return this.sendLocal(action, params, timeoutMs, signal);
}
- private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
+ private sendLocal(action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const command = { id, action, params };
return new Promise((resolveCommand, rejectCommand) => {
+ if (signal?.aborted) {
+ rejectCommand(new Error("Chrome command aborted"));
+ return;
+ }
+ const cleanupAbort = () => {
+ if (signal) signal.removeEventListener("abort", onAbort);
+ };
+ const onAbort = () => {
+ clearTimeout(timer);
+ this.pending.delete(id);
+ this.queue = this.queue.filter((queued) => queued.id !== id);
+ cleanupAbort();
+ rejectCommand(new Error("Chrome command aborted"));
+ };
const timer = setTimeout(() => {
this.pending.delete(id);
this.queue = this.queue.filter((queued) => queued.id !== id);
+ cleanupAbort();
rejectCommand(
new Error(
`Timed out waiting for Chrome extension after ${timeoutMs}ms. Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile.`,
),
);
}, timeoutMs);
- this.pending.set(id, { command, resolve: resolveCommand, reject: rejectCommand, timer });
+ this.pending.set(id, {
+ command,
+ resolve: (value) => { cleanupAbort(); resolveCommand(value); },
+ reject: (err) => { cleanupAbort(); rejectCommand(err); },
+ timer,
+ });
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
this.enqueue(command);
});
}
- private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown> {
+ private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
+ const forwardAbort = () => controller.abort();
+ if (signal) {
+ if (signal.aborted) controller.abort();
+ else signal.addEventListener("abort", forwardAbort, { once: true });
+ }
try {
const response = await fetch(`${this.url}/command`, {
method: "POST",
@@ -280,11 +306,13 @@ class ChromeProfileBridge {
return payload.result;
} catch (error) {
if ((error as Error).name === "AbortError") {
+ if (signal?.aborted) throw new Error("Chrome command aborted");
throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
+ if (signal) signal.removeEventListener("abort", forwardAbort);
}
}
@@ -458,9 +486,9 @@ export default function (pi: ExtensionAPI): void {
}
};
- const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> => {
+ const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => {
requireChromeControlAuthorized();
- return bridge.send(action, params, timeoutMs);
+ return bridge.send(action, params, timeoutMs, signal);
};
// Translate the public `background` parameter (default false = visible/foreground) into the
@@ -833,9 +861,9 @@ Usage rules:
useDefaultProfile: Type.Optional(Type.Boolean({ description: "Ignored; existing-profile access comes from the companion Chrome extension." })),
headless: Type.Optional(Type.Boolean({ description: "Ignored." })),
}),
- async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
+ async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
if (params.url && bridge.connected) {
- const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
}
return {
@@ -869,8 +897,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS, signal);
if (params.action === "list") {
const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>;
const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
@@ -900,11 +928,12 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
+ async execute(_id, params, signal): Promise<ToolTextResult> {
const snapshot = await authorizedBridgeSend(
"page.snapshot",
withBackground({ ...params, maxElements: params.maxElements ?? MAX_ELEMENTS }),
DEFAULT_TIMEOUT_MS,
+ signal,
);
return { content: [{ type: "text", text: truncateText(safeJson(snapshot)) }], details: { snapshot } };
},
@@ -930,8 +959,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.navigate", withBackground(params), (params.timeoutMs ?? 15_000) + 2_000, signal);
return { content: [{ type: "text", text: `Navigated to ${params.url}${params.initScript ? " (with initScript)" : ""}` }], details: { result: result as Json } };
},
});
@@ -954,8 +983,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const value = await authorizedBridgeSend("page.evaluate", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
const text = value === undefined
? "undefined"
: typeof value === "string"
@@ -987,8 +1016,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const raw = await authorizedBridgeSend("page.click", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const raw = await authorizedBridgeSend("page.click", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
@@ -1019,8 +1048,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const raw = await authorizedBridgeSend("page.type", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const raw = await authorizedBridgeSend("page.type", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
@@ -1051,8 +1080,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const raw = await authorizedBridgeSend("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const raw = await authorizedBridgeSend("page.fill", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
@@ -1086,8 +1115,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const raw = await authorizedBridgeSend("page.key", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const raw = await authorizedBridgeSend("page.key", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
const summary = summarizeActionResult(result);
const base = `Pressed ${params.key}.`;
@@ -1111,8 +1140,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.waitFor", params, (params.timeoutMs ?? 10_000) + 2_000, signal);
return { content: [{ type: "text", text: `Observed ${params.kind}: ${params.value}` }], details: { result: result as Json } };
},
});
@@ -1132,8 +1161,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.console.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
},
});
@@ -1154,8 +1183,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.network.list", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
},
});
@@ -1174,8 +1203,8 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.network.get", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: truncateText(safeJson(result)) }], details: { result: result as Json } };
},
});
@@ -1200,12 +1229,12 @@ Usage rules:
host: Type.Optional(Type.String()),
port: Type.Optional(Type.Number()),
}),
- async execute(_id, params, _signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
+ async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<ToolTextResult> {
const format = params.format ?? "png";
const cwd = workspaceCwd(ctx);
const defaultPath = join(cwd, ".pi", "chrome-screenshots", `${new Date().toISOString().replace(/[:.]/g, "-")}.${format}`);
const outputPath = params.path ? resolve(cwd, params.path) : defaultPath;
- const result = (await authorizedBridgeSend("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS)) as {
+ const result = (await authorizedBridgeSend("page.screenshot", withBackground(params), params.fullPage ? 120_000 : DEFAULT_TIMEOUT_MS, signal)) as {
dataUrl?: string;
tab?: unknown;
fullPage?: boolean;
@@ -1254,8 +1283,8 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: `Hovered ${params.uid ?? params.selector ?? `${params.x},${params.y}`}` }], details: { result: result as Json } };
},
});
@@ -1280,8 +1309,8 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: `Dragged from ${params.fromUid ?? params.fromSelector} to ${params.toUid ?? params.toSelector}` }], details: { result: result as Json } };
},
});
@@ -1302,8 +1331,8 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
},
@@ -1325,8 +1354,8 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
}),
- async execute(_id, params): Promise<ToolTextResult> {
- const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const result = await authorizedBridgeSend("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
},
});
@@ -1345,10 +1374,10 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
background: Type.Optional(Type.Boolean()),
}),
- async execute(_id, params, _signal, _onUpdate, ctx): Promise<ToolTextResult> {
+ async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
const cwd = workspaceCwd(ctx);
const paths = params.paths.map((p) => resolve(cwd, p));
- const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS);
+ const result = await authorizedBridgeSend("page.upload", withBackground({ ...params, paths }), DEFAULT_TIMEOUT_MS, signal);
return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
},
});
diff --git a/package.json b/package.json
index 49da905..3e5f4e1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pi-chrome",
- "version": "0.15.19",
+ "version": "0.15.20",
"scripts": {
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"