@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 +9 -4
- package/bin/broker.cjs +158 -11
- package/dist/plugin.js +35 -0
- package/package.json +3 -1
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
|
-
-
|
|
116
|
-
-
|
|
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
|
-
-
|
|
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({
|
|
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.
|
|
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
|
-
|
|
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, { ...
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|