opencode-plannotator-tui 0.3.0 → 0.4.2

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.
@@ -0,0 +1,105 @@
1
+ import { HOST, safeUrl, validateReview } from "./protocol.mjs"
2
+
3
+ const pageKey = async url => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(url)))].map(v => v.toString(16).padStart(2, "0")).join("")
4
+ let writes = Promise.resolve()
5
+ const serial = fn => { const next = writes.then(fn); writes = next.catch(() => {}); return next }
6
+ async function activate(tab) {
7
+ if (!tab?.id || !/^(https?|file):/.test(tab.url || "")) throw new Error("Open an ordinary webpage or authorized local HTML")
8
+ await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["inspector.js", "capture.js"] })
9
+ await chrome.storage.session.set({ [`tab:${tab.id}`]: { page: { url: safeUrl(tab.url), title: tab.title || "Page" }, anchor: null } })
10
+ }
11
+ chrome.action.onClicked.addListener(tab => {
12
+ // open() must retain the browser's action user gesture, before asynchronous injection.
13
+ chrome.sidePanel.open({ tabId: tab.id }).catch(console.error)
14
+ activate(tab).catch(async error => { await chrome.storage.session.set({ [`tab:${tab.id}`]: { error: error.message } }) })
15
+ })
16
+ async function current() {
17
+ const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
18
+ if (!tab) throw new Error("No active page")
19
+ const state = (await chrome.storage.session.get(`tab:${tab.id}`))[`tab:${tab.id}`]
20
+ if (!state?.page) throw new Error(state?.error || "Click the extension icon on this page to grant access")
21
+ const hash = await pageKey(state.page.url)
22
+ const storage = await chrome.storage.local.get(null)
23
+ const notes = Object.entries(storage).filter(([key, value]) => key.startsWith(`note:${hash}:`) && !value.deleted).map(([, value]) => value).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
24
+ let anchorStates = notes.map(() => "unavailable")
25
+ try { anchorStates = (await chrome.tabs.sendMessage(tab.id, { action: "locate", anchors: notes.map(note => note.anchor) })).states } catch { /* visibly unavailable while navigating */ }
26
+ return { tabID: tab.id, ...state, notes, anchorStates, hash }
27
+ }
28
+ async function handle(message, sender) {
29
+ if (message.action === "capture") {
30
+ if (!sender.tab || !sender.url || new URL(message.page.url).origin !== new URL(sender.url).origin) throw new Error("Invalid page source")
31
+ const key = `tab:${sender.tab.id}`
32
+ const previous = (await chrome.storage.session.get(key))[key] || {}
33
+ await chrome.storage.session.set({ [key]: { ...previous, page: { url: safeUrl(message.page.url), title: String(message.page.title).slice(0, 500) }, picking: !!message.picking,
34
+ ...(!message.modeOnly ? { anchor: message.anchor, error: message.error } : {}) } })
35
+ return {}
36
+ }
37
+ if (sender.url && !sender.url.startsWith(chrome.runtime.getURL(""))) throw new Error("Only the extension panel can manage feedback")
38
+ if (message.action === "current") return current()
39
+ if (message.action === "targets") return chrome.runtime.sendNativeMessage(HOST, { action: "targets" })
40
+ if (message.action === "connection") {
41
+ const result = await chrome.runtime.sendNativeMessage(HOST, { action: "targets" })
42
+ if (!result.ok) throw new Error(result.error)
43
+ const { paired } = await chrome.storage.local.get("paired")
44
+ const target = paired ? result.targets.find(target => target.id === paired.id) : result.targets.length === 1 ? result.targets[0] : null
45
+ return { targets: result.targets, target: target || null, stale: !!paired && !target }
46
+ }
47
+ if (message.action === "pair") {
48
+ const result = await chrome.runtime.sendNativeMessage(HOST, { action: "info", targetID: message.target.id })
49
+ if (!result.ok) throw new Error(result.error)
50
+ await chrome.storage.local.set({ paired: result.target }); return { target: result.target }
51
+ }
52
+ if (message.action === "pairing") return { target: (await chrome.storage.local.get("paired")).paired || null }
53
+ if (message.action === "unpair") { await chrome.storage.local.remove("paired"); return {} }
54
+ const state = await current()
55
+ if (message.url && message.url !== state.page.url) throw new Error("The active page changed; retry on the original page")
56
+ if (message.action === "pick") return chrome.tabs.sendMessage(state.tabID, { action: "pick" })
57
+ if (message.action === "save" || message.action === "delete") return serial(async () => {
58
+ const key = `note:${state.hash}:${message.note.id}`
59
+ const old = (await chrome.storage.local.get(key))[key]
60
+ if ((old?.revision || 0) !== message.baseRevision) throw new Error("This note changed in another tab; refresh and retry")
61
+ const note = { ...message.note, page: state.page, revision: (old?.revision || 0) + 1, deleted: message.action === "delete" }
62
+ validateReview({ version: 1, page: state.page, annotations: [note] })
63
+ if (!old && state.notes.length >= 200) throw new Error("This page has 200 notes; export and remove some first")
64
+ await chrome.storage.local.set({ [key]: note }); return { note }
65
+ })
66
+ if (message.action === "markers") return chrome.tabs.sendMessage(state.tabID, { action: "markers", notes: state.notes.map(note => ({ anchor: note.anchor })) })
67
+ if (message.action === "send") return serial(async () => {
68
+ const latest = await current()
69
+ if (latest.page.url !== state.page.url) throw new Error("The active page changed; return to the original page to send")
70
+ if (typeof message.targetID !== "string" || !message.targetID) throw new Error("Choose a destination session before sending")
71
+ let { paired } = await chrome.storage.local.get("paired")
72
+ if (paired && paired.id !== message.targetID) throw new Error("The destination changed in another panel; choose it again")
73
+ if (!paired) {
74
+ // A sole target is only preselected. The user's Send click authorizes this binding.
75
+ const result = await chrome.runtime.sendNativeMessage(HOST, { action: "info", targetID: message.targetID })
76
+ if (!result.ok) throw new Error(result.error)
77
+ paired = result.target
78
+ await chrome.storage.local.set({ paired })
79
+ }
80
+ const outboxKey = `outbox:${state.hash}`
81
+ let batch = (await chrome.storage.local.get(outboxKey))[outboxKey]
82
+ if (batch && batch.targetID !== paired.id) throw new Error("An unsent batch belongs to the previous pairing; restore that pairing or export your notes")
83
+ if (!batch) {
84
+ const pending = latest.notes.filter(note => note.sentRevision !== note.revision)
85
+ if (!pending.length) throw new Error("No new feedback to send")
86
+ const review = validateReview({ version: 1, page: state.page, annotations: pending })
87
+ batch = { action: "send", batchID: crypto.randomUUID(), targetID: paired.id, review }
88
+ await chrome.storage.local.set({ [outboxKey]: batch })
89
+ }
90
+ const response = await chrome.runtime.sendNativeMessage(HOST, batch)
91
+ if (!response.ok) throw new Error(response.error)
92
+ for (const sent of batch.review.annotations) {
93
+ const key = `note:${state.hash}:${sent.id}`
94
+ const note = (await chrome.storage.local.get(key))[key]
95
+ if (note && note.revision === sent.revision) await chrome.storage.local.set({ [key]: { ...note, sentRevision: sent.revision } })
96
+ }
97
+ await chrome.storage.local.remove(outboxKey)
98
+ return response
99
+ })
100
+ throw new Error("Unknown annotation action")
101
+ }
102
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
103
+ handle(message, sender).then(result => respond({ ok: true, ...result }), error => respond({ ok: false, error: error.message }))
104
+ return true
105
+ })
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "opencode-plannotator-tui",
3
- "version": "0.3.0",
4
- "description": "Unofficial OpenCode adapter for Plannotator TUI: annotate replies and documents without leaving your terminal.",
3
+ "version": "0.4.2",
4
+ "description": "Unofficial OpenCode plugin: annotate replies, documents and webpages, then send the feedback to the originating session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  "./tui": "./tui.ts"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test review.test.mjs",
11
+ "test": "node --test review.test.mjs bridge/*.test.mjs",
12
+ "test:browser": "node tests/browser.mjs",
13
+ "install:bridge": "node scripts/install-bridge.mjs",
14
+ "install:plugin": "node scripts/install-plugin.mjs",
12
15
  "test:smoke": "python3 smoke.py",
