pi-chrome 0.15.38 → 0.15.39

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 CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.39 — 2026-06-07
6
+
7
+ - **Dedicated automation tab/window (no more hijacking your active tab).** When a chrome_* action runs without an explicit target, pi-chrome now opens and reuses a dedicated automation window it owns (falling back to a dedicated tab if a separate window can't be created) instead of navigating whatever tab you currently have open. Your existing tabs/windows are left untouched. Pass `targetId`/`urlIncludes`/`titleIncludes` to act on a specific existing tab.
8
+ - **Per session.** Ownership is scoped to the calling Pi session, so concurrent sessions sharing one companion extension each get their own automation window instead of fighting over a tab.
9
+ - **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
10
+ - **Guarded tab management.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no target now act on the session's automation tab if present, otherwise error — they no longer fall back to (or spawn a throwaway tab to touch) your active tab.
11
+ - **Safe, non-blocking cleanup.** The owned target is closed on `/chrome revoke` and on real session end (never on `/reload`); cleanup only ever closes the calling session's own window/tab — never user tabs/windows or another session's target — and is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
12
+ - **One session, one tab group.** `chrome_tab new` now reuses this session's existing Pi tab group even when another Chrome window is focused, creating the tab in that group's window because Chrome tab groups cannot span windows. The old `group:false` / `groupTitle:""` opt-out is ignored; Pi-created tabs are never intentionally left ungrouped, and a grouping failure closes the just-created tab before returning an error.
13
+
5
14
  ## 0.15.38 — 2026-06-07
6
15
 
7
16
  - **Overlay-safe click/fill fallbacks.** `chrome_click` and `chrome_fill` now fall back to DOM-dispatched click/value events when Chrome's debugger input path is blocked by another extension overlay (for example password-manager/autofill UI), unless `domFallback:false` is passed.
package/README.md CHANGED
@@ -173,6 +173,17 @@ Agents can verify page state immediately instead of blindly retrying.
173
173
 
174
174
  Each tool is documented inline in Pi — agents see the parameters and gotchas (Chrome input, CSP limits, file upload behavior) without trial-and-error.
175
175
 
176
+ ### Tab/window isolation
177
+
178
+ pi-chrome never overwrites the tab you're currently looking at. The first time a chrome_* action runs without an explicit target, pi-chrome opens a **dedicated automation window** (falling back to a dedicated tab if a separate window can't be created) and reuses it for the rest of the session. Your existing tabs and windows are left untouched. To point pi-chrome at a specific tab you already have open, pass `targetId`/`urlIncludes`/`titleIncludes`.
179
+
180
+ Details:
181
+
182
+ - **Per session.** Each Pi session owns its *own* automation window, so concurrent sessions (which all share one companion extension) never fight over a tab.
183
+ - **Survives `/reload` and Chrome service-worker restarts.** Ownership is tracked by id and mirrored to `chrome.storage.session`, so the window is reused rather than orphaned.
184
+ - **Cleanup is safe.** The dedicated target is closed when you revoke Chrome control (`/chrome revoke`) and on real session end — never on `/reload`. Cleanup only ever closes the calling session's own automation window/tab; user tabs/windows and other sessions' targets are never closed. Cleanup is fire-and-forget so it never blocks `/quit`, `/reload`, or session end.
185
+ - **Management actions are guarded.** `chrome_tab` `activate`/`close`/`group`/`ungroup` with no explicit target act on the session's automation tab if it exists, otherwise they error instead of touching your active tab.
186
+
176
187
  ### Known limits vs. human Chrome use
177
188
 
178
189
  pi-chrome is strongest on web-page workflows exposed through DOM, screenshots, tabs, and Chrome input. It is not a full human/OS substitute. Current limitations include native Chrome/OS surfaces (print/save dialogs, permission bubbles, password-manager prompts), cross-origin iframe DOM access, rich multitouch/pinch/stylus gestures, visual CAPTCHA/bot challenges, hardware-backed auth (passkeys/security keys/biometrics), and arbitrary OS app interaction. For strict-CSP pages, use screenshot + coordinate input when `chrome_snapshot`/`chrome_evaluate` are blocked.
package/docs/FAQ.md CHANGED
@@ -30,7 +30,15 @@ Chrome control is also locked per Pi session until you run `/chrome authorize`;
30
30
 
31
31
  ## Can multiple Pi sessions use it at once?
32
32
 
