@different-ai/opencode-browser 4.4.0 → 4.5.0

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
@@ -112,14 +112,19 @@ export OPENCODE_BROWSER_AGENT_PORT=9833
112
112
  ## Per-tab ownership
113
113
 
114
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.
115
+ - Each session tracks a default tab; tools without `tabId` route to it.
116
+ - `browser_open_tab` always works; if another session owns the active tab, the new tab opens in the background.
117
+ - Claims expire after inactivity (`OPENCODE_BROWSER_CLAIM_TTL_MS`, default 5 minutes).
118
+ - Use `browser_status` or `browser_list_claims` to inspect claims if needed.
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`
124
129
  - `browser_navigate`
125
130
  - `browser_query` (modes: `text`, `value`, `list`, `exists`, `page_text`; optional `timeoutMs`/`pollMs`)
@@ -157,8 +162,8 @@ Diagnostics:
157
162
  - If you loaded a custom extension ID, rerun with `--extension-id <id>`
158
163
 
159
164
  **Tab ownership errors**
160
- - Use `browser_status` to see current claims
161
- - Close the other OpenCode session to release ownership
165
+ - Use `browser_status` or `browser_list_claims` to see current claims
166
+ - Use `browser_release_tab` or close the other OpenCode session to release ownership
162
167
 
163
168
  ## Uninstall
164
169
 
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() {
@@ -117,25 +217,47 @@ async function handleTool(pluginSocket, req) {
117
217
  const { tool, args = {}, sessionId } = req;
118
218
  if (!tool) throw new Error("Missing tool");
119
219
 
220
+ if (sessionId) touchSession(sessionId);
221
+
120
222
  let tabId = args.tabId;
223
+ const toolArgs = { ...args };
224
+
225
+ if (tool === "open_tab" && toolArgs.active !== false) {
226
+ const activeTabId = await resolveActiveTab(sessionId);
227
+ const claimCheck = checkClaim(activeTabId, sessionId);
228
+ if (!claimCheck.ok) {
229
+ toolArgs.active = false;
230
+ }
231
+ }
121
232
 
122
233
  if (wantsTab(tool)) {
123
234
  if (typeof tabId !== "number") {
124
- tabId = await resolveActiveTab(sessionId);
235
+ const state = getSessionState(sessionId);
236
+ const defaultTabId = state && Number.isFinite(state.defaultTabId) ? state.defaultTabId : null;
237
+ if (Number.isFinite(defaultTabId)) {
238
+ tabId = defaultTabId;
239
+ } else {
240
+ const activeTabId = await resolveActiveTab(sessionId);
241
+ const claimCheck = checkClaim(activeTabId, sessionId);
242
+ if (!claimCheck.ok) {
243
+ throw new Error(`${claimCheck.error}. No default tab for session; open a new tab or claim one.`);
244
+ }
245
+ tabId = activeTabId;
246
+ setDefaultTab(sessionId, tabId);
247
+ }
125
248
  }
126
249
 
127
250
  const claimCheck = checkClaim(tabId, sessionId);
128
251
  if (!claimCheck.ok) throw new Error(claimCheck.error);
129
252
  }
130
253
 
131
- const res = await callExtension(tool, { ...args, tabId }, sessionId);
254
+ const res = await callExtension(tool, { ...toolArgs, tabId }, sessionId);
132
255
 
133
256
  const usedTabId =
134
257
  res && typeof res.tabId === "number" ? res.tabId : typeof tabId === "number" ? tabId : undefined;
135
258
  if (typeof usedTabId === "number") {
136
- // Auto-claim on first touch
137
- const existing = claims.get(usedTabId);
138
- if (!existing) setClaim(usedTabId, sessionId);
259
+ touchClaim(usedTabId, sessionId);
260
+ setDefaultTab(sessionId, usedTabId);
139
261
  }
140
262
 
141
263
  return res;
@@ -145,6 +267,7 @@ function handleClientMessage(socket, client, msg) {
145
267
  if (msg && msg.type === "hello") {
146
268
  client.role = msg.role || "unknown";
147
269
  client.sessionId = msg.sessionId;
270
+ if (client.sessionId) touchSession(client.sessionId);
148
271
  if (client.role === "native-host") {
149
272
  host = { socket };
150
273
  // allow host to see current state
@@ -174,6 +297,7 @@ function handleClientMessage(socket, client, msg) {
174
297
  if (msg && msg.type === "request" && typeof msg.id === "number") {
175
298
  const requestId = msg.id;
176
299
  const sessionId = msg.sessionId || client.sessionId;
300
+ if (sessionId) touchSession(sessionId);
177
301
 
178
302
  const replyOk = (data) => writeJsonLine(socket, { type: "response", id: requestId, ok: true, data });
179
303
  const replyErr = (err) =>
@@ -182,7 +306,21 @@ function handleClientMessage(socket, client, msg) {
182
306
  (async () => {
183
307
  try {
184
308
  if (msg.op === "status") {
185
- replyOk({ broker: true, hostConnected: !!host && !!host.socket && !host.socket.destroyed, claims: listClaims() });
309
+ const state = sessionId ? sessionState.get(sessionId) : null;
310
+ const sessionInfo = state
311
+ ? {
312
+ sessionId,
313
+ defaultTabId: state.defaultTabId,
314
+ lastSeenAt: new Date(state.lastSeenAt).toISOString(),
315
+ }
316
+ : null;
317
+ replyOk({
318
+ broker: true,
319
+ hostConnected: !!host && !!host.socket && !host.socket.destroyed,
320
+ claims: listClaims(),
321
+ leaseTtlMs: LEASE_TTL_MS,
322
+ session: sessionInfo,
323
+ });
186
324
  return;
187
325
  }
188
326
 
@@ -199,7 +337,11 @@ function handleClientMessage(socket, client, msg) {
199
337
  if (existing && existing.sessionId !== sessionId && !force) {
200
338
  throw new Error(`Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})`);
201
339
  }
340
+ if (existing && existing.sessionId !== sessionId && force) {
341
+ clearDefaultTab(existing.sessionId, tabId);
342
+ }
202
343
  setClaim(tabId, sessionId);
344
+ setDefaultTab(sessionId, tabId);
203
345
  replyOk({ ok: true, tabId, sessionId });
204
346
  return;
205
347
  }
@@ -215,7 +357,7 @@ function handleClientMessage(socket, client, msg) {
215
357
  if (existing.sessionId !== sessionId) {
216
358
  throw new Error(`Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})`);
217
359
  }
218
- claims.delete(tabId);
360
+ releaseClaim(tabId);
219
361
  replyOk({ ok: true, tabId, released: true });
220
362
  return;
221
363
  }
@@ -287,4 +429,9 @@ function start() {
287
429
  });
288
430
  }
289
431
 
432
+ if (LEASE_TTL_MS > 0 && LEASE_SWEEP_MS > 0) {
433
+ const timer = setInterval(cleanupStaleClaims, LEASE_SWEEP_MS);
434
+ if (typeof timer.unref === "function") timer.unref();
435
+ }
436
+
290
437
  start();
package/dist/plugin.js CHANGED
@@ -13140,6 +13140,12 @@ async function brokerRequest(op, payload) {
13140
13140
  }, 60000);
13141
13141
  });
13142
13142
  }
13143
+ async function brokerOnlyRequest(op, payload) {
13144
+ if (USE_AGENT_BACKEND) {
13145
+ throw new Error("Tab claims are not supported with agent-browser backend");
13146
+ }
13147
+ return await brokerRequest(op, payload);
13148
+ }
13143
13149
  function toolResultText(data, fallback) {
13144
13150
  if (typeof data?.content === "string")
13145
13151
  return data.content;
@@ -13222,6 +13228,35 @@ var plugin = async (ctx) => {
13222
13228
  return toolResultText(data, "ok");
13223
13229
  }
13224
13230
  }),
13231
+ browser_list_claims: tool({
13232
+ description: "List tab ownership claims",
13233
+ args: {},
13234
+ async execute(args, ctx2) {
13235
+ const data = await brokerOnlyRequest("list_claims", {});
13236
+ return JSON.stringify(data);
13237
+ }
13238
+ }),
13239
+ browser_claim_tab: tool({
13240
+ description: "Claim a browser tab for this session",
13241
+ args: {
13242
+ tabId: schema.number(),
13243
+ force: schema.boolean().optional()
13244
+ },
13245
+ async execute({ tabId, force }, ctx2) {
13246
+ const data = await brokerOnlyRequest("claim_tab", { tabId, force });
13247
+ return JSON.stringify(data);
13248
+ }
13249
+ }),
13250
+ browser_release_tab: tool({
13251
+ description: "Release a claimed browser tab",
13252
+ args: {
13253
+ tabId: schema.number()
13254
+ },
13255
+ async execute({ tabId }, ctx2) {
13256
+ const data = await brokerOnlyRequest("release_tab", { tabId });
13257
+ return JSON.stringify(data);
13258
+ }
13259
+ }),
13225
13260
  browser_open_tab: tool({
13226
13261
  description: "Open a new browser tab",
13227
13262
  args: {
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.0",
4
4
  "description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,8 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "build": "bun build src/plugin.ts --target=node --outfile=dist/plugin.js",
23
+ "prepublishOnly": "bun run build",
24
+ "publish": "node -e \"const argv=(() => { try { return JSON.parse(process.env.npm_config_argv || '{}').original || []; } catch { return []; } })(); if (argv.length === 1 && argv[0] === 'publish') process.exit(0); require('child_process').execSync('npm publish --access public', { stdio: 'inherit' });\"",
23
25
  "install": "node bin/cli.js install",
24
26
  "uninstall": "node bin/cli.js uninstall",
25
27
  "status": "node bin/cli.js status",