13
16
  "check": "node --check review.mjs && npm test && npm pack --dry-run",
14
17
  "prepublishOnly": "npm run check"
@@ -16,6 +19,15 @@
16
19
  "files": [
17
20
  "tui.ts",
18
21
  "review.mjs",
22
+ "targets.mjs",
23
+ "web-review.mjs",
24
+ "browser",
25
+ "bridge/native-host.mjs",
26
+ "bridge/receiver.mjs",
27
+ "bridge/wire.mjs",
28
+ "scripts/install-bridge.mjs",
29
+ "scripts/install-plugin.mjs",
30
+ "scripts/migrate-page-context.mjs",
19
31
  "README.md",
20
32
  "LICENSE",
21
33
  "CHANGELOG.md"
package/review.mjs CHANGED
@@ -4,6 +4,8 @@ import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/
4
4
  import os from "node:os"
5
5
  import path from "node:path"
6
6
  import { promisify } from "node:util"
7
+ import { createWebReview } from "./web-review.mjs"
8
+ import { wantsBrowser } from "./targets.mjs"
7
9
 
8
10
  const execute = promisify(execFile)
9
11
  const documentExtensions = new Set([".md", ".mdx", ".markdown", ".txt"])
@@ -111,6 +113,7 @@ async function interactive({ binary, file, cwd, env, signal }) {
111
113
  }
112
114
 
113
115
  export function createReviewCommand(context, options = {}) {
116
+ const web = createWebReview(context, options.web)
114
117
  const binary = options.binary ?? context.options.binary ?? "plannotator-tui"
115
118
  const runInteractive = options.interactive ?? interactive
116
119
  let active
@@ -135,6 +138,10 @@ export function createReviewCommand(context, options = {}) {
135
138
  // The UI cache contains only a recent page; a long tool run can push the last
136
139
  // completed reply out of it. Read the session-scoped history, newest first.
137
140
  const target = input.trim()
141
+ if (wantsBrowser(target)) {
142
+ await web.run(target, sessionID)
143
+ return
144
+ }
138
145
  const numeric = target !== "" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(target)
139
146
  const position = numeric ? Number(target) : 1
140
147
  if (numeric && (!/^\d+$/.test(target) || !Number.isSafeInteger(position) || position < 1)) {
@@ -173,13 +180,12 @@ export function createReviewCommand(context, options = {}) {
173
180
  PLANNOTATOR_TUI_THEME: context.themeMode === "light" ? "light" : "dark",
174
181
  })
175
182
  context.ui.dialog.clear()
176
- context.renderer.suspend()
183
+ // OpenCode 2.1.1+ suspends and resumes asynchronously; resume forces a full repaint.
184
+ await context.renderer.suspend()
177
185
  try {
178
- context.renderer.currentRenderBuffer.clear()
179
186
  await runInteractive({ binary, file, cwd: directory, env, signal: controller.signal })
180
187
  } finally {
181
- context.renderer.currentRenderBuffer.clear()
182
- context.renderer.resume()
188
+ await context.renderer.resume()
183
189
  context.renderer.requestRender()
184
190
  }
185
191
  if (controller.signal.aborted) return
@@ -240,6 +246,7 @@ export function createReviewCommand(context, options = {}) {
240
246
  dispose() {
241
247
  disposed = true
242
248
  active?.abort()
249
+ return web.dispose()
243
250
  },
244
251
  }
245
252
  }
@@ -0,0 +1,27 @@
1
+ import { readFile, mkdir, writeFile, chmod, copyFile } from "node:fs/promises"
2
+ import { createHash } from "node:crypto"
3
+ import { fileURLToPath } from "node:url"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { registryRoot } from "../bridge/receiver.mjs"
7
+ const root = fileURLToPath(new URL("../", import.meta.url))
8
+ export async function installBridge({ profileDirectory = path.join(os.homedir(), "Library/Application Support/Google/Chrome"), installRoot = path.join(os.homedir(), ".local/share/opencode-annotate/native"), node = process.execPath, targetsDirectory = registryRoot() } = {}) {
9
+ const manifest = JSON.parse(await readFile(path.join(root, "browser/manifest.json"), "utf8"))
10
+ const extensionID = [...createHash("sha256").update(Buffer.from(manifest.key, "base64")).digest().subarray(0, 16)].flatMap(byte => [String.fromCharCode(97 + (byte >> 4)), String.fromCharCode(97 + (byte & 15))]).join("")
11
+ await mkdir(installRoot, { recursive: true, mode: 0o700 })
12
+ const launcher = path.join(installRoot, "host"), config = path.join(installRoot, "config.json")
13
+ const quote = value => `'${value.replaceAll("'", "'\\''")}'`
14
+ await writeFile(config, JSON.stringify({ extensionID, targetsDirectory }), { mode: 0o600 })
15
+ await writeFile(launcher, `#!/bin/sh\nexec ${quote(node)} ${quote(path.join(root, "bridge/native-host.mjs"))} ${quote(config)} "$@"\n`, { mode: 0o700 })
16
+ await chmod(launcher, 0o700)
17
+ const directory = path.join(profileDirectory, "NativeMessagingHosts")
18
+ await mkdir(directory, { recursive: true })
19
+ const hostManifest = path.join(directory, "fun.mjshao.annotate.json")
20
+ try { await copyFile(hostManifest, `${hostManifest}.backup-${Date.now()}`) } catch (error) { if (error.code !== "ENOENT") throw error }
21
+ await writeFile(hostManifest, JSON.stringify({ name: "fun.mjshao.annotate", description: "Paired OpenCode annotation delivery", path: launcher, type: "stdio", allowed_origins: [`chrome-extension://${extensionID}/`] }, null, 2), { mode: 0o600 })
22
+ return { extensionID, extensionDirectory: path.join(root, "browser"), hostManifest, launcher }
23
+ }
24
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
25
+ if (process.platform !== "darwin") throw new Error("Local installer currently supports macOS only")
26
+ console.log(JSON.stringify(await installBridge(), null, 2))
27
+ }
@@ -0,0 +1,34 @@
1
+ import { readFile, writeFile, mkdir, realpath, rename, stat } from "node:fs/promises"
2
+ import { fileURLToPath } from "node:url"
3
+ import { createHash } from "node:crypto"
4
+ import path from "node:path"
5
+ import os from "node:os"
6
+
7
+ const root = fileURLToPath(new URL("../", import.meta.url)).replace(/\/$/, "")
8
+ const source = entry => typeof entry === "string" ? entry : entry.package
9
+ const oldEntry = entry => source(entry) === "opencode-plannotator-tui" || source(entry)?.startsWith("opencode-plannotator-tui@")
10
+ const configPath = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "opencode/cli.json")
11
+ const filename = await realpath(configPath)
12
+ const bytes = await readFile(filename, "utf8"), config = JSON.parse(bytes)
13
+ let backup
14
+ if (process.argv[2] === "--undo") {
15
+ if (!process.argv[3]) throw new Error("Supply the cli-before.json backup")
16
+ const previous = JSON.parse(await readFile(process.argv[3], "utf8"))
17
+ const old = previous.plugins.filter(oldEntry)
18
+ if (old.length !== 1 || config.plugins.filter(entry => source(entry) === root).length !== 1) throw new Error("Ambiguous plugin rollback; no changes made")
19
+ config.plugins = config.plugins.map(entry => source(entry) === root ? old[0] : entry)
20
+ } else {
21
+ const candidates = config.plugins.filter(oldEntry)
22
+ if (config.plugins.some(entry => source(entry) === root)) { console.log("Local plugin is already configured."); process.exit(0) }
23
+ if (candidates.length !== 1) throw new Error("Expected exactly one existing Plannotator TUI entry; no changes made")
24
+ const backupDir = path.join(root, ".local/migration")
25
+ await mkdir(backupDir, { recursive: true, mode: 0o700 })
26
+ backup = path.join(backupDir, `cli-before-${Date.now()}.json`)
27
+ await writeFile(backup, bytes, { mode: 0o600, flag: "wx" })
28
+ config.plugins = config.plugins.map(entry => oldEntry(entry) ? typeof entry === "string" ? root : { ...entry, package: root } : entry)
29
+ }
30
+ if (await readFile(filename, "utf8") !== bytes) throw new Error("CLI configuration changed concurrently; retry")
31
+ const output = `${JSON.stringify(config, null, 2)}\n`, temporary = `${filename}.annotate-${process.pid}`
32
+ await writeFile(temporary, output, { mode: (await stat(filename)).mode & 0o777, flag: "wx" })
33
+ await rename(temporary, filename)
34
+ console.log(JSON.stringify({ configured: root, config: filename, backup, installedSha256: createHash("sha256").update(output).digest("hex"), next: "Reopen the OpenCode terminal client. Do not restart the shared service." }, null, 2))
@@ -0,0 +1,31 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readFile, writeFile } from "node:fs/promises"
3
+ import { fileURLToPath } from "node:url"
4
+ import { safeUrl, validateReview } from "../browser/protocol.mjs"
5
+
6
+ export function migratePageContext(storage) {
7
+ const result = {}
8
+ for (const [key, page] of Object.entries(storage)) {
9
+ if (!key.startsWith("page:")) continue
10
+ if (!page?.page?.url || !Array.isArray(page.notes)) throw new Error(`Unrecognized old draft: ${key}`)
11
+ const source = { url: safeUrl(page.page.url), title: page.page.title || "Page" }
12
+ const hash = createHash("sha256").update(source.url).digest("hex")
13
+ for (const old of page.notes) {
14
+ const note = { id: old.id, revision: 1, kind: old.kind, comment: old.body,
15
+ createdAt: old.createdAt, updatedAt: old.updatedAt || old.createdAt,
16
+ anchor: { ...old.target, element: old.target.label || old.target.role || old.target.type,
17
+ styles: old.target.styles || "", accessibility: old.target.accessibility || "", fullPath: old.target.selector }, page: source }
18
+ validateReview({ version: 1, page: source, annotations: [note] })
19
+ const destination = `note:${hash}:${note.id}`
20
+ if (result[destination]) throw new Error("Duplicate note during migration")
21
+ result[destination] = note
22
+ }
23
+ }
24
+ return result
25
+ }
26
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
27
+ if (!process.argv[2] || !process.argv[3]) throw new Error("Usage: migrate-page-context.mjs input.json output.json")
28
+ const records = migratePageContext(JSON.parse(await readFile(process.argv[2], "utf8")))
29
+ await writeFile(process.argv[3], JSON.stringify(records, null, 2), { mode: 0o600, flag: "wx" })
30
+ console.log(`Prepared ${Object.keys(records).length} notes for import; source backup unchanged.`)
31
+ }
package/targets.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import { stat } from "node:fs/promises"
2
+ import os from "node:os"
3
+ import path from "node:path"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+
6
+ const unquote = input => {
7
+ const value = input.trim()
8
+ return ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value
9
+ }
10
+ export const wantsBrowser = input => /^web(?: off)?$/.test(unquote(input)) || /\.html?$/i.test(unquote(input)) || /^[a-z][a-z0-9+.-]*:/i.test(unquote(input))
11
+
12
+ export async function browserTarget(input, directory) {
13
+ let value = unquote(input)
14
+ if (value === "web") return { type: "browser" }
15
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
16
+ const url = new URL(value)
17
+ if (["http:", "https:"].includes(url.protocol)) {
18
+ if (url.username || url.password) throw new Error("Remove credentials from the page URL.")
19
+ if (/^chrome(webstore)?\.google\.com$/.test(url.hostname) || /\.pdf$/i.test(url.pathname)) throw new Error("This page cannot be annotated.")
20
+ return { type: "browser", url: url.href }
21
+ }
22
+ if (url.protocol !== "file:") throw new Error("Use an http, https, or local HTML target.")
23
+ value = fileURLToPath(url)
24
+ if (!/\.html?$/i.test(value)) throw new Error("File URLs must point to an HTML document.")
25
+ }
26
+ if (!/\.html?$/i.test(value)) return null
27
+ if (value.startsWith("~/")) value = path.join(os.homedir(), value.slice(2))
28
+ if (!path.isAbsolute(value) && !directory) throw new Error("Can't determine the session working directory.")
29
+ const file = path.resolve(directory || ".", value)
30
+ if (!(await stat(file)).isFile()) throw new Error("The HTML target is not a file.")
31
+ return { type: "browser", url: pathToFileURL(file).href }
32
+ }
package/tui.ts CHANGED
@@ -2,7 +2,7 @@ import { Plugin } from "@opencode/plugin/tui"
2
2
  import { createReviewCommand } from "./review.mjs"
