aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-08 12:37:44 +0000
committerIgor Soarez <igor@soarez.org>2026-11-08 12:37:44 +0000
commit66fbd3015ff68aa94f1faff1ad84740052b86a0d (patch)
treedf9a7be3c1d0f4fb91fcfa501eb33778a9a6ac6e
parente538c3e53e203fabb8a9fed94b6810c345ee6f80 (diff)
tabs: agent sessions share one window instead of opening one each
A session's first tab used chrome.windows.create, so every new session name (and every Claude Code process) got its own window. It now goes into the window that already holds any 'Pi Session:' group; a window is created only when none exists. chrome_tab new for a session with no group also uses that window, not the user's focused one. The primer and the chrome_tab description say so.
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js34
-rw-r--r--extensions/chrome-profile-bridge/index.ts5
-rw-r--r--package.json2
-rw-r--r--test-suite/unit/shared-window.test.mjs78
4 files changed, 113 insertions, 6 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index c62aff9..7fbbbba 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -226,11 +226,26 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
return false;
}
+// The window agents share. Every session has its own tab group and a group cannot span windows,
+// but sessions do not need their own windows: new tabs go to the window that already holds any
+// "Pi Session:" group, so agents stay together instead of opening one window each. Returns
+// undefined when no such window exists (the first session then creates one).
+async function sharedPiWindowId() {
+ if (!chrome.tabGroups || typeof chrome.windows?.get !== "function") return undefined;
+ const groups = await chrome.tabGroups.query({}).catch(() => []);
+ for (const group of groups) {
+ if (!/^pi session:/i.test((group.title || "").trim()) || typeof group.windowId !== "number") continue;
+ const win = await chrome.windows.get(group.windowId).catch(() => null);
+ if (win && (win.type === undefined || win.type === "normal")) return win.id ?? group.windowId;
+ }
+ return undefined;
+}
+
// Create a fresh automation target for `sessionKey`. If this session already has a tab group,
// create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
-// groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
-// tab. When the tab is created in a pre-existing group window, leave windowId unset so cleanup only
-// closes our tab, never that whole window.
+// groups cannot span windows). If it has none, use the shared Pi window; only when there is no
+// Pi window yet, create one; fall back to a tab. When the tab is created in a pre-existing window,
+// leave windowId unset so cleanup only closes our tab, never that whole window.
async function createAutomationTarget(sessionKey, groupTitle) {
const existingGroup = groupTitle ? await findGroupRecordByTitle(groupTitle) : null;
if (existingGroup && typeof existingGroup.windowId === "number") {
@@ -239,6 +254,14 @@ async function createAutomationTarget(sessionKey, groupTitle) {
await persistAutomationTargets();
return tab;
}
+ const sharedWindowId = await sharedPiWindowId();
+ if (sharedWindowId !== undefined) {
+ // Someone else's window: leave windowId unset so cleanup only ever closes our tab.
+ const tab = await chrome.tabs.create({ url: "about:blank", active: false, windowId: sharedWindowId });
+ automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined });
+ await persistAutomationTargets();
+ return tab;
+ }
if (chrome.windows && typeof chrome.windows.create === "function") {
try {
const win = await chrome.windows.create({ url: "about:blank", focused: false });
@@ -1825,6 +1848,11 @@ async function dispatch(action, params) {
const existingGroup = await findGroupRecordByTitle(groupTitle);
const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
+ else {
+ // No group of this session yet: use the shared Pi window, never the user's focused one.
+ const sharedWindowId = await sharedPiWindowId();
+ if (sharedWindowId !== undefined) createParams.windowId = sharedWindowId;
+ }
// Register the creation as in flight until the tab is tracked, so early capture (which only
// runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
const creation = (async () => {
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 90f080e..32ae79f 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1276,7 +1276,8 @@ Usage rules:
6. /chrome background on (default) is a hard policy: per-call \`background=false\` cannot override it, new tabs stay inactive, and \`chrome_tab activate\` is blocked. Ask the user to run /chrome background off when they want foreground/watch mode. With background off, per-call \`background=true\` still avoids explicit focus/tab activation. Screenshots use CDP without activating background tabs; debugger failures never fall back to tab activation. Page scripts, trusted input, native prompts, and Chrome/OS behavior can still affect focus.
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.
8. Run /chrome doctor when in doubt about connectivity or capabilities.
-9. A page can open dialogs you cannot see: alert, confirm, prompt, and the \"Leave site? Changes you made may not be saved\" prompt of a page with unsaved changes. They are dismissed for you (stay, cancel). \`chrome_navigate\` reports one by failing, and \`leaveIfAsked: true\` leaves anyway; use that only when you know nothing unsaved matters, and ask the user when unsure. If a click or key press that should have navigated did nothing, a Leave-site prompt may have stopped it: check the page, then ask the user before discarding anything.`;
+9. A page can open dialogs you cannot see: alert, confirm, prompt, and the \"Leave site? Changes you made may not be saved\" prompt of a page with unsaved changes. They are dismissed for you (stay, cancel). \`chrome_navigate\` reports one by failing, and \`leaveIfAsked: true\` leaves anyway; use that only when you know nothing unsaved matters, and ask the user when unsure. If a click or key press that should have navigated did nothing, a Leave-site prompt may have stopped it: check the page, then ask the user before discarding anything.
+10. All agent sessions share one browser window; your tabs sit in your own colored tab group inside it. Reuse a tab you already have (navigate it) rather than opening new ones, and open a new tab only when you need two pages at once. Do not open new windows unless the user asks.`;
// Shared handlers, dispatched by the unified /chrome command below.
const doctorHandler = async (ctx: ExtensionContext) => {
@@ -1651,7 +1652,7 @@ Usage rules:
pi.registerTool({
name: "chrome_tab",
label: "Chrome Tab",
- description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group. Background mode keeps new tabs inactive and blocks activate; ask the user to run /chrome background off for foreground/watch mode. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
+ description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group, inside the one window all agent sessions share; prefer navigating a tab you already have to opening another. Background mode keeps new tabs inactive and blocks activate; ask the user to run /chrome background off for foreground/watch mode. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
parameters: Type.Object({
action: StringEnum(tabActionValues),
diff --git a/package.json b/package.json
index de5cabb..c9b422b 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "pi-chrome",
"version": "0.15.57",
"scripts": {
- "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs && node test-suite/unit/upload-guard.test.mjs && node test-suite/unit/grant-scope.test.mjs && node test-suite/unit/leave-site-prompt.test.mjs",
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs && node test-suite/unit/upload-guard.test.mjs && node test-suite/unit/grant-scope.test.mjs && node test-suite/unit/leave-site-prompt.test.mjs && node test-suite/unit/shared-window.test.mjs",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
diff --git a/test-suite/unit/shared-window.test.mjs b/test-suite/unit/shared-window.test.mjs
new file mode 100644
index 0000000..7afeb77
--- /dev/null
+++ b/test-suite/unit/shared-window.test.mjs
@@ -0,0 +1,78 @@
+// Agent sessions share one window: a new session's tab goes into the window that already holds a
+// "Pi Session:" group, and a window is created only when there is none.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { test } from "node:test";
+
+const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
+
+function worker({ groups = [], windows = { 5: { id: 5, type: "normal" } } } = {}) {
+ const calls = [];
+ const listener = { addListener() {}, removeListener() {} };
+ let nextTab = 100;
+ const w = {
+ console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); },
+ chrome: {
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener },
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
+ debugger: { onDetach: listener, onEvent: listener, getTargets: (cb) => cb([]) },
+ storage: { session: { get: async () => ({}), set: async (v) => { calls.push({ method: 'storage.set', json: JSON.stringify(v) }); } } },
+ tabGroups: { query: async () => groups },
+ windows: {
+ get: async (id) => windows[id] ?? Promise.reject(new Error("No window")),
+ create: async (props) => { calls.push({ method: "windows.create", props }); return { id: 9, tabs: [{ id: 900, windowId: 9 }] }; },
+ },
+ tabs: {
+ create: async (props) => { calls.push({ method: "tabs.create", props }); return { id: nextTab++, windowId: props.windowId }; },
+ query: async () => [], get: async () => null, onUpdated: listener,
+ },
+ },
+ };
+ w.self = w;
+ vm.runInNewContext(workerSource, w);
+ return { w, calls };
+}
+
+const piGroup = (windowId, name = "Pi Session: claude-code 1 other") => ({ id: 1, windowId, title: name });
+
+test("a new session's tab goes into the window that already holds another Pi session", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5)] });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["tabs.create"]);
+ assert.equal(calls[0].props.windowId, 5);
+});
+
+test("with no Pi window yet, the first session creates one", async () => {
+ const { w, calls } = worker({ groups: [] });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
+});
+
+test("groups that are not Pi sessions do not attract agent tabs", async () => {
+ const { w, calls } = worker({ groups: [{ id: 2, windowId: 5, title: "Work" }] });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
+});
+
+test("a Pi group whose window is gone, or is not a normal window, is skipped", async () => {
+ const { w, calls } = worker({ groups: [piGroup(7), piGroup(8)], windows: { 8: { id: 8, type: "popup" } } });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ assert.deepEqual(calls.filter((c) => c.method !== "storage.set").map((c) => c.method), ["windows.create"]);
+});
+
+test("the shared window is never recorded as ours, so cleanup cannot close it", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5)] });
+ await w.createAutomationTarget("claude-code 2 mine", "Pi Session: claude-code 2 mine");
+ const stored = calls.filter((c) => c.method === "storage.set").map((c) => c.json).join("");
+ assert.doesNotMatch(stored, /"windowId":5/);
+});
+
+test("chrome_tab new for a session with no group uses the shared window, not the focused one", async () => {
+ const { w, calls } = worker({ groups: [piGroup(5)] });
+ w.groupTab = async (tab) => tab;
+ w.bringToFront = async () => {};
+ await w.dispatch("tab.new", { url: "about:blank", groupTitle: "Pi Session: claude-code 2 mine", sessionKey: "claude-code 2 mine" });
+ const created = calls.find((c) => c.method === "tabs.create");
+ assert.equal(created.props.windowId, 5);
+});