@different-ai/opencode-browser 4.4.0 → 4.5.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.
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: github-release
3
+ description: Create a GitHub release after pnpm publish with clear feature updates.
4
+ ---
5
+
6
+ Use this skill after running `pnpm publish`.
7
+
8
+ 1. Read the current version from `package.json` and set the tag name to `v<version>`.
9
+ 2. Summarize changes from git commits since the last tag, or if no tags exist, from the current conversation and latest commits.
10
+ 3. Draft release notes with a "## Features" section using those changes, and confirm with the user.
11
+ 4. Create the GitHub release with `gh release create v<version> --title "v<version>" --notes "<notes>"`.
12
+ 5. Confirm the release appears on GitHub and share the URL.
package/README.md CHANGED
@@ -111,16 +111,22 @@ export OPENCODE_BROWSER_AGENT_PORT=9833
111
111
 
112
112
  ## Per-tab ownership
113
113
 
114
- - First time a session touches a tab, the broker **auto-claims** it for that session.
115
- - Other sessions attempting to use the same tab will get an error.
116
- - Use `browser_status` to inspect claims if needed.
114
+ - Each session owns its own tabs; tabs are never shared between sessions.
115
+ - If a session has no tab yet, the broker auto-creates a background tab on first tool use.
116
+ - `browser_open_tab` always creates and claims a new tab for the session.
117
+ - Claims expire after inactivity (`OPENCODE_BROWSER_CLAIM_TTL_MS`, default 5 minutes).
118
+ - Use `browser_status` or `browser_list_claims` for debugging.
117
119
 
118
120
  ## Available tools
119
121
 
120
122
  Core primitives:
121
123
  - `browser_status`
122
124
  - `browser_get_tabs`
125
+ - `browser_list_claims`
126
+ - `browser_claim_tab`
127
+ - `browser_release_tab`
123
128
  - `browser_open_tab`
129
+ - `browser_close_tab`
124
130
  - `browser_navigate`
125
131
  - `browser_query` (modes: `text`, `value`, `list`, `exists`, `page_text`; optional `timeoutMs`/`pollMs`)
126
132
  - `browser_click` (optional `timeoutMs`/`pollMs`)
@@ -144,7 +150,7 @@ Diagnostics:
144
150
 
145
151
  ## Roadmap
146
152
 
147
- - [ ] Add tab management tools (`browser_set_active_tab`, `browser_close_tab`)
153
+ - [ ] Add tab management tools (`browser_set_active_tab`)
148
154
  - [ ] Add navigation helpers (`browser_back`, `browser_forward`, `browser_reload`)
149
155
  - [ ] Add keyboard input tool (`browser_key`)
150
156
  - [ ] Add download support (`browser_download`, `browser_list_downloads`)
@@ -157,8 +163,9 @@ Diagnostics:
157
163
  - If you loaded a custom extension ID, rerun with `--extension-id <id>`
158
164
 
159
165
  **Tab ownership errors**
160
- - Use `browser_status` to see current claims
161
- - Close the other OpenCode session to release ownership
166
+ - Errors usually mean you passed a `tabId` owned by another session
167
+ - Use `browser_open_tab` to create a tab for your session (or omit `tabId` to use your default)
168
+ - Use `browser_status` or `browser_list_claims` for debugging
162
169
 
163
170
  ## Uninstall
164
171
 
package/bin/broker.cjs CHANGED
@@ -11,6 +11,20 @@ const SOCKET_PATH = path.join(BASE_DIR, "broker.sock");
11
11
 
12
12
  fs.mkdirSync(BASE_DIR, { recursive: true });
13
13
 
14
+ const DEFAULT_LEASE_TTL_MS = 5 * 60 * 1000;
15
+ const LEASE_TTL_MS = (() => {
16
+ const raw = process.env.OPENCODE_BROWSER_CLAIM_TTL_MS;
17
+ const value = Number(raw);
18
+ if (Number.isFinite(value) && value >= 0) return value;
19
+ return DEFAULT_LEASE_TTL_MS;
20
+ })();
21
+ const LEASE_SWEEP_MS =
22
+ LEASE_TTL_MS > 0 ? Math.min(Math.max(10000, Math.floor(LEASE_TTL_MS / 2)), 60000) : 0;
23
+
24
+ function nowMs() {
25
+ return Date.now();
26
+ }
27
+
14
28
  function nowIso() {
15
29
  return new Date().toISOString();
16
30
  }
