pi-chrome 0.15.29 → 0.15.31

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,21 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.31 — 2026-05-31
6
+
7
+ Per-session tab groups.
8
+
9
+ - **Each Pi session gets its own tab group.** Auto-grouped tabs are now named `Pi Session <name>` using the session's display name, falling back to the session id when unnamed. Multiple Pi sessions driving the same Chrome no longer share one group — each collects its tabs separately. Pass an explicit `groupTitle` to override, or `group:false` / `groupTitle:""` on `action=new` to opt out.
10
+
11
+ ## 0.15.30 — 2026-05-31
12
+
13
+ Tab grouping for `chrome_tab`.
14
+
15
+ - **Pi-opened tabs auto-group.** `action=new` now drops every tab into a shared `Pi` tab group per window by default (created once, then reused), so agent tabs stay visually separated from your own. Opt out per call with `groupTitle:""` or `group:false`.
16
+ - **`chrome_tab` can group/ungroup tabs.** New `action=group` (and `action=ungroup`) plus `groupTitle`/`groupColor` params. Grouping reuses an existing same-title group in the window instead of spawning duplicates. Defaults: title `Pi`, color `blue`; color validated against Chrome's 9 group colors. Target an existing tab with `targetId`/`urlIncludes`/`titleIncludes`.
17
+ - **Tab listings include group info.** `formatTab` now reports `groupId` and a `group` record (`title`, `color`, `collapsed`, `windowId`, `piGroup`), and `chrome_tab list` prefixes grouped tabs with `[Group Title]`.
18
+ - Requires the new `tabGroups` extension permission — reload the companion extension after updating.
19
+
5
20
  ## 0.15.29 — 2026-05-31
6
21
 
7
22
  Strict-CSP support: `chrome_evaluate`, `chrome_snapshot`, `chrome_wait_for`, and `chrome_navigate initScript` now work on pages that block `unsafe-eval`.
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.29",
4
+ "version": "0.15.31",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
8
+ "tabGroups",
8
9
  "scripting",
9
10
  "storage",
10
11
  "activeTab",
@@ -1,6 +1,9 @@
1
1
  const BRIDGE_URL = "http://127.0.0.1:17318";
2
2
  const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
3
3
  const POLL_ERROR_BACKOFF_MS = 2000;
4
+ const DEFAULT_GROUP_COLOR = "blue";
5
+ const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
6
+ const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
4
7
  let polling = false;
5
8
 
6
9
  // =================== Chrome input (CDP) layer ===================
@@ -709,6 +712,58 @@ function isVersionOlder(a, b) {
709
712
  return false;
710
713
  }
711
714
 