3
3
 
4
4
  export default Plugin.define({
5
- id: "opencode-plannotator-tui",
5
+ id: "opencode-annotate",
6
6
  setup(context) {
7
7
  const review = createReviewCommand(context)
8
8
  // 2.0.22's keymap.layer needs the mounted provider; newer hosts also allow setup().
@@ -12,18 +12,18 @@ export default Plugin.define({
12
12
  context.keymap.layer(() => ({
13
13
  mode: "global",
14
14
  commands: [{
15
- id: "opencode-plannotator-tui.annotate",
16
- title: "Annotate reply or document (TUI)",
17
- description: "Annotate a reply (1 = latest, 2 = previous) or a file; press q, then confirm to send",
18
- group: "Plannotator",
15
+ id: "opencode-annotate.annotate",
16
+ title: "Annotate reply, document, or webpage",
17
+ description: "Text defaults to TUI; explicit HTML, URL or web pairs browser review",
18
+ group: "Annotate",
19
19
  palette: true,
20
20
  slash: { name: "annotate", arguments: true },
21
21
  run: review.run,
22
22
  }, {
23
- id: "opencode-plannotator-tui.annotate-last",
23
+ id: "opencode-annotate.annotate-last",
24
24
  title: "Annotate last reply (TUI)",
25
25
  description: "Annotate the latest completed reply; press q, then confirm to send",
26
- group: "Plannotator",
26
+ group: "Annotate",
27
27
  palette: true,
28
28
  // Keep slash submission with a trailing space local to the command.
29
29
  // Any supplied argument is deliberately ignored: this always means latest.
package/web-review.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { execFile } from "node:child_process"
2
+ import { promisify } from "node:util"
3
+ import { browserTarget } from "./targets.mjs"
4
+ import { createReceiver } from "./bridge/receiver.mjs"
5
+
6
+ const execute = promisify(execFile)
7
+ export function createWebReview(context, options = {}) {
8
+ const receivers = new Map()
9
+ let disposed = false
10
+ return {
11
+ async run(input, sessionID) {
12
+ if (input.trim() === "web off") {
13
+ await receivers.get(sessionID)?.close(); receivers.delete(sessionID)
14
+ context.ui.toast.show({ title: "Annotate web", message: "Browser pairing revoked for this session." })
15
+ return true
16
+ }
17
+ const session = await context.client.session.get({ sessionID })
18
+ const target = await browserTarget(input, session.location?.directory)
19
+ if (!target) return false
20
+ if (disposed) throw new Error("Annotation plugin is closed")
21
+ if (!receivers.has(sessionID)) {
22
+ const receiver = await createReceiver({ sessionID, title: session.title || sessionID, directory: session.location?.directory || "", client: context.client, ...options.receiver })
23
+ if (disposed) { await receiver.close(); return true }
24
+ receivers.set(sessionID, receiver)
25
+ }
26
+ if (target.url) {
27
+ if (options.open) await options.open(target.url)
28
+ else if (process.platform === "darwin") await execute("open", ["-a", "Google Chrome", target.url])
29
+ else throw new Error("Browser review currently requires macOS")
30
+ }
31
+ context.ui.toast.show({ title: "Annotate web", message: `Ready for browser pairing: ${session.title || sessionID}. Open the extension and select this session.`, duration: 10000 })
32
+ return true
33
+ },
34
+ async dispose() { disposed = true; await Promise.all([...receivers.values()].map(receiver => receiver.close())); receivers.clear() },
35
+ }
36
+ }