qz-browser-agent 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,21 +5,26 @@ Requires Node.js 24 or later and Chrome. CLI and MCP share the same browser conn
5
5
 
6
6
  ## Quick start
7
7
 
8
+ Install [qz from the Chrome Web Store](https://chromewebstore.google.com/detail/dknbdgkgoinlgjpfebapkklbmjjgdblm)
9
+ with **Add to Chrome**. Open the qz side panel, complete its first-use data notice, and configure
10
+ your provider and model in Settings.
11
+
8
12
  Run the published package with npx:
9
13
 
10
14
  ```bash
11
- npx qz-browser-agent@latest install
12
15
  npx qz-browser-agent@latest connect
13
16
  npx qz-browser-agent@latest create "Summarize the current page." --stream
14
17
  ```
15
18
 
16
- `install` opens the [latest extension release](https://github.com/alibaba/qz-browser-agent/releases/latest).
17
- Extract its ZIP, open `chrome://extensions`, enable **Developer mode**, and choose **Load unpacked**.
18
- Select the extracted extension folder. Open qz and accept its first-use data notice.
19
+ `npx qz-browser-agent@latest install` opens the same Chrome Web Store page. For a manual ZIP
20
+ installation, download the [latest GitHub release](https://github.com/alibaba/qz-browser-agent/releases/latest)
21
+ and follow the [manual installation steps](https://github.com/alibaba/qz-browser-agent/blob/main/README.md#manual-installation).
19
22
 
20
- `connect` starts the local MCP service and opens a connection page if a browser has not connected.
21
- Open that page in the Chrome profile where qz is installed. The connection page identifies the
22
- local service to the extension; no token or DevTools configuration is required.
23
+ `connect` starts the local MCP service and opens its connection page for first pairing. Use the
24
+ Chrome profile where qz is installed. The first new profile is named `default`;
25
+ accepting the data notice alone does not enable the local connection. Repeating `connect` reuses
26
+ an online browser. Add further profiles with a [pairing URL](#pair-a-selected-browser-from-the-shell)
27
+ or from their own qz Settings.
23
28
 
24
29
  The examples and suggested next commands use `qz`. Replace it with
25
30
  `npx qz-browser-agent@latest`, or install the command globally:
@@ -31,35 +36,138 @@ qz --version
31
36
 
32
37
  On Windows, use PowerShell 7.3 or newer with its default native argument passing. After a global
33
38
  install, use `qz.ps1` for commands containing JSON or quoted names; Windows command suggestions
34
- use that wrapper. For automation, pin an exact npm version such as `qz-browser-agent@0.1.0`.
39
+ use that wrapper. For automation, pin a compatible release version verified with your extension.
35
40
 
36
41
  ## Connect and stop
37
42
 
38
43
  ```bash
39
44
  qz connect
40
45
  qz status
46
+ qz browsers
41
47
  qz mcp
42
48
  qz stop
43
49
  ```
44
50
 
45
- `connect` reuses the healthy service already running for this installation and protocol. It prefers port
46
- 18765; if another program owns that port, it uses a free port chosen by the operating system.
47
- It confirms that a browser responds before reporting success, and opens the connection page to
48
- resume polling if a previously connected browser no longer responds.
49
- `mcp` prints the actual MCP URL. `status` and `mcp` never start a service or open a browser.
50
- `stop` shuts down the shared service and disconnects the extension and MCP clients.
51
-
52
- After first-use consent, an unavailable local service gets up to three connection attempts.
53
- Once a connection succeeds, later interruptions switch to one recovery attempt per minute after
54
- three consecutive failed polls, using that same endpoint. Recovery continues across worker restarts;
55
- disabling the bridge stops it. Run `qz connect` to retry immediately. Connected browsers use long polls
56
- capped at 20 seconds and reload saved settings before each poll. The side panel and connection page
57
- can be closed.
58
-
59
- `status` distinguishes an unavailable local service from a running service with no connected
60
- browser. A failed health check is retried once; an unresponsive live service is reported without
61
- starting another instance. After updating the extension, reload it at `chrome://extensions` and
62
- run `qz connect` to reconnect.
51
+ `connect` reuses the healthy service already running for this installation. It prefers port 18765;
52
+ if that port belongs to another program, the service chooses a free port. `status` shows the actual
53
+ service address, and `mcp` prints its MCP URL. Both commands are read-only. To request a custom port,
54
+ run `qz connect --port 18766`; an existing service on another port remains running and the command
55
+ reports its address. The extension connects only to its configured address.
56
+
57
+ The connection page can be closed once `qz browsers` shows the profile online. Connections start
58
+ only through `qz connect` or **qz Settings → Advanced → CLI → Connect CLI**. For ordinary connection
59
+ failures, the extension makes at most three recovery attempts, 30 seconds apart, then pauses.
60
+ After a browser or background restart, it may first wait once for the previous connection to expire.
61
+ The saved retry budget survives worker restarts. Click **Connect CLI** in that browser's settings to retry.
62
+
63
+ To disconnect one browser, click **Disconnect: NAME** in its qz Settings. `qz stop` stops the local
64
+ service and disconnects every browser and MCP client using it. Browser tasks already running
65
+ remain in the extension; stopping the service does not cancel them. Use `qz cancel TASK_ID` to stop
66
+ a task, adding `--client NAME|UUID` when needed.
67
+
68
+ ### Pair a selected browser from the shell
69
+
70
+ Get a pairing URL without opening the default browser or waiting for a connection:
71
+
72
+ ```bash
73
+ qz connect --no-open --json
74
+ qz connect --no-open --name work --json
75
+ ```
76
+
77
+ The JSON envelope contains `result.endpoint`, `result.url`, `result.mcpUrl`, and
78
+ `result.opened: false`. The command starts or reuses the service even when other browsers are
79
+ already connected. Open the exact returned URL in the intended browser Profile with qz installed.
80
+
81
+ Set `QZ_PAIRING_URL` to that URL and `QZ_PROFILE_DIRECTORY` to the selected profile's directory
82
+ name, such as `Default` or `Profile 2`. These are directory names, not Chrome's display labels.
83
+ Use a Profile already chosen by the user. On macOS:
84
+
85
+ ```sh
86
+ open -na "Google Chrome" --args --profile-directory="$QZ_PROFILE_DIRECTORY" "$QZ_PAIRING_URL"
87
+ ```
88
+
89
+ For Chrome on Linux:
90
+
91
+ ```sh
92
+ google-chrome --profile-directory="$QZ_PROFILE_DIRECTORY" "$QZ_PAIRING_URL"
93
+ ```
94
+
95
+ For Chrome installed under Program Files on Windows, set `$QzProfileDirectory` and
96
+ `$QzPairingUrl` to those same values in PowerShell:
97
+
98
+ ```powershell
99
+ & "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" "--profile-directory=$QzProfileDirectory" $QzPairingUrl
100
+ ```
101
+
102
+ Use the installed browser's executable or app name. For an existing custom data directory, also
103
+ pass `--user-data-dir` with its exact path; for example on macOS:
104
+
105
+ ```sh
106
+ open -na "Google Chrome" --args --user-data-dir="$QZ_USER_DATA_DIR" --profile-directory="$QZ_PROFILE_DIRECTORY" "$QZ_PAIRING_URL"
107
+ ```
108
+
109
+ The [Chrome profile documentation](https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md)
110
+ distinguishes the data directory from the Profile inside it. A new data directory needs its own
111
+ extension installation and first-use setup.
112
+
113
+ `--name work` adds `?name=work` to the URL. The Profile opening it receives that identifier,
114
+ including when it already has a connection. Without `--name`, a registered Profile keeps its name;
115
+ a new Profile receives the next available name: `default`, `browser-2`, and so on. A name conflict
116
+ requires another name. `qz connect --name work` opens the URL automatically and returns immediately.
117
+ These URL-returning commands do not confirm that a browser connected. Verify it with:
118
+
119
+ ```bash
120
+ qz browsers --json
121
+ qz create "Summarize the current page." --client UUID --stream
122
+ ```
123
+
124
+ Read `result.browsers`, then use the intended online entry's `clientId` UUID. `--client` selects an
125
+ existing connection and cannot be combined with `--no-open` or `--name`. `--port` works with either
126
+ pairing mode and follows the existing-service behavior above.
127
+
128
+ ### Add and select browsers
129
+
130
+ You can also pair from **qz Settings → Advanced → CLI** in the intended Profile. Enter a distinct
131
+ browser identifier and connect. Use the service address printed by `qz status` in **Local service address** if
132
+ its port differs from 18765. Names use 1–64 letters, digits, underscores, or hyphens, beginning with
133
+ a letter or digit; names are case-sensitive. UUID-shaped names are reserved for profile IDs.
134
+
135
+ ```bash
136
+ qz browsers
137
+ qz create "Summarize the current page." --client work --stream
138
+ qz show TASK_ID --client work
139
+ qz data --client work
140
+ ```
141
+
142
+ `--client NAME|UUID` and `-c NAME|UUID` select a profile. With exactly one online browser, the CLI
143
+ selects it automatically. With several online browsers, a selection is required; `default` is a
144
+ name, not a routing preference. An unknown or offline selection fails without opening or using
145
+ another browser. Each command resolves its name once and carries the UUID through subsequent
146
+ requests. JSON task results and suggested next commands include that UUID.
147
+
148
+ Names remain registered while their browsers are offline and across service restarts. To release
149
+ an offline name, remove its registration explicitly:
150
+
151
+ ```bash
152
+ qz browsers forget work
153
+ ```
154
+
155
+ An online browser must first disconnect in its settings. Forgetting a registration leaves the
156
+ browser's tasks and data intact. A browser can then explicitly pair using the released name.
157
+
158
+ ### Update the connection
159
+
160
+ Use the current CLI and extension together. Source edits and a newer npm package leave a healthy
161
+ running service in place; `qz status` reports an available update. An incompatible service returns
162
+ an update instruction instead of being stopped automatically.
163
+
164
+ 1. Use the latest CLI and let current tasks finish.
165
+ 2. Run `npx --yes qz-browser-agent@latest stop`. This disconnects all browsers using that service.
166
+ 3. Update qz from the [Chrome Web Store](https://chromewebstore.google.com/detail/dknbdgkgoinlgjpfebapkklbmjjgdblm).
167
+ For a manual ZIP installation, replace its extracted files with the
168
+ [latest release](https://github.com/alibaba/qz-browser-agent/releases/latest) and reload it at `chrome://extensions`.
169
+ 4. Run `npx --yes qz-browser-agent@latest connect`, then reconnect additional profiles using their
170
+ pairing URL or settings.
63
171
 
64
172
  ## Connect an MCP client
65
173
 
@@ -72,7 +180,9 @@ url = "http://127.0.0.1:18765/mcp"
72
180
 
73
181
  Use the URL printed by your command if its port differs. `qz mcp --json` returns the URL as JSON
74
182
  for scripts. The `qingzhou_*` MCP tool names and extension protocol remain available. Responses
75
- to pending input bind to an explicit `waitId`. MCP initialization and streaming are handled by your
183
+ to pending input bind to an explicit `waitId`. Use `qingzhou_bridge_status` to list browser names
184
+ and UUIDs, then pass `clientId: "work"` or a UUID to browser tools. Selection is required with
185
+ multiple online browsers. Task results include the resolved `clientId`. MCP initialization and streaming are handled by your
76
186
  client's MCP implementation.
77
187
 
78
188
  ### Task and resource operations
@@ -81,6 +191,7 @@ CLI and MCP use the same task observer and browser operations. The task and reso
81
191
 
82
192
  | CLI | MCP tool |
83
193
  | --- | --- |
194
+ | `browsers`, `browsers forget` | `qingzhou_bridge_status`, `qingzhou_forget_browser` |
84
195
  | `create`, `send`, `cancel` | `qingzhou_create_session`, `qingzhou_send_message`, `qingzhou_stop_session` |
85
196
  | `tasks`, `show`, `watch` | `qingzhou_list_sessions`, `qingzhou_get_session_snapshot`, `qingzhou_watch_task` |
86
197
  | `show`, `submit` / `approve` / `reject` | `qingzhou_get_session_snapshot`, `qingzhou_respond_to_user_input` |
@@ -185,7 +296,8 @@ continues in the browser.
185
296
 
186
297
  ## Browser selection and MCP tools
187
298
 
188
- Use `--client ID` or `-c ID` to select a browser when several are connected. Find IDs in `qz status`.
299
+ Use `--client NAME|UUID` or `-c NAME|UUID` to select a browser. `qz browsers` lists registered names,
300
+ UUIDs, and online status. See [browser selection](#add-and-select-browsers) for pairing and name reuse.
189
301
  Use `-t` for `--title`. Run `qz help COMMAND` or `qz COMMAND --help` for command-specific options.
190
302
 
191
303
  MCP clients can inspect browser tool schemas with `qingzhou_list_tools` and `qingzhou_get_tool`,
@@ -252,8 +364,8 @@ a patch containing the supplied top-level fields. Follow the field descriptions
252
364
  replace an entire object, such as `modelParameters`.
253
365
 
254
366
  Read credential patches from stdin to keep them out of shell history. JSON is validated before
255
- connecting. The extension validates field names and values before saving. All commands accept
256
- `--client ID` to select a browser profile. `automations run` starts an existing automation and
367
+ connecting. The extension validates field names and values before saving. Browser commands accept
368
+ `--client NAME|UUID` to select a profile. `automations run` starts an existing automation and
257
369
  returns its task ID.
258
370
 
259
371
  The CLI uses the same browser operations as MCP. Its commands correspond to tasks, data,
@@ -283,8 +395,10 @@ extension polling only from extension origins or local clients. Ordinary website
283
395
  requests through the MCP endpoint. Local programs can control connected qz tasks while the
284
396
  service is running. Run `qz stop` to disconnect.
285
397
 
286
- Connection metadata is stored in `~/.qz-browser-agent/run/`; `QZ_STATE_DIR` selects another local
287
- directory for isolated tests. Atomic startup locks prevent duplicate services. Development copies
398
+ Connection metadata and browser registrations are stored in `~/.qz-browser-agent/run/`;
399
+ `QZ_STATE_DIR` selects another local directory for isolated tests. Registration writes are atomic;
400
+ a failed write leaves the previous registration active. Private pairing proofs stay in local storage
401
+ and are omitted from status output. Atomic startup locks prevent duplicate services. Development copies
288
402
  and published installations use separate identities. The client verifies the service identity,
289
403
  protocol version, instance, and process before reusing or stopping it. Editing source files or
290
404
  running a newer package keeps the healthy service and its connections alive. `status` reports
@@ -320,7 +434,7 @@ using the public npm registry. It checks the installed command, versions, MCP to
320
434
  and shutdown on an isolated port. The test owns its npm configuration, cache, connection state,
321
435
  and service process. It requires network access to download dependencies.
322
436
 
323
- Use `--output tests/results/npm/qz-browser-agent-0.1.0.tgz` to retain the verified tarball.
437
+ Use `--output tests/results/npm/qz-browser-agent-0.1.1.tgz` to retain the verified tarball.
324
438
  Use `--tarball PATH` to check an existing tarball. The check also runs through
325
439
  `npm test -- release --match npm-package`.
326
440
 
@@ -345,9 +459,9 @@ For the first manual release, log in with npm, verify the saved tarball, and pub
345
459
 
346
460
  ```bash
347
461
  npm login --registry=https://registry.npmjs.org
348
- node tests/release/npm-package.mjs --output tests/results/npm/qz-browser-agent-0.1.0.tgz
349
- npm publish tests/results/npm/qz-browser-agent-0.1.0.tgz --access public --registry=https://registry.npmjs.org
462
+ node tests/release/npm-package.mjs --output tests/results/npm/qz-browser-agent-0.1.1.tgz
463
+ npm publish tests/results/npm/qz-browser-agent-0.1.1.tgz --access public --registry=https://registry.npmjs.org
350
464
  ```
351
465
 
352
466
  The root package stays private. Publishing the service does not publish the Chrome extension.
353
- After publication, check `npx qz-browser-agent@0.1.0 --version` from a directory outside the repository.
467
+ After publication, check `npx qz-browser-agent@0.1.1 --version` from a directory outside the repository.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qz-browser-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local browser tools and MCP access for the qz extension",
5
5
  "type": "module",
6
6
  "engines": { "node": ">=24" },
package/src/args.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { QzError } from "./client.mjs";
3
+ import { browserName } from "./browser-registry.mjs";
3
4
  import { COMMANDS, commandOptions } from "./commands.mjs";
4
5
 
5
6
  function invalid(message) {
@@ -22,7 +23,7 @@ export function parseCommand(argv) {
22
23
  title: { type: "string", short: "t" }, client: { type: "string", short: "c" },
23
24
  "wait-id": { type: "string" }, format: { type: "string" }, query: { type: "string" },
24
25
  start: { type: "string" }, limit: { type: "string" }, scope: { type: "string" }, filename: { type: "string" },
25
- parent: { type: "string" },
26
+ parent: { type: "string" }, port: { type: "string" }, name: { type: "string" }, "no-open": { type: "boolean" },
26
27
  wait: { type: "boolean" }, stream: { type: "boolean" }, steer: { type: "boolean" },
27
28
  json: { type: "boolean" }, plain: { type: "boolean" }, "no-color": { type: "boolean" },
28
29
  help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
@@ -56,6 +57,17 @@ export function parseCommand(argv) {
56
57
  for (const name of ["session", "client", "wait-id", "parent"]) {
57
58
  if (options[name] !== undefined && !options[name].trim()) invalid(`--${name} requires a non-empty ID.`);
58
59
  }
60
+ if (options.name !== undefined) {
61
+ try { options.name = browserName(options.name); } catch (error) { invalid(error.message); }
62
+ }
63
+ if (options.client !== undefined && (options.name !== undefined || options["no-open"])) {
64
+ invalid("Use --client to select an existing browser, or --name/--no-open to prepare a binding URL.");
65
+ }
66
+ if (options.port !== undefined) {
67
+ const port = Number(options.port);
68
+ if (!options.port.trim() || !Number.isInteger(port) || port < 1 || port > 65535) invalid("--port must be an integer from 1 to 65535.");
69
+ options.port = port;
70
+ }
59
71
  if (options.filename !== undefined && !options.filename.trim()) invalid("--filename requires a non-empty name.");
60
72
  if (options.scope !== undefined && !["all", "global", "site", "resident"].includes(options.scope)) invalid("--scope must be all, global, site, or resident.");
61
73
  if (options.client !== undefined) {
@@ -78,7 +90,11 @@ export function parseCommand(argv) {
78
90
  }
79
91
 
80
92
  let command = originalCommand, action, id;
81
- if (command === "send") {
93
+ if (command === "browsers") {
94
+ action = words[0] || "list";
95
+ if (action === "list" ? words.length > 1 : action !== "forget" || words.length !== 2 || !words[1].trim()) invalid("Use qz browsers [list], or qz browsers forget NAME|UUID.");
96
+ id = words[1];
97
+ } else if (command === "send") {
82
98
  id = words[0]; options.prompt = words.slice(1).join(" ");
83
99
  if (!id?.trim() || !options.prompt.trim()) invalid('Use qz send TASK_ID "MESSAGE".');
84
100
  } else if (command === "data" || command === "images" || command === "skills") {
@@ -0,0 +1,101 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ export const isBrowserId = value => typeof value === "string" && uuid.test(value);
7
+ export function pairingError(code, message) {
8
+ return Object.assign(new Error(`${code}: ${message}`), { code });
9
+ }
10
+ export function browserName(value) {
11
+ const name = typeof value === "string" ? value.trim() : "";
12
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || uuid.test(name)) {
13
+ throw pairingError("BRIDGE_NAME_INVALID", "Use 1–64 letters, digits, underscores, or hyphens, starting with a letter or digit. A UUID cannot be used as a name.");
14
+ }
15
+ return name;
16
+ }
17
+ export function sameToken(actual, expected) {
18
+ if (typeof actual !== "string" || typeof expected !== "string") return false;
19
+ const a = Buffer.from(actual), b = Buffer.from(expected);
20
+ return a.length === b.length && timingSafeEqual(a, b);
21
+ }
22
+
23
+ async function missingRegistry(path) {
24
+ // On Windows, ENOENT can also mean that a parent component is a file.
25
+ for (let candidate = path; ; candidate = dirname(candidate)) {
26
+ try { await lstat(candidate); }
27
+ catch (error) {
28
+ if (error.code !== "ENOENT" || dirname(candidate) === candidate) return false;
29
+ continue;
30
+ }
31
+ return candidate !== path && await stat(candidate).then(entry => entry.isDirectory(), () => false);
32
+ }
33
+ }
34
+
35
+ /** The daemon owns this file; its scope lock prevents competing writers. */
36
+ export class BrowserRegistry {
37
+ constructor({ path, entries = [], persist } = {}) {
38
+ this.path = path;
39
+ this.entries = new Map(entries.map(entry => [entry.clientId, entry]));
40
+ this.persist = persist || (async entries => {
41
+ if (!path) return;
42
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
43
+ const temporary = `${path}.${randomUUID()}.tmp`;
44
+ try {
45
+ await writeFile(temporary, `${JSON.stringify({ version: 1, browsers: entries })}\n`, { mode: 0o600 });
46
+ await rename(temporary, path);
47
+ } finally { await rm(temporary, { force: true }).catch(() => {}); }
48
+ });
49
+ }
50
+
51
+ static async open(path) {
52
+ if (!path) return new BrowserRegistry();
53
+ let document;
54
+ try { document = JSON.parse(await readFile(path, "utf8")); }
55
+ catch (error) {
56
+ if (error.code === "ENOENT" && await missingRegistry(path)) return new BrowserRegistry({ path });
57
+ throw pairingError("BRIDGE_REGISTRY_READ_FAILED", "Cannot read the browser registry. Restore its valid contents before restarting qz.");
58
+ }
59
+ try {
60
+ if (document.version !== 1 || !Array.isArray(document.browsers)) throw new Error();
61
+ const ids = new Set(), names = new Set();
62
+ for (const entry of document.browsers) {
63
+ if (!isBrowserId(entry.clientId) || browserName(entry.name) !== entry.name
64
+ || !/^[A-Za-z0-9_-]{43}$/.test(entry.pairingToken) || ids.has(entry.clientId) || names.has(entry.name)) throw new Error();
65
+ ids.add(entry.clientId); names.add(entry.name);
66
+ }
67
+ return new BrowserRegistry({ path, entries: document.browsers });
68
+ } catch { throw pairingError("BRIDGE_REGISTRY_READ_FAILED", "The browser registry is invalid. Restore its valid contents before restarting qz."); }
69
+ }
70
+
71
+ list() { return [...this.entries.values()].map(({ clientId, name }) => ({ clientId, name })); }
72
+ get(selector) { return this.entries.get(selector) || [...this.entries.values()].find(entry => entry.name === selector); }
73
+ async save(entries) {
74
+ try { await this.persist([...entries.values()]); }
75
+ catch { throw pairingError("BRIDGE_REGISTRY_WRITE_FAILED", "Could not save the browser registry. Check its directory permissions and retry."); }
76
+ this.entries = entries;
77
+ }
78
+ async pair({ clientId, name, pairingToken }) {
79
+ const existing = this.entries.get(clientId);
80
+ if (existing && !sameToken(pairingToken, existing.pairingToken)) {
81
+ throw pairingError("BRIDGE_PAIRING_REQUIRED", "This browser identity is already paired. Use its original profile, or forget the offline browser before pairing again.");
82
+ }
83
+ if (!name && !existing) {
84
+ let index = this.entries.size ? 2 : 1;
85
+ do { name = index === 1 ? "default" : `browser-${index}`; index++; } while (this.get(name));
86
+ }
87
+ name = browserName(name || existing?.name);
88
+ const occupied = this.get(name);
89
+ if (occupied && occupied.clientId !== clientId) throw pairingError("BRIDGE_NAME_IN_USE", `Browser name ${name} is already registered. Choose another name or forget its offline registration.`);
90
+ if (existing?.name === name) return existing;
91
+ if (pairingToken !== undefined && !/^[A-Za-z0-9_-]{43}$/.test(pairingToken)) throw pairingError("BRIDGE_PAIRING_REQUIRED", "The browser pairing proof is invalid. Reconnect from the extension settings.");
92
+ const entry = { clientId, name, pairingToken: existing?.pairingToken || pairingToken || randomBytes(32).toString("base64url") };
93
+ await this.save(new Map(this.entries).set(clientId, entry));
94
+ return entry;
95
+ }
96
+ async forget(clientId) {
97
+ const entries = new Map(this.entries);
98
+ entries.delete(clientId);
99
+ await this.save(entries);
100
+ }
101
+ }