715
+ function cleanGroupTitle(value) {
716
+ const text = String(value || "Pi").replace(/\s+/g, " ").trim().slice(0, 80);
717
+ return text || "Pi";
718
+ }
719
+
720
+ function cleanGroupColor(value) {
721
+ const color = String(value || DEFAULT_GROUP_COLOR).toLowerCase();
722
+ return VALID_GROUP_COLORS.has(color) ? color : DEFAULT_GROUP_COLOR;
723
+ }
724
+
725
+ async function groupRecord(groupId) {
726
+ if (typeof groupId !== "number" || groupId < 0 || !chrome.tabGroups) return null;
727
+ const group = await chrome.tabGroups.get(groupId).catch(() => null);
728
+ if (!group) return null;
729
+ return {
730
+ id: group.id,
731
+ title: group.title || "",
732
+ color: group.color || "",
733
+ collapsed: Boolean(group.collapsed),
734
+ windowId: group.windowId,
735
+ piGroup: Boolean(group.title && PI_GROUP_RE.test(group.title)),
736
+ };
737
+ }
738
+
739
+ // Find an existing tab group in `windowId` whose title matches `title` (case-insensitive).
740
+ // Used so all Pi-opened tabs collect into one group per window instead of spawning new ones.
741
+ async function findGroupByTitle(windowId, title) {
742
+ if (!chrome.tabGroups) return null;
743
+ const wanted = cleanGroupTitle(title).toLowerCase();
744
+ const groups = await chrome.tabGroups.query({ windowId }).catch(() => []);
745
+ const match = groups.find((g) => (g.title || "").trim().toLowerCase() === wanted);
746
+ return match ? match.id : null;
747
+ }
748
+
749
+ // Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
750
+ // existing same-title group in its window when present, otherwise create a new group.
751
+ async function groupTab(tab, title, color) {
752
+ if (!chrome.tabGroups) throw new Error("chrome.tabGroups API unavailable; reload the extension after granting the tabGroups permission");
753
+ if (!tab || typeof tab.id !== "number") throw new Error("No tab to group");
754
+ const groupTitle = cleanGroupTitle(title);
755
+ let groupId = tab.groupId;
756
+ if (typeof groupId !== "number" || groupId < 0) {
757
+ const existing = await findGroupByTitle(tab.windowId, groupTitle);
758
+ groupId = existing !== null
759
+ ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
760
+ : await chrome.tabs.group({ tabIds: [tab.id] });
761
+ }
762
+ await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
763
+ const grouped = await chrome.tabs.get(tab.id);
764
+ return { tab: await formatTab(grouped), group: await groupRecord(groupId) };
765
+ }
766
+
712
767
  async function dispatch(action, params) {
713
768
  switch (action) {
714
769
  case "tab.version":
@@ -718,17 +773,31 @@ async function dispatch(action, params) {
718
773
  bridgeUrl: BRIDGE_URL,
719
774
  userAgent: navigator.userAgent,
720
775
  };
721
- case "tab.list":
722
- return (await chrome.tabs.query({})).map(formatTab);
776
+ case "tab.list": {
777
+ const tabs = await chrome.tabs.query({});
778
+ return Promise.all(tabs.map(formatTab));
779
+ }
723
780
  case "tab.new": {
724
781
  const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
725
- return formatTab(tab);
782
+ // Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out.
783
+ const optOut = params.groupTitle === "" || params.group === false;
784
+ if (optOut && !params.groupColor) return formatTab(tab);
785
+ return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
726
786
  }
727
787
  case "tab.activate": {
728
788
  const tab = await getTabByParams(params);
729
789
  await chrome.windows.update(tab.windowId, { focused: true });
730
790
  return formatTab(await chrome.tabs.update(tab.id, { active: true }));
731
791
  }
792
+ case "tab.group": {
793
+ const tab = await getTabByParams(params);
794
+ return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
795
+ }
796
+ case "tab.ungroup": {
797
+ const tab = await getTabByParams(params);
798
+ if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
799
+ return formatTab(await chrome.tabs.get(tab.id));
800
+ }
732
801
  case "tab.close": {
733
802
  const tab = await getTabByParams(params);
734
803
  await chrome.tabs.remove(tab.id);
@@ -811,7 +880,7 @@ async function dispatch(action, params) {
811
880
  } finally {
812
881
  if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
813
882
  }
814
- return formatTab(await chrome.tabs.get(updated.id));
883
+ return await formatTab(await chrome.tabs.get(updated.id));
815
884
  }
816
885
  case "page.screenshot":
817
886
  return takeScreenshot(params);
@@ -820,7 +889,7 @@ async function dispatch(action, params) {
820
889
  }
821
890
  }
822
891
 
823
- function formatTab(tab) {
892
+ async function formatTab(tab) {
824
893
  return {
825
894
  id: tab.id,
826
895
  windowId: tab.windowId,
@@ -831,6 +900,8 @@ function formatTab(tab) {
831
900
  status: tab.status,
832
901
  pinned: tab.pinned,
833
902
  incognito: tab.incognito,
903
+ groupId: typeof tab.groupId === "number" ? tab.groupId : -1,
904
+ group: await groupRecord(tab.groupId),
834
905
  };
835
906
  }
836
907
 
@@ -1095,7 +1166,7 @@ async function takeScreenshot(params) {
1095
1166
  await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]);
1096
1167
  return {
1097
1168
  fullPage: true,
1098
- tab: formatTab(tab),
1169
+ tab: await formatTab(tab),
1099
1170
  dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
1100
1171
  tiles: captured,
1101
1172
  };
@@ -1104,7 +1175,7 @@ async function takeScreenshot(params) {
1104
1175
  format: params.format || "png",
1105
1176
  quality: params.format === "jpeg" ? params.quality : undefined,
1106
1177
  });
1107
- return { dataUrl, tab: formatTab(tab) };
1178
+ return { dataUrl, tab: await formatTab(tab) };
1108
1179
  } finally {
1109
1180
  if (previousActiveId !== undefined && previousActiveId !== tab.id) {
1110
1181
  await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
@@ -500,7 +500,7 @@ class ChromeProfileBridge {
500
500
  }
501
501
  }
502
502
 
503
- const tabActionValues = ["list", "new", "activate", "close", "version"] as const;
503
+ const tabActionValues = ["list", "new", "activate", "close", "group", "ungroup", "version"] as const;
504
504
  const imageFormatValues = ["png", "jpeg"] as const;
505
505
  const waitForValues = ["selector", "expression"] as const;
506
506
  const CHROME_TOOL_NAMES = [
@@ -599,6 +599,14 @@ export default function (pi: ExtensionAPI): void {
599
599
  }
600
600
  };
601
601
 
602
+ // Tab-group title for this Pi session: prefer the user-set display name, else the session id.
603
+ const sessionGroupTitle = (ctx: ExtensionContext): string => {
604
+ const sm = ctx.sessionManager;
605
+ const name = sm.getSessionName?.();
606
+ const id = sm.getSessionId?.();
607
+ return `Pi Session ${name || id || "unknown"}`;
608
+ };
609
+
602
610
  const updateChromeStatus = (ctx: ExtensionContext): void => {
603
611
  if (chromeControlAuthorized()) {
604
612
  ctx.ui.setStatus("chrome", ctx.ui.theme.fg("success", "●") + " Chrome Bridge");
@@ -1029,20 +1037,32 @@ Usage rules:
1029
1037
  pi.registerTool({
1030
1038
  name: "chrome_tab",
1031
1039
  label: "Chrome Tab",
1032
- description: "List, create, activate, close, or inspect tabs in the user's existing Chrome profile via the companion extension.",
1033
- promptSnippet: "List/open/activate/close existing Chrome tabs through the companion extension.",
1040
+ description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension.",
1041
+ promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
1034
1042
  parameters: Type.Object({
1035
1043
  action: StringEnum(tabActionValues),
1036
1044
  url: Type.Optional(Type.String({ description: "URL for action=new." })),
1037
- targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close." })),
1045
+ targetId: Type.Optional(Type.String({ description: "Chrome tab id for activate/close/group/ungroup." })),
1046
+ urlIncludes: Type.Optional(Type.String({ description: "Match the target tab by URL substring for activate/close/group/ungroup." })),
1047
+ titleIncludes: Type.Optional(Type.String({ description: "Match the target tab by title substring for activate/close/group/ungroup." })),
1048
+ 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." })),
1049
+ 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." })),
1050
+ 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." })),
1038
1051
  host: Type.Optional(Type.String()),
1039
1052
  port: Type.Optional(Type.Number()),
1040
1053
  }),
1041
- async execute(_id, params, signal): Promise<ToolTextResult> {
1042
- const result = await authorizedBridgeSend(`tab.${params.action}`, params, DEFAULT_TIMEOUT_MS, signal);
1054
+ async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1055
+ const forwarded = { ...params } as typeof params & { groupTitle?: string };
1056
+ // Default every Pi-opened/explicitly-grouped tab into this session's own group,
1057
+ // named after the session display name (falling back to the session id), unless
1058
+ // the caller specified a group title or opted out with group:false.
1059
+ if ((params.action === "new" || params.action === "group") && params.groupTitle === undefined && params.group !== false) {
1060
+ forwarded.groupTitle = sessionGroupTitle(ctx);
1061
+ }
1062
+ const result = await authorizedBridgeSend(`tab.${params.action}`, forwarded, DEFAULT_TIMEOUT_MS, signal);
1043
1063
  if (params.action === "list") {
1044
- const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number }>;
1045
- const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
1064
+ const tabs = result as Array<{ id: number; title: string; url: string; active: boolean; windowId: number; group?: { title?: string } | null }>;
1065
+ const text = tabs.map((tab) => `${tab.id}\t${tab.active ? "*" : " "}\t${tab.group?.title ? `[${tab.group.title}] ` : ""}${tab.title || "(untitled)"}\t${tab.url}`).join("\n") || "No tabs.";
1046
1066
  return { content: [{ type: "text", text }], details: { tabs } };
1047
1067
  }
1048
1068
  return { content: [{ type: "text", text: safeJson(result) }], details: { result: result as Json } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.29",
3
+ "version": "0.15.31",
4
4
  "scripts": {
5
5
  "test": "node test-suite/unit/csp-eval.test.mjs",
6
6
  "version": "node scripts/sync-manifest-version.js",