From 4ec8861636b2dec3c28dc58ac752b531716bbb64 Mon Sep 17 00:00:00 2001 From: Karn Date: Sat, 26 Sep 2026 22:33:09 +0530 Subject: [PATCH 1/9] docs: tab groups design spec Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-26-tab-groups-design.md | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-26-tab-groups-design.md diff --git a/docs/superpowers/specs/2026-09-26-tab-groups-design.md b/docs/superpowers/specs/2026-09-26-tab-groups-design.md new file mode 100644 index 0000000..d5311d4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-26-tab-groups-design.md @@ -0,0 +1,187 @@ +# Tab groups — design + +Date: 2026-09-26 +Status: approved in chat, pending spec review + +## Problem + +Users want to see which tabs their agent is working in. Chromium tab groups +are the natural marker, and they're also a general "organize my tabs" tool. +reins works with any Chromium browser (Chrome, Edge, Brave, Vivaldi, Opera, +Arc, Dia, …), and not all of them support tab groups. + +## Principle + +reins ships **primitives only**: list, create/add, edit, ungroup. It never +groups anything on its own. The agent decides when and how to group (for +example "put the tabs I opened for this task in a group titled `reins`"), +the same way it decides what to click. + +## Scope + +In: + +- `Tab.groupId` in `reins tabs` +- `reins groups`: list groups +- `reins group`: create a group, add tabs to a group, edit a group's title, + color, or collapsed state +- `reins ungroup`: remove tabs from their group, or dissolve a whole group +- A clear `unsupported` error on browsers without tab groups +- Skill and web docs, store/privacy permission text, changeset + +Out (YAGNI): + +- Auto-grouping, a popup toggle, or any other extension-side behavior +- Deleting a group together with its tabs. `ungroup` never closes tabs; the + agent uses `reins close` when it means to close them. +- Moving groups between windows, or reordering groups +- Advertising a per-browser capability in the hello/roster + +## CLI (flat verbs, matching tabs/open/close/focus) + +``` +reins groups [--browser ] +reins group --tab [--tab …] [--group ] [--title ] [--color ] [--collapse|--expand] +reins group --group [--title ] [--color ] [--collapse|--expand] +reins ungroup --tab [--tab …] +reins ungroup --group +``` + +- `group` with `--tab` maps to `group_tabs`. Without `--group` it creates a + new group; with `--group` it adds the tabs to that group. Title, color, and + collapsed are applied afterwards. Prints `group `. +- `group` with `--group` and no `--tab` maps to `update_group`. It needs at + least one of `--title`, `--color`, `--collapse`, or `--expand`. +- `group` with neither `--tab` nor `--group` is a UsageError. +- `--collapse` and `--expand` together is a UsageError. +- `ungroup` needs exactly one of `--tab` (repeatable) or `--group`. +- `--color` must be one of: grey, blue, red, yellow, green, pink, purple, + cyan, orange. Anything else is a UsageError. +- `tabs` text output gets a group marker per grouped tab (`g`). +- `groups` text output: one line per group, + ` group "" <color> <n> tabs[ (collapsed)] window <wid>`. + Prints `(no groups)` when there are none. +- Help lists `groups`, `group`, and `ungroup` under "Tabs & pages". + +## Protocol (`@reins/protocol`) + +- `Tab` gets `groupId: z.number().optional()`. It's left out when the tab is + ungrouped (Chrome reports `-1`) or when the browser has no groups. +- `TabGroupColor = z.enum([grey, blue, red, yellow, green, pink, purple, cyan, orange])` +- `TabGroup = { groupId, title, color, collapsed, windowId, tabCount, browserId?, browser? }` +- `ListGroupsResult = { groups: TabGroup[] }` +- `GroupTabsParams = { tabIds: number[] (min 1), groupId?, title?, color?, collapsed? }`, result `{ groupId }` +- `UpdateGroupParams = { groupId, title?, color?, collapsed? }`, result `OkResult` +- `UngroupTabsParams = { tabIds?: number[] (min 1) , groupId? }`, with exactly one of the two set. Result is `OkResult`. +- `METHOD_TIERS`: `list_groups`, `group_tabs`, `update_group`, and + `ungroup_tabs` are all `"read"`. Grouping never touches page content, so + readonly sites can be grouped; denied sites are refused. + +## Extension + +New module `packages/extension/src/lib/tab-groups.ts`: + +- `groupsSupported()`: true only when `chrome.tabGroups?.query`, + `chrome.tabGroups?.update`, `chrome.tabs.group`, and `chrome.tabs.ungroup` + are all functions. +- Every handler first calls `requireGroups()`. When groups are unsupported it + throws an `Error` whose `code` is `"unsupported"`, with the message: `this + browser doesn't support tab groups (chrome.tabGroups unavailable)`. The + bridge client already forwards `err.code` in the response frame. +- `listGroups()`: `chrome.tabGroups.query({})`, with `tabCount` taken from + `chrome.tabs.query({})` counted per `groupId`. +- `groupTabs(p)`: `chrome.tabs.group({ tabIds, groupId? })`, then + `chrome.tabGroups.update` if any props were given. Returns `{ groupId }`. +- `updateGroup(p)`: `chrome.tabGroups.update(groupId, props)`. +- `ungroupTabs(p)`: when given a `groupId`, it resolves the group's tabs with + `chrome.tabs.query({ groupId })`, then calls `chrome.tabs.ungroup(tabIds)`. +- Chrome's own errors (bad group id, a tab in a non-normal window) pass + through unchanged. + +`tab-handler.ts` `listTabs()` sets `groupId` when `t.groupId` is a number +other than `-1`. + +`dispatch.ts` gate: + +- `list_groups` and `update_group` get no host check. Group titles are the + user's own labels, not site content. `list_tabs` redaction already rebuilds + blocked tabs from scratch as `{tabId, title:"", url:"", active, blocked}`, + so `groupId` is dropped for them. That's consistent: blocked tabs show + nothing beyond their id. +- `group_tabs` and `ungroup_tabs` get a multi-tab check. The gate resolves + every target tab (for `ungroup --group`, the group's current tabs), then + runs `ensureAllowed(method, host)` on each one. The first denial refuses + the whole call, and `PolicyDenied.meta` gets that tab's id. The response's + `meta` has no single host or tabId, so it stays `{}`. The audit record + still carries the params (the tabIds). +- The handler receives the resolved `tabIds`, so the gate and the handler + act on the same set. + +Manifest: add `"tabGroups"` to `permissions`, and update the comment block. +The permission shows no install warning, so existing users don't have to +re-approve anything on update. Browsers that don't know the permission +ignore it with a load-time warning; the extension still loads. + +## Daemon (`packages/cli/src/rpc.ts`) + +`list_groups` aggregates across browsers the same way `list_tabs` does: it +fans out to every targeted browser and tags each group with `browserId` and +`browser`. A browser that fails (`unsupported`, or an older extension that +answers `unknown method`) contributes no groups. If every targeted browser +fails, the first error is rethrown so the agent sees why. The call is still +audited like any other. + +All other group methods route to one browser through the existing +`bridge.requestFull` path. + +## Errors + +| Case | Result | +|---|---| +| Browser lacks tab groups | `unsupported`: "this browser doesn't support tab groups …" | +| Older extension without these methods | `unknown method: <m>`, as today | +| Denied host on any target tab | `policy_denied`, audited with `denied: true` | +| Bad group id or tab id | Chrome's error message, unchanged | +| Bad flags | UsageError with usage text | + +## Docs + +- `skills/reins/SKILL.md`: document the three commands, plus one line of + guidance: "you can group the tabs you open for a task (e.g. title `reins`) + so the user sees which tabs are yours; don't regroup the user's tabs + unless asked." +- Web docs (`packages/web/src/routes/docs/commands.tsx`): add the three + commands. +- `docs/CHROME_WEB_STORE.md`: add a `tabGroups` justification and update the + permission list. `docs/PRIVACY.md`: add a mention if it lists permissions. +- One changeset per PR (minor bump for cli, extension, and protocol). + +## Testing + +- protocol: schema tests for the new params (exactly-one rule in + `UngroupTabsParams`, color enum) and for `Tab.groupId` +- extension `tab-groups.test.ts` with stubbed `chrome`: unsupported + detection, create, add to existing, props applied, ungroup by tabs, + ungroup by group, tabCount +- extension `tab-handler.test.ts`: groupId is mapped, and `-1` is left out +- extension `dispatch.test.ts`: multi-tab gate refuses when any tab is + denied, lets readonly tabs through, and `ungroup --group` checks the + group's tabs +- cli `commands` tests: build and format for groups/group/ungroup, plus + every usage error +- cli `rpc` test: `list_groups` aggregation, including partial failure and + total failure +- Manual e2e in real Chrome: only on tabs reins opens itself. The user's + existing tabs and groups are never touched. Clean up afterwards by closing + those tabs. + +## Delivery (stacked PRs) + +1. `feat/tab-groups`, the read side: protocol `Tab.groupId`, `TabGroup`, + and `list_groups`; the `tabGroups` permission; `listGroups` + + `groupsSupported`; `reins groups`; the `tabs` marker; daemon + aggregation; store/privacy text; skill and web docs for `groups`; + changeset. +2. `feat/tab-groups-write`, stacked on 1: `group_tabs`, `update_group`, + `ungroup_tabs`, the multi-tab gate, `reins group`/`ungroup`, skill and + web docs for them plus the grouping guidance line, changeset. From 5e6b28ec33d5dc40069ac16b58ec52d1a9e5fa01 Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sat, 26 Sep 2026 23:02:16 +0530 Subject: [PATCH 2/9] docs: tab groups implementation plan Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../plans/2026-09-26-tab-groups.md | 1474 +++++++++++++++++ 1 file changed, 1474 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-26-tab-groups.md diff --git a/docs/superpowers/plans/2026-09-26-tab-groups.md b/docs/superpowers/plans/2026-09-26-tab-groups.md new file mode 100644 index 0000000..d08afcd --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-tab-groups.md @@ -0,0 +1,1474 @@ +# Tab Groups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the agent tab-group primitives (`reins groups`, `reins group`, `reins ungroup`, and `groupId` in `reins tabs`) so it can mark and organize tabs. reins never groups anything on its own. + +**Architecture:** Each command follows the existing pipeline. First, zod schemas and `METHOD_TIERS` in `@reins/protocol`. Next, a handler in the extension (new `tab-groups.ts`) behind the `dispatch.ts` policy gate. Then daemon routing and aggregation (`rpc.ts`), and finally a CLI `ToolCommand` (`commands.ts`). Browsers without `chrome.tabGroups` answer with error code `unsupported`. + +**Tech Stack:** TypeScript, zod, vitest, pnpm + turbo monorepo, Chrome MV3 extension (`@types/chrome` 0.2.0), biome. + +**Spec:** `docs/superpowers/specs/2026-09-26-tab-groups-design.md` + +## Global Constraints + +- The four group methods (`list_groups`, `group_tabs`, `update_group`, `ungroup_tabs`) are all tier `"read"`. +- Colors are exactly: grey, blue, red, yellow, green, pink, purple, cyan, orange. +- Unsupported browser → `Error` with `code = "unsupported"`, message `this browser doesn't support tab groups (chrome.tabGroups unavailable)`. +- `ungroup` never closes tabs. +- No auto-grouping and no settings toggle. +- Manual e2e only touches tabs reins opened itself (see memory: never mutate the user's browser-wide state). +- Rebuild `@reins/protocol` (`pnpm --filter @reins/protocol build`) before running cli or extension tests. They import its `dist`. +- Two stacked PRs: + - PR1 is branch `feat/tab-groups`, Tasks 1–4. + - PR2 is branch `feat/tab-groups-write`, cut from PR1, Tasks 5–9. +- Commit messages: conventional style, ending with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. + +--- + +# PR1 — read side (`feat/tab-groups`) + +### Task 1: Protocol — `Tab.groupId`, `TabGroup`, `list_groups` + +**Files:** +- Modify: `packages/protocol/src/bridge.ts` (Tab schema, new TabGroup schemas) +- Modify: `packages/protocol/src/policy.ts:78-102` (METHOD_TIERS) +- Test: `packages/protocol/src/bridge.test.ts`, `packages/protocol/src/policy.test.ts:86-119` + +**Interfaces:** +- Produces: + - `Tab.groupId?: number` + - `TabGroupColor` (zod enum) + - `TabGroup { groupId, title, color, collapsed, windowId, tabCount, browserId?, browser? }` + - `ListGroupsResult { groups: TabGroup[] }` + - `METHOD_TIERS.list_groups = "read"` + +- [ ] **Step 1: Write failing tests** + +Append to `packages/protocol/src/bridge.test.ts` (and add `ListGroupsResult, TabGroup` to its import from `./bridge.js`): + +```ts +describe("tab groups", () => { + it("Tab accepts an optional groupId", () => { + expect(Tab.parse({ tabId: 1, title: "t", url: "u", active: true, groupId: 7 }).groupId).toBe(7); + expect(Tab.parse({ tabId: 1, title: "t", url: "u", active: true }).groupId).toBeUndefined(); + }); + + it("TabGroup parses a group and rejects unknown colors", () => { + const g = { + groupId: 7, + title: "reins", + color: "blue", + collapsed: false, + windowId: 1, + tabCount: 2, + }; + expect(TabGroup.parse(g)).toEqual(g); + expect(() => TabGroup.parse({ ...g, color: "magenta" })).toThrow(); + expect(ListGroupsResult.parse({ groups: [g] }).groups).toHaveLength(1); + }); +}); +``` + +In `packages/protocol/src/policy.test.ts`, change the METHOD_TIERS test: +- rename it to `"classifies exactly the 24 bridge methods"` +- add `"list_groups"` to the `read` array + +- [ ] **Step 2: Run to verify fail** + +Run: `pnpm --filter @reins/protocol test` +Expected: FAIL. `TabGroup` is not exported, and `list_groups` is not classified. + +- [ ] **Step 3: Implement** + +In `packages/protocol/src/bridge.ts`, add to the `Tab` object (after `active`): + +```ts + /** The tab's group, when it is in one (omitted when ungrouped, or when the + * browser has no tab groups). */ + groupId: z.number().optional(), +``` + +After `export type Tab = …`, add: + +```ts +/** Chromium's fixed tab-group palette. */ +export const TabGroupColor = z.enum([ + "grey", + "blue", + "red", + "yellow", + "green", + "pink", + "purple", + "cyan", + "orange", +]); +export type TabGroupColor = z.infer<typeof TabGroupColor>; + +/** A tab group. browserId/browser are tagged by the daemon when aggregating + * across several connected browsers. */ +export const TabGroup = z.object({ + groupId: z.number(), + title: z.string(), + color: TabGroupColor, + collapsed: z.boolean(), + windowId: z.number(), + tabCount: z.number(), + browserId: z.string().optional(), + browser: z.string().optional(), +}); +export type TabGroup = z.infer<typeof TabGroup>; +``` + +At the end of the file, add: + +```ts +/** Result payload for the `list_groups` method. */ +export const ListGroupsResult = z.object({ groups: z.array(TabGroup) }); +export type ListGroupsResult = z.infer<typeof ListGroupsResult>; +``` + +In `packages/protocol/src/policy.ts` METHOD_TIERS, add `list_groups: "read",` after `list_tabs: "read",`. + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --filter @reins/protocol test && pnpm --filter @reins/protocol build` +Expected: PASS, and dist rebuilt. + +- [ ] **Step 5: Commit** + +```bash +git add packages/protocol/src +git commit -m "feat(protocol): tab group schema, Tab.groupId, list_groups tier" +``` + +--- + +### Task 2: Extension — `listGroups`, `groupsSupported`, `groupId` in tabs, permission + +**Files:** +- Create: `packages/extension/src/lib/tab-groups.ts` +- Create: `packages/extension/src/lib/tab-groups.test.ts` +- Modify: `packages/extension/src/lib/tab-handler.ts:4-14` (listTabs) +- Modify: `packages/extension/src/lib/tab-handler.test.ts` +- Modify: `packages/extension/src/lib/dispatch.ts` (gate + runHandler) +- Modify: `packages/extension/src/lib/dispatch.test.ts` +- Modify: `packages/extension/manifest.config.ts` + +**Interfaces:** +- Consumes: `ListGroupsResult`, `TabGroup` from `@reins/protocol` (Task 1) +- Produces: + - `groupsSupported(): boolean` + - `requireGroups(): void`, which throws `GroupsUnsupported` + - `class GroupsUnsupported extends Error { code = "unsupported" }` + - `listGroups(): Promise<ListGroupsResult>` + - the `list_groups` dispatch route + +- [ ] **Step 1: Write failing tests** + +Create `packages/extension/src/lib/tab-groups.test.ts`: + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GroupsUnsupported, groupsSupported, listGroups } from "./tab-groups.js"; + +afterEach(() => vi.unstubAllGlobals()); + +/** Minimal chrome with a working tab-group API. */ +function stubGroups(overrides: Record<string, unknown> = {}) { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [ + { id: 1, groupId: 7 }, + { id: 2, groupId: 7 }, + { id: 3, groupId: -1 }, + ], + group: vi.fn(async () => 7), + ungroup: vi.fn(async () => undefined), + get: async (id: number) => ({ id, windowId: 1 }), + }, + tabGroups: { + query: async () => [{ id: 7, title: "reins", color: "blue", collapsed: false, windowId: 1 }], + update: vi.fn(async () => ({})), + }, + ...overrides, + }); +} + +describe("groupsSupported", () => { + it("is true when tabGroups and tabs.group/ungroup exist", () => { + stubGroups(); + expect(groupsSupported()).toBe(true); + }); + + it("is false without chrome.tabGroups (Arc, Dia, …)", () => { + stubGroups({ tabGroups: undefined }); + expect(groupsSupported()).toBe(false); + }); + + it("is false when tabs.group is missing", () => { + stubGroups({ tabs: { query: async () => [] } }); + expect(groupsSupported()).toBe(false); + }); +}); + +describe("listGroups", () => { + it("maps groups and counts their tabs", async () => { + stubGroups(); + expect(await listGroups()).toEqual({ + groups: [ + { groupId: 7, title: "reins", color: "blue", collapsed: false, windowId: 1, tabCount: 2 }, + ], + }); + }); + + it("untitled groups get an empty title", async () => { + stubGroups({ + tabGroups: { + query: async () => [{ id: 9, color: "red", collapsed: true, windowId: 2 }], + update: async () => ({}), + }, + }); + const { groups } = await listGroups(); + expect(groups[0]).toMatchObject({ groupId: 9, title: "", tabCount: 0 }); + }); + + it("throws code=unsupported when the browser has no tab groups", async () => { + stubGroups({ tabGroups: undefined }); + const err = await listGroups().catch((e: unknown) => e); + expect(err).toBeInstanceOf(GroupsUnsupported); + expect((err as GroupsUnsupported).code).toBe("unsupported"); + expect((err as Error).message).toBe( + "this browser doesn't support tab groups (chrome.tabGroups unavailable)", + ); + }); +}); +``` + +Append to the `listTabs` describe in `packages/extension/src/lib/tab-handler.test.ts`: + +```ts + it("carries groupId for grouped tabs, omits it for ungrouped (-1)", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [ + { id: 1, title: "a", url: "https://a", active: false, groupId: 7 }, + { id: 2, title: "b", url: "https://b", active: false, groupId: -1 }, + ], + }, + }); + const { tabs } = await listTabs(); + expect(tabs[0]).toEqual({ tabId: 1, title: "a", url: "https://a", active: false, groupId: 7 }); + expect(tabs[1]).not.toHaveProperty("groupId"); + }); +``` + +Append to `packages/extension/src/lib/dispatch.test.ts`, inside `describe("dispatchMethod", …)`: + +```ts + it("list_groups routes to listGroups without a host gate", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [{ id: 1, groupId: 7 }], + group: async () => 7, + ungroup: async () => undefined, + }, + tabGroups: { + query: async () => [{ id: 7, title: "x", color: "grey", collapsed: false, windowId: 1 }], + update: async () => ({}), + }, + }); + const out = await dispatchWithMeta("list_groups", {}); + expect(out.result).toEqual({ + groups: [{ groupId: 7, title: "x", color: "grey", collapsed: false, windowId: 1, tabCount: 1 }], + }); + expect(out.meta).toEqual({}); + expect(ensureAllowed).not.toHaveBeenCalled(); + }); + + it("list_tabs redaction drops groupId from blocked tabs", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [{ id: 1, title: "t", url: "https://bank.com/", active: true, groupId: 7 }], + }, + }); + vi.mocked(policy).mockResolvedValueOnce({ + defaultTier: "full", + rules: [{ pattern: "bank.com", tier: "deny" }], + }); + const out = (await dispatchMethod("list_tabs", {})) as { tabs: unknown[] }; + expect(out.tabs[0]).toEqual({ tabId: 1, title: "", url: "", active: true, blocked: true }); + }); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `pnpm --filter @reins/extension test` +Expected: FAIL. `./tab-groups.js` is missing, `groupId` isn't mapped, and `list_groups` hits the default branch ("unknown method"). + +- [ ] **Step 3: Implement** + +Create `packages/extension/src/lib/tab-groups.ts`: + +```ts +import type { ListGroupsResult, TabGroup } from "@reins/protocol"; + +/** The browser has no tab-group API (Arc, Dia, …). `code` survives to the + * ResponseFrame, so the agent can tell the user instead of guessing. */ +export class GroupsUnsupported extends Error { + readonly code = "unsupported"; + constructor() { + super("this browser doesn't support tab groups (chrome.tabGroups unavailable)"); + } +} + +/** Every Chromium ships chrome.tabs, but not all ship tab groups: check the + * exact calls we make rather than guessing from the browser's name. */ +export function groupsSupported(): boolean { + return ( + typeof chrome.tabGroups?.query === "function" && + typeof chrome.tabGroups?.update === "function" && + typeof chrome.tabs?.group === "function" && + typeof chrome.tabs?.ungroup === "function" + ); +} + +export function requireGroups(): void { + if (!groupsSupported()) throw new GroupsUnsupported(); +} + +/** Handle the `list_groups` bridge method. */ +export async function listGroups(): Promise<ListGroupsResult> { + requireGroups(); + const [groups, tabs] = await Promise.all([chrome.tabGroups.query({}), chrome.tabs.query({})]); + const counts = new Map<number, number>(); + for (const t of tabs) { + if (typeof t.groupId === "number" && t.groupId !== -1) { + counts.set(t.groupId, (counts.get(t.groupId) ?? 0) + 1); + } + } + return { + groups: groups.map((g) => ({ + groupId: g.id, + title: g.title ?? "", + color: g.color as TabGroup["color"], + collapsed: g.collapsed, + windowId: g.windowId, + tabCount: counts.get(g.id) ?? 0, + })), + }; +} +``` + +In `packages/extension/src/lib/tab-handler.ts` `listTabs`, replace the map body: + +```ts + tabs: tabs.map((t) => ({ + tabId: t.id ?? -1, + title: t.title ?? "", + url: t.url ?? "", + active: t.active ?? false, + ...(typeof t.groupId === "number" && t.groupId !== -1 ? { groupId: t.groupId } : {}), + })), +``` + +In `packages/extension/src/lib/dispatch.ts`: +- Add `import { listGroups } from "./tab-groups.js";` +- In `gate`, change the first early return to + `if (method === "list_tabs" || method === "list_groups") return { params: p, meta: {} };` +- In the doc comment above `gate`, add: "list_groups has no host (group titles are the user's own labels)." +- In `runHandler`, add after the `list_tabs` case: + +```ts + case "list_groups": + return listGroups(); +``` + +In `packages/extension/manifest.config.ts`: +- Add this comment line after the `tabs:` line: + `// - tabGroups: list/create/edit tab groups for the agent (no install warning)` +- Change permissions to `["debugger", "tabs", "tabGroups", "storage", "offscreen"]`. + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --filter @reins/extension test && pnpm --filter @reins/extension typecheck` +Expected: PASS. If typecheck flags `chrome.tabGroups?.` as a non-optional access, keep it anyway: at runtime the namespace can be undefined. If biome complains, add `// biome-ignore lint/complexity/useOptionalChain: undefined outside Chrome proper` or cast through `(chrome as Partial<typeof chrome>)`. + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension +git commit -m "feat(extension): list_groups and groupId on tabs, tabGroups permission" +``` + +--- + +### Task 3: Daemon aggregation + `reins groups` + tabs marker + +**Files:** +- Modify: `packages/cli/src/rpc.ts:12-27` (target helper, `listAllGroups`), `:94-99` (routing) +- Modify: `packages/cli/src/rpc.test.ts` +- Modify: `packages/cli/src/cli-commands.ts` (`groupsText`, tabs marker, help list) +- Modify: `packages/cli/src/cli-commands.test.ts` +- Modify: `packages/cli/src/commands.ts` (`groups` command) +- Modify: `packages/cli/src/commands.test.ts` + +**Interfaces:** +- Consumes: `ListGroupsResult`, `TabGroup` (Task 1); the extension's `list_groups` (Task 2) +- Produces: + - `listAllGroups(bridge: BridgePort, browserId?: string): Promise<TabGroup[]>` + - `groupsText(groups: TabGroup[]): string` + - `TOOL_COMMANDS.groups` + +- [ ] **Step 1: Write failing tests** + +Append to `packages/cli/src/rpc.test.ts` (import `listAllGroups` too): + +```ts +describe("listAllGroups", () => { + const G = { groupId: 7, title: "reins", color: "blue", collapsed: false, windowId: 1, tabCount: 2 }; + const two = [ + { id: "b1", browser: "Chrome", connectedAt: 0 }, + { id: "b2", browser: "Dia", connectedAt: 1 }, + ]; + + it("aggregates across browsers with tags, via handleRpc", async () => { + const bridge = fakeBridge({ + browsers: two, + request: vi.fn(async () => ({ groups: [G] })), + }); + const out = (await handleRpc(bridge, { method: "list_groups" })) as { groups: unknown[] }; + expect(out.groups).toEqual([ + { ...G, browserId: "b1", browser: "Chrome" }, + { ...G, browserId: "b2", browser: "Dia" }, + ]); + }); + + it("skips browsers that fail when another answers", async () => { + const bridge = fakeBridge({ + browsers: two, + request: vi.fn(async (_m: string, _p: unknown, opts?: { browserId?: string }) => { + if (opts?.browserId === "b2") throw new Error("unsupported: no tab groups"); + return { groups: [G] }; + }), + }); + const groups = await listAllGroups(bridge); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ browserId: "b1" }); + }); + + it("rethrows the first error when every browser fails", async () => { + const bridge = fakeBridge({ + request: vi.fn(async () => { + throw new Error("unsupported: this browser doesn't support tab groups"); + }), + }); + await expect(listAllGroups(bridge)).rejects.toThrow("unsupported"); + }); + + it("returns [] with no browsers connected", async () => { + expect(await listAllGroups(fakeBridge({ browsers: [] }))).toEqual([]); + }); + + it("errors on an unknown browserId", async () => { + await expect(listAllGroups(fakeBridge(), "b9")).rejects.toThrow('unknown browserId "b9"'); + }); +}); +``` + +Append to `packages/cli/src/cli-commands.test.ts` (import `groupsText`): + +```ts +describe("groupsText", () => { + it("renders one line per group", () => { + const g = { + groupId: 7, + title: "reins", + color: "blue" as const, + collapsed: true, + windowId: 1, + tabCount: 2, + browserId: "b1", + }; + expect(groupsText([g])).toBe(' b1 group 7 "reins" blue 2 tabs (collapsed) window 1'); + expect(groupsText([{ ...g, collapsed: false, tabCount: 1 }])).toBe( + ' b1 group 7 "reins" blue 1 tab window 1', + ); + expect(groupsText([])).toBe("(no groups)"); + }); + + it("tabsText marks grouped tabs", () => { + const text = tabsText([ + { tabId: 12, title: "T", url: "https://x", active: true, groupId: 7, browserId: "b1" }, + ]); + expect(text).toBe(" b1 tab 12 * g7 T — https://x"); + }); +}); +``` + +Append to `describe("TOOL_COMMANDS: params", …)` in `packages/cli/src/commands.test.ts`: + +```ts + it("groups: optional browser filter", () => { + expect(build("groups", [])).toEqual({}); + expect(build("groups", ["--browser", "b2"])).toEqual({ browserId: "b2" }); + expect(cmd("groups").method).toBe("list_groups"); + }); +``` + +- [ ] **Step 2: Run to verify fail** + +Run: `pnpm --filter @reins/protocol build && pnpm --filter @karnstack/reins test` +Expected: FAIL. `listAllGroups`, `groupsText`, and `groups` don't exist yet. + +- [ ] **Step 3: Implement** + +In `packages/cli/src/rpc.ts`: +- Change the import to `import { ListGroupsResult, ListTabsResult, type ResponseMeta, type Tab, type TabGroup } from "@reins/protocol";` +- Replace the top of `listAllTabs` with a shared helper: + +```ts +/** The browsers a fan-out call targets: all, or the one named. */ +function targetBrowsers(bridge: BridgePort, browserId?: string): BridgePort["browsers"] { + const targets = browserId ? bridge.browsers.filter((b) => b.id === browserId) : bridge.browsers; + if (browserId !== undefined && targets.length === 0) { + const roster = bridge.browsers.map((b) => `${b.id} (${b.browser})`).join(", "); + throw new Error(`unknown browserId "${browserId}"${roster ? `. Connected: ${roster}` : ""}`); + } + return targets; +} + +/** List tabs across connected browsers (all, or one), tagging each tab with + * its browserId + browser name. */ +export async function listAllTabs(bridge: BridgePort, browserId?: string): Promise<Tab[]> { + const results = await Promise.all( + targetBrowsers(bridge, browserId).map(async (b) => { + const raw = await bridge.request("list_tabs", {}, { browserId: b.id }); + const { tabs } = ListTabsResult.parse(raw); + return tabs.map((t) => ({ ...t, browserId: b.id, browser: b.browser })); + }), + ); + return results.flat(); +} + +/** List tab groups across connected browsers, tagged like listAllTabs. A + * browser without tab groups (Arc, Dia) or with an older extension adds + * nothing; only when every targeted browser fails does the error surface. */ +export async function listAllGroups(bridge: BridgePort, browserId?: string): Promise<TabGroup[]> { + const settled = await Promise.allSettled( + targetBrowsers(bridge, browserId).map(async (b) => { + const raw = await bridge.request("list_groups", {}, { browserId: b.id }); + const { groups } = ListGroupsResult.parse(raw); + return groups.map((g) => ({ ...g, browserId: b.id, browser: b.browser })); + }), + ); + const ok = settled.filter((s) => s.status === "fulfilled"); + const failed = settled.find((s) => s.status === "rejected"); + if (ok.length === 0 && failed) throw failed.reason; + return ok.flatMap((s) => s.value); +} +``` + +(If TS doesn't narrow `settled.filter`, use `settled.flatMap((s) => (s.status === "fulfilled" ? s.value : []))`. Keep the `failed` check as it is.) + +- In `handleRpc`'s `try`, after the `list_tabs` branch: + +```ts + if (method === "list_groups") { + const groups = await listAllGroups(bridge, browserId); + finish({ ok: true, browserId }); + return { groups }; + } +``` + +In `packages/cli/src/cli-commands.ts`: +- Change the type import to `import type { BrowserInfo, Tab, TabGroup } from "@reins/protocol";` +- In `helpText`, change the tabs list to `...["tabs", "groups", "open", "close", "focus", "nav"].map(tool),` +- Replace `tabsText`'s map line and add `groupsText`: + +```ts +/** Tab listing for `reins tabs`. */ +export function tabsText(tabs: Tab[]): string { + if (tabs.length === 0) return "(no tabs)"; + return tabs + .map( + (t) => + ` ${t.browserId ?? "?"} tab ${t.tabId}${t.active ? " *" : " "}${t.groupId !== undefined ? ` g${t.groupId}` : ""} ${t.title || "(untitled)"} — ${t.url}`, + ) + .join("\n"); +} + +/** Group listing for `reins groups`. */ +export function groupsText(groups: TabGroup[]): string { + if (groups.length === 0) return "(no groups)"; + return groups + .map( + (g) => + ` ${g.browserId ?? "?"} group ${g.groupId} "${g.title}" ${g.color} ${g.tabCount} tab${g.tabCount === 1 ? "" : "s"}${g.collapsed ? " (collapsed)" : ""} window ${g.windowId}`, + ) + .join("\n"); +} +``` + +In `packages/cli/src/commands.ts`: +- Change the imports to `import type { ConsoleEntry, NetworkEntry, SnapshotRef, Tab, TabGroup } from "@reins/protocol";` and `import { groupsText, tabsText } from "./cli-commands.js";` +- Add after `tabs:`: + +```ts + groups: { + method: "list_groups", + usage: "reins groups [--browser <id>]", + summary: "list tab groups across all connected browsers", + build: (a) => { + const browser = flagStr(a, "browser"); + return browser !== undefined ? { browserId: browser } : {}; + }, + format: (r) => groupsText((r as { groups: TabGroup[] }).groups), + }, +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --filter @karnstack/reins test && pnpm --filter @karnstack/reins typecheck` +Expected: PASS, including the existing `tabsText`/help tests (ungrouped tabs render unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src +git commit -m "feat(cli): reins groups, group marker in reins tabs" +``` + +--- + +### Task 4: PR1 docs, changeset, verification, PR + +**Files:** +- Modify: `skills/reins/SKILL.md` (Commands block ~line 90) +- Modify: `packages/web/src/routes/docs/commands.tsx:22-30` (tabs group rows) +- Modify: `docs/CHROME_WEB_STORE.md` (tabGroups justification after the tabs justification ~line 59; the permission list ~line 155; the feature bullet ~line 137) +- Modify: `docs/PRIVACY.md:11-12` +- Create: `.changeset/tab-groups-read.md` + +- [ ] **Step 1: Docs** + +`skills/reins/SKILL.md` Commands block, after the `tabs / open …` line: + +``` +groups tab groups (id, title, color); `tabs` shows g<id> per grouped tab +``` + +`packages/web/src/routes/docs/commands.tsx`, after the `reins tabs` row: + +```ts + [ + "reins groups [--browser <id>]", + "List tab groups. reins tabs marks each grouped tab with g<id>.", + ], +``` + +`docs/CHROME_WEB_STORE.md`: +- After the tabs justification block, add: + +````md +**tabGroups justification** + +```text +Lists the user's tab groups (title, color, collapsed state) and, on the agent's behalf, creates groups, adds or removes tabs, and edits a group's title and color — so the user can see which tabs their agent is working in. Group metadata is sent only to the user's own local daemon on 127.0.0.1, never to a remote server. +``` +```` + +- In the "PERMISSIONS, AND WHY" list, add after the `tabs` bullet: + `• tabGroups — list, create, and edit tab groups, so you can see which tabs your agent is working in.` +- Change the `• Tabs — …` feature bullet (~line 137) to: + `• Tabs — list, open, close, focus, and group tabs across every connected browser` + +`docs/PRIVACY.md` line 11: change "**Page content and tab metadata** (titles, URLs, …" to "**Page content and tab metadata** (titles, URLs, tab group names, …". Also update `_Last updated:` to `2026-09-26`. + +- [ ] **Step 2: Changeset** + +Create `.changeset/tab-groups-read.md`: + +```md +--- +"@karnstack/reins": minor +"@reins/extension": minor +--- + +`reins groups` lists tab groups (title, color, collapsed, tab count) across connected browsers, and `reins tabs` marks each grouped tab with `g<id>`. Browsers without tab groups (Arc, Dia) answer `unsupported` instead of failing silently. The extension asks for the `tabGroups` permission, which shows no install prompt. +``` + +(Check `.changeset/config.json`. If `@reins/protocol` is not ignored/private, add `"@reins/protocol": minor` too.) + +- [ ] **Step 3: Full verification** + +Run: `pnpm build && pnpm test && pnpm typecheck && pnpm lint` +Expected: all pass. + +- [ ] **Step 4: Manual e2e (real Chrome, own tabs only)** + +Follow `local-dev-setup` and `sideload-bundle-order` from memory: `pnpm build` → reload the extension (`reins extension --reload`) → `reins kill`. Then: + +```bash +reins groups # lists the user's existing groups (read-only), or "(no groups)" +reins tabs | grep ' g' | head -3 # grouped tabs show g<id> +``` + +Expected: no errors. Nothing is mutated in PR1. + +- [ ] **Step 5: Commit, push, PR** + +```bash +git add skills packages/web docs .changeset +git commit -m "docs: reins groups, tabGroups permission" +git push -u origin feat/tab-groups +gh pr create --base main --title "feat: reins groups — list tab groups" --body "…summary, test plan… + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +# PR2 — write side (`feat/tab-groups-write`, stacked on PR1) + +Start: `git checkout -b feat/tab-groups-write` from `feat/tab-groups`. + +### Task 5: Protocol — write params + tiers + +**Files:** +- Modify: `packages/protocol/src/cdp.ts` (after `SelectTabParams` ~line 92) +- Modify: `packages/protocol/src/policy.ts` (METHOD_TIERS) +- Test: `packages/protocol/src/cdp.test.ts`, `packages/protocol/src/policy.test.ts` + +**Interfaces:** +- Consumes: `TabGroupColor` (Task 1, from `./bridge.js`) +- Produces: + - `GroupTabsParams { browserId?, tabIds: number[] (≥1), groupId?, title?, color?, collapsed? }` + - `GroupTabsResult { groupId }` + - `UpdateGroupParams { browserId?, groupId, title?, color?, collapsed? }` (refined: at least one prop) + - `UngroupTabsParams { browserId?, tabIds?: number[] (≥1), groupId? }` (refined: exactly one) + - tiers: `group_tabs`, `update_group`, `ungroup_tabs` are all `"read"` + +- [ ] **Step 1: Failing tests** + +Append to `packages/protocol/src/cdp.test.ts` (import the three schemas from `./cdp.js`): + +```ts +describe("tab group params", () => { + it("GroupTabsParams needs at least one tab and a known color", () => { + expect(GroupTabsParams.parse({ tabIds: [1, 2], title: "reins", color: "blue" })).toMatchObject({ + tabIds: [1, 2], + }); + expect(() => GroupTabsParams.parse({ tabIds: [] })).toThrow(); + expect(() => GroupTabsParams.parse({ tabIds: [1], color: "magenta" })).toThrow(); + }); + + it("UpdateGroupParams needs something to change", () => { + expect(UpdateGroupParams.parse({ groupId: 7, collapsed: true }).collapsed).toBe(true); + expect(() => UpdateGroupParams.parse({ groupId: 7 })).toThrow(/title, color, or collapsed/); + }); + + it("UngroupTabsParams needs exactly one of tabIds or groupId", () => { + expect(UngroupTabsParams.parse({ tabIds: [1] }).tabIds).toEqual([1]); + expect(UngroupTabsParams.parse({ groupId: 7 }).groupId).toBe(7); + expect(() => UngroupTabsParams.parse({})).toThrow(/exactly one/); + expect(() => UngroupTabsParams.parse({ tabIds: [1], groupId: 7 })).toThrow(/exactly one/); + }); +}); +``` + +In `policy.test.ts`, rename the METHOD_TIERS test to `"classifies exactly the 27 bridge methods"` and add `"group_tabs", "update_group", "ungroup_tabs"` to the `read` array. + +- [ ] **Step 2: Run, verify fail** + +Run: `pnpm --filter @reins/protocol test` +Expected: FAIL (the schemas aren't exported yet). + +- [ ] **Step 3: Implement** + +In `packages/protocol/src/cdp.ts`, add `import { TabGroupColor } from "./bridge.js";` at the top. Check first that `bridge.ts` doesn't import `cdp.ts`; it only imports `./policy.js`. After `SelectTabParams`, add: + +```ts +/** Group properties the agent may set (all optional). */ +const groupProps = { + title: z.string().optional(), + color: TabGroupColor.optional(), + collapsed: z.boolean().optional(), +}; + +/** `group_tabs`: put tabs in a new group (no groupId) or an existing one. */ +export const GroupTabsParams = z.object({ + browserId, + tabIds: z.array(z.number()).min(1), + groupId: z.number().optional(), + ...groupProps, +}); +export type GroupTabsParams = z.infer<typeof GroupTabsParams>; + +export const GroupTabsResult = z.object({ groupId: z.number() }); +export type GroupTabsResult = z.infer<typeof GroupTabsResult>; + +/** `update_group`: retitle / recolor / collapse a group. */ +export const UpdateGroupParams = z + .object({ browserId, groupId: z.number(), ...groupProps }) + .refine((v) => v.title !== undefined || v.color !== undefined || v.collapsed !== undefined, { + message: "update_group needs a title, color, or collapsed", + }); +export type UpdateGroupParams = z.infer<typeof UpdateGroupParams>; + +/** `ungroup_tabs`: pull tabs out of their group, or dissolve a whole group. + * Never closes tabs. */ +export const UngroupTabsParams = z + .object({ + browserId, + tabIds: z.array(z.number()).min(1).optional(), + groupId: z.number().optional(), + }) + .refine((v) => (v.tabIds === undefined) !== (v.groupId === undefined), { + message: "ungroup_tabs needs exactly one of tabIds or groupId", + }); +export type UngroupTabsParams = z.infer<typeof UngroupTabsParams>; +``` + +In `policy.ts` METHOD_TIERS, after `list_groups: "read",`, add: + +```ts + group_tabs: "read", + update_group: "read", + ungroup_tabs: "read", +``` + +Add a comment above these lines: `// Tab-group ops touch the tab strip, never page content: readonly sites may be grouped, denied ones may not.` + +- [ ] **Step 4: Run, verify pass; rebuild dist** + +Run: `pnpm --filter @reins/protocol test && pnpm --filter @reins/protocol build` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/protocol/src +git commit -m "feat(protocol): group_tabs, update_group, ungroup_tabs" +``` + +--- + +### Task 6: Extension — group/update/ungroup handlers + +**Files:** +- Modify: `packages/extension/src/lib/tab-groups.ts` +- Modify: `packages/extension/src/lib/tab-groups.test.ts` + +**Interfaces:** +- Consumes: `GroupTabsParams`, `GroupTabsResult`, `UpdateGroupParams`, `UngroupTabsParams`, `OkResult` (Task 5); `requireGroups` (Task 2) +- Produces: + - `groupTabs(p: GroupTabsParams): Promise<GroupTabsResult>` + - `updateGroup(p: UpdateGroupParams): Promise<OkResult>` + - `ungroupTabs(p: UngroupTabsParams): Promise<OkResult>` + - `groupTabIds(groupId: number): Promise<number[]>` + +- [ ] **Step 1: Failing tests** + +Append to `tab-groups.test.ts` (import `groupTabIds, groupTabs, ungroupTabs, updateGroup`). They reuse `stubGroups` from Task 2. + +```ts +describe("groupTabs", () => { + it("creates a new group in the first tab's window, then applies props", async () => { + stubGroups(); + const out = await groupTabs({ tabIds: [1, 2], title: "reins", color: "blue" }); + expect(out).toEqual({ groupId: 7 }); + expect(chrome.tabs.group).toHaveBeenCalledWith({ + tabIds: [1, 2], + createProperties: { windowId: 1 }, + }); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(7, { title: "reins", color: "blue" }); + }); + + it("adds to an existing group without createProperties, and skips update when no props", async () => { + stubGroups(); + await groupTabs({ tabIds: [3], groupId: 7 }); + expect(chrome.tabs.group).toHaveBeenCalledWith({ tabIds: [3], groupId: 7 }); + expect(chrome.tabGroups.update).not.toHaveBeenCalled(); + }); + + it("refuses with code=unsupported on browsers without groups", async () => { + stubGroups({ tabGroups: undefined }); + await expect(groupTabs({ tabIds: [1] })).rejects.toBeInstanceOf(GroupsUnsupported); + }); +}); + +describe("updateGroup", () => { + it("passes only the given props", async () => { + stubGroups(); + expect(await updateGroup({ groupId: 7, collapsed: true })).toEqual({ ok: true }); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(7, { collapsed: true }); + }); +}); + +describe("ungroupTabs / groupTabIds", () => { + it("ungroups the given tabs", async () => { + stubGroups(); + expect(await ungroupTabs({ tabIds: [1] })).toEqual({ ok: true }); + expect(chrome.tabs.ungroup).toHaveBeenCalledWith([1]); + }); + + it("dissolves a group by ungrouping its tabs (never closes them)", async () => { + stubGroups(); + const query = vi.fn(async () => [{ id: 1 }, { id: 2 }]); + (chrome.tabs as { query: unknown }).query = query; + await ungroupTabs({ groupId: 7 }); + expect(query).toHaveBeenCalledWith({ groupId: 7 }); + expect(chrome.tabs.ungroup).toHaveBeenCalledWith([1, 2]); + }); + + it("an empty group is a no-op", async () => { + stubGroups(); + (chrome.tabs as { query: unknown }).query = async () => []; + await ungroupTabs({ groupId: 7 }); + expect(chrome.tabs.ungroup).not.toHaveBeenCalled(); + }); + + it("groupTabIds drops tabs without an id", async () => { + stubGroups(); + (chrome.tabs as { query: unknown }).query = async () => [{ id: 4 }, {}]; + expect(await groupTabIds(7)).toEqual([4]); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `pnpm --filter @reins/extension test -- tab-groups` +Expected: FAIL (the functions aren't exported). + +- [ ] **Step 3: Implement** + +Append to `tab-groups.ts`. Extend the type import to `import type { GroupTabsParams, GroupTabsResult, ListGroupsResult, OkResult, TabGroup, UngroupTabsParams, UpdateGroupParams } from "@reins/protocol";` + +```ts +type GroupProps = { title?: string; color?: TabGroup["color"]; collapsed?: boolean }; + +/** Only the props the agent actually set — undefined would reset them. */ +function groupProps(p: GroupProps): chrome.tabGroups.UpdateProperties | undefined { + const out: chrome.tabGroups.UpdateProperties = {}; + if (p.title !== undefined) out.title = p.title; + if (p.color !== undefined) out.color = p.color as chrome.tabGroups.UpdateProperties["color"]; + if (p.collapsed !== undefined) out.collapsed = p.collapsed; + return Object.keys(out).length > 0 ? out : undefined; +} + +/** The ids of every tab currently in `groupId`. */ +export async function groupTabIds(groupId: number): Promise<number[]> { + const tabs = await chrome.tabs.query({ groupId }); + return tabs.map((t) => t.id).filter((id): id is number => id !== undefined); +} + +/** Handle `group_tabs`: a new group, or add to `groupId`; then apply props. */ +export async function groupTabs(p: GroupTabsParams): Promise<GroupTabsResult> { + requireGroups(); + const tabIds = p.tabIds as [number, ...number[]]; + let groupId: number; + if (p.groupId !== undefined) { + groupId = await chrome.tabs.group({ tabIds, groupId: p.groupId }); + } else { + // A new group defaults to the *focused* window, which would drag the + // tabs across windows — keep it where the first tab already lives. + const first = await chrome.tabs.get(tabIds[0]); + groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId: first.windowId } }); + } + const props = groupProps(p); + if (props) await chrome.tabGroups.update(groupId, props); + return { groupId }; +} + +/** Handle `update_group`. */ +export async function updateGroup(p: UpdateGroupParams): Promise<OkResult> { + requireGroups(); + await chrome.tabGroups.update(p.groupId, groupProps(p) ?? {}); + return { ok: true }; +} + +/** Handle `ungroup_tabs`: the given tabs, or every tab in `groupId`. The + * dispatch gate pins `tabIds` for a group, so gate and handler act on the + * same set. Tabs stay open. */ +export async function ungroupTabs(p: UngroupTabsParams): Promise<OkResult> { + requireGroups(); + const tabIds = p.tabIds ?? (p.groupId !== undefined ? await groupTabIds(p.groupId) : []); + if (tabIds.length > 0) await chrome.tabs.ungroup(tabIds as [number, ...number[]]); + return { ok: true }; +} +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `pnpm --filter @reins/extension test && pnpm --filter @reins/extension typecheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension/src/lib/tab-groups.ts packages/extension/src/lib/tab-groups.test.ts +git commit -m "feat(extension): group, update and ungroup tab handlers" +``` + +--- + +### Task 7: Extension — multi-tab policy gate + routing + +**Files:** +- Modify: `packages/extension/src/lib/dispatch.ts` (gate, runHandler) +- Modify: `packages/extension/src/lib/dispatch.test.ts` + +**Interfaces:** +- Consumes: Task 5 schemas; Task 6 handlers plus `groupTabIds` and `requireGroups`; `ensureAllowed(method, host)` and `PolicyDenied` from `./policy.js` +- Produces: `group_tabs`, `update_group`, and `ungroup_tabs` routed and gated + +- [ ] **Step 1: Failing tests** + +Append to `dispatch.test.ts` (imports: `PolicyDenied` is already imported): + +```ts +describe("tab group gate", () => { + const URLS: Record<number, string> = { + 1: "https://x.com/", + 2: "https://bank.com/", + 3: "https://docs.com/", + }; + function stubGroupChrome() { + vi.stubGlobal("chrome", { + tabs: { + get: async (id: number) => ({ id, url: URLS[id], windowId: 1 }), + query: vi.fn(async () => [{ id: 1 }, { id: 2 }]), + group: vi.fn(async () => 7), + ungroup: vi.fn(async () => undefined), + }, + tabGroups: { query: async () => [], update: vi.fn(async () => ({})) }, + }); + } + function denyBank() { + vi.mocked(ensureAllowed).mockImplementation(async (_m, host) => { + if (host === "bank.com") { + const e = new PolicyDenied("blocked by policy: bank.com is denied"); + e.meta = { host: "bank.com", tier: "deny" }; + throw e; + } + return "read"; + }); + } + + it("group_tabs checks every tab's host at the group_tabs tier", async () => { + stubGroupChrome(); + const out = await dispatchWithMeta("group_tabs", { tabIds: [1, 3], title: "t" }); + expect(out.result).toEqual({ groupId: 7 }); + expect(vi.mocked(ensureAllowed).mock.calls).toEqual([ + ["group_tabs", "x.com"], + ["group_tabs", "docs.com"], + ]); + expect(out.meta).toEqual({}); + }); + + it("one denied tab refuses the whole group_tabs call, tagged with that tab", async () => { + stubGroupChrome(); + denyBank(); + const err = (await dispatchWithMeta("group_tabs", { tabIds: [1, 2] }).catch( + (e: unknown) => e, + )) as PolicyDenied; + expect(err).toBeInstanceOf(PolicyDenied); + expect(err.meta).toEqual({ host: "bank.com", tier: "deny", tabId: 2 }); + expect(chrome.tabs.group).not.toHaveBeenCalled(); + }); + + it("ungroup --group checks the group's current tabs and ungroups exactly those", async () => { + stubGroupChrome(); + denyBank(); + await expect(dispatchWithMeta("ungroup_tabs", { groupId: 7 })).rejects.toBeInstanceOf( + PolicyDenied, + ); + expect(chrome.tabs.query).toHaveBeenCalledWith({ groupId: 7 }); + + vi.mocked(ensureAllowed).mockReset(); + vi.mocked(ensureAllowed).mockResolvedValue("read"); + await dispatchWithMeta("ungroup_tabs", { groupId: 7 }); + expect(chrome.tabs.ungroup).toHaveBeenCalledWith([1, 2]); + }); + + it("update_group has no host gate", async () => { + stubGroupChrome(); + await dispatchWithMeta("update_group", { groupId: 7, title: "done" }); + expect(ensureAllowed).not.toHaveBeenCalled(); + expect(chrome.tabGroups.update).toHaveBeenCalledWith(7, { title: "done" }); + }); + + it("validates params (exactly-one rule) before touching tabs", async () => { + stubGroupChrome(); + await expect(dispatchWithMeta("ungroup_tabs", {})).rejects.toThrow(/exactly one/); + await expect(dispatchWithMeta("update_group", { groupId: 7 })).rejects.toThrow( + /title, color, or collapsed/, + ); + }); + + it("unsupported browser fails before any policy check", async () => { + vi.stubGlobal("chrome", { tabs: { get: async () => ({}), query: async () => [] } }); + await expect(dispatchWithMeta("ungroup_tabs", { groupId: 7 })).rejects.toThrow( + "doesn't support tab groups", + ); + expect(ensureAllowed).not.toHaveBeenCalled(); + }); +}); +``` + +Note: `afterEach` already runs `vi.clearAllMocks()`. `mockImplementation` persists across tests unless reset, so add `vi.mocked(ensureAllowed).mockReset(); vi.mocked(ensureAllowed).mockResolvedValue(undefined as never);` in a `beforeEach` inside this describe (import `beforeEach` from vitest). + +- [ ] **Step 2: Run, verify fail** + +Run: `pnpm --filter @reins/extension test -- dispatch` +Expected: FAIL ("unknown method" or the gate calling `resolveTabId`). + +- [ ] **Step 3: Implement** + +In `dispatch.ts`: +- Add `GroupTabsParams, UngroupTabsParams, UpdateGroupParams` to the `@reins/protocol` import. +- Replace the tab-groups import with `import { groupTabIds, groupTabs, listGroups, requireGroups, ungroupTabs, updateGroup } from "./tab-groups.js";` + +Add this helper above `gate`: + +```ts +/** Check every target tab's host; the first denial refuses the whole call, + * tagged with that tab for the audit trail. */ +async function ensureTabsAllowed(method: GatedMethod, tabIds: number[]): Promise<void> { + for (const tabId of tabIds) { + const tab = await chrome.tabs.get(tabId); + try { + await ensureAllowed(method, hostOf(tab.url ?? "")); + } catch (err) { + if (err instanceof PolicyDenied && err.meta) err.meta = { ...err.meta, tabId }; + throw err; + } + } +} +``` + +In `gate`, replace the first line after `const p = …`: + +```ts + if (method === "list_tabs" || method === "list_groups") return { params: p, meta: {} }; + if (method === "update_group") { + requireGroups(); + return { params: UpdateGroupParams.parse(p), meta: {} }; + } + if (method === "group_tabs" || method === "ungroup_tabs") { + // Before any lookup: on a browser without groups, tabs.query({groupId}) + // may ignore the filter and return every tab. + requireGroups(); + const parsed = (method === "group_tabs" ? GroupTabsParams : UngroupTabsParams).parse(p); + const tabIds = parsed.tabIds ?? (await groupTabIds(parsed.groupId as number)); + await ensureTabsAllowed(method, tabIds); + return { params: { ...parsed, tabIds }, meta: {} }; + } +``` + +Update the doc comment above `gate`: "Group ops check every target tab (for a whole group, its current tabs) and pin that set in params; update_group only edits the group's label." + +In `runHandler`, add after `list_groups`: + +```ts + case "group_tabs": + return groupTabs(gated as Parameters<typeof groupTabs>[0]); + case "update_group": + return updateGroup(gated as Parameters<typeof updateGroup>[0]); + case "ungroup_tabs": + return ungroupTabs(gated as Parameters<typeof ungroupTabs>[0]); +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `pnpm --filter @reins/extension test && pnpm --filter @reins/extension typecheck` +Expected: PASS, all existing dispatch tests included. + +- [ ] **Step 5: Commit** + +```bash +git add packages/extension/src/lib/dispatch.ts packages/extension/src/lib/dispatch.test.ts +git commit -m "feat(extension): policy gate for tab group ops" +``` + +--- + +### Task 8: CLI — `reins group`, `reins ungroup` + +**Files:** +- Modify: `packages/cli/src/commands.ts` (ToolCommand `methodFor`, two commands) +- Modify: `packages/cli/src/cli.ts:55` (use `methodFor`) +- Modify: `packages/cli/src/cli-commands.ts` (help list) +- Modify: `packages/cli/src/commands.test.ts` + +**Interfaces:** +- Consumes: the protocol methods `group_tabs`, `update_group`, `ungroup_tabs` (Task 5) +- Produces: + - `ToolCommand.methodFor?(params: Record<string, unknown>): string` + - `TOOL_COMMANDS.group`, `TOOL_COMMANDS.ungroup` + +- [ ] **Step 1: Failing tests** + +Append to `describe("TOOL_COMMANDS: params", …)` in `commands.test.ts`: + +```ts + it("group: repeatable --tab creates a group; props map through", () => { + const p = build("group", ["--tab", "12", "--tab", "13", "--title", "reins", "--color", "blue"]); + expect(p).toEqual({ tabIds: [12, 13], title: "reins", color: "blue" }); + expect(cmd("group").methodFor?.(p)).toBe("group_tabs"); + }); + + it("group: --tab with --group adds to that group", () => { + const p = build("group", ["--tab", "14", "--group", "7", "--browser", "b1"]); + expect(p).toEqual({ tabIds: [14], groupId: 7, browserId: "b1" }); + expect(cmd("group").methodFor?.(p)).toBe("group_tabs"); + }); + + it("group: --group without --tab edits the group", () => { + const p = build("group", ["--group", "7", "--collapse"]); + expect(p).toEqual({ groupId: 7, collapsed: true }); + expect(cmd("group").methodFor?.(p)).toBe("update_group"); + expect(build("group", ["--group", "7", "--expand"])).toEqual({ groupId: 7, collapsed: false }); + }); + + it("group: usage errors", () => { + expect(() => build("group", [])).toThrow(UsageError); + expect(() => build("group", ["--group", "7"])).toThrow(/--title, --color, --collapse/); + expect(() => build("group", ["--tab", "1", "--collapse", "--expand"])).toThrow(UsageError); + expect(() => build("group", ["--tab", "1", "--color", "magenta"])).toThrow(UsageError); + expect(() => build("group", ["--tab", "x"])).toThrow(UsageError); + }); + + it("group: formats the group id or ok", () => { + expect(format("group", { groupId: 7 })).toBe("group 7"); + expect(format("group", { ok: true })).toBe("ok"); + }); + + it("ungroup: exactly one of --tab or --group", () => { + expect(build("ungroup", ["--tab", "1", "--tab", "2"])).toEqual({ tabIds: [1, 2] }); + expect(build("ungroup", ["--group", "7"])).toEqual({ groupId: 7 }); + expect(() => build("ungroup", [])).toThrow(UsageError); + expect(() => build("ungroup", ["--tab", "1", "--group", "7"])).toThrow(UsageError); + expect(cmd("ungroup").method).toBe("ungroup_tabs"); + }); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `pnpm --filter @reins/protocol build && pnpm --filter @karnstack/reins test -- commands` +Expected: FAIL (no such command: group). + +- [ ] **Step 3: Implement** + +In `commands.ts`, add to the `ToolCommand` interface after `method: string;`: + +```ts + /** Pick the bridge method from the built params, when one command covers + * several (default: `method`). */ + methodFor?(params: Record<string, unknown>): string; +``` + +Add helpers after `oneOf`: + +```ts +const GROUP_COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]; + +/** Repeatable --tab → tab ids (undefined when absent). */ +function tabList(a: ParsedArgs): number[] | undefined { + const v = a.flags.tab; + if (v === undefined) return undefined; + const list = Array.isArray(v) ? v : [v]; + return list.map((s) => { + const n = Number(s); + if (typeof s !== "string" || !Number.isInteger(n)) { + throw new UsageError(`--tab must be an integer, got "${String(s)}"`); + } + return n; + }); +} + +/** --title / --color / --collapse|--expand → group props. */ +function groupProps(a: ParsedArgs): Record<string, unknown> { + const out: Record<string, unknown> = {}; + const title = flagStr(a, "title"); + if (title !== undefined) out.title = title; + const color = oneOf(a, "color", GROUP_COLORS); + if (color !== undefined) out.color = color; + if (a.flags.collapse === true && a.flags.expand === true) { + throw new UsageError("--collapse and --expand are mutually exclusive"); + } + if (a.flags.collapse === true) out.collapsed = true; + if (a.flags.expand === true) out.collapsed = false; + return out; +} + +function browserOnly(a: ParsedArgs): Record<string, unknown> { + const browser = flagStr(a, "browser"); + return browser !== undefined ? { browserId: browser } : {}; +} +``` + +(Refactor `tabs.build` and `groups.build` to `browserOnly(a)` while here.) + +Add the commands after `groups:`: + +```ts + group: { + method: "group_tabs", + methodFor: (p) => (p.tabIds === undefined ? "update_group" : "group_tabs"), + usage: + "reins group --tab <id> [--tab <id> …] [--group <gid>] [--title <t>] [--color <c>] [--collapse|--expand]\n reins group --group <gid> [--title <t>] [--color <c>] [--collapse|--expand]", + summary: "group tabs (new group, or --group to add/edit one)", + booleans: ["collapse", "expand"], + multi: ["tab"], + build: (a) => { + const tabIds = tabList(a); + const groupId = flagInt(a, "group"); + const props = groupProps(a); + if (tabIds === undefined && groupId === undefined) { + throw new UsageError("--tab (to group tabs) or --group (to edit a group) is required"); + } + if (tabIds === undefined && Object.keys(props).length === 0) { + throw new UsageError("editing a group needs --title, --color, --collapse or --expand"); + } + return { + ...browserOnly(a), + ...(tabIds !== undefined ? { tabIds } : {}), + ...(groupId !== undefined ? { groupId } : {}), + ...props, + }; + }, + format: (r) => { + const g = r as { groupId?: number }; + return g.groupId !== undefined ? `group ${g.groupId}` : "ok"; + }, + }, + ungroup: { + method: "ungroup_tabs", + usage: "reins ungroup --tab <id> [--tab <id> …] | --group <gid>", + summary: "take tabs out of their group, or dissolve a group (tabs stay open)", + multi: ["tab"], + build: (a) => { + const tabIds = tabList(a); + const groupId = flagInt(a, "group"); + if ((tabIds === undefined) === (groupId === undefined)) { + throw new UsageError("exactly one of --tab or --group is required"); + } + return { + ...browserOnly(a), + ...(tabIds !== undefined ? { tabIds } : { groupId }), + }; + }, + format: ok, + }, +``` + +In `cli.ts:55`, change the call to `const result = await rpc(ensured.port, cmd.methodFor?.(params) ?? cmd.method, params);` + +In `cli-commands.ts` `helpText`, change the tabs list to `["tabs", "groups", "group", "ungroup", "open", "close", "focus", "nav"]`. + +Check `format`'s helper in the test: it calls `c.format?.(result, parseArgs(argv))`, which works as is. + +- [ ] **Step 4: Run, verify pass** + +Run: `pnpm --filter @karnstack/reins test && pnpm --filter @karnstack/reins typecheck` +Expected: PASS. The "every command has a usage line" test must see `reins group` and `reins ungroup` in their usage. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src +git commit -m "feat(cli): reins group and reins ungroup" +``` + +--- + +### Task 9: PR2 docs, changeset, e2e, PR + +**Files:** +- Modify: `skills/reins/SKILL.md` +- Modify: `packages/web/src/routes/docs/commands.tsx` +- Create: `.changeset/tab-groups-write.md` + +- [ ] **Step 1: Docs** + +`skills/reins/SKILL.md` Commands block, replace the `groups` line from Task 4 with: + +``` +groups tab groups (id, title, color); `tabs` shows g<id> per grouped tab +group --tab <id> [--tab …] [--group <gid>] [--title T] [--color blue] [--collapse|--expand] + no --tab + --group <gid>: edit that group +ungroup --tab <id> [--tab …] | --group <gid> (tabs stay open) +``` + +Add a short paragraph after the Commands block (or in the tips section): + +```md +**Tab groups.** You can put the tabs you open for a task into a group +(`reins group --tab 12 --tab 13 --title reins --color blue`) so the user sees +which tabs are yours. Don't regroup the user's own tabs unless they ask. +Arc and Dia have no tab groups; those commands answer `unsupported` there. +``` + +`packages/web/src/routes/docs/commands.tsx`, after the `reins groups` row: + +```ts + [ + "reins group --tab <id> [--tab <id> …] [--group <gid>] [--title <t>] [--color <c>] [--collapse|--expand]", + "Put tabs in a new group, or in an existing one with --group. With --group and no --tab, edit the group.", + ], + [ + "reins ungroup --tab <id> [--tab <id> …] | --group <gid>", + "Take tabs out of their group, or dissolve a whole group. Tabs stay open.", + ], +``` + +- [ ] **Step 2: Changeset** + +Create `.changeset/tab-groups-write.md` (add `@reins/protocol` if Task 4 found it versioned): + +```md +--- +"@karnstack/reins": minor +"@reins/extension": minor +--- + +`reins group` puts tabs in a new or existing tab group and edits a group's title, color, and collapsed state. `reins ungroup` takes tabs out of their group or dissolves a whole group, and never closes tabs. reins groups nothing on its own; the agent decides. Group operations count as reading: read-only sites can be grouped, denied sites cannot. +``` + +- [ ] **Step 3: Full verification** + +Run: `pnpm build && pnpm test && pnpm typecheck && pnpm lint` +Expected: all pass. + +- [ ] **Step 4: Manual e2e (real Chrome, only tabs reins opens)** + +Rebuild and reload per memory: `pnpm build` → `reins extension --reload` → `reins kill`. Then: + +```bash +A=$(reins open https://example.com --background --json | jq .tabId) +B=$(reins open https://example.org --background --json | jq .tabId) +G=$(reins group --tab $A --tab $B --title reins-e2e --color orange --json | jq .groupId) +reins groups | grep reins-e2e # 2 tabs, orange +reins tabs | grep "g$G" # both tabs marked +reins group --group $G --collapse && reins groups | grep collapsed +reins ungroup --tab $A && reins groups | grep reins-e2e # 1 tab +reins ungroup --group $G && reins groups | grep -c reins-e2e # 0 — group gone +reins close --tab $A && reins close --tab $B # cleanup +``` + +Expected: every step matches the comment, and the user's own tabs and groups are unchanged. If a Dia or Arc browser is connected, run `reins group --browser <id> --tab <id> …` and confirm it answers `unsupported: …`. + +- [ ] **Step 5: Commit, push, stacked PR** + +```bash +git add skills packages/web .changeset +git commit -m "docs: reins group / ungroup" +git push -u origin feat/tab-groups-write +gh pr create --base feat/tab-groups --title "feat: reins group / ungroup" --body "Stacked on #<PR1>. …summary, test plan… + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` From ad27c3de0a0134a58fe97555ae2647beac8b8e85 Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sat, 26 Sep 2026 23:05:31 +0530 Subject: [PATCH 3/9] feat(protocol): tab group schema, Tab.groupId, list_groups tier Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- packages/protocol/src/bridge.test.ts | 31 +++++++++++++++++++++++- packages/protocol/src/bridge.ts | 35 ++++++++++++++++++++++++++++ packages/protocol/src/policy.test.ts | 3 ++- packages/protocol/src/policy.ts | 1 + 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/protocol/src/bridge.test.ts b/packages/protocol/src/bridge.test.ts index 997d18c..98269c7 100644 --- a/packages/protocol/src/bridge.test.ts +++ b/packages/protocol/src/bridge.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { ListTabsResult, RequestFrame, ResponseFrame, Tab, WelcomeFrame } from "./bridge.js"; +import { + ListGroupsResult, + ListTabsResult, + RequestFrame, + ResponseFrame, + Tab, + TabGroup, + WelcomeFrame, +} from "./bridge.js"; describe("bridge frames", () => { it("accepts a valid request frame", () => { @@ -68,3 +76,24 @@ describe("ResponseMeta", () => { expect(frame.meta?.tabId).toBeUndefined(); }); }); + +describe("tab groups", () => { + it("Tab accepts an optional groupId", () => { + expect(Tab.parse({ tabId: 1, title: "t", url: "u", active: true, groupId: 7 }).groupId).toBe(7); + expect(Tab.parse({ tabId: 1, title: "t", url: "u", active: true }).groupId).toBeUndefined(); + }); + + it("TabGroup parses a group and rejects unknown colors", () => { + const g = { + groupId: 7, + title: "reins", + color: "blue", + collapsed: false, + windowId: 1, + tabCount: 2, + }; + expect(TabGroup.parse(g)).toEqual(g); + expect(() => TabGroup.parse({ ...g, color: "magenta" })).toThrow(); + expect(ListGroupsResult.parse({ groups: [g] }).groups).toHaveLength(1); + }); +}); diff --git a/packages/protocol/src/bridge.ts b/packages/protocol/src/bridge.ts index a6d4ebb..05220fa 100644 --- a/packages/protocol/src/bridge.ts +++ b/packages/protocol/src/bridge.ts @@ -8,6 +8,9 @@ export const Tab = z.object({ title: z.string(), url: z.string(), active: z.boolean(), + /** The tab's group, when it is in one (omitted when ungrouped, or when the + * browser has no tab groups). */ + groupId: z.number().optional(), /** true when the tab's host is policy-denied: title/url are redacted. */ blocked: z.boolean().optional(), browserId: z.string().optional(), @@ -15,6 +18,34 @@ export const Tab = z.object({ }); export type Tab = z.infer<typeof Tab>; +/** Chromium's fixed tab-group palette. */ +export const TabGroupColor = z.enum([ + "grey", + "blue", + "red", + "yellow", + "green", + "pink", + "purple", + "cyan", + "orange", +]); +export type TabGroupColor = z.infer<typeof TabGroupColor>; + +/** A tab group. browserId/browser are tagged by the daemon when aggregating + * across several connected browsers. */ +export const TabGroup = z.object({ + groupId: z.number(), + title: z.string(), + color: TabGroupColor, + collapsed: z.boolean(), + windowId: z.number(), + tabCount: z.number(), + browserId: z.string().optional(), + browser: z.string().optional(), +}); +export type TabGroup = z.infer<typeof TabGroup>; + /** A browser connected to the daemon's bridge. */ export const BrowserInfo = z.object({ id: z.string(), @@ -73,3 +104,7 @@ export type WelcomeFrame = z.infer<typeof WelcomeFrame>; /** Result payload for the `list_tabs` method. */ export const ListTabsResult = z.object({ tabs: z.array(Tab) }); export type ListTabsResult = z.infer<typeof ListTabsResult>; + +/** Result payload for the `list_groups` method. */ +export const ListGroupsResult = z.object({ groups: z.array(TabGroup) }); +export type ListGroupsResult = z.infer<typeof ListGroupsResult>; diff --git a/packages/protocol/src/policy.test.ts b/packages/protocol/src/policy.test.ts index 27479a0..19ae040 100644 --- a/packages/protocol/src/policy.test.ts +++ b/packages/protocol/src/policy.test.ts @@ -84,9 +84,10 @@ describe("effectiveTier", () => { }); describe("METHOD_TIERS", () => { - it("classifies exactly the 23 bridge methods", () => { + it("classifies exactly the 24 bridge methods", () => { const read = [ "list_tabs", + "list_groups", "read_snapshot", "read_text", "screenshot", diff --git a/packages/protocol/src/policy.ts b/packages/protocol/src/policy.ts index 73f1036..8a5e310 100644 --- a/packages/protocol/src/policy.ts +++ b/packages/protocol/src/policy.ts @@ -77,6 +77,7 @@ export function effectiveTier(policy: Policy, host: string | undefined): Tier { */ export const METHOD_TIERS = { list_tabs: "read", + list_groups: "read", read_snapshot: "read", read_text: "read", screenshot: "read", From 92b44b16df969415ea9f0ee77bbbd8aa6bc77015 Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sat, 26 Sep 2026 23:11:24 +0530 Subject: [PATCH 4/9] feat(extension): list_groups and groupId on tabs, tabGroups permission Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- packages/extension/manifest.config.ts | 3 +- packages/extension/src/lib/dispatch.test.ts | 38 ++++++++++ packages/extension/src/lib/dispatch.ts | 8 +- packages/extension/src/lib/tab-groups.test.ts | 74 +++++++++++++++++++ packages/extension/src/lib/tab-groups.ts | 47 ++++++++++++ .../extension/src/lib/tab-handler.test.ts | 14 ++++ packages/extension/src/lib/tab-handler.ts | 1 + 7 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 packages/extension/src/lib/tab-groups.test.ts create mode 100644 packages/extension/src/lib/tab-groups.ts diff --git a/packages/extension/manifest.config.ts b/packages/extension/manifest.config.ts index 5e9ee88..fb7d25a 100644 --- a/packages/extension/manifest.config.ts +++ b/packages/extension/manifest.config.ts @@ -4,6 +4,7 @@ import pkg from "./package.json"; // Permissions are deliberately minimal for Chrome Web Store review: // - debugger: run CDP commands (click/type/screenshot/eval/monitor) on tabs // - tabs: list/open/close/focus tabs; resize the tab's window +// - tabGroups: list/create/edit tab groups for the agent (no install warning) // - storage: persist settings (local) and connection status (session) // - offscreen: host the persistent WebSocket to the local reins daemon // No host_permissions: all page access goes through chrome.debugger, and the @@ -13,7 +14,7 @@ export default defineManifest({ name: "reins", version: pkg.version, description: "Drive your real, logged-in browser from your coding agent (via the reins CLI).", - permissions: ["debugger", "tabs", "storage", "offscreen"], + permissions: ["debugger", "tabs", "tabGroups", "storage", "offscreen"], icons: { 16: "icons/icon-16.png", 32: "icons/icon-32.png", diff --git a/packages/extension/src/lib/dispatch.test.ts b/packages/extension/src/lib/dispatch.test.ts index 339f146..f3a530d 100644 --- a/packages/extension/src/lib/dispatch.test.ts +++ b/packages/extension/src/lib/dispatch.test.ts @@ -97,6 +97,44 @@ describe("dispatchMethod", () => { it("unknown method rejects with /unknown method/", async () => { await expect(dispatchMethod("foo_bar", {})).rejects.toThrow(/unknown method/); }); + + it("list_groups routes to listGroups without a host gate", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [{ id: 1, groupId: 7 }], + group: async () => 7, + ungroup: async () => undefined, + }, + tabGroups: { + query: async () => [{ id: 7, title: "x", color: "grey", collapsed: false, windowId: 1 }], + update: async () => ({}), + }, + }); + const out = await dispatchWithMeta("list_groups", {}); + expect(out.result).toEqual({ + groups: [ + { groupId: 7, title: "x", color: "grey", collapsed: false, windowId: 1, tabCount: 1 }, + ], + }); + expect(out.meta).toEqual({}); + expect(ensureAllowed).not.toHaveBeenCalled(); + }); + + it("list_tabs redaction drops groupId from blocked tabs", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [ + { id: 1, title: "t", url: "https://bank.com/", active: true, groupId: 7 }, + ], + }, + }); + vi.mocked(policy).mockResolvedValueOnce({ + defaultTier: "full", + rules: [{ pattern: "bank.com", tier: "deny" }], + }); + const out = (await dispatchMethod("list_tabs", {})) as { tabs: unknown[] }; + expect(out.tabs[0]).toEqual({ tabId: 1, title: "", url: "", active: true, blocked: true }); + }); }); describe("dispatchMethod routing (CDP)", () => { diff --git a/packages/extension/src/lib/dispatch.ts b/packages/extension/src/lib/dispatch.ts index 20418e0..8f6e9f8 100644 --- a/packages/extension/src/lib/dispatch.ts +++ b/packages/extension/src/lib/dispatch.ts @@ -31,6 +31,7 @@ import { } from "./page-actions.js"; import { ensureAllowed, PolicyDenied, policy, tightenPolicy } from "./policy.js"; import { reloadExtension } from "./reload.js"; +import { listGroups } from "./tab-groups.js"; import { closeTab, listTabs, resizeWindow, selectTab } from "./tab-handler.js"; const NAV_HISTORY = new Set(["back", "forward", "reload"]); @@ -45,11 +46,12 @@ interface Gated { * checks the host's tier against the method's required tier, and returns * params with tabId pinned plus the resolved host/tier/tabId for the audit * trail. list_tabs is gated per-tab (redaction) in runHandler; open_tab has - * no current tab and checks its destination. + * no current tab and checks its destination. list_groups has no host (group + * titles are the user's own labels). */ async function gate(method: GatedMethod, params: unknown): Promise<Gated> { const p = { ...((params ?? {}) as Record<string, unknown>) }; - if (method === "list_tabs") return { params: p, meta: {} }; + if (method === "list_tabs" || method === "list_groups") return { params: p, meta: {} }; if (method === "open_tab") { const host = hostOf(String(p.url ?? "")); const tier = await ensureAllowed("open_tab", host); @@ -103,6 +105,8 @@ async function runHandler(method: GatedMethod, gated: Record<string, unknown>): ), }; } + case "list_groups": + return listGroups(); case "open_tab": return cdpOpenTab(gated as Parameters<typeof cdpOpenTab>[0]); case "close_tab": diff --git a/packages/extension/src/lib/tab-groups.test.ts b/packages/extension/src/lib/tab-groups.test.ts new file mode 100644 index 0000000..c52b258 --- /dev/null +++ b/packages/extension/src/lib/tab-groups.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GroupsUnsupported, groupsSupported, listGroups } from "./tab-groups.js"; + +afterEach(() => vi.unstubAllGlobals()); + +/** Minimal chrome with a working tab-group API. */ +function stubGroups(overrides: Record<string, unknown> = {}) { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [ + { id: 1, groupId: 7 }, + { id: 2, groupId: 7 }, + { id: 3, groupId: -1 }, + ], + group: vi.fn(async () => 7), + ungroup: vi.fn(async () => undefined), + get: async (id: number) => ({ id, windowId: 1 }), + }, + tabGroups: { + query: async () => [{ id: 7, title: "reins", color: "blue", collapsed: false, windowId: 1 }], + update: vi.fn(async () => ({})), + }, + ...overrides, + }); +} + +describe("groupsSupported", () => { + it("is true when tabGroups and tabs.group/ungroup exist", () => { + stubGroups(); + expect(groupsSupported()).toBe(true); + }); + + it("is false without chrome.tabGroups (Arc, Dia, …)", () => { + stubGroups({ tabGroups: undefined }); + expect(groupsSupported()).toBe(false); + }); + + it("is false when tabs.group is missing", () => { + stubGroups({ tabs: { query: async () => [] } }); + expect(groupsSupported()).toBe(false); + }); +}); + +describe("listGroups", () => { + it("maps groups and counts their tabs", async () => { + stubGroups(); + expect(await listGroups()).toEqual({ + groups: [ + { groupId: 7, title: "reins", color: "blue", collapsed: false, windowId: 1, tabCount: 2 }, + ], + }); + }); + + it("untitled groups get an empty title", async () => { + stubGroups({ + tabGroups: { + query: async () => [{ id: 9, color: "red", collapsed: true, windowId: 2 }], + update: async () => ({}), + }, + }); + const { groups } = await listGroups(); + expect(groups[0]).toMatchObject({ groupId: 9, title: "", tabCount: 0 }); + }); + + it("throws code=unsupported when the browser has no tab groups", async () => { + stubGroups({ tabGroups: undefined }); + const err = await listGroups().catch((e: unknown) => e); + expect(err).toBeInstanceOf(GroupsUnsupported); + expect((err as GroupsUnsupported).code).toBe("unsupported"); + expect((err as Error).message).toBe( + "this browser doesn't support tab groups (chrome.tabGroups unavailable)", + ); + }); +}); diff --git a/packages/extension/src/lib/tab-groups.ts b/packages/extension/src/lib/tab-groups.ts new file mode 100644 index 0000000..37ea0b4 --- /dev/null +++ b/packages/extension/src/lib/tab-groups.ts @@ -0,0 +1,47 @@ +import type { ListGroupsResult, TabGroup } from "@reins/protocol"; + +/** The browser has no tab-group API (Arc, Dia, …). `code` survives to the + * ResponseFrame, so the agent can tell the user instead of guessing. */ +export class GroupsUnsupported extends Error { + readonly code = "unsupported"; + constructor() { + super("this browser doesn't support tab groups (chrome.tabGroups unavailable)"); + } +} + +/** Every Chromium ships chrome.tabs, but not all ship tab groups: check the + * exact calls we make rather than guessing from the browser's name. */ +export function groupsSupported(): boolean { + return ( + typeof chrome.tabGroups?.query === "function" && + typeof chrome.tabGroups?.update === "function" && + typeof chrome.tabs?.group === "function" && + typeof chrome.tabs?.ungroup === "function" + ); +} + +export function requireGroups(): void { + if (!groupsSupported()) throw new GroupsUnsupported(); +} + +/** Handle the `list_groups` bridge method. */ +export async function listGroups(): Promise<ListGroupsResult> { + requireGroups(); + const [groups, tabs] = await Promise.all([chrome.tabGroups.query({}), chrome.tabs.query({})]); + const counts = new Map<number, number>(); + for (const t of tabs) { + if (typeof t.groupId === "number" && t.groupId !== -1) { + counts.set(t.groupId, (counts.get(t.groupId) ?? 0) + 1); + } + } + return { + groups: groups.map((g) => ({ + groupId: g.id, + title: g.title ?? "", + color: g.color as TabGroup["color"], + collapsed: g.collapsed, + windowId: g.windowId, + tabCount: counts.get(g.id) ?? 0, + })), + }; +} diff --git a/packages/extension/src/lib/tab-handler.test.ts b/packages/extension/src/lib/tab-handler.test.ts index c44ec91..a7d6033 100644 --- a/packages/extension/src/lib/tab-handler.test.ts +++ b/packages/extension/src/lib/tab-handler.test.ts @@ -25,6 +25,20 @@ describe("listTabs", () => { const { tabs } = await listTabs(); expect(tabs).toEqual([{ tabId: -1, title: "", url: "", active: false }]); }); + + it("carries groupId for grouped tabs, omits it for ungrouped (-1)", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: async () => [ + { id: 1, title: "a", url: "https://a", active: false, groupId: 7 }, + { id: 2, title: "b", url: "https://b", active: false, groupId: -1 }, + ], + }, + }); + const { tabs } = await listTabs(); + expect(tabs[0]).toEqual({ tabId: 1, title: "a", url: "https://a", active: false, groupId: 7 }); + expect(tabs[1]).not.toHaveProperty("groupId"); + }); }); describe("closeTab", () => { diff --git a/packages/extension/src/lib/tab-handler.ts b/packages/extension/src/lib/tab-handler.ts index 279b6f9..0dd01d3 100644 --- a/packages/extension/src/lib/tab-handler.ts +++ b/packages/extension/src/lib/tab-handler.ts @@ -9,6 +9,7 @@ export async function listTabs(): Promise<{ tabs: Tab[] }> { title: t.title ?? "", url: t.url ?? "", active: t.active ?? false, + ...(typeof t.groupId === "number" && t.groupId !== -1 ? { groupId: t.groupId } : {}), })), }; } From 045daf6fe06d0f3134b5c0280a0db92a9123b3f0 Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sat, 26 Sep 2026 23:16:04 +0530 Subject: [PATCH 5/9] feat(cli): reins groups, group marker in reins tabs Daemon fans list_groups out across connected browsers, tagging each group with browserId + browser. A browser that fails (no tab-group support, or an older extension) contributes nothing; only when every targeted browser fails is the first error rethrown. Adds the groups command, groupsText, and a g<id> marker on grouped tabs in reins tabs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- packages/cli/src/cli-commands.test.ts | 27 ++++++++++++ packages/cli/src/cli-commands.ts | 17 ++++++-- packages/cli/src/commands.test.ts | 6 +++ packages/cli/src/commands.ts | 14 ++++++- packages/cli/src/rpc.test.ts | 59 ++++++++++++++++++++++++++- packages/cli/src/rpc.ts | 43 ++++++++++++++++--- 6 files changed, 155 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli-commands.test.ts b/packages/cli/src/cli-commands.test.ts index c8a8f0f..4928c08 100644 --- a/packages/cli/src/cli-commands.test.ts +++ b/packages/cli/src/cli-commands.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { browsersText, doctorReport, + groupsText, healthSummary, helpText, logsInfo, @@ -83,6 +84,32 @@ describe("browsersText / tabsText", () => { }); }); +describe("groupsText", () => { + it("renders one line per group", () => { + const g = { + groupId: 7, + title: "reins", + color: "blue" as const, + collapsed: true, + windowId: 1, + tabCount: 2, + browserId: "b1", + }; + expect(groupsText([g])).toBe(' b1 group 7 "reins" blue 2 tabs (collapsed) window 1'); + expect(groupsText([{ ...g, collapsed: false, tabCount: 1 }])).toBe( + ' b1 group 7 "reins" blue 1 tab window 1', + ); + expect(groupsText([])).toBe("(no groups)"); + }); + + it("tabsText marks grouped tabs", () => { + const text = tabsText([ + { tabId: 12, title: "T", url: "https://x", active: true, groupId: 7, browserId: "b1" }, + ]); + expect(text).toBe(" b1 tab 12 * g7 T — https://x"); + }); +}); + describe("doctorReport", () => { it("passes all checks with a healthy daemon and a browser", () => { const report = doctorReport(cfg(), HEALTH); diff --git a/packages/cli/src/cli-commands.ts b/packages/cli/src/cli-commands.ts index ee24890..f17f161 100644 --- a/packages/cli/src/cli-commands.ts +++ b/packages/cli/src/cli-commands.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import type { BrowserInfo, Tab } from "@reins/protocol"; +import type { BrowserInfo, Tab, TabGroup } from "@reins/protocol"; import type { ToolCommand } from "./commands.js"; import type { ReinsConfig } from "./config.js"; @@ -25,7 +25,7 @@ export function helpText(version: string, tools: Record<string, ToolCommand>): s "Usage: reins <command> [flags]", "", "Tabs & pages:", - ...["tabs", "open", "close", "focus", "nav"].map(tool), + ...["tabs", "groups", "open", "close", "focus", "nav"].map(tool), "", "Interaction:", ...[ @@ -103,7 +103,18 @@ export function tabsText(tabs: Tab[]): string { return tabs .map( (t) => - ` ${t.browserId ?? "?"} tab ${t.tabId}${t.active ? " *" : " "} ${t.title || "(untitled)"} — ${t.url}`, + ` ${t.browserId ?? "?"} tab ${t.tabId}${t.active ? " *" : " "}${t.groupId !== undefined ? ` g${t.groupId}` : ""} ${t.title || "(untitled)"} — ${t.url}`, + ) + .join("\n"); +} + +/** Group listing for `reins groups`. */ +export function groupsText(groups: TabGroup[]): string { + if (groups.length === 0) return "(no groups)"; + return groups + .map( + (g) => + ` ${g.browserId ?? "?"} group ${g.groupId} "${g.title}" ${g.color} ${g.tabCount} tab${g.tabCount === 1 ? "" : "s"}${g.collapsed ? " (collapsed)" : ""} window ${g.windowId}`, ) .join("\n"); } diff --git a/packages/cli/src/commands.test.ts b/packages/cli/src/commands.test.ts index c87d63b..e544878 100644 --- a/packages/cli/src/commands.test.ts +++ b/packages/cli/src/commands.test.ts @@ -44,6 +44,12 @@ describe("TOOL_COMMANDS: params", () => { expect(build("tabs", ["--browser", "b1"])).toEqual({ browserId: "b1" }); }); + it("groups: optional browser filter", () => { + expect(build("groups", [])).toEqual({}); + expect(build("groups", ["--browser", "b2"])).toEqual({ browserId: "b2" }); + expect(cmd("groups").method).toBe("list_groups"); + }); + it("open: url positional, --background flips activate", () => { expect(build("open", ["https://x"])).toEqual({ url: "https://x", activate: true }); expect(build("open", ["https://x", "--background"])).toEqual({ diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index bf325e0..8efbcef 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; -import type { ConsoleEntry, NetworkEntry, SnapshotRef, Tab } from "@reins/protocol"; +import type { ConsoleEntry, NetworkEntry, SnapshotRef, Tab, TabGroup } from "@reins/protocol"; import { type ParsedArgs, UsageError } from "./args.js"; -import { tabsText } from "./cli-commands.js"; +import { groupsText, tabsText } from "./cli-commands.js"; /** One `reins <name>` tool subcommand: flags → /rpc params → printed text. */ export interface ToolCommand { @@ -85,6 +85,16 @@ export const TOOL_COMMANDS: Record<string, ToolCommand> = { }, format: (r) => tabsText((r as { tabs: Tab[] }).tabs), }, + groups: { + method: "list_groups", + usage: "reins groups [--browser <id>]", + summary: "list tab groups across all connected browsers", + build: (a) => { + const browser = flagStr(a, "browser"); + return browser !== undefined ? { browserId: browser } : {}; + }, + format: (r) => groupsText((r as { groups: TabGroup[] }).groups), + }, open: { method: "open_tab", usage: "reins open <url> [--background]", diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts index 981c023..2e175f6 100644 --- a/packages/cli/src/rpc.test.ts +++ b/packages/cli/src/rpc.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AuditRecord } from "./audit.js"; import type { BridgePort } from "./bridge.js"; -import { handleRpc, listAllTabs, RpcBadRequest } from "./rpc.js"; +import { handleRpc, listAllGroups, listAllTabs, RpcBadRequest } from "./rpc.js"; function fakeBridge(overrides: Partial<BridgePort> = {}): BridgePort { return { @@ -95,6 +95,63 @@ describe("listAllTabs", () => { }); }); +describe("listAllGroups", () => { + const G = { + groupId: 7, + title: "reins", + color: "blue", + collapsed: false, + windowId: 1, + tabCount: 2, + }; + const two = [ + { id: "b1", browser: "Chrome", connectedAt: 0 }, + { id: "b2", browser: "Dia", connectedAt: 1 }, + ]; + + it("aggregates across browsers with tags, via handleRpc", async () => { + const bridge = fakeBridge({ + browsers: two, + request: vi.fn(async () => ({ groups: [G] })), + }); + const out = (await handleRpc(bridge, { method: "list_groups" })) as { groups: unknown[] }; + expect(out.groups).toEqual([ + { ...G, browserId: "b1", browser: "Chrome" }, + { ...G, browserId: "b2", browser: "Dia" }, + ]); + }); + + it("skips browsers that fail when another answers", async () => { + const bridge = fakeBridge({ + browsers: two, + request: vi.fn(async (_m: string, _p: unknown, opts?: { browserId?: string }) => { + if (opts?.browserId === "b2") throw new Error("unsupported: no tab groups"); + return { groups: [G] }; + }), + }); + const groups = await listAllGroups(bridge); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ browserId: "b1" }); + }); + + it("rethrows the first error when every browser fails", async () => { + const bridge = fakeBridge({ + request: vi.fn(async () => { + throw new Error("unsupported: this browser doesn't support tab groups"); + }), + }); + await expect(listAllGroups(bridge)).rejects.toThrow("unsupported"); + }); + + it("returns [] with no browsers connected", async () => { + expect(await listAllGroups(fakeBridge({ browsers: [] }))).toEqual([]); + }); + + it("errors on an unknown browserId", async () => { + await expect(listAllGroups(fakeBridge(), "b9")).rejects.toThrow('unknown browserId "b9"'); + }); +}); + describe("audit hook", () => { it("records a successful action with meta, browser name, and redacted params", async () => { const records: AuditRecord[] = []; diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts index 2ecfe99..957c23a 100644 --- a/packages/cli/src/rpc.ts +++ b/packages/cli/src/rpc.ts @@ -1,4 +1,10 @@ -import { ListTabsResult, type ResponseMeta, type Tab } from "@reins/protocol"; +import { + ListGroupsResult, + ListTabsResult, + type ResponseMeta, + type Tab, + type TabGroup, +} from "@reins/protocol"; import { z } from "zod"; import { type AuditHook, redactParams } from "./audit.js"; import type { BridgePort, BridgeReply } from "./bridge.js"; @@ -8,16 +14,21 @@ const RpcBody = z.object({ params: z.record(z.string(), z.unknown()).optional(), }); -/** List tabs across connected browsers (all, or one), tagging each tab with - * its browserId + browser name. */ -export async function listAllTabs(bridge: BridgePort, browserId?: string): Promise<Tab[]> { +/** The browsers a fan-out call targets: all, or the one named. */ +function targetBrowsers(bridge: BridgePort, browserId?: string): BridgePort["browsers"] { const targets = browserId ? bridge.browsers.filter((b) => b.id === browserId) : bridge.browsers; if (browserId !== undefined && targets.length === 0) { const roster = bridge.browsers.map((b) => `${b.id} (${b.browser})`).join(", "); throw new Error(`unknown browserId "${browserId}"${roster ? `. Connected: ${roster}` : ""}`); } + return targets; +} + +/** List tabs across connected browsers (all, or one), tagging each tab with + * its browserId + browser name. */ +export async function listAllTabs(bridge: BridgePort, browserId?: string): Promise<Tab[]> { const results = await Promise.all( - targets.map(async (b) => { + targetBrowsers(bridge, browserId).map(async (b) => { const raw = await bridge.request("list_tabs", {}, { browserId: b.id }); const { tabs } = ListTabsResult.parse(raw); return tabs.map((t) => ({ ...t, browserId: b.id, browser: b.browser })); @@ -26,6 +37,23 @@ export async function listAllTabs(bridge: BridgePort, browserId?: string): Promi return results.flat(); } +/** List tab groups across connected browsers, tagged like listAllTabs. A + * browser without tab groups (Arc, Dia) or with an older extension adds + * nothing; only when every targeted browser fails does the error surface. */ +export async function listAllGroups(bridge: BridgePort, browserId?: string): Promise<TabGroup[]> { + const settled = await Promise.allSettled( + targetBrowsers(bridge, browserId).map(async (b) => { + const raw = await bridge.request("list_groups", {}, { browserId: b.id }); + const { groups } = ListGroupsResult.parse(raw); + return groups.map((g) => ({ ...g, browserId: b.id, browser: b.browser })); + }), + ); + const ok = settled.filter((s) => s.status === "fulfilled"); + const failed = settled.find((s) => s.status === "rejected"); + if (ok.length === 0 && failed) throw failed.reason; + return ok.flatMap((s) => s.value); +} + /** Split the client-facing params into routing (browserId) + browser payload. */ function route(raw: Record<string, unknown>): { browserId: string | undefined; @@ -97,6 +125,11 @@ export async function handleRpc( finish({ ok: true, browserId }); return { tabs }; } + if (method === "list_groups") { + const groups = await listAllGroups(bridge, browserId); + finish({ ok: true, browserId }); + return { groups }; + } const reply: BridgeReply = await bridge.requestFull(method, params, { browserId }); finish({ ok: true, browserId: reply.browserId, meta: reply.meta }); return reply.result; From 3fbab3e3b4f20b6877c254ba035222840beb9fff Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sat, 26 Sep 2026 23:19:42 +0530 Subject: [PATCH 6/9] docs: reins groups, tabGroups permission Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .changeset/tab-groups-read.md | 6 ++++++ docs/CHROME_WEB_STORE.md | 9 ++++++++- docs/PRIVACY.md | 4 ++-- packages/web/src/routes/docs/commands.tsx | 4 ++++ skills/reins/SKILL.md | 1 + 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 .changeset/tab-groups-read.md diff --git a/.changeset/tab-groups-read.md b/.changeset/tab-groups-read.md new file mode 100644 index 0000000..cf0a729 --- /dev/null +++ b/.changeset/tab-groups-read.md @@ -0,0 +1,6 @@ +--- +"@karnstack/reins": minor +"@reins/extension": minor +--- + +`reins groups` lists tab groups (title, color, collapsed, tab count) across connected browsers, and `reins tabs` marks each grouped tab with `g<id>`. Browsers without tab groups (Arc, Dia) answer `unsupported` instead of failing silently. The extension asks for the `tabGroups` permission, which shows no install prompt. diff --git a/docs/CHROME_WEB_STORE.md b/docs/CHROME_WEB_STORE.md index 3b9a1e1..1196111 100644 --- a/docs/CHROME_WEB_STORE.md +++ b/docs/CHROME_WEB_STORE.md @@ -59,6 +59,12 @@ Core function of the extension: executes the user's agent commands on tabs via t Lists open tabs (title and URL) so the user's agent can pick a target tab, and opens, closes, and focuses tabs — plus resizes the tab's window — on the agent's behalf. Tab metadata is sent only to the user's own local daemon on 127.0.0.1, never to a remote server. ``` +**tabGroups justification** + +```text +Lists the user's tab groups (title, color, collapsed state) and, on the agent's behalf, creates groups, adds or removes tabs, and edits a group's title and color — so the user can see which tabs their agent is working in. Group metadata is sent only to the user's own local daemon on 127.0.0.1, never to a remote server. +``` + **storage justification** ```text @@ -134,7 +140,7 @@ Once both are in place, the extension discovers the daemon on its own and the to WHAT YOUR AGENT CAN DO -• Tabs — list, open, close, and focus tabs across every connected browser +• Tabs — list, open, close, focus, and group tabs across every connected browser • Navigate — go to a URL, or back / forward / reload • Inspect — snapshot the page's interactive elements, read visible text, capture screenshots • Interact — click, type, fill inputs, choose dropdown options, hover, scroll, press keys, upload files @@ -153,6 +159,7 @@ PERMISSIONS, AND WHY • debugger — runs your agent's commands (click, type, screenshot, read console/network) on tabs via the Chrome DevTools Protocol. Chrome shows its native debugging banner while attached. • tabs — list, open, close, and focus tabs, and resize the tab's window. +• tabGroups — list, create, and edit tab groups, so you can see which tabs your agent is working in. • storage — stores the auto-connect setting, cached daemon port, and connection status on your device. • offscreen — hosts the persistent WebSocket to your local daemon; MV3 service workers can't hold long-lived connections. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 209fb68..c8cc184 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,6 +1,6 @@ # reins — Privacy Policy -_Last updated: 2026-07-04_ +_Last updated: 2026-09-26_ reins is a browser extension that lets a **local** daemon on your own machine (installed by you, via the `@karnstack/reins` CLI) drive your @@ -8,7 +8,7 @@ browser. It is a developer tool; you install both halves yourself. ## What data reins handles -- **Page content and tab metadata** (titles, URLs, screenshots, console and +- **Page content and tab metadata** (titles, URLs, tab group names, screenshots, console and network activity of tabs you interact with through your agent) are read via the Chrome DevTools Protocol **only when the local reins daemon asks**, and are sent **only** to that daemon over a WebSocket bound to `127.0.0.1` on diff --git a/packages/web/src/routes/docs/commands.tsx b/packages/web/src/routes/docs/commands.tsx index a25faa1..097a513 100644 --- a/packages/web/src/routes/docs/commands.tsx +++ b/packages/web/src/routes/docs/commands.tsx @@ -23,6 +23,10 @@ const GROUPS: Array<{ id: string; title: string; intro?: string; rows: [string, title: "Tabs and pages", rows: [ ["reins tabs [--browser <id>]", "List tabs across all connected browsers."], + [ + "reins groups [--browser <id>]", + "List tab groups. reins tabs marks each grouped tab with g<id>.", + ], ["reins open <url> [--background]", "Open a new tab."], ["reins close --tab <id>", "Close a tab."], ["reins focus --tab <id>", "Focus (activate) a tab."], diff --git a/skills/reins/SKILL.md b/skills/reins/SKILL.md index 178727a..7aebc32 100644 --- a/skills/reins/SKILL.md +++ b/skills/reins/SKILL.md @@ -88,6 +88,7 @@ reins status ``` tabs / open <url> / close / focus / nav <url|back|forward|reload> +groups tab groups (id, title, color); `tabs` shows g<id> per grouped tab snapshot interactive elements + refs click --ref|--selector [--button right|middle] [--count 2] type --text "…" [--enter] keystrokes into an element From 437bfa0331ae420d7a77b99b45e0d725a9b9ea5d Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sat, 26 Sep 2026 23:50:39 +0530 Subject: [PATCH 7/9] fix: lenient tab group color on output; store and skill copy Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/CHROME_WEB_STORE.md | 4 ++-- docs/PRIVACY.md | 2 +- packages/extension/src/lib/tab-groups.ts | 4 ++-- packages/protocol/src/bridge.test.ts | 8 ++++++-- packages/protocol/src/bridge.ts | 4 +++- skills/reins/SKILL.md | 5 +++-- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/CHROME_WEB_STORE.md b/docs/CHROME_WEB_STORE.md index 1196111..0e09cd1 100644 --- a/docs/CHROME_WEB_STORE.md +++ b/docs/CHROME_WEB_STORE.md @@ -44,7 +44,7 @@ Each answer below fits its field's 1,000-character limit. Paste verbatim. **Single purpose description** ```text -reins has one narrow purpose: let the user's own coding agent (software running on their machine) drive their own browser. A local companion daemon — installed by the user via the reins CLI (npm: @karnstack/reins) and bound to 127.0.0.1 — sends commands that this extension executes: list/open/close/focus tabs, navigate, click, type, fill forms, scroll, take screenshots, read page text, and read console messages and network requests for debugging. All communication is confined to the user's machine; the extension never contacts a remote server and never sends data anywhere except the user's own local daemon. +reins has one narrow purpose: let the user's own coding agent (software running on their machine) drive their own browser. A local companion daemon — installed by the user via the reins CLI (npm: @karnstack/reins) and bound to 127.0.0.1 — sends commands that this extension executes: list/open/close/focus/group tabs, navigate, click, type, fill forms, scroll, take screenshots, read page text, and read console messages and network requests for debugging. All communication is confined to the user's machine; the extension never contacts a remote server and never sends data anywhere except the user's own local daemon. ``` **debugger justification** @@ -56,7 +56,7 @@ Core function of the extension: executes the user's agent commands on tabs via t **tabs justification** ```text -Lists open tabs (title and URL) so the user's agent can pick a target tab, and opens, closes, and focuses tabs — plus resizes the tab's window — on the agent's behalf. Tab metadata is sent only to the user's own local daemon on 127.0.0.1, never to a remote server. +Lists open tabs (title, URL, and tab group) so the user's agent can pick a target tab, and opens, closes, and focuses tabs — plus resizes the tab's window — on the agent's behalf. Tab metadata is sent only to the user's own local daemon on 127.0.0.1, never to a remote server. ``` **tabGroups justification** diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index c8cc184..63afe4d 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -8,7 +8,7 @@ browser. It is a developer tool; you install both halves yourself. ## What data reins handles -- **Page content and tab metadata** (titles, URLs, tab group names, screenshots, console and +- **Page content and tab metadata** (titles, URLs, tab group names and colors, screenshots, console and network activity of tabs you interact with through your agent) are read via the Chrome DevTools Protocol **only when the local reins daemon asks**, and are sent **only** to that daemon over a WebSocket bound to `127.0.0.1` on diff --git a/packages/extension/src/lib/tab-groups.ts b/packages/extension/src/lib/tab-groups.ts index 37ea0b4..f8d8c51 100644 --- a/packages/extension/src/lib/tab-groups.ts +++ b/packages/extension/src/lib/tab-groups.ts @@ -1,4 +1,4 @@ -import type { ListGroupsResult, TabGroup } from "@reins/protocol"; +import type { ListGroupsResult } from "@reins/protocol"; /** The browser has no tab-group API (Arc, Dia, …). `code` survives to the * ResponseFrame, so the agent can tell the user instead of guessing. */ @@ -38,7 +38,7 @@ export async function listGroups(): Promise<ListGroupsResult> { groups: groups.map((g) => ({ groupId: g.id, title: g.title ?? "", - color: g.color as TabGroup["color"], + color: g.color, collapsed: g.collapsed, windowId: g.windowId, tabCount: counts.get(g.id) ?? 0, diff --git a/packages/protocol/src/bridge.test.ts b/packages/protocol/src/bridge.test.ts index 98269c7..243fd19 100644 --- a/packages/protocol/src/bridge.test.ts +++ b/packages/protocol/src/bridge.test.ts @@ -6,6 +6,7 @@ import { ResponseFrame, Tab, TabGroup, + TabGroupColor, WelcomeFrame, } from "./bridge.js"; @@ -83,7 +84,7 @@ describe("tab groups", () => { expect(Tab.parse({ tabId: 1, title: "t", url: "u", active: true }).groupId).toBeUndefined(); }); - it("TabGroup parses a group and rejects unknown colors", () => { + it("TabGroup parses a group and accepts unknown colors on output", () => { const g = { groupId: 7, title: "reins", @@ -93,7 +94,10 @@ describe("tab groups", () => { tabCount: 2, }; expect(TabGroup.parse(g)).toEqual(g); - expect(() => TabGroup.parse({ ...g, color: "magenta" })).toThrow(); + // Output is lenient: a future Chromium color must not reject the whole list. + expect(TabGroup.parse({ ...g, color: "magenta" }).color).toBe("magenta"); + // Inputs stay on the enum. + expect(() => TabGroupColor.parse("magenta")).toThrow(); expect(ListGroupsResult.parse({ groups: [g] }).groups).toHaveLength(1); }); }); diff --git a/packages/protocol/src/bridge.ts b/packages/protocol/src/bridge.ts index 05220fa..b58e84d 100644 --- a/packages/protocol/src/bridge.ts +++ b/packages/protocol/src/bridge.ts @@ -37,7 +37,9 @@ export type TabGroupColor = z.infer<typeof TabGroupColor>; export const TabGroup = z.object({ groupId: z.number(), title: z.string(), - color: TabGroupColor, + // Lenient on output so a future Chromium color doesn't reject the whole + // list; inputs stay on TabGroupColor. + color: z.string(), collapsed: z.boolean(), windowId: z.number(), tabCount: z.number(), diff --git a/skills/reins/SKILL.md b/skills/reins/SKILL.md index 7aebc32..a6dae2f 100644 --- a/skills/reins/SKILL.md +++ b/skills/reins/SKILL.md @@ -109,8 +109,9 @@ eval 'document.title' [--await] JS in the page's own origin cdp <Domain.method> ['{json}'] raw Chrome DevTools Protocol ``` -Every command takes `--tab <id>` (default: the active tab) and `--json` -(raw result). `reins help <command>` shows exact usage. +Page commands take `--tab <id>` (default: the active tab); `tabs` and +`groups` take no tab. Every command takes `--json` (raw result). +`reins help <command>` shows exact usage. ## Recipes for the powerful stuff From 9fa2dfdebcb99722f493d6477033926c022c37e0 Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sun, 27 Sep 2026 00:19:32 +0530 Subject: [PATCH 8/9] feat: name the browser when tab groups are unavailable Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- packages/cli/src/cli-commands.test.ts | 29 ++++++ packages/cli/src/cli-commands.ts | 31 ++++-- packages/cli/src/commands.ts | 14 ++- packages/cli/src/rpc.test.ts | 132 ++++++++++++++++++++++++-- packages/cli/src/rpc.ts | 99 +++++++++++++++---- packages/protocol/src/bridge.test.ts | 14 +++ packages/protocol/src/bridge.ts | 19 +++- 7 files changed, 299 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/cli-commands.test.ts b/packages/cli/src/cli-commands.test.ts index 4928c08..c28a672 100644 --- a/packages/cli/src/cli-commands.test.ts +++ b/packages/cli/src/cli-commands.test.ts @@ -102,6 +102,35 @@ describe("groupsText", () => { expect(groupsText([])).toBe("(no groups)"); }); + it("lists skipped browsers after the groups, one line per reason", () => { + const g = { + groupId: 7, + title: "reins", + color: "blue" as const, + collapsed: false, + windowId: 1, + tabCount: 1, + browserId: "b1", + }; + const skipped = [ + { browserId: "b2", browser: "Dia", reason: "unsupported" as const, message: "Dia (b2) ..." }, + { browserId: "b3", browser: "Arc", reason: "outdated" as const, message: "old" }, + { browserId: "b4", browser: "Brave", reason: "error" as const, message: "timed out" }, + ]; + expect(groupsText([g], skipped)).toBe( + [ + ' b1 group 7 "reins" blue 1 tab window 1', + " b2 Dia — tab groups not supported", + " b3 Arc — reins extension too old for tab groups (update it)", + " b4 Brave — failed: timed out", + ].join("\n"), + ); + expect(groupsText([], skipped.slice(0, 1))).toBe( + "(no groups)\n b2 Dia — tab groups not supported", + ); + expect(groupsText([], [])).toBe("(no groups)"); + }); + it("tabsText marks grouped tabs", () => { const text = tabsText([ { tabId: 12, title: "T", url: "https://x", active: true, groupId: 7, browserId: "b1" }, diff --git a/packages/cli/src/cli-commands.ts b/packages/cli/src/cli-commands.ts index f17f161..cc5f49e 100644 --- a/packages/cli/src/cli-commands.ts +++ b/packages/cli/src/cli-commands.ts @@ -1,6 +1,6 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import type { BrowserInfo, Tab, TabGroup } from "@reins/protocol"; +import type { BrowserInfo, SkippedBrowser, Tab, TabGroup } from "@reins/protocol"; import type { ToolCommand } from "./commands.js"; import type { ReinsConfig } from "./config.js"; @@ -108,15 +108,26 @@ export function tabsText(tabs: Tab[]): string { .join("\n"); } -/** Group listing for `reins groups`. */ -export function groupsText(groups: TabGroup[]): string { - if (groups.length === 0) return "(no groups)"; - return groups - .map( - (g) => - ` ${g.browserId ?? "?"} group ${g.groupId} "${g.title}" ${g.color} ${g.tabCount} tab${g.tabCount === 1 ? "" : "s"}${g.collapsed ? " (collapsed)" : ""} window ${g.windowId}`, - ) - .join("\n"); +/** Group listing for `reins groups`, then one line per browser the daemon + * couldn't ask. */ +export function groupsText(groups: TabGroup[], skipped: SkippedBrowser[] = []): string { + const lines = + groups.length === 0 + ? ["(no groups)"] + : groups.map( + (g) => + ` ${g.browserId ?? "?"} group ${g.groupId} "${g.title}" ${g.color} ${g.tabCount} tab${g.tabCount === 1 ? "" : "s"}${g.collapsed ? " (collapsed)" : ""} window ${g.windowId}`, + ); + for (const s of skipped) { + const why = + s.reason === "unsupported" + ? "tab groups not supported" + : s.reason === "outdated" + ? "reins extension too old for tab groups (update it)" + : `failed: ${s.message}`; + lines.push(` ${s.browserId} ${s.browser} — ${why}`); + } + return lines.join("\n"); } export interface DoctorReport { diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 8efbcef..9bf0ea7 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,5 +1,12 @@ import { resolve } from "node:path"; -import type { ConsoleEntry, NetworkEntry, SnapshotRef, Tab, TabGroup } from "@reins/protocol"; +import type { + ConsoleEntry, + NetworkEntry, + SkippedBrowser, + SnapshotRef, + Tab, + TabGroup, +} from "@reins/protocol"; import { type ParsedArgs, UsageError } from "./args.js"; import { groupsText, tabsText } from "./cli-commands.js"; @@ -93,7 +100,10 @@ export const TOOL_COMMANDS: Record<string, ToolCommand> = { const browser = flagStr(a, "browser"); return browser !== undefined ? { browserId: browser } : {}; }, - format: (r) => groupsText((r as { groups: TabGroup[] }).groups), + format: (r) => { + const { groups, skipped } = r as { groups: TabGroup[]; skipped?: SkippedBrowser[] }; + return groupsText(groups, skipped); + }, }, open: { method: "open_tab", diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts index 2e175f6..fc7f7a7 100644 --- a/packages/cli/src/rpc.test.ts +++ b/packages/cli/src/rpc.test.ts @@ -121,30 +121,95 @@ describe("listAllGroups", () => { ]); }); - it("skips browsers that fail when another answers", async () => { + const UNSUPPORTED_DIA = + "Dia (b2) doesn't support tab groups — reins groups/group/ungroup need the chrome.tabGroups API, which this browser doesn't provide. Other reins commands work normally."; + const OUTDATED_DIA = + "Dia (b2)'s reins extension predates tab groups — update it (Chrome Web Store), or run `reins extension --reload` for an unpacked build."; + + function coded(message: string, code?: string): Error { + const e = new Error(message) as Error & { code?: string }; + if (code) e.code = code; + return e; + } + + it("reports a browser that can't do tab groups in skipped, naming it", async () => { + const bridge = fakeBridge({ + browsers: two, + request: vi.fn(async (_m: string, _p: unknown, opts?: { browserId?: string }) => { + if (opts?.browserId === "b2") { + throw coded( + "unsupported: this browser doesn't support tab groups (chrome.tabGroups unavailable)", + "unsupported", + ); + } + return { groups: [G] }; + }), + }); + const { groups, skipped } = await listAllGroups(bridge); + expect(groups).toEqual([{ ...G, browserId: "b1", browser: "Chrome" }]); + expect(skipped).toEqual([ + { browserId: "b2", browser: "Dia", reason: "unsupported", message: UNSUPPORTED_DIA }, + ]); + }); + + it("reports an outdated extension in skipped", async () => { + const bridge = fakeBridge({ + browsers: two, + request: vi.fn(async (_m: string, _p: unknown, opts?: { browserId?: string }) => { + if (opts?.browserId === "b2") { + throw coded("HANDLER_ERROR: unknown method: list_groups", "HANDLER_ERROR"); + } + return { groups: [G] }; + }), + }); + const { skipped } = await listAllGroups(bridge); + expect(skipped).toEqual([ + { browserId: "b2", browser: "Dia", reason: "outdated", message: OUTDATED_DIA }, + ]); + }); + + it("reports a generic failure in skipped with the raw message", async () => { const bridge = fakeBridge({ browsers: two, request: vi.fn(async (_m: string, _p: unknown, opts?: { browserId?: string }) => { - if (opts?.browserId === "b2") throw new Error("unsupported: no tab groups"); + if (opts?.browserId === "b2") throw new Error('request "list_groups" timed out after 5ms'); return { groups: [G] }; }), }); - const groups = await listAllGroups(bridge); - expect(groups).toHaveLength(1); - expect(groups[0]).toMatchObject({ browserId: "b1" }); + const { skipped } = await listAllGroups(bridge); + expect(skipped).toEqual([ + { + browserId: "b2", + browser: "Dia", + reason: "error", + message: 'request "list_groups" timed out after 5ms', + }, + ]); }); - it("rethrows the first error when every browser fails", async () => { + it("rethrows the first failure, named, when every browser fails", async () => { const bridge = fakeBridge({ + browsers: [{ id: "b2", browser: "Dia", connectedAt: 1 }], request: vi.fn(async () => { - throw new Error("unsupported: this browser doesn't support tab groups"); + throw coded( + "unsupported: this browser doesn't support tab groups (chrome.tabGroups unavailable)", + "unsupported", + ); }), }); - await expect(listAllGroups(bridge)).rejects.toThrow("unsupported"); + const p = listAllGroups(bridge); + await expect(p).rejects.toThrow(UNSUPPORTED_DIA); + await expect(p).rejects.toMatchObject({ code: "unsupported" }); + }); + + it("handleRpc list_groups always returns skipped", async () => { + const bridge = fakeBridge({ request: vi.fn(async () => ({ groups: [G] })) }); + const out = await handleRpc(bridge, { method: "list_groups" }); + expect(out).toEqual({ groups: [{ ...G, browserId: "b1", browser: "Chrome" }], skipped: [] }); }); it("returns [] with no browsers connected", async () => { - expect(await listAllGroups(fakeBridge({ browsers: [] }))).toEqual([]); + expect(await listAllGroups(fakeBridge({ browsers: [] }))).toEqual({ groups: [], skipped: [] }); }); it("errors on an unknown browserId", async () => { @@ -237,6 +302,55 @@ describe("audit hook", () => { expect(records[0]?.denied).toBeUndefined(); }); + it("names the browser when a routed group method is unsupported, in the error and audit", async () => { + const records: AuditRecord[] = []; + const err = new Error( + "unsupported: this browser doesn't support tab groups (chrome.tabGroups unavailable)", + ) as Error & { code?: string; meta?: unknown; browserId?: string }; + err.code = "unsupported"; + err.meta = { host: "app.example.com", tier: "full", tabId: 7 }; + err.browserId = "b1"; + const bridge = fakeBridge({ + requestFull: async () => { + throw err; + }, + }); + const expected = + "Chrome (b1) doesn't support tab groups — reins groups/group/ungroup need the chrome.tabGroups API, which this browser doesn't provide. Other reins commands work normally."; + const p = handleRpc(bridge, { method: "group_tabs", params: { tabIds: [7] } }, (r) => + records.push(r), + ); + await expect(p).rejects.toThrow(expected); + await expect(p).rejects.toMatchObject({ + code: "unsupported", + browserId: "b1", + meta: { host: "app.example.com", tier: "full", tabId: 7 }, + }); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + method: "group_tabs", + ok: false, + browserId: "b1", + browser: "Chrome", + host: "app.example.com", + error: expected, + }); + }); + + it("leaves an unsupported error on a non-group method untouched", async () => { + const err = new Error("unsupported: nope") as Error & { code?: string; browserId?: string }; + err.code = "unsupported"; + err.browserId = "b1"; + const bridge = fakeBridge({ + requestFull: async () => { + throw err; + }, + }); + const p = handleRpc(bridge, { method: "click", params: {} }); + await expect(p).rejects.toThrow("unsupported: nope"); + await expect(p).rejects.toBe(err); + }); + it("audits list_tabs as one aggregate line without host", async () => { const records: AuditRecord[] = []; const bridge = fakeBridge(); diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts index 957c23a..9e5ba21 100644 --- a/packages/cli/src/rpc.ts +++ b/packages/cli/src/rpc.ts @@ -2,6 +2,7 @@ import { ListGroupsResult, ListTabsResult, type ResponseMeta, + type SkippedBrowser, type Tab, type TabGroup, } from "@reins/protocol"; @@ -37,21 +38,85 @@ export async function listAllTabs(bridge: BridgePort, browserId?: string): Promi return results.flat(); } +const GROUP_METHODS = new Set(["list_groups", "group_tabs", "update_group", "ungroup_tabs"]); +const UNKNOWN_GROUP_METHOD = /unknown method: (list_groups|group_tabs|update_group|ungroup_tabs)\b/; + +type BridgeError = Error & { code?: string; meta?: ResponseMeta; browserId?: string }; + +/** Why a tab-group call failed, when the cause is the browser itself. */ +function classifyGroupError(err: BridgeError): "unsupported" | "outdated" | undefined { + if (err.code === "unsupported") return "unsupported"; + if (UNKNOWN_GROUP_METHOD.test(err.message)) return "outdated"; + return undefined; +} + +/** + * Rewrite a failed tab-group call into a message naming the browser: the + * extension only knows "this browser", but the user may have several + * connected. Only `unsupported` (no chrome.tabGroups API) and an older + * extension's "unknown method" are rewritten; anything else is returned + * as-is. The rewritten error keeps `code`, `meta`, and `browserId`. + */ +export function describeGroupError( + bridge: BridgePort, + err: BridgeError, + browserId: string | undefined = err.browserId, +): { error: BridgeError; reason: "unsupported" | "outdated" | undefined } { + const reason = classifyGroupError(err); + if (reason === undefined) return { error: err, reason }; + const name = browserId ? bridge.browsers.find((b) => b.id === browserId)?.browser : undefined; + const who = name && browserId ? `${name} (${browserId})` : "this browser"; + const message = + reason === "unsupported" + ? `${who} doesn't support tab groups — reins groups/group/ungroup need the chrome.tabGroups API, which this browser doesn't provide. Other reins commands work normally.` + : `${who}'s reins extension predates tab groups — update it (Chrome Web Store), or run \`reins extension --reload\` for an unpacked build.`; + const error = new Error(message) as BridgeError; + if (err.code !== undefined) error.code = err.code; + if (err.meta !== undefined) error.meta = err.meta; + if (err.browserId !== undefined) error.browserId = err.browserId; + return { error, reason }; +} + /** List tab groups across connected browsers, tagged like listAllTabs. A - * browser without tab groups (Arc, Dia) or with an older extension adds - * nothing; only when every targeted browser fails does the error surface. */ -export async function listAllGroups(bridge: BridgePort, browserId?: string): Promise<TabGroup[]> { + * browser that can't answer — no tab groups (Arc, Dia), an older extension, + * or a plain failure — is reported in `skipped` with a message naming it. + * Only when every targeted browser fails does the (rewritten) error throw. */ +export async function listAllGroups( + bridge: BridgePort, + browserId?: string, +): Promise<{ groups: TabGroup[]; skipped: SkippedBrowser[] }> { + const targets = targetBrowsers(bridge, browserId); const settled = await Promise.allSettled( - targetBrowsers(bridge, browserId).map(async (b) => { + targets.map(async (b) => { const raw = await bridge.request("list_groups", {}, { browserId: b.id }); const { groups } = ListGroupsResult.parse(raw); return groups.map((g) => ({ ...g, browserId: b.id, browser: b.browser })); }), ); - const ok = settled.filter((s) => s.status === "fulfilled"); - const failed = settled.find((s) => s.status === "rejected"); - if (ok.length === 0 && failed) throw failed.reason; - return ok.flatMap((s) => s.value); + const groups: TabGroup[] = []; + const skipped: SkippedBrowser[] = []; + let firstFailure: BridgeError | undefined; + settled.forEach((s, i) => { + const b = targets[i]; + if (!b) return; + if (s.status === "fulfilled") { + groups.push(...s.value); + return; + } + const raw = (s.reason instanceof Error ? s.reason : new Error(String(s.reason))) as BridgeError; + const { error, reason } = describeGroupError(bridge, raw, b.id); + firstFailure ??= error; + skipped.push({ + browserId: b.id, + browser: b.browser, + reason: reason ?? "error", + message: error.message, + }); + }); + // Every targeted browser failed (incl. the single --browser case): keep the + // non-zero exit, but with the named message. + if (firstFailure && skipped.length === targets.length) throw firstFailure; + return { groups, skipped }; } /** Split the client-facing params into routing (browserId) + browser payload. */ @@ -126,20 +191,22 @@ export async function handleRpc( return { tabs }; } if (method === "list_groups") { - const groups = await listAllGroups(bridge, browserId); + const { groups, skipped } = await listAllGroups(bridge, browserId); finish({ ok: true, browserId }); - return { groups }; + return { groups, skipped }; } const reply: BridgeReply = await bridge.requestFull(method, params, { browserId }); finish({ ok: true, browserId: reply.browserId, meta: reply.meta }); return reply.result; } catch (err) { - const e = (err instanceof Error ? err : new Error(String(err))) as Error & { - code?: string; - meta?: ResponseMeta; - browserId?: string; - }; + const raw = (err instanceof Error ? err : new Error(String(err))) as BridgeError; + // Name the browser before auditing so the record carries the same + // message the user sees. list_groups already did this per browser. + const e = + GROUP_METHODS.has(method) && method !== "list_groups" + ? describeGroupError(bridge, raw, raw.browserId ?? browserId).error + : raw; finish({ ok: false, browserId: e.browserId ?? browserId, meta: e.meta, error: e }); - throw err; + throw e === raw ? err : e; } } diff --git a/packages/protocol/src/bridge.test.ts b/packages/protocol/src/bridge.test.ts index 243fd19..714ed64 100644 --- a/packages/protocol/src/bridge.test.ts +++ b/packages/protocol/src/bridge.test.ts @@ -100,4 +100,18 @@ describe("tab groups", () => { expect(() => TabGroupColor.parse("magenta")).toThrow(); expect(ListGroupsResult.parse({ groups: [g] }).groups).toHaveLength(1); }); + + it("ListGroupsResult accepts the daemon's skipped list and still parses without it", () => { + const skipped = [ + { browserId: "b2", browser: "Dia", reason: "unsupported", message: "Dia (b2) ..." }, + { browserId: "b3", browser: "Arc", reason: "outdated", message: "old" }, + { browserId: "b4", browser: "Chrome", reason: "error", message: "timeout" }, + ]; + const parsed = ListGroupsResult.parse({ groups: [], skipped }); + expect(parsed.skipped).toEqual(skipped); + expect(ListGroupsResult.parse({ groups: [] }).skipped).toBeUndefined(); + expect(() => + ListGroupsResult.parse({ groups: [], skipped: [{ ...skipped[0], reason: "nope" }] }), + ).toThrow(); + }); }); diff --git a/packages/protocol/src/bridge.ts b/packages/protocol/src/bridge.ts index b58e84d..f707f6b 100644 --- a/packages/protocol/src/bridge.ts +++ b/packages/protocol/src/bridge.ts @@ -107,6 +107,21 @@ export type WelcomeFrame = z.infer<typeof WelcomeFrame>; export const ListTabsResult = z.object({ tabs: z.array(Tab) }); export type ListTabsResult = z.infer<typeof ListTabsResult>; -/** Result payload for the `list_groups` method. */ -export const ListGroupsResult = z.object({ groups: z.array(TabGroup) }); +/** A browser the daemon left out of a `list_groups` aggregate, and why: + * no chrome.tabGroups API, an extension that predates tab groups, or a + * plain failure. */ +export const SkippedBrowser = z.object({ + browserId: z.string(), + browser: z.string(), + reason: z.enum(["unsupported", "outdated", "error"]), + message: z.string(), +}); +export type SkippedBrowser = z.infer<typeof SkippedBrowser>; + +/** Result payload for the `list_groups` method. `skipped` is added only by + * the daemon's aggregate; the extension never sends it. */ +export const ListGroupsResult = z.object({ + groups: z.array(TabGroup), + skipped: z.array(SkippedBrowser).optional(), +}); export type ListGroupsResult = z.infer<typeof ListGroupsResult>; From 696a47d818cf42d9297d889f219950cf5797b19b Mon Sep 17 00:00:00 2001 From: Karn <mail@karngyan.com> Date: Sun, 27 Sep 2026 00:23:26 +0530 Subject: [PATCH 9/9] docs: Dia supports tab groups; don't name unverified browsers Dia was verified live: groups show in its sidebar (cyan renders as blue). Remove the claim that Dia (and, unverified, Arc) lack tab groups from the changeset and comments, use a neutral browser in test fixtures, and bring the spec in line with the skipped-browser reporting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .changeset/tab-groups-read.md | 2 +- .../specs/2026-09-26-tab-groups-design.md | 8 ++++--- packages/cli/src/cli-commands.test.ts | 11 +++++++--- packages/cli/src/rpc.test.ts | 22 +++++++++---------- packages/cli/src/rpc.ts | 2 +- packages/extension/src/lib/tab-groups.test.ts | 2 +- packages/extension/src/lib/tab-groups.ts | 2 +- packages/protocol/src/bridge.test.ts | 2 +- 8 files changed, 29 insertions(+), 22 deletions(-) diff --git a/.changeset/tab-groups-read.md b/.changeset/tab-groups-read.md index cf0a729..c08b5ff 100644 --- a/.changeset/tab-groups-read.md +++ b/.changeset/tab-groups-read.md @@ -3,4 +3,4 @@ "@reins/extension": minor --- -`reins groups` lists tab groups (title, color, collapsed, tab count) across connected browsers, and `reins tabs` marks each grouped tab with `g<id>`. Browsers without tab groups (Arc, Dia) answer `unsupported` instead of failing silently. The extension asks for the `tabGroups` permission, which shows no install prompt. +`reins groups` lists tab groups (title, color, collapsed, tab count) across connected browsers, and `reins tabs` marks each grouped tab with `g<id>`. A browser without the tab-group API answers with an error that names it (`<browser> (b2) doesn't support tab groups …`), and `reins groups` lists such browsers as skipped instead of dropping them silently. Dia supports tab groups. The extension asks for the `tabGroups` permission, which shows no install prompt. diff --git a/docs/superpowers/specs/2026-09-26-tab-groups-design.md b/docs/superpowers/specs/2026-09-26-tab-groups-design.md index d5311d4..d4b0342 100644 --- a/docs/superpowers/specs/2026-09-26-tab-groups-design.md +++ b/docs/superpowers/specs/2026-09-26-tab-groups-design.md @@ -127,9 +127,11 @@ ignore it with a load-time warning; the extension still loads. `list_groups` aggregates across browsers the same way `list_tabs` does: it fans out to every targeted browser and tags each group with `browserId` and `browser`. A browser that fails (`unsupported`, or an older extension that -answers `unknown method`) contributes no groups. If every targeted browser -fails, the first error is rethrown so the agent sees why. The call is still -audited like any other. +answers `unknown method`) contributes no groups and is listed in `skipped` +(`{browserId, browser, reason, message}`); the text output prints one line +per skipped browser. If every targeted browser fails, the first error is +rethrown, rewritten to name the browser. Routed group calls rewrite the same +way. The call is still audited like any other. All other group methods route to one browser through the existing `bridge.requestFull` path. diff --git a/packages/cli/src/cli-commands.test.ts b/packages/cli/src/cli-commands.test.ts index c28a672..3046d0c 100644 --- a/packages/cli/src/cli-commands.test.ts +++ b/packages/cli/src/cli-commands.test.ts @@ -113,20 +113,25 @@ describe("groupsText", () => { browserId: "b1", }; const skipped = [ - { browserId: "b2", browser: "Dia", reason: "unsupported" as const, message: "Dia (b2) ..." }, + { + browserId: "b2", + browser: "Chromium", + reason: "unsupported" as const, + message: "Chromium (b2) ...", + }, { browserId: "b3", browser: "Arc", reason: "outdated" as const, message: "old" }, { browserId: "b4", browser: "Brave", reason: "error" as const, message: "timed out" }, ]; expect(groupsText([g], skipped)).toBe( [ ' b1 group 7 "reins" blue 1 tab window 1', - " b2 Dia — tab groups not supported", + " b2 Chromium — tab groups not supported", " b3 Arc — reins extension too old for tab groups (update it)", " b4 Brave — failed: timed out", ].join("\n"), ); expect(groupsText([], skipped.slice(0, 1))).toBe( - "(no groups)\n b2 Dia — tab groups not supported", + "(no groups)\n b2 Chromium — tab groups not supported", ); expect(groupsText([], [])).toBe("(no groups)"); }); diff --git a/packages/cli/src/rpc.test.ts b/packages/cli/src/rpc.test.ts index fc7f7a7..87861af 100644 --- a/packages/cli/src/rpc.test.ts +++ b/packages/cli/src/rpc.test.ts @@ -106,7 +106,7 @@ describe("listAllGroups", () => { }; const two = [ { id: "b1", browser: "Chrome", connectedAt: 0 }, - { id: "b2", browser: "Dia", connectedAt: 1 }, + { id: "b2", browser: "Chromium", connectedAt: 1 }, ]; it("aggregates across browsers with tags, via handleRpc", async () => { @@ -117,14 +117,14 @@ describe("listAllGroups", () => { const out = (await handleRpc(bridge, { method: "list_groups" })) as { groups: unknown[] }; expect(out.groups).toEqual([ { ...G, browserId: "b1", browser: "Chrome" }, - { ...G, browserId: "b2", browser: "Dia" }, + { ...G, browserId: "b2", browser: "Chromium" }, ]); }); - const UNSUPPORTED_DIA = - "Dia (b2) doesn't support tab groups — reins groups/group/ungroup need the chrome.tabGroups API, which this browser doesn't provide. Other reins commands work normally."; - const OUTDATED_DIA = - "Dia (b2)'s reins extension predates tab groups — update it (Chrome Web Store), or run `reins extension --reload` for an unpacked build."; + const UNSUPPORTED_B2 = + "Chromium (b2) doesn't support tab groups — reins groups/group/ungroup need the chrome.tabGroups API, which this browser doesn't provide. Other reins commands work normally."; + const OUTDATED_B2 = + "Chromium (b2)'s reins extension predates tab groups — update it (Chrome Web Store), or run `reins extension --reload` for an unpacked build."; function coded(message: string, code?: string): Error { const e = new Error(message) as Error & { code?: string }; @@ -148,7 +148,7 @@ describe("listAllGroups", () => { const { groups, skipped } = await listAllGroups(bridge); expect(groups).toEqual([{ ...G, browserId: "b1", browser: "Chrome" }]); expect(skipped).toEqual([ - { browserId: "b2", browser: "Dia", reason: "unsupported", message: UNSUPPORTED_DIA }, + { browserId: "b2", browser: "Chromium", reason: "unsupported", message: UNSUPPORTED_B2 }, ]); }); @@ -164,7 +164,7 @@ describe("listAllGroups", () => { }); const { skipped } = await listAllGroups(bridge); expect(skipped).toEqual([ - { browserId: "b2", browser: "Dia", reason: "outdated", message: OUTDATED_DIA }, + { browserId: "b2", browser: "Chromium", reason: "outdated", message: OUTDATED_B2 }, ]); }); @@ -180,7 +180,7 @@ describe("listAllGroups", () => { expect(skipped).toEqual([ { browserId: "b2", - browser: "Dia", + browser: "Chromium", reason: "error", message: 'request "list_groups" timed out after 5ms', }, @@ -189,7 +189,7 @@ describe("listAllGroups", () => { it("rethrows the first failure, named, when every browser fails", async () => { const bridge = fakeBridge({ - browsers: [{ id: "b2", browser: "Dia", connectedAt: 1 }], + browsers: [{ id: "b2", browser: "Chromium", connectedAt: 1 }], request: vi.fn(async () => { throw coded( "unsupported: this browser doesn't support tab groups (chrome.tabGroups unavailable)", @@ -198,7 +198,7 @@ describe("listAllGroups", () => { }), }); const p = listAllGroups(bridge); - await expect(p).rejects.toThrow(UNSUPPORTED_DIA); + await expect(p).rejects.toThrow(UNSUPPORTED_B2); await expect(p).rejects.toMatchObject({ code: "unsupported" }); }); diff --git a/packages/cli/src/rpc.ts b/packages/cli/src/rpc.ts index 9e5ba21..699221f 100644 --- a/packages/cli/src/rpc.ts +++ b/packages/cli/src/rpc.ts @@ -78,7 +78,7 @@ export function describeGroupError( } /** List tab groups across connected browsers, tagged like listAllTabs. A - * browser that can't answer — no tab groups (Arc, Dia), an older extension, + * browser that can't answer — no tab-group API, an older extension, * or a plain failure — is reported in `skipped` with a message naming it. * Only when every targeted browser fails does the (rewritten) error throw. */ export async function listAllGroups( diff --git a/packages/extension/src/lib/tab-groups.test.ts b/packages/extension/src/lib/tab-groups.test.ts index c52b258..8f95243 100644 --- a/packages/extension/src/lib/tab-groups.test.ts +++ b/packages/extension/src/lib/tab-groups.test.ts @@ -30,7 +30,7 @@ describe("groupsSupported", () => { expect(groupsSupported()).toBe(true); }); - it("is false without chrome.tabGroups (Arc, Dia, …)", () => { + it("is false without chrome.tabGroups", () => { stubGroups({ tabGroups: undefined }); expect(groupsSupported()).toBe(false); }); diff --git a/packages/extension/src/lib/tab-groups.ts b/packages/extension/src/lib/tab-groups.ts index f8d8c51..8c5cc00 100644 --- a/packages/extension/src/lib/tab-groups.ts +++ b/packages/extension/src/lib/tab-groups.ts @@ -1,6 +1,6 @@ import type { ListGroupsResult } from "@reins/protocol"; -/** The browser has no tab-group API (Arc, Dia, …). `code` survives to the +/** The browser has no tab-group API (some Chromium forks). `code` survives to the * ResponseFrame, so the agent can tell the user instead of guessing. */ export class GroupsUnsupported extends Error { readonly code = "unsupported"; diff --git a/packages/protocol/src/bridge.test.ts b/packages/protocol/src/bridge.test.ts index 714ed64..fcc7b5c 100644 --- a/packages/protocol/src/bridge.test.ts +++ b/packages/protocol/src/bridge.test.ts @@ -103,7 +103,7 @@ describe("tab groups", () => { it("ListGroupsResult accepts the daemon's skipped list and still parses without it", () => { const skipped = [ - { browserId: "b2", browser: "Dia", reason: "unsupported", message: "Dia (b2) ..." }, + { browserId: "b2", browser: "Chromium", reason: "unsupported", message: "Chromium (b2) ..." }, { browserId: "b3", browser: "Arc", reason: "outdated", message: "old" }, { browserId: "b4", browser: "Chrome", reason: "error", message: "timeout" }, ];