33
- Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Each Pi session must be authorized with `/chrome authorize` before its chrome_* tools work.
33
+ Yes. The first session opens the local bridge; later sessions detect it and pipe their commands through the same bridge. Each Pi session must be authorized with `/chrome authorize` before its chrome_* tools work. Each session also owns its **own** dedicated automation window (ownership is keyed by session id inside the one extension), so concurrent sessions never navigate into or close each other's tabs.
34
+
35
+ ## Does pi-chrome navigate my current tab?
36
+
37
+ No. The first chrome_* action that has no explicit target opens a **dedicated automation window** that pi-chrome owns (falling back to a dedicated tab only if a separate window can't be created), and reuses it for the rest of the session. Your existing tabs and windows are never reused or overwritten. Pass `targetId`/`urlIncludes`/`titleIncludes` to deliberately act on a tab you already have open.
38
+
39
+ The window survives `/reload` and Chrome service-worker restarts because ownership is tracked by id and mirrored to `chrome.storage.session`. It is closed when you run `/chrome revoke` and on real session end (not on `/reload`); cleanup only ever closes that session's own window/tab.
40
+
41
+ **After a full browser restart**, `chrome.storage.session` is cleared by Chrome. If Chrome's session-restore reopens the old automation window, pi-chrome no longer recognizes it (its tracking is gone), so it is left alone as an ordinary window — pi-chrome will open a fresh dedicated window for the new run rather than reclaim or close the restored one. pi-chrome never closes a window it can't positively identify as its own, so a user window is never at risk.
34
42
 
35
43
  ## Why ship as an unpacked extension?
36
44
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.38",
4
+ "version": "0.15.39",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -10,6 +10,151 @@ const SCRIPTING_TIMEOUT_MS = 8_000;
10
10
  const ATTACH_TIMEOUT_MS = 3_000;
11
11
  let polling = false;
12
12
 
13
+ // =================== pi-chrome automation target ownership ===================
14
+ // pi-chrome must never hijack the user's active tab. When a page/navigation action runs without
15
+ // an explicit target (targetId/urlIncludes/titleIncludes), we route it to a dedicated automation
16
+ // target that pi-chrome created and owns. We prefer a separate Chrome window so the user's
17
+ // windows are left untouched; if the windows API is unavailable we fall back to a dedicated tab.
18
+ //
19
+ // Ownership is SESSION-SCOPED, keyed by the calling Pi session's `sessionKey` (forwarded on the
20
+ // wire). One Chrome extension / service worker brokers commands for *all* Pi sessions (see the
21
+ // client/server bridge in index.ts), so a single global target would make concurrent sessions
22
+ // fight over one window. A per-session map gives each session its own isolated window and lets
23
+ // cleanup close exactly that session's target — never another session's, never a user's.
24
+ //
25
+ // State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the
26
+ // worker at any time) re-hydrates ownership instead of orphaning the window it already created.
27
+ // storage.session is cleared on browser restart; any window restored by Chrome's session-restore
28
+ // is then untracked and simply left alone (we only ever close ids we still recognize as ours).
29
+ const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
30
+ const DEFAULT_SESSION_KEY = "__default__";
31
+ const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
32
+ let automationHydrated = false;
33
+
34
+ function sessionKeyOf(params) {
35
+ return params && typeof params.sessionKey === "string" && params.sessionKey
36
+ ? params.sessionKey
37
+ : DEFAULT_SESSION_KEY;
38
+ }
39
+
40
+ // Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best
41
+ // effort: storage may be unavailable on old Chrome, and a failure just means we may create a
42
+ // fresh window (a harmless orphan) rather than reusing one.
43
+ async function hydrateAutomationTargets() {
44
+ if (automationHydrated) return;
45
+ automationHydrated = true;
46
+ try {
47
+ const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
48
+ const saved = stored && stored[AUTOMATION_STORAGE_KEY];
49
+ if (saved && typeof saved === "object") {
50
+ for (const [key, value] of Object.entries(saved)) {
51
+ if (value && typeof value.tabId === "number") {
52
+ automationTargets.set(key, {
53
+ windowId: typeof value.windowId === "number" ? value.windowId : undefined,
54
+ tabId: value.tabId,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ } catch {
60
+ // Ignore: treat as "no persisted state".
61
+ }
62
+ }
63
+
64
+ async function persistAutomationTargets() {
65
+ try {
66
+ const obj = {};
67
+ for (const [key, value] of automationTargets) {
68
+ obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId };
69
+ }
70
+ await chrome.storage?.session?.set?.({ [AUTOMATION_STORAGE_KEY]: obj });
71
+ } catch {
72
+ // Ignore: persistence is an optimization, not a correctness requirement.
73
+ }
74
+ }
75
+
76
+ // True if `tabId` is a pi-chrome-owned automation tab. Pass `sessionKey` to check a specific
77
+ // session; omit it to check ownership across *any* session (used as a safety predicate so we
78
+ // never operate on a user-created tab). Never infers ownership from "active".
79
+ function isPiChromeOwnedTarget(tabId, sessionKey) {
80
+ if (typeof tabId !== "number") return false;
81
+ if (sessionKey !== undefined) {
82
+ const t = automationTargets.get(sessionKey);
83
+ return !!t && t.tabId === tabId;
84
+ }
85
+ for (const t of automationTargets.values()) if (t.tabId === tabId) return true;
86
+ return false;
87
+ }
88
+
89
+ // Create a fresh dedicated automation target for `sessionKey`. Prefer an isolated window; fall
90
+ // back to a tab. Structured so a window can be the default while tab-fallback stays a one-liner.
91
+ async function createAutomationTarget(sessionKey) {
92
+ if (chrome.windows && typeof chrome.windows.create === "function") {
93
+ try {
94
+ const win = await chrome.windows.create({ url: "about:blank", focused: false });
95
+ const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
96
+ if (created && typeof created.id === "number") {
97
+ automationTargets.set(sessionKey, { windowId: typeof win.id === "number" ? win.id : undefined, tabId: created.id });
98
+ await persistAutomationTargets();
99
+ return created;
100
+ }
101
+ } catch {
102
+ // Window creation can fail (policy, headless, etc.); fall back to a dedicated tab below.
103
+ }
104
+ }
105
+ // Tab fallback: the tab lives in a pre-existing (user/shared) window we did NOT create, so we
106
+ // must leave windowId unset — cleanup then closes only our tab, never the user's window.
107
+ const tab = await chrome.tabs.create({ url: "about:blank", active: false });
108
+ automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined });
109
+ await persistAutomationTargets();
110
+ return tab;
111
+ }
112
+
113
+ // Return the session's owned automation target if it still exists, else null. Robust to the user
114
+ // (or Chrome) having closed it: a stale entry is forgotten so callers can recreate cleanly.
115
+ async function resolveOwnedAutomationTarget(sessionKey) {
116
+ await hydrateAutomationTargets();
117
+ const t = automationTargets.get(sessionKey);
118
+ if (!t || typeof t.tabId !== "number") return null;
119
+ const existing = await chrome.tabs.get(t.tabId).catch(() => null);
120
+ if (existing && typeof existing.id === "number") return existing;
121
+ automationTargets.delete(sessionKey);
122
+ await persistAutomationTargets();
123
+ return null;
124
+ }
125
+
126
+ // Return the session's dedicated automation target, creating it on first use (or after the user
127
+ // closed it). Used by page/navigation actions that need a live surface to drive.
128
+ async function getOrCreateAutomationTarget(sessionKey) {
129
+ return (await resolveOwnedAutomationTarget(sessionKey)) || createAutomationTarget(sessionKey);
130
+ }
131
+
132
+ // Close only the session's pi-chrome-owned window/tab, and only if it still exists. Never touches
133
+ // user tabs/windows or other sessions' targets. Safe to call repeatedly and when nothing exists.
134
+ async function cleanupAutomationTarget(sessionKey) {
135
+ await hydrateAutomationTargets();
136
+ const t = automationTargets.get(sessionKey);
137
+ automationTargets.delete(sessionKey);
138
+ await persistAutomationTargets();
139
+ if (!t) return { closedWindowId: null, closedTabId: null };
140
+ const { windowId, tabId } = t;
141
+ if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
142
+ const win = await chrome.windows.get(windowId).catch(() => null);
143
+ if (win) {
144
+ await chrome.windows.remove(windowId).catch(() => {});
145
+ return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
146
+ }
147
+ }
148
+ if (typeof tabId === "number") {
149
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
150
+ if (tab) {
151
+ await chrome.tabs.remove(tabId).catch(() => {});
152
+ return { closedWindowId: null, closedTabId: tabId };
153
+ }
154
+ }
155
+ return { closedWindowId: null, closedTabId: null };
156
+ }
157
+
13
158
  function withTimeout(promise, ms, label, onTimeout) {
14
159
  let timer;
15
160
  return Promise.race([
@@ -928,8 +1073,10 @@ async function groupRecord(groupId) {
928
1073
  };
929
1074
  }
930
1075
 
931
- // Find an existing tab group in `windowId` whose title matches `title` (case-insensitive).
932
- // Used so all Pi-opened tabs collect into one group per window instead of spawning new ones.
1076
+ // Find existing tab groups whose title matches `title` (case-insensitive).
1077
+ // Same-window lookup is used when grouping an already-created tab. Any-window lookup is used before
1078
+ // creating a new Pi tab so one Pi session keeps one tab group and new tabs are created in that
1079
+ // group's window (Chrome tab groups cannot span windows).
933
1080
  async function findGroupByTitle(windowId, title) {
934
1081
  if (!chrome.tabGroups) return null;
935
1082
  const wanted = cleanGroupTitle(title).toLowerCase();
@@ -938,6 +1085,13 @@ async function findGroupByTitle(windowId, title) {
938
1085
  return match ? match.id : null;
939
1086
  }
940
1087
 
1088
+ async function findGroupRecordByTitle(title) {
1089
+ if (!chrome.tabGroups) return null;
1090
+ const wanted = cleanGroupTitle(title).toLowerCase();
1091
+ const groups = await chrome.tabGroups.query({}).catch(() => []);
1092
+ return groups.find((g) => (g.title || "").trim().toLowerCase() === wanted) || null;
1093
+ }
1094
+
941
1095
  // Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
942
1096
  // existing same-title group in its window when present, otherwise create a new group.
943
1097
  async function groupTab(tab, title, color) {
@@ -970,28 +1124,40 @@ async function dispatch(action, params) {
970
1124
  return Promise.all(tabs.map(formatTab));
971
1125
  }
972
1126
  case "tab.new": {
973
- const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
974
- // Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out.
975
- const optOut = params.groupTitle === "" || params.group === false;
976
- if (optOut && !params.groupColor) return formatTab(tab);
977
- return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
1127
+ // Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
1128
+ // Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
1129
+ // tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
1130
+ const groupTitle = params.groupTitle || "Pi";
1131
+ const existingGroup = await findGroupRecordByTitle(groupTitle);
1132
+ const createParams = { url: params.url || "about:blank", active: true };
1133
+ if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
1134
+ const tab = await chrome.tabs.create(createParams);
1135
+ try {
1136
+ return await groupTab(tab, groupTitle, params.groupColor);
1137
+ } catch (error) {
1138
+ if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
1139
+ throw error;
1140
+ }
978
1141
  }
979
1142
  case "tab.activate": {
980
- const tab = await getTabByParams(params);
1143
+ // Management actions never auto-create an automation target (createOwnedTarget:false): with
1144
+ // no explicit target they act on an owned target if one exists, else error — they must never
1145
+ // fall back to (or spawn a tab just to touch) the user's active tab.
1146
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
981
1147
  await chrome.windows.update(tab.windowId, { focused: true });
982
1148
  return formatTab(await chrome.tabs.update(tab.id, { active: true }));
983
1149
  }
984
1150
  case "tab.group": {
985
- const tab = await getTabByParams(params);
1151
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
986
1152
  return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
987
1153
  }
988
1154
  case "tab.ungroup": {
989
- const tab = await getTabByParams(params);
1155
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
990
1156
  if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
991
1157
  return formatTab(await chrome.tabs.get(tab.id));
992
1158
  }
993
1159
  case "tab.close": {
994
- const tab = await getTabByParams(params);
1160
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
995
1161
  await chrome.tabs.remove(tab.id);
996
1162
  return { closed: tab.id };
997
1163
  }
@@ -1073,6 +1239,16 @@ async function dispatch(action, params) {
1073
1239
  }
1074
1240
  case "page.screenshot":
1075
1241
  return takeScreenshot(params);
1242
+ case "automation.status": {
1243
+ // Report this session's owned automation target (ids only). Used for diagnostics/tests.
1244
+ await hydrateAutomationTargets();
1245
+ const t = automationTargets.get(sessionKeyOf(params));
1246
+ return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
1247
+ }
1248
+ case "automation.cleanup":
1249
+ // Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
1250
+ // another Pi session's target.
1251
+ return cleanupAutomationTarget(sessionKeyOf(params));
1076
1252
  default:
1077
1253
  throw new Error(`Unknown action: ${action}`);
1078
1254
  }
@@ -1094,7 +1270,23 @@ async function formatTab(tab) {
1094
1270
  };
1095
1271
  }
1096
1272
 
1097
- async function getTabByParams(params) {
1273
+ // Resolve which Chrome tab an action targets.
1274
+ //
1275
+ // Explicit targeting (targetId / urlIncludes / titleIncludes) is unchanged: callers can still act
1276
+ // on any existing tab, including a user tab, when they ask for it by name. Only the implicit
1277
+ // "no target given" case changed — it used to grab the user's *active* tab (and page.navigate
1278
+ // would then overwrite it); it now resolves to this Pi session's dedicated automation target.
1279
+ //
1280
+ // `createOwnedTarget` controls the implicit case:
1281
+ // - true (default): create the automation target on first use. Used by every page/content
1282
+ // action — page.navigate, click/type/fill/key/hover/drag/scroll/tap/upload, snapshot,
1283
+ // inspect, evaluate, screenshot, waitFor, console/network list, probe. These need a live
1284
+ // surface to drive, so auto-creating is correct and they no longer touch the user's tab.
1285
+ // - false: do NOT create. Used by tab.activate/close/group/ungroup (tab *management*): with no
1286
+ // explicit target they operate on an already-owned automation target if one exists, else
1287
+ // throw asking for an explicit target — so e.g. `chrome_tab close` can never silently close
1288
+ // the user's active tab the way it used to, and never spawns a throwaway tab just to close it.
1289
+ async function getTabByParams(params, { createOwnedTarget = true } = {}) {
1098
1290
  const tabs = await chrome.tabs.query({});
1099
1291
  let tab;
1100
1292
  if (params.targetId !== undefined) {
@@ -1119,8 +1311,20 @@ async function getTabByParams(params) {
1119
1311
  } else if (params.titleIncludes) {
1120
1312
  tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes));
1121
1313
  } else {
1122
- const active = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
1123
- tab = active[0] || tabs.find((candidate) => candidate.active) || tabs[0];
1314
+ // No explicit target: use this session's dedicated automation target instead of hijacking the
1315
+ // user's active tab. This keeps human browsing and Pi automation separated — navigating here
1316
+ // never replaces whatever the user currently has open. Callers that *want* a specific
1317
+ // existing tab pass targetId/urlIncludes/titleIncludes above.
1318
+ const sessionKey = sessionKeyOf(params);
1319
+ tab = createOwnedTarget
1320
+ ? await getOrCreateAutomationTarget(sessionKey)
1321
+ : await resolveOwnedAutomationTarget(sessionKey);
1322
+ if (!tab) {
1323
+ throw new Error(
1324
+ "No target tab specified and this Pi session has no automation tab yet. " +
1325
+ "Pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first.",
1326
+ );
1327
+ }
1124
1328
  }
1125
1329
  if (!tab?.id) throw new Error("No matching Chrome tab found");
1126
1330
  const url = tab.url || "";
@@ -702,6 +702,7 @@ export default function (pi: ExtensionAPI): void {
702
702
  };
703
703
  let chromeToolsRegistered = false;
704
704
  let authExpiryTimer: NodeJS.Timeout | undefined;
705
+ let countdownInterval: NodeJS.Timeout | undefined;
705
706
  // Remembered so bridge sends can tag tabs with this session's group even when ctx isn't handy.
706
707
  let sessionCtx: ExtensionContext | undefined;
707
708
 
@@ -711,6 +712,12 @@ export default function (pi: ExtensionAPI): void {
711
712
  authExpiryTimer = undefined;
712
713
  };
713
714
 
715
+ const clearCountdownInterval = (): void => {
716
+ if (!countdownInterval) return;
717
+ clearInterval(countdownInterval);
718
+ countdownInterval = undefined;
719
+ };
720
+
714
721
  const activeToolNamesWithoutChrome = (): string[] => pi.getActiveTools().filter((name) => !CHROME_TOOL_NAME_SET.has(name));
715
722
 
716
723
  const activateChromeTools = (): void => {
@@ -722,11 +729,23 @@ export default function (pi: ExtensionAPI): void {
722
729
  pi.setActiveTools(activeToolNamesWithoutChrome());
723
730
  };
724
731
 
732
+ // Close THIS session's dedicated automation window/tab. Fire-and-forget and best-effort: it
733
+ // must never block /quit, /reload, revoke, or session end, and the service-worker side only
734
+ // ever closes targets this session created itself (never user tabs/windows, never another
735
+ // session's target). Errors (bridge down, target already closed) are intentionally swallowed.
736
+ const cleanupAutomationTargetBestEffort = (): void => {
737
+ const sessionKey = sessionKeyFor(sessionCtx);
738
+ void bridge.send("automation.cleanup", sessionKey !== undefined ? { sessionKey } : {}, 3_000).catch(() => undefined);
739
+ };
740
+
725
741
  const lockChromeControl = (): void => {
726
742
  clearAuthExpiryTimer();
743
+ clearCountdownInterval();
727
744
  chromeAuthorizedUntil = undefined;
728
745
  persistAuth();
729
746
  deactivateChromeTools();
747
+ // Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
748
+ cleanupAutomationTargetBestEffort();
730
749
  };
731
750
 
732
751
  const authSummary = (): string => {
@@ -759,16 +778,51 @@ export default function (pi: ExtensionAPI): void {
759
778
  return `Pi Session: ${name || id || "unknown"}`;
760
779
  };
761
780
 
781
+ const authCountdownLabel = (): string => {
782
+ if (chromeAuthorizedUntil === "indefinite") return " (indefinite)";
783
+ if (typeof chromeAuthorizedUntil === "number") {
784
+ const remainingMs = chromeAuthorizedUntil - Date.now();
785
+ if (remainingMs > 0) {
786
+ const mins = Math.ceil(remainingMs / 60_000);
787
+ return mins >= 1 ? ` (${mins}m)` : " (<1m)";
788
+ }
789
+ }
790
+ return "";
791
+ };
792
+
793
+ // Stable per-session key the service worker uses to scope its dedicated automation tab/window
794
+ // to *this* session (one extension brokers all sessions). The session id is stable across
795
+ // /reload, so the automation target is reused rather than orphaned. Returns undefined only
796
+ // before session_start, in which case the worker uses its default bucket.
797
+ const sessionKeyFor = (ctx: ExtensionContext | undefined): string | undefined => {
798
+ const id = ctx?.sessionManager?.getSessionId?.();
799
+ return typeof id === "string" && id ? `session:${id}` : undefined;
800
+ };
801
+
762
802
  const updateChromeStatus = (ctx: ExtensionContext): void => {
763
803
  if (chromeControlAuthorized()) {
764
- ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge");
804
+ ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge" + authCountdownLabel());
765
805
  } else {
766
806
  ctx.ui.setStatus("chrome", undefined);
767
807
  }
768
808
  };
769
809
 
810
+ // Ticks every 60 s while a timed authorization is active to keep the countdown current.
811
+ const startCountdownTicker = (ctx: ExtensionContext): void => {
812
+ clearCountdownInterval();
813
+ if (chromeAuthorizedUntil === "indefinite" || typeof chromeAuthorizedUntil !== "number") return;
814
+ countdownInterval = setInterval(() => {
815
+ if (!chromeControlAuthorized()) {
816
+ clearCountdownInterval();
817
+ return;
818
+ }
819
+ updateChromeStatus(ctx);
820
+ }, 60_000);
821
+ };
822
+
770
823
  const scheduleAuthExpiry = (ctx: ExtensionContext, until: number | "indefinite"): void => {
771
824
  clearAuthExpiryTimer();
825
+ startCountdownTicker(ctx);
772
826
  if (until === "indefinite") return;
773
827
  authExpiryTimer = setTimeout(() => {
774
828
  if (chromeAuthorizedUntil !== until) return;
@@ -784,14 +838,27 @@ export default function (pi: ExtensionAPI): void {
784
838
 
785
839
  const authorizedBridgeSend = (action: string, params: Record<string, unknown>, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> => {
786
840
  requireChromeControlAuthorized();
841
+ // Scope the service worker's dedicated automation tab/window to this session. Forwarded on
842
+ // every action so tab resolution, navigation, and cleanup all agree on which target is ours.
843
+ const sessionKey = sessionKeyFor(sessionCtx);
844
+ let wireParams: Record<string, unknown> = sessionKey !== undefined && params.sessionKey === undefined
845
+ ? { ...params, sessionKey }
846
+ : params;
847
+ const sessionTitle = sessionCtx !== undefined ? sessionGroupTitle(sessionCtx) : undefined;
848
+ // Any tab Pi opens through tab.new/tab.group must use THIS session's group, even if a caller
849
+ // passes group:false or a custom groupTitle. This central guard covers chrome_tab plus internal
850
+ // callers such as chrome_launch(url).
851
+ if ((action === "tab.new" || action === "tab.group") && sessionTitle !== undefined) {
852
+ wireParams = { ...wireParams, groupTitle: sessionTitle };
853
+ }
787
854
  // Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
788
855
  // 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;
856
+ // the resolved tab into the session group on the service-worker side. We skip tab.* actions:
857
+ // tab.new/group are forced above, and activate/close/ungroup/list must not group tabs.
858
+ const shouldJoinGroup = action.startsWith("page.") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
859
+ if (shouldJoinGroup) {
860
+ wireParams = { ...wireParams, sessionGroupTitle: sessionTitle, joinSessionGroup: true };
861
+ }
795
862
  return bridge.send(action, wireParams, timeoutMs, signal);
796
863
  };
797
864
 
@@ -816,12 +883,22 @@ export default function (pi: ExtensionAPI): void {
816
883
  if (chromeControlAuthorized()) {
817
884
  activateChromeTools();
818
885
  if (typeof chromeAuthorizedUntil === "number") scheduleAuthExpiry(ctx, chromeAuthorizedUntil);
886
+ else if (chromeAuthorizedUntil === "indefinite") startCountdownTicker(ctx);
819
887
  }
820
888
  updateChromeStatus(ctx);
821
889
  });
822
890
 
823
- pi.on("session_shutdown", () => {
891
+ pi.on("session_shutdown", (event) => {
824
892
  clearAuthExpiryTimer();
893
+ clearCountdownInterval();
894
+ // Tidy up this session's dedicated automation window on real session end, but NOT on
895
+ // "reload": /reload tears down and re-evaluates this module while the *same* session
896
+ // (same sessionKey) continues, so we keep the window so it is reused, not churned. The
897
+ // call is fire-and-forget and runs before bridge.stop() so it never blocks shutdown.
898
+ // (Owner-session quit may not deliver in time since stop() closes the bridge server;
899
+ // that only ever leaves a clearly pi-chrome window for the user to close — never a user
900
+ // tab — and /chrome revoke remains the reliable, bridge-alive cleanup path.)
901
+ if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
825
902
  bridge.stop();
826
903
  if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) {
827
904
  delete globalState[PI_CHROME_GLOBAL_KEY];
@@ -836,6 +913,11 @@ export default function (pi: ExtensionAPI): void {
836
913
  <chrome-profile-bridge>
837
914
  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
915
 
916
+ Tab/window isolation (important):
917
+ - pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
918
+ - To act on a specific *existing* tab (e.g. one the user asks you to use), pass targetId/urlIncludes/titleIncludes. Without one of those, assume you are working in pi-chrome's own automation target.
919
+ - pi-chrome's automation target may be closed automatically when Chrome control is revoked; user tabs/windows are never closed by pi-chrome.
920
+
839
921
  Capability model (important):
840
922
  - 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
923
  - 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.
@@ -1204,7 +1286,7 @@ Usage rules:
1204
1286
  pi.registerTool({
1205
1287
  name: "chrome_tab",
1206
1288
  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.",
1289
+ 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. 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.",
1208
1290
  promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
1209
1291
  parameters: Type.Object({
1210
1292
  action: StringEnum(tabActionValues),
@@ -1212,18 +1294,18 @@ Usage rules:
1212
1294
  targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
1213
1295
  urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })),
1214
1296
  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." })),
1297
+ group: Type.Optional(Type.Boolean({ description: "Deprecated; ignored. Pi-created tabs always join this session's own tab group." })),
1298
+ groupTitle: Type.Optional(Type.String({ description: "Deprecated for action=new/group; ignored so one Pi session uses one tab group ('Pi Session: <name-or-id>')." })),
1217
1299
  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
1300
  host: Type.Optional(Type.String()),
1219
1301
  port: Type.Optional(Type.Number()),
1220
1302
  }),
1221
1303
  async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1222
1304
  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) {
1305
+ // Force every Pi-opened/explicitly-grouped tab into this session's own group,
1306
+ // named after the session display name (falling back to the session id). There is
1307
+ // intentionally no opt-out: one Pi session should create/use one tab group.
1308
+ if (params.action === "new" || params.action === "group") {
1227
1309
  forwarded.groupTitle = sessionGroupTitle(ctx);
1228
1310
  }
1229
1311
  const result = await authorizedBridgeSend(`tab.${params.action}`, forwarded, DEFAULT_TIMEOUT_MS, signal);
@@ -1351,7 +1433,7 @@ Usage rules:
1351
1433
  name: "chrome_navigate",
1352
1434
  label: "Chrome Navigate",
1353
1435
  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.",
1436
+ "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. 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
1437
  promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
1356
1438
  parameters: Type.Object({
1357
1439
  url: Type.String(),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.38",
3
+ "version": "0.15.39",
4
4
  "scripts": {
5
- "test": "node test-suite/unit/csp-eval.test.mjs",
5
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs",
6
6
  "version": "node scripts/sync-manifest-version.js",
7
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
8
8
  },
@@ -0,0 +1,387 @@
1
+ // Unit harness for pi-chrome's dedicated automation tab/window isolation in service_worker.js.
2
+ //
3
+ // Feature under test: pi-chrome must never navigate or replace the user's active tab. Page and
4
+ // navigation actions without an explicit target are routed to a dedicated automation target that
5
+ // the *calling Pi session* created and owns. Ownership is session-scoped (one extension brokers
6
+ // every session) and mirrored to chrome.storage.session so a service-worker restart re-hydrates
7
+ // it instead of orphaning the window. Cleanup closes only the calling session's owned target.
8
+ //
9
+ // Like csp-eval.test.mjs we load the *real* worker into a vm sandbox with a stateful chrome.*
10
+ // mock, then exercise the real helpers and the real dispatch() paths. Chrome state (tabs/windows/
11
+ // storage.session) can be shared across two sandbox loads to simulate a service-worker restart.
12
+
13
+ import vm from "node:vm";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
20
+ const src = fs.readFileSync(workerPath, "utf8");
21
+
22
+ let failures = 0;
23
+ let passes = 0;
24
+ function ok(cond, msg) {
25
+ if (cond) { passes++; }
26
+ else { failures++; console.error(` ✗ ${msg}`); }
27
+ }
28
+ async function throwsWith(fn, re, msg) {
29
+ try { await fn(); ok(false, `${msg} (expected throw)`); }
30
+ catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
31
+ }
32
+
33
+ // ---- stateful Chrome mock. `state` (tabs/windows/storage) can be shared to simulate a
34
+ // service-worker restart: the browser keeps its tabs/windows/session-storage, the worker memory
35
+ // is wiped (a fresh sandbox).
36
+ function makeChromeState() {
37
+ const tabs = new Map(); // id -> { id, windowId, url, active, groupId }
38
+ const windows = new Map(); // id -> { id }
39
+ const groups = new Map(); // groupId -> { id, title, color, collapsed, windowId }
40
+ const storage = {}; // chrome.storage.session backing
41
+ let nextTabId = 1;
42
+ let nextWindowId = 1;
43
+ let nextGroupId = 1;
44
+ const alloc = { tab: () => nextTabId++, window: () => nextWindowId++, group: () => nextGroupId++ };
45
+
46
+ // Seed a user window with two real user tabs (Gmail + a research article, the active one).
47
+ const userWindowId = alloc.window();
48
+ windows.set(userWindowId, { id: userWindowId });
49
+ const userGmail = { id: alloc.tab(), windowId: userWindowId, url: "https://mail.google.com/", active: false, groupId: -1 };
50
+ const userArticle = { id: alloc.tab(), windowId: userWindowId, url: "https://example.com/research-article", active: true, groupId: -1 };
51
+ tabs.set(userGmail.id, userGmail);
52
+ tabs.set(userArticle.id, userArticle);
53
+
54
+ return { tabs, windows, groups, storage, alloc, userWindowId, userGmail, userArticle };
55
+ }
56
+
57
+ function makeChrome(state, { withWindows = true, withStorage = true, withTabGroups = false } = {}) {
58
+ const { tabs, windows, groups, storage, alloc, userWindowId } = state;
59
+ const noop = () => {};
60
+ const listener = { addListener: noop, removeListener: noop };
61
+
62
+ const chrome = {
63
+ runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
64
+ alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
65
+ action: { onClicked: listener },
66
+ debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]), onDetach: listener },
67
+ scripting: { executeScript: async () => [{ result: undefined }], registerContentScripts: async () => {}, unregisterContentScripts: async () => {} },
68
+ webNavigation: { onCommitted: listener },
69
+ tabs: {
70
+ onUpdated: listener,
71
+ query: async (q = {}) => {
72
+ let list = [...tabs.values()];
73
+ if (q.active === true) list = list.filter((t) => t.active);
74
+ if (typeof q.windowId === "number") list = list.filter((t) => t.windowId === q.windowId);
75
+ return list.map((t) => ({ ...t }));
76
+ },
77
+ get: async (id) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); return { ...t }; },
78
+ create: async ({ url = "about:blank", active = false, windowId = userWindowId } = {}) => {
79
+ const tab = { id: alloc.tab(), windowId, url, active, groupId: -1 };
80
+ tabs.set(tab.id, tab);
81
+ return { ...tab };
82
+ },
83
+ update: async (id, props = {}) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); Object.assign(t, props); return { ...t }; },
84
+ remove: async (id) => { tabs.delete(id); },
85
+ group: async ({ groupId, tabIds = [] } = {}) => {
86
+ let gid = groupId;
87
+ if (typeof gid !== "number") {
88
+ gid = alloc.group();
89
+ const firstTab = tabs.get(tabIds[0]);
90
+ groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
91
+ }
92
+ for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
93
+ return gid;
94
+ },
95
+ ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } },
96
+ },
97
+ storage: withStorage ? {
98
+ session: {
99
+ get: async (key) => (key in storage ? { [key]: storage[key] } : {}),
100
+ set: async (obj) => { Object.assign(storage, obj); },
101
+ },
102
+ } : undefined,
103
+ };
104
+
105
+ if (withTabGroups) {
106
+ chrome.tabGroups = {
107
+ query: async ({ windowId } = {}) => [...groups.values()].filter((g) => windowId === undefined || g.windowId === windowId).map((g) => ({ ...g })),
108
+ get: async (id) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); return { ...g }; },
109
+ update: async (id, props = {}) => { const g = groups.get(id); if (!g) throw new Error(`No group ${id}`); Object.assign(g, props); return { ...g }; },
110
+ };
111
+ }
112
+
113
+ if (withWindows) {
114
+ chrome.windows = {
115
+ create: async ({ url = "about:blank", focused = false } = {}) => {
116
+ const id = alloc.window();
117
+ windows.set(id, { id });
118
+ const tab = { id: alloc.tab(), windowId: id, url, active: true, groupId: -1 };
119
+ tabs.set(tab.id, tab);
120
+ return { id, focused, tabs: [{ ...tab }] };
121
+ },
122
+ get: async (id) => { const w = windows.get(id); if (!w) throw new Error(`No window with id ${id}`); return { ...w }; },
123
+ remove: async (id) => { windows.delete(id); for (const [tid, t] of [...tabs]) if (t.windowId === id) tabs.delete(tid); },
124
+ update: async () => {},
125
+ };
126
+ } else {
127
+ chrome.windows = { update: async () => {} }; // no create/get/remove -> tab fallback path
128
+ }
129
+
130
+ return chrome;
131
+ }
132
+
133
+ function loadWorker(chrome) {
134
+ const noop = () => {};
135
+ const sandbox = {
136
+ console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
137
+ Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
138
+ setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop,
139
+ fetch: async () => { throw new Error("no network in unit test"); },
140
+ navigator: { userAgent: "unit-test" },
141
+ WebSocket: function () {},
142
+ chrome,
143
+ };
144
+ sandbox.globalThis = sandbox;
145
+ sandbox.self = sandbox;
146
+ vm.createContext(sandbox);
147
+ vm.runInContext(src, sandbox);
148
+ return sandbox;
149
+ }
150
+
151
+ const SK = "session:alpha"; // a representative sessionKey
152
+
153
+ async function run() {
154
+ // ===== Isolation: navigation does not touch the user's active/other tabs. =====
155
+ {
156
+ const state = makeChromeState();
157
+ const w = loadWorker(makeChrome(state));
158
+ const userActiveUrl = state.userArticle.url;
159
+
160
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/task", waitUntilLoad: false, sessionKey: SK });
161
+ ok(state.userArticle.url === userActiveUrl, "navigate: active user tab (research article) is not overwritten");
162
+ ok(state.userGmail.url === "https://mail.google.com/", "navigate: other user tab (Gmail) untouched");
163
+ ok(nav.url === "https://pi.test/task", "navigate: automation target navigated to requested URL");
164
+ ok(nav.id !== state.userArticle.id && nav.id !== state.userGmail.id, "navigate: did not reuse any user tab");
165
+ ok(nav.windowId !== state.userWindowId, "navigate: automation target lives in a dedicated window");
166
+
167
+ const status = await w.dispatch("automation.status", { sessionKey: SK });
168
+ ok(status.tabId === nav.id && status.windowId === nav.windowId, "ownership: target ids tracked for the session");
169
+ ok(w.isPiChromeOwnedTarget(nav.id, SK) === true, "ownership: isPiChromeOwnedTarget(owned, session) === true");
170
+ ok(w.isPiChromeOwnedTarget(state.userArticle.id) === false, "ownership: user tab is never owned (any session)");
171
+
172
+ // Reuse: a later navigation reuses the same owned target.
173
+ const nav2 = await w.dispatch("page.navigate", { url: "https://pi.test/step-2", waitUntilLoad: false, sessionKey: SK });
174
+ ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "reuse: second navigation reuses the same automation window/tab");
175
+ ok(state.userArticle.url === userActiveUrl, "reuse: user tab still untouched after second navigation");
176
+
177
+ // Cleanup closes only the owned window; user tabs/windows survive.
178
+ const cleanup = await w.dispatch("automation.cleanup", { sessionKey: SK });
179
+ ok(cleanup.closedWindowId === nav.windowId, "cleanup: closed the owned window");
180
+ ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "cleanup: user tabs never closed");
181
+ ok(state.windows.has(state.userWindowId), "cleanup: user window never closed");
182
+ ok(!state.tabs.has(nav.id), "cleanup: the owned automation tab is gone");
183
+ const status2 = await w.dispatch("automation.status", { sessionKey: SK });
184
+ ok(status2.tabId === null && status2.windowId === null, "cleanup: ownership cleared");
185
+ }
186
+
187
+ // ===== Session-group integration: the dedicated-window tab joins this session's group. =====
188
+ {
189
+ const state = makeChromeState();
190
+ const w = loadWorker(makeChrome(state, { withTabGroups: true }));
191
+ // index.ts tags page.* actions with joinSessionGroup + sessionGroupTitle; replicate that here.
192
+ const groupTitle = "Pi Session: alpha";
193
+ const nav = await w.dispatch("page.navigate", {
194
+ url: "https://pi.test/grouped", waitUntilLoad: false,
195
+ sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
196
+ });
197
+ const navTab = state.tabs.get(nav.id);
198
+ ok(navTab.windowId !== state.userWindowId, "group: automation tab is in its dedicated window");
199
+ ok(typeof navTab.groupId === "number" && navTab.groupId >= 0, "group: automation tab joined a tab group");
200
+ const grp = state.groups.get(navTab.groupId);
201
+ ok(grp && grp.title === groupTitle, "group: the group is titled with this session's title");
202
+ ok(grp.windowId === navTab.windowId, "group: the session group lives inside the dedicated automation window (not the user window)");
203
+
204
+ // A second page action reuses the same tab and does not spawn a second group.
205
+ const groupsBefore = state.groups.size;
206
+ await w.dispatch("page.navigate", { url: "https://pi.test/grouped-2", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle });
207
+ ok(state.groups.size === groupsBefore, "group: reusing the automation tab does not create a second group");
208
+ }
209
+
210
+ // ===== tab.new joins the existing session group instead of creating one group per window. =====
211
+ {
212
+ const state = makeChromeState();
213
+ const w = loadWorker(makeChrome(state, { withTabGroups: true }));
214
+ const groupTitle = "Pi Session: alpha";
215
+ const nav = await w.dispatch("page.navigate", {
216
+ url: "https://pi.test/group-owner", waitUntilLoad: false,
217
+ sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: groupTitle,
218
+ });
219
+ const navTab = state.tabs.get(nav.id);
220
+ const groupId = navTab.groupId;
221
+ const groupsBefore = state.groups.size;
222
+
223
+ const opened = await w.dispatch("tab.new", { url: "https://pi.test/new-tab", groupTitle, sessionKey: SK });
224
+ ok(state.groups.size === groupsBefore, "tab.new-group: did not create another same-session group");
225
+ ok(opened.tab.groupId === groupId, "tab.new-group: opened tab joined the existing session group");
226
+ ok(opened.tab.windowId === nav.windowId, "tab.new-group: opened tab was created in the existing group's window");
227
+
228
+ const forced = await w.dispatch("tab.new", { url: "https://pi.test/no-opt-out", groupTitle, group: false, sessionKey: SK });
229
+ ok(forced.tab.groupId === groupId, "tab.new-group: group:false is ignored; tab still joins the session group");
230
+ ok(state.groups.size === groupsBefore, "tab.new-group: group:false does not create another group");
231
+
232
+ const blankTitle = await w.dispatch("tab.new", { url: "https://pi.test/blank-title", groupTitle: "", group: false, sessionKey: SK });
233
+ ok(typeof blankTitle.tab.groupId === "number" && blankTitle.tab.groupId >= 0, "tab.new-group: groupTitle:'' still creates a grouped tab");
234
+ ok(blankTitle.group.title === "Pi", "tab.new-group: blank groupTitle falls back to a group instead of opting out");
235
+ }
236
+
237
+ // ===== tab.new never leaves an ungrouped tab behind when grouping fails. =====
238
+ {
239
+ const state = makeChromeState();
240
+ const chrome = makeChrome(state, { withTabGroups: true });
241
+ const w = loadWorker(chrome);
242
+ const tabsBefore = state.tabs.size;
243
+ chrome.tabs.group = async () => { throw new Error("group blew up"); };
244
+
245
+ await throwsWith(
246
+ () => w.dispatch("tab.new", { url: "https://pi.test/group-fail", groupTitle: "Pi Session: alpha", sessionKey: SK }),
247
+ /group blew up/,
248
+ "tab.new-group-fail: surfaces grouping error",
249
+ );
250
+ ok(state.tabs.size === tabsBefore, "tab.new-group-fail: closes the created tab instead of leaving it ungrouped");
251
+ }
252
+
253
+ // ===== Grouping is best-effort: a tabGroups failure must not break navigation. =====
254
+ {
255
+ const state = makeChromeState();
256
+ const chrome = makeChrome(state, { withTabGroups: true });
257
+ chrome.tabs.group = async () => { throw new Error("group blew up"); };
258
+ const w = loadWorker(chrome);
259
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/group-fail", waitUntilLoad: false, sessionKey: SK, joinSessionGroup: true, sessionGroupTitle: "Pi Session: alpha" });
260
+ ok(nav.url === "https://pi.test/group-fail", "group-fail: navigation still succeeds when grouping throws");
261
+ ok(state.tabs.get(nav.id).windowId !== state.userWindowId, "group-fail: still used the dedicated automation window");
262
+ }
263
+
264
+ // ===== Concurrency: two sessions get separate windows; cleanup is per-session. =====
265
+ {
266
+ const state = makeChromeState();
267
+ const w = loadWorker(makeChrome(state));
268
+ const a = await w.dispatch("page.navigate", { url: "https://pi.test/a", waitUntilLoad: false, sessionKey: "session:A" });
269
+ const b = await w.dispatch("page.navigate", { url: "https://pi.test/b", waitUntilLoad: false, sessionKey: "session:B" });
270
+ ok(a.id !== b.id && a.windowId !== b.windowId, "concurrency: each session gets its own dedicated window/tab");
271
+ ok(w.isPiChromeOwnedTarget(a.id, "session:A") && !w.isPiChromeOwnedTarget(a.id, "session:B"), "concurrency: ownership is scoped to the creating session");
272
+
273
+ // Cleaning up session A must not touch session B's target.
274
+ await w.dispatch("automation.cleanup", { sessionKey: "session:A" });
275
+ ok(!state.tabs.has(a.id), "concurrency: cleanup closed session A's tab");
276
+ ok(state.tabs.has(b.id), "concurrency: cleanup left session B's tab open");
277
+ const bStatus = await w.dispatch("automation.status", { sessionKey: "session:B" });
278
+ ok(bStatus.tabId === b.id, "concurrency: session B still owns its target after A cleanup");
279
+ }
280
+
281
+ // ===== Service-worker restart / reconnect: persisted ownership re-hydrates from storage. =====
282
+ {
283
+ const state = makeChromeState();
284
+ const w1 = loadWorker(makeChrome(state));
285
+ const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/persist", waitUntilLoad: false, sessionKey: SK });
286
+ ok(typeof state.storage.piChromeAutomationTargets === "object", "restart: ownership was persisted to storage.session");
287
+
288
+ // Simulate the MV3 service worker being suspended and restarted: fresh sandbox (memory wiped),
289
+ // same browser tabs/windows + same session storage.
290
+ const w2 = loadWorker(makeChrome(state));
291
+ const statusAfterRestart = await w2.dispatch("automation.status", { sessionKey: SK });
292
+ ok(statusAfterRestart.tabId === nav.id && statusAfterRestart.windowId === nav.windowId, "restart: re-hydrated the owned target from storage");
293
+
294
+ // A navigation after restart must REUSE the existing window, not orphan it with a new one.
295
+ const windowsBefore = state.windows.size;
296
+ const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/persist-2", waitUntilLoad: false, sessionKey: SK });
297
+ ok(nav2.id === nav.id && nav2.windowId === nav.windowId, "restart: navigation after restart reuses the persisted window (no orphan)");
298
+ ok(state.windows.size === windowsBefore, "restart: no new window created after restart");
299
+
300
+ // Cleanup after restart works and clears persisted state.
301
+ await w2.dispatch("automation.cleanup", { sessionKey: SK });
302
+ const persisted = state.storage.piChromeAutomationTargets || {};
303
+ ok(!(SK in persisted), "restart: cleanup removed the session from persisted storage");
304
+ }
305
+
306
+ // ===== Restart after the user manually closed the window: no orphan, fresh target. =====
307
+ {
308
+ const state = makeChromeState();
309
+ const w1 = loadWorker(makeChrome(state));
310
+ const nav = await w1.dispatch("page.navigate", { url: "https://pi.test/closed", waitUntilLoad: false, sessionKey: SK });
311
+ await state.windows.delete(nav.windowId); // user closed pi-chrome's window
312
+ for (const [tid, t] of [...state.tabs]) if (t.windowId === nav.windowId) state.tabs.delete(tid);
313
+
314
+ const w2 = loadWorker(makeChrome(state)); // SW restart
315
+ const nav2 = await w2.dispatch("page.navigate", { url: "https://pi.test/reopened", waitUntilLoad: false, sessionKey: SK });
316
+ ok(nav2.id !== nav.id, "restart-after-close: a fresh automation target is created when the persisted one is gone");
317
+ ok(state.tabs.has(nav2.id), "restart-after-close: new target exists");
318
+ }
319
+
320
+ // ===== tab.* management never auto-creates / never falls back to the user's active tab. =====
321
+ {
322
+ const state = makeChromeState();
323
+ const w = loadWorker(makeChrome(state));
324
+ const windowsBefore = state.windows.size;
325
+ const tabsBefore = state.tabs.size;
326
+
327
+ await throwsWith(
328
+ () => w.dispatch("tab.close", { sessionKey: SK }),
329
+ /no automation tab yet|Pass targetId/,
330
+ "tab.close: with no target and no owned target, errors instead of closing the user's active tab",
331
+ );
332
+ ok(state.tabs.has(state.userArticle.id), "tab.close: user's active tab was NOT closed");
333
+ ok(state.windows.size === windowsBefore && state.tabs.size === tabsBefore, "tab.close: did not spawn a throwaway tab/window");
334
+
335
+ await throwsWith(() => w.dispatch("tab.activate", { sessionKey: SK }), /no automation tab yet|Pass targetId/, "tab.activate: errors with no target/owned target");
336
+
337
+ // Once an automation target exists, management actions operate on it (not on the user tab).
338
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/manage", waitUntilLoad: false, sessionKey: SK });
339
+ const closed = await w.dispatch("tab.close", { sessionKey: SK });
340
+ ok(closed.closed === nav.id, "tab.close: with an owned target, closes that target");
341
+ ok(state.tabs.has(state.userArticle.id), "tab.close: user tab still safe after closing the owned target");
342
+ }
343
+
344
+ // ===== Explicit targeting still works on any existing tab (no regression). =====
345
+ {
346
+ const state = makeChromeState();
347
+ const w = loadWorker(makeChrome(state));
348
+ const nav = await w.dispatch("page.navigate", { url: "https://pi.test/explicit", targetId: String(state.userGmail.id), waitUntilLoad: false, sessionKey: SK });
349
+ ok(nav.id === state.userGmail.id, "explicit: targetId routes to the requested existing tab");
350
+ ok(state.userGmail.url === "https://pi.test/explicit", "explicit: explicitly targeted tab is navigated");
351
+ const status = await w.dispatch("automation.status", { sessionKey: SK });
352
+ ok(status.tabId === null, "explicit: explicit targeting does not create/claim an automation target");
353
+ }
354
+
355
+ // ===== Window-unavailable fallback: a dedicated TAB is used, and the user's window is safe. =====
356
+ {
357
+ const state = makeChromeState();
358
+ const w = loadWorker(makeChrome(state, { withWindows: false }));
359
+ const target = await w.getOrCreateAutomationTarget(SK);
360
+ ok(target.id !== state.userArticle.id && target.id !== state.userGmail.id, "fallback: created a dedicated tab, not a user tab");
361
+ ok(w.isPiChromeOwnedTarget(target.id, SK) === true, "fallback: dedicated tab is owned");
362
+ const cleanup = await w.cleanupAutomationTarget(SK);
363
+ ok(cleanup.closedTabId === target.id && cleanup.closedWindowId === null, "fallback: cleanup closes only the owned tab (never the shared window)");
364
+ ok(state.windows.has(state.userWindowId), "fallback: cleanup never closes the user/shared window");
365
+ ok(state.tabs.has(state.userArticle.id) && state.tabs.has(state.userGmail.id), "fallback: cleanup leaves user tabs intact");
366
+ }
367
+
368
+ // ===== Robust cleanup: no-op when nothing created, and when target already closed manually. =====
369
+ {
370
+ const state = makeChromeState();
371
+ const w = loadWorker(makeChrome(state));
372
+ const empty = await w.cleanupAutomationTarget(SK);
373
+ ok(empty.closedWindowId === null && empty.closedTabId === null, "cleanup: no-op when nothing was ever created");
374
+
375
+ const t = await w.getOrCreateAutomationTarget(SK);
376
+ // User closed pi-chrome's window manually (Chrome closes its tabs too).
377
+ state.windows.delete(t.windowId);
378
+ for (const [tid, tab] of [...state.tabs]) if (tab.windowId === t.windowId) state.tabs.delete(tid);
379
+ const stale = await w.cleanupAutomationTarget(SK);
380
+ ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
381
+ }
382
+
383
+ console.log(`\n${passes} passed, ${failures} failed`);
384
+ if (failures) process.exit(1);
385
+ }
386
+
387
+ run().catch((e) => { console.error(e); process.exit(1); });