@@ -39,7 +53,7 @@ function writeJsonLine(socket, msg) {
39
53
  }
40
54
 
41
55
  function wantsTab(toolName) {
42
- return !["get_tabs", "get_active_tab"].includes(toolName);
56
+ return !["get_tabs", "get_active_tab", "open_tab"].includes(toolName);
43
57
  }
44
58
 
45
59
  // --- State ---
@@ -49,22 +63,78 @@ const extPending = new Map(); // extId -> { pluginSocket, pluginRequestId, sessi
49
63
 
50
64
  const clients = new Set();
51
65
 
52
- // Tab ownership: tabId -> { sessionId, claimedAt }
66
+ // Tab ownership: tabId -> { sessionId, claimedAt, lastSeenAt }
53
67
  const claims = new Map();
68
+ // Session state: sessionId -> { defaultTabId, lastSeenAt }
69
+ const sessionState = new Map();
54
70
 
55
71
  function listClaims() {
56
72
  const out = [];
57
73
  for (const [tabId, info] of claims.entries()) {
58
- out.push({ tabId, ...info });
74
+ out.push({
75
+ tabId,
76
+ sessionId: info.sessionId,
77
+ claimedAt: info.claimedAt,
78
+ lastSeenAt: new Date(info.lastSeenAt).toISOString(),
79
+ });
59
80
  }
60
81
  out.sort((a, b) => a.tabId - b.tabId);
61
82
  return out;
62
83
  }
63
84
 
85
+ function sessionHasClaims(sessionId) {
86
+ for (const info of claims.values()) {
87
+ if (info.sessionId === sessionId) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function getSessionState(sessionId) {
93
+ if (!sessionId) return null;
94
+ let state = sessionState.get(sessionId);
95
+ if (!state) {
96
+ state = { defaultTabId: null, lastSeenAt: nowMs() };
97
+ sessionState.set(sessionId, state);
98
+ }
99
+ return state;
100
+ }
101
+
102
+ function touchSession(sessionId) {
103
+ const state = getSessionState(sessionId);
104
+ if (!state) return null;
105
+ state.lastSeenAt = nowMs();
106
+ return state;
107
+ }
108
+
109
+ function setDefaultTab(sessionId, tabId) {
110
+ const state = getSessionState(sessionId);
111
+ if (!state) return;
112
+ state.defaultTabId = tabId;
113
+ state.lastSeenAt = nowMs();
114
+ }
115
+
116
+ function clearDefaultTab(sessionId, tabId) {
117
+ const state = sessionState.get(sessionId);
118
+ if (!state) return;
119
+ if (tabId === undefined || state.defaultTabId === tabId) {
120
+ state.defaultTabId = null;
121
+ }
122
+ state.lastSeenAt = nowMs();
123
+ }
124
+
125
+ function releaseClaim(tabId) {
126
+ const info = claims.get(tabId);
127
+ if (!info) return;
128
+ claims.delete(tabId);
129
+ clearDefaultTab(info.sessionId, tabId);
130
+ }
131
+
64
132
  function releaseClaimsForSession(sessionId) {
65
133
  for (const [tabId, info] of claims.entries()) {
66
134
  if (info.sessionId === sessionId) claims.delete(tabId);
67
135
  }
136
+ clearDefaultTab(sessionId);
137
+ sessionState.delete(sessionId);
68
138
  }
69
139
 
70
140
  function checkClaim(tabId, sessionId) {
@@ -75,7 +145,37 @@ function checkClaim(tabId, sessionId) {
75
145
  }
76
146
 
77
147
  function setClaim(tabId, sessionId) {
78
- claims.set(tabId, { sessionId, claimedAt: nowIso() });
148
+ const existing = claims.get(tabId);
149
+ claims.set(tabId, {
150
+ sessionId,
151
+ claimedAt: existing ? existing.claimedAt : nowIso(),
152
+ lastSeenAt: nowMs(),
153
+ });
154
+ }
155
+
156
+ function touchClaim(tabId, sessionId) {
157
+ const existing = claims.get(tabId);
158
+ if (existing && existing.sessionId !== sessionId) return;
159
+ if (existing) {
160
+ existing.lastSeenAt = nowMs();
161
+ } else {
162
+ setClaim(tabId, sessionId);
163
+ }
164
+ }
165
+
166
+ function cleanupStaleClaims() {
167
+ if (!LEASE_TTL_MS) return;
168
+ const now = nowMs();
169
+ for (const [tabId, info] of claims.entries()) {
170
+ if (now - info.lastSeenAt > LEASE_TTL_MS) {
171
+ releaseClaim(tabId);
172
+ }
173
+ }
174
+ for (const [sessionId, state] of sessionState.entries()) {
175
+ if (!sessionHasClaims(sessionId) && now - state.lastSeenAt > LEASE_TTL_MS) {
176
+ sessionState.delete(sessionId);
177
+ }
178
+ }
79
179
  }
80
180
 
81
181
  function ensureHost() {
@@ -106,10 +206,13 @@ function callExtension(tool, args, sessionId) {
106
206
  });
107
207
  }
108
208
 
109
- async function resolveActiveTab(sessionId) {
110
- const res = await callExtension("get_active_tab", {}, sessionId);
209
+ async function ensureSessionTab(sessionId) {
210
+ if (!sessionId) throw new Error("Missing sessionId for tab creation");
211
+ const res = await callExtension("open_tab", { active: false }, sessionId);
111
212
  const tabId = res && typeof res.tabId === "number" ? res.tabId : undefined;
112
- if (!tabId) throw new Error("Could not determine active tab");
213
+ if (!tabId) throw new Error("Failed to create a new tab for this session");
214
+ touchClaim(tabId, sessionId);
215
+ setDefaultTab(sessionId, tabId);
113
216
  return tabId;
114
217
  }
115
218
 
@@ -117,25 +220,45 @@ async function handleTool(pluginSocket, req) {
117
220
  const { tool, args = {}, sessionId } = req;
118
221
  if (!tool) throw new Error("Missing tool");
119
222
 
223
+ if (sessionId) touchSession(sessionId);
224
+
120
225
  let tabId = args.tabId;
226
+ const toolArgs = { ...args };
227
+
228
+ const isCloseTool = tool === "close_tab";
121
229
 
122
230
  if (wantsTab(tool)) {
123
231
  if (typeof tabId !== "number") {
124
- tabId = await resolveActiveTab(sessionId);
232
+ const state = getSessionState(sessionId);
233
+ const defaultTabId = state && Number.isFinite(state.defaultTabId) ? state.defaultTabId : null;
234
+ if (Number.isFinite(defaultTabId)) {
235
+ tabId = defaultTabId;
236
+ } else if (!isCloseTool) {
237
+ tabId = await ensureSessionTab(sessionId);
238
+ } else {
239
+ throw new Error("No tab owned by this session. Open a new tab first.");
240
+ }
125
241
  }
126
242
 
127
243
  const claimCheck = checkClaim(tabId, sessionId);
128
244
  if (!claimCheck.ok) throw new Error(claimCheck.error);
129
245
  }
130
246
 
131
- const res = await callExtension(tool, { ...args, tabId }, sessionId);
247
+ const res = await callExtension(tool, { ...toolArgs, tabId }, sessionId);
132
248
 
133
249
  const usedTabId =
134
250
  res && typeof res.tabId === "number" ? res.tabId : typeof tabId === "number" ? tabId : undefined;
135
251
  if (typeof usedTabId === "number") {
136
- // Auto-claim on first touch
137
- const existing = claims.get(usedTabId);
138
- if (!existing) setClaim(usedTabId, sessionId);
252
+ if (isCloseTool) {
253
+ if (claims.has(usedTabId)) {
254
+ releaseClaim(usedTabId);
255
+ } else {
256
+ clearDefaultTab(sessionId, usedTabId);
257
+ }
258
+ } else {
259
+ touchClaim(usedTabId, sessionId);
260
+ setDefaultTab(sessionId, usedTabId);
261
+ }
139
262
  }
140
263
 
141
264
  return res;
@@ -145,6 +268,7 @@ function handleClientMessage(socket, client, msg) {
145
268
  if (msg && msg.type === "hello") {
146
269
  client.role = msg.role || "unknown";
147
270
  client.sessionId = msg.sessionId;
271
+ if (client.sessionId) touchSession(client.sessionId);
148
272
  if (client.role === "native-host") {
149
273
  host = { socket };
150
274
  // allow host to see current state
@@ -174,6 +298,7 @@ function handleClientMessage(socket, client, msg) {
174
298
  if (msg && msg.type === "request" && typeof msg.id === "number") {
175
299
  const requestId = msg.id;
176
300
  const sessionId = msg.sessionId || client.sessionId;
301
+ if (sessionId) touchSession(sessionId);
177
302
 
178
303
  const replyOk = (data) => writeJsonLine(socket, { type: "response", id: requestId, ok: true, data });
179
304
  const replyErr = (err) =>
@@ -182,7 +307,21 @@ function handleClientMessage(socket, client, msg) {
182
307
  (async () => {
183
308
  try {
184
309
  if (msg.op === "status") {
185
- replyOk({ broker: true, hostConnected: !!host && !!host.socket && !host.socket.destroyed, claims: listClaims() });
310
+ const state = sessionId ? sessionState.get(sessionId) : null;
311
+ const sessionInfo = state
312
+ ? {
313
+ sessionId,
314
+ defaultTabId: state.defaultTabId,
315
+ lastSeenAt: new Date(state.lastSeenAt).toISOString(),
316
+ }
317
+ : null;
318
+ replyOk({
319
+ broker: true,
320
+ hostConnected: !!host && !!host.socket && !host.socket.destroyed,
321
+ claims: listClaims(),
322
+ leaseTtlMs: LEASE_TTL_MS,
323
+ session: sessionInfo,
324
+ });
186
325
  return;
187
326
  }
188
327
 
@@ -199,7 +338,11 @@ function handleClientMessage(socket, client, msg) {
199
338
  if (existing && existing.sessionId !== sessionId && !force) {
200
339
  throw new Error(`Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})`);
201
340
  }
341
+ if (existing && existing.sessionId !== sessionId && force) {
342
+ clearDefaultTab(existing.sessionId, tabId);
343
+ }
202
344
  setClaim(tabId, sessionId);
345
+ setDefaultTab(sessionId, tabId);
203
346
  replyOk({ ok: true, tabId, sessionId });
204
347
  return;
205
348
  }
@@ -215,7 +358,7 @@ function handleClientMessage(socket, client, msg) {
215
358
  if (existing.sessionId !== sessionId) {
216
359
  throw new Error(`Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})`);
217
360
  }
218
- claims.delete(tabId);
361
+ releaseClaim(tabId);
219
362
  replyOk({ ok: true, tabId, released: true });
220
363
  return;
221
364
  }
@@ -287,4 +430,9 @@ function start() {
287
430
  });
288
431
  }
289
432
 
433
+ if (LEASE_TTL_MS > 0 && LEASE_SWEEP_MS > 0) {
434
+ const timer = setInterval(cleanupStaleClaims, LEASE_SWEEP_MS);
435
+ if (typeof timer.unref === "function") timer.unref();
436
+ }
437
+
290
438
  start();
package/dist/plugin.js CHANGED
@@ -12864,6 +12864,14 @@ function createAgentBackend(sessionId) {
12864
12864
  }
12865
12865
  return { content: { tabId: created.index, url: args.url, active: active !== false } };
12866
12866
  }
12867
+ case "close_tab": {
12868
+ const payload = {};
12869
+ if (Number.isFinite(args.tabId))
12870
+ payload.index = args.tabId;
12871
+ const result = await agentCommand("tab_close", payload);
12872
+ const closed = Number.isFinite(result?.closed) ? result.closed : args.tabId;
12873
+ return { content: { tabId: closed, remaining: result?.remaining } };
12874
+ }
12867
12875
  case "navigate": {
12868
12876
  return await withTab(args.tabId, async () => {
12869
12877
  if (!args.url)
@@ -13140,6 +13148,12 @@ async function brokerRequest(op, payload) {
13140
13148
  }, 60000);
13141
13149
  });
13142
13150
  }
13151
+ async function brokerOnlyRequest(op, payload) {
13152
+ if (USE_AGENT_BACKEND) {
13153
+ throw new Error("Tab claims are not supported with agent-browser backend");
13154
+ }
13155
+ return await brokerRequest(op, payload);
13156
+ }
13143
13157
  function toolResultText(data, fallback) {
13144
13158
  if (typeof data?.content === "string")
13145
13159
  return data.content;
@@ -13222,6 +13236,35 @@ var plugin = async (ctx) => {
13222
13236
  return toolResultText(data, "ok");
13223
13237
  }
13224
13238
  }),
13239
+ browser_list_claims: tool({
13240
+ description: "List tab ownership claims",
13241
+ args: {},
13242
+ async execute(args, ctx2) {
13243
+ const data = await brokerOnlyRequest("list_claims", {});
13244
+ return JSON.stringify(data);
13245
+ }
13246
+ }),
13247
+ browser_claim_tab: tool({
13248
+ description: "Claim a browser tab for this session",
13249
+ args: {
13250
+ tabId: schema.number(),
13251
+ force: schema.boolean().optional()
13252
+ },
13253
+ async execute({ tabId, force }, ctx2) {
13254
+ const data = await brokerOnlyRequest("claim_tab", { tabId, force });
13255
+ return JSON.stringify(data);
13256
+ }
13257
+ }),
13258
+ browser_release_tab: tool({
13259
+ description: "Release a claimed browser tab",
13260
+ args: {
13261
+ tabId: schema.number()
13262
+ },
13263
+ async execute({ tabId }, ctx2) {
13264
+ const data = await brokerOnlyRequest("release_tab", { tabId });
13265
+ return JSON.stringify(data);
13266
+ }
13267
+ }),
13225
13268
  browser_open_tab: tool({
13226
13269
  description: "Open a new browser tab",
13227
13270
  args: {
@@ -13233,6 +13276,16 @@ var plugin = async (ctx) => {
13233
13276
  return toolResultText(data, "Opened new tab");
13234
13277
  }
13235
13278
  }),
13279
+ browser_close_tab: tool({
13280
+ description: "Close a browser tab owned by this session",
13281
+ args: {
13282
+ tabId: schema.number().optional()
13283
+ },
13284
+ async execute({ tabId }, ctx2) {
13285
+ const data = await toolRequest("close_tab", { tabId });
13286
+ return toolResultText(data, "Closed tab");
13287
+ }
13288
+ }),
13236
13289
  browser_navigate: tool({
13237
13290
  description: "Navigate to a URL in the browser",
13238
13291
  args: {
@@ -101,6 +101,7 @@ async function executeTool(toolName, args) {
101
101
  get_active_tab: toolGetActiveTab,
102
102
  get_tabs: toolGetTabs,
103
103
  open_tab: toolOpenTab,
104
+ close_tab: toolCloseTab,
104
105
  navigate: toolNavigate,
105
106
  click: toolClick,
106
107
  type: toolType,
@@ -709,6 +710,12 @@ async function toolOpenTab({ url, active = true }) {
709
710
  return { tabId: tab.id, content: { tabId: tab.id, url: tab.url, active: tab.active } }
710
711
  }
711
712
 
713
+ async function toolCloseTab({ tabId }) {
714
+ if (!Number.isFinite(tabId)) throw new Error("tabId is required")
715
+ await chrome.tabs.remove(tabId)
716
+ return { tabId, content: { tabId, closed: true } }
717
+ }
718
+
712
719
  async function toolNavigate({ url, tabId }) {
713
720
  if (!url) throw new Error("URL is required")
714
721
  const tab = await getTabById(tabId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@different-ai/opencode-browser",
3
- "version": "4.4.0",
3
+ "version": "4.5.1",
4
4
  "description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,13 +18,6 @@
18
18
  "extension",
19
19
  "README.md"
20
20
  ],
21
- "scripts": {
22
- "build": "bun build src/plugin.ts --target=node --outfile=dist/plugin.js",
23
- "install": "node bin/cli.js install",
24
- "uninstall": "node bin/cli.js uninstall",
25
- "status": "node bin/cli.js status",
26
- "tool-test": "bun bin/tool-test.ts"
27
- },
28
21
  "keywords": [
29
22
  "opencode",
30
23
  "browser",
@@ -52,5 +45,12 @@
52
45
  "devDependencies": {
53
46
  "@opencode-ai/plugin": "*",
54
47
  "bun-types": "*"
48
+ },
49
+ "scripts": {
50
+ "build": "bun build src/plugin.ts --target=node --outfile=dist/plugin.js",
51
+ "install": "node bin/cli.js install",
52
+ "uninstall": "node bin/cli.js uninstall",
53
+ "status": "node bin/cli.js status",
54
+ "tool-test": "bun bin/tool-test.ts"
55
55
  }
56
- }
56
+ }