opendevbrowser 0.0.12 → 0.0.15

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -28
  3. package/dist/chunk-JVBMT2O5.js +7173 -0
  4. package/dist/chunk-JVBMT2O5.js.map +1 -0
  5. package/dist/cli/index.js +2486 -589
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.js +1057 -194
  8. package/dist/index.js.map +1 -1
  9. package/dist/opendevbrowser.js +1057 -194
  10. package/dist/opendevbrowser.js.map +1 -1
  11. package/extension/dist/annotate-content.css +237 -0
  12. package/extension/dist/annotate-content.js +934 -0
  13. package/extension/dist/background.js +1194 -32
  14. package/extension/dist/logging.js +50 -0
  15. package/extension/dist/ops/dom-bridge.js +355 -0
  16. package/extension/dist/ops/ops-runtime.js +1249 -0
  17. package/extension/dist/ops/ops-session-store.js +189 -0
  18. package/extension/dist/ops/redaction.js +52 -0
  19. package/extension/dist/ops/snapshot-builder.js +4 -0
  20. package/extension/dist/ops/snapshot-shared.js +220 -0
  21. package/extension/dist/popup.js +370 -25
  22. package/extension/dist/relay-settings.js +1 -0
  23. package/extension/dist/services/CDPRouter.js +501 -103
  24. package/extension/dist/services/ConnectionManager.js +464 -57
  25. package/extension/dist/services/NativePortManager.js +182 -0
  26. package/extension/dist/services/RelayClient.js +227 -26
  27. package/extension/dist/services/TabManager.js +81 -0
  28. package/extension/dist/services/TargetSessionMap.js +146 -0
  29. package/extension/dist/services/cdp-router-commands.js +203 -0
  30. package/extension/dist/services/url-restrictions.js +41 -0
  31. package/extension/dist/types.js +3 -1
  32. package/extension/manifest.json +17 -3
  33. package/extension/popup.html +144 -0
  34. package/package.json +2 -2
  35. package/skills/AGENTS.md +34 -62
  36. package/skills/data-extraction/SKILL.md +95 -103
  37. package/skills/form-testing/SKILL.md +75 -82
  38. package/skills/login-automation/SKILL.md +76 -66
  39. package/skills/opendevbrowser-best-practices/SKILL.md +90 -49
  40. package/skills/opendevbrowser-continuity-ledger/SKILL.md +57 -23
  41. package/dist/chunk-WTFSMBVH.js +0 -2815
  42. package/dist/chunk-WTFSMBVH.js.map +0 -1
  43. package/extension/dist/popup.jsx +0 -150
package/dist/index.js CHANGED
@@ -1,7 +1,211 @@
1
1
  import {
2
+ DaemonClient,
3
+ ScriptRunner,
4
+ buildAnnotateResult,
2
5
  createOpenDevBrowserCore,
3
- extractExtension
4
- } from "./chunk-WTFSMBVH.js";
6
+ extractExtension,
7
+ fetchDaemonStatusFromMetadata,
8
+ startDaemon
9
+ } from "./chunk-JVBMT2O5.js";
10
+
11
+ // src/cli/remote-manager.ts
12
+ function isLegacyRelayEndpoint(wsEndpoint) {
13
+ try {
14
+ const url = new URL(wsEndpoint);
15
+ const path = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
16
+ return path === "/cdp";
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+ var RemoteManager = class {
22
+ client;
23
+ constructor(client) {
24
+ this.client = client;
25
+ }
26
+ launch(options) {
27
+ return this.client.call("session.launch", options);
28
+ }
29
+ connect(options) {
30
+ return this.client.call("session.connect", options);
31
+ }
32
+ connectRelay(wsEndpoint) {
33
+ return this.client.call(
34
+ "session.connect",
35
+ isLegacyRelayEndpoint(wsEndpoint) ? { wsEndpoint, extensionLegacy: true } : { wsEndpoint }
36
+ );
37
+ }
38
+ disconnect(sessionId, closeBrowser = false) {
39
+ return this.client.call("session.disconnect", { sessionId, closeBrowser });
40
+ }
41
+ status(sessionId) {
42
+ return this.client.call("session.status", { sessionId });
43
+ }
44
+ goto(sessionId, url, waitUntil = "load", timeoutMs = 3e4) {
45
+ return this.client.call("nav.goto", { sessionId, url, waitUntil, timeoutMs });
46
+ }
47
+ waitForLoad(sessionId, until, timeoutMs = 3e4) {
48
+ return this.client.call("nav.wait", { sessionId, until, timeoutMs });
49
+ }
50
+ waitForRef(sessionId, ref, state = "attached", timeoutMs = 3e4) {
51
+ return this.client.call("nav.wait", { sessionId, ref, state, timeoutMs });
52
+ }
53
+ snapshot(sessionId, mode, maxChars, cursor) {
54
+ return this.client.call("nav.snapshot", { sessionId, mode, maxChars, cursor });
55
+ }
56
+ click(sessionId, ref) {
57
+ return this.client.call("interact.click", { sessionId, ref });
58
+ }
59
+ hover(sessionId, ref) {
60
+ return this.client.call("interact.hover", { sessionId, ref });
61
+ }
62
+ press(sessionId, key, ref) {
63
+ return this.client.call("interact.press", { sessionId, key, ref });
64
+ }
65
+ check(sessionId, ref) {
66
+ return this.client.call("interact.check", { sessionId, ref });
67
+ }
68
+ uncheck(sessionId, ref) {
69
+ return this.client.call("interact.uncheck", { sessionId, ref });
70
+ }
71
+ type(sessionId, ref, text, clear = false, submit = false) {
72
+ return this.client.call("interact.type", { sessionId, ref, text, clear, submit });
73
+ }
74
+ select(sessionId, ref, values) {
75
+ return this.client.call("interact.select", { sessionId, ref, values });
76
+ }
77
+ scroll(sessionId, dy, ref) {
78
+ return this.client.call("interact.scroll", { sessionId, dy, ref });
79
+ }
80
+ scrollIntoView(sessionId, ref) {
81
+ return this.client.call("interact.scrollIntoView", { sessionId, ref });
82
+ }
83
+ domGetHtml(sessionId, ref, maxChars = 8e3) {
84
+ return this.client.call("dom.getHtml", { sessionId, ref, maxChars });
85
+ }
86
+ domGetText(sessionId, ref, maxChars = 8e3) {
87
+ return this.client.call("dom.getText", { sessionId, ref, maxChars });
88
+ }
89
+ domGetAttr(sessionId, ref, name) {
90
+ return this.client.call("dom.getAttr", { sessionId, ref, name });
91
+ }
92
+ domGetValue(sessionId, ref) {
93
+ return this.client.call("dom.getValue", { sessionId, ref });
94
+ }
95
+ domIsVisible(sessionId, ref) {
96
+ return this.client.call("dom.isVisible", { sessionId, ref });
97
+ }
98
+ domIsEnabled(sessionId, ref) {
99
+ return this.client.call("dom.isEnabled", { sessionId, ref });
100
+ }
101
+ domIsChecked(sessionId, ref) {
102
+ return this.client.call("dom.isChecked", { sessionId, ref });
103
+ }
104
+ clonePage(sessionId) {
105
+ return this.client.call("export.clonePage", { sessionId });
106
+ }
107
+ cloneComponent(sessionId, ref) {
108
+ return this.client.call("export.cloneComponent", { sessionId, ref });
109
+ }
110
+ perfMetrics(sessionId) {
111
+ return this.client.call("devtools.perf", { sessionId });
112
+ }
113
+ screenshot(sessionId, path) {
114
+ return this.client.call("page.screenshot", { sessionId, path });
115
+ }
116
+ consolePoll(sessionId, sinceSeq, max = 50) {
117
+ return this.client.call("devtools.consolePoll", { sessionId, sinceSeq, max });
118
+ }
119
+ networkPoll(sessionId, sinceSeq, max = 50) {
120
+ return this.client.call("devtools.networkPoll", { sessionId, sinceSeq, max });
121
+ }
122
+ listTargets(sessionId, includeUrls = false) {
123
+ return this.client.call("targets.list", { sessionId, includeUrls });
124
+ }
125
+ useTarget(sessionId, targetId) {
126
+ return this.client.call("targets.use", { sessionId, targetId });
127
+ }
128
+ newTarget(sessionId, url) {
129
+ return this.client.call("targets.new", { sessionId, url });
130
+ }
131
+ closeTarget(sessionId, targetId) {
132
+ return this.client.call("targets.close", { sessionId, targetId });
133
+ }
134
+ page(sessionId, name, url) {
135
+ return this.client.call("page.open", { sessionId, name, url });
136
+ }
137
+ listPages(sessionId) {
138
+ return this.client.call("page.list", { sessionId });
139
+ }
140
+ closePage(sessionId, name) {
141
+ return this.client.call("page.close", { sessionId, name });
142
+ }
143
+ async withPage(_sessionId, _targetId, _fn) {
144
+ throw new Error("Direct annotate is unavailable via daemon-managed sessions.");
145
+ }
146
+ };
147
+
148
+ // src/cli/remote-relay.ts
149
+ var emptyStatus = {
150
+ running: false,
151
+ extensionConnected: false,
152
+ extensionHandshakeComplete: false,
153
+ cdpConnected: false,
154
+ annotationConnected: false,
155
+ opsConnected: false,
156
+ pairingRequired: false,
157
+ instanceId: "",
158
+ epoch: 0,
159
+ health: {
160
+ ok: false,
161
+ reason: "relay_down",
162
+ extensionConnected: false,
163
+ extensionHandshakeComplete: false,
164
+ cdpConnected: false,
165
+ annotationConnected: false,
166
+ opsConnected: false,
167
+ pairingRequired: false
168
+ }
169
+ };
170
+ var RemoteRelay = class {
171
+ client;
172
+ lastStatus = emptyStatus;
173
+ lastCdpUrl = null;
174
+ lastAnnotationUrl = null;
175
+ lastOpsUrl = null;
176
+ constructor(client) {
177
+ this.client = client;
178
+ }
179
+ async refresh() {
180
+ try {
181
+ const status = await this.client.call("relay.status");
182
+ this.lastStatus = status;
183
+ const cdpUrl = await this.client.call("relay.cdpUrl");
184
+ this.lastCdpUrl = typeof cdpUrl === "string" ? cdpUrl : null;
185
+ const annotationUrl = await this.client.call("relay.annotationUrl");
186
+ this.lastAnnotationUrl = typeof annotationUrl === "string" ? annotationUrl : null;
187
+ const opsUrl = await this.client.call("relay.opsUrl");
188
+ this.lastOpsUrl = typeof opsUrl === "string" ? opsUrl : null;
189
+ } catch {
190
+ this.lastStatus = emptyStatus;
191
+ this.lastCdpUrl = null;
192
+ this.lastAnnotationUrl = null;
193
+ this.lastOpsUrl = null;
194
+ }
195
+ }
196
+ status() {
197
+ return this.lastStatus;
198
+ }
199
+ getCdpUrl() {
200
+ return this.lastCdpUrl;
201
+ }
202
+ getAnnotationUrl() {
203
+ return this.lastAnnotationUrl;
204
+ }
205
+ getOpsUrl() {
206
+ return this.lastOpsUrl;
207
+ }
208
+ };
5
209
 
6
210
  // src/skills/skill-nudge.ts
7
211
  var SKILL_NUDGE_MARKER = "[opendevbrowser:skill-nudge]";
@@ -98,7 +302,7 @@ function serializeError(error) {
98
302
  var z = tool.schema;
99
303
  function createLaunchTool(deps) {
100
304
  return tool({
101
- description: "Launch a managed Chrome session and return a sessionId.",
305
+ description: "Launch a browser session (extension relay first) and return a sessionId.",
102
306
  args: {
103
307
  profile: z.string().optional().describe("Profile name for persistent browsing"),
104
308
  headless: z.boolean().optional().describe("Run Chrome in headless mode"),
@@ -108,102 +312,350 @@ function createLaunchTool(deps) {
108
312
  persistProfile: z.boolean().optional().describe("Persist profile data between sessions"),
109
313
  noExtension: z.boolean().optional().describe("Skip extension relay and launch a new browser"),
110
314
  extensionOnly: z.boolean().optional().describe("Require extension relay or fail"),
315
+ extensionLegacy: z.boolean().optional().describe("Use legacy extension relay (/cdp) instead of ops"),
111
316
  waitForExtension: z.boolean().optional().describe("Wait for extension to connect before launching"),
112
317
  waitTimeoutMs: z.number().int().optional().describe("Timeout for waiting on extension (ms)")
113
318
  },
114
319
  async execute(args) {
115
- try {
116
- let relayStatus = deps.relay?.status();
117
- const relayUrl = deps.relay?.getCdpUrl();
118
- const waitTimeoutMs = args.waitTimeoutMs ?? 3e4;
119
- if (args.waitForExtension && deps.relay) {
120
- const connected = await waitForExtension(deps.relay, waitTimeoutMs);
121
- if (connected) {
122
- relayStatus = deps.relay.status();
320
+ let attemptedRebind = false;
321
+ while (true) {
322
+ try {
323
+ await deps.relay?.refresh?.();
324
+ const config = deps.config.get();
325
+ const extensionLegacy = args.extensionLegacy === true;
326
+ let relayStatus = deps.relay?.status();
327
+ let relayUrl = extensionLegacy ? deps.relay?.getCdpUrl() ?? null : deps.relay?.getOpsUrl?.() ?? null;
328
+ const relayPort = relayStatus?.port;
329
+ if (!relayUrl && isValidPort(relayPort)) {
330
+ relayUrl = `ws://127.0.0.1:${relayPort}/${extensionLegacy ? "cdp" : "ops"}`;
123
331
  }
124
- }
125
- const useRelay = Boolean(!args.noExtension && relayStatus?.extensionConnected && relayUrl);
126
- let usedRelay = false;
127
- let relayWarning = null;
128
- let result = null;
129
- if (args.extensionOnly && !useRelay) {
130
- return failure("Extension not connected; use --no-extension to launch a new browser.", "extension_not_connected");
131
- }
132
- if (useRelay && relayUrl) {
133
- try {
134
- result = await deps.manager.connectRelay(relayUrl);
135
- usedRelay = true;
136
- } catch {
137
- if (args.extensionOnly) {
138
- return failure("Extension relay connection failed.", "extension_connect_failed");
332
+ const waitTimeoutMs = clampWaitTimeout(args.waitTimeoutMs ?? 3e4);
333
+ const headlessExplicit = args.headless === true;
334
+ const managedExplicit = Boolean(args.noExtension || headlessExplicit);
335
+ const managedHeadless = headlessExplicit ? true : false;
336
+ if (args.waitForExtension && !managedExplicit) {
337
+ const observedPort2 = resolveObservedPort(relayStatus, config.relayPort);
338
+ const connected = await waitForExtensionHandshake(deps.relay, observedPort2, waitTimeoutMs);
339
+ if (connected) {
340
+ relayStatus = deps.relay?.status() ?? relayStatus;
341
+ relayUrl = extensionLegacy ? deps.relay?.getCdpUrl() ?? relayUrl : deps.relay?.getOpsUrl?.() ?? relayUrl;
139
342
  }
140
- relayWarning = "Relay connection failed; falling back to managed Chrome.";
141
343
  }
142
- }
143
- if (!result) {
144
- if (relayUrl && !args.noExtension) {
145
- relayWarning ??= "Extension not connected; launching managed Chrome instead.";
344
+ const observedPort = resolveObservedPort(relayStatus, config.relayPort);
345
+ const shouldFetchObserved = !managedExplicit && (!relayUrl || !(relayStatus?.extensionHandshakeComplete || relayStatus?.extensionConnected));
346
+ const observedStatus = shouldFetchObserved ? await fetchRelayObservedStatus(observedPort) : null;
347
+ if (!relayUrl) {
348
+ const fallbackPort = isValidPort(observedStatus?.port) ? observedStatus?.port : observedPort;
349
+ relayUrl = fallbackPort ? `ws://127.0.0.1:${fallbackPort}/${extensionLegacy ? "cdp" : "ops"}` : null;
350
+ }
351
+ const extensionReady = Boolean(
352
+ relayUrl && (relayStatus?.extensionHandshakeComplete || relayStatus?.extensionConnected || observedStatus?.extensionHandshakeComplete || observedStatus?.extensionConnected)
353
+ );
354
+ let usedRelay = false;
355
+ let result = null;
356
+ if (args.extensionOnly && !extensionReady) {
357
+ const diagnostics = buildRelayNotReadyDiagnostics("Extension not connected.", {
358
+ relayUrl,
359
+ relayStatus,
360
+ observedStatus,
361
+ observedPort
362
+ });
363
+ if (await maybeRetryHubMismatch(diagnostics.hint, attemptedRebind, deps)) {
364
+ attemptedRebind = true;
365
+ continue;
366
+ }
367
+ return failure(buildExtensionMissingMessage(diagnostics.message), "extension_not_connected");
368
+ }
369
+ if (!managedExplicit) {
370
+ if (!extensionReady || !relayUrl) {
371
+ const diagnostics = buildRelayNotReadyDiagnostics("Extension not connected.", {
372
+ relayUrl,
373
+ relayStatus,
374
+ observedStatus,
375
+ observedPort
376
+ });
377
+ if (await maybeRetryHubMismatch(diagnostics.hint, attemptedRebind, deps)) {
378
+ attemptedRebind = true;
379
+ continue;
380
+ }
381
+ return failure(buildExtensionMissingMessage(diagnostics.message), "extension_not_connected");
382
+ }
383
+ try {
384
+ result = await deps.manager.connectRelay(relayUrl);
385
+ usedRelay = true;
386
+ } catch (error) {
387
+ const errorMessage = serializeError(error).message;
388
+ const unauthorized = errorMessage.toLowerCase().includes("unauthorized") || errorMessage.includes("401");
389
+ const relayLabel = extensionLegacy ? "/cdp" : "/ops";
390
+ const errorObservedStatus = observedStatus ?? await fetchRelayObservedStatus(observedPort);
391
+ const diagnostics = buildRelayNotReadyDiagnostics(
392
+ unauthorized ? `Extension relay connection failed: relay ${relayLabel} unauthorized (token mismatch).` : `Extension relay connection failed: ${errorMessage}`,
393
+ {
394
+ relayUrl,
395
+ relayStatus,
396
+ observedStatus: errorObservedStatus,
397
+ observedPort
398
+ }
399
+ );
400
+ if (await maybeRetryHubMismatch(diagnostics.hint, attemptedRebind, deps)) {
401
+ attemptedRebind = true;
402
+ continue;
403
+ }
404
+ return failure(buildExtensionMissingMessage(diagnostics.message), "extension_connect_failed");
405
+ }
406
+ }
407
+ if (!result) {
408
+ try {
409
+ result = await deps.manager.launch({
410
+ profile: args.profile,
411
+ headless: managedHeadless,
412
+ startUrl: args.startUrl,
413
+ chromePath: args.chromePath,
414
+ flags: args.flags,
415
+ persistProfile: args.persistProfile,
416
+ noExtension: args.noExtension
417
+ });
418
+ } catch (error) {
419
+ return failure(buildManagedFailureMessage(error), "launch_failed");
420
+ }
146
421
  }
147
- result = await deps.manager.launch({
148
- profile: args.profile,
149
- headless: args.headless,
150
- startUrl: args.startUrl,
151
- chromePath: args.chromePath,
152
- flags: args.flags,
153
- persistProfile: args.persistProfile
422
+ if (usedRelay && args.startUrl && result.activeTargetId) {
423
+ await deps.manager.goto(result.sessionId, args.startUrl, "load", 3e4);
424
+ }
425
+ const warnings = result.warnings ?? [];
426
+ return ok({
427
+ sessionId: result.sessionId,
428
+ mode: result.mode,
429
+ browserWsEndpoint: result.wsEndpoint,
430
+ activeTargetId: result.activeTargetId,
431
+ warnings: warnings.length ? warnings : void 0
154
432
  });
433
+ } catch (error) {
434
+ return failure(serializeError(error).message, "launch_failed");
155
435
  }
156
- if (usedRelay && args.startUrl && result.activeTargetId) {
157
- await deps.manager.goto(result.sessionId, args.startUrl, "load", 3e4);
158
- }
159
- const warnings = [
160
- ...result.warnings ?? [],
161
- ...relayWarning ? [relayWarning] : []
162
- ];
163
- return ok({
164
- sessionId: result.sessionId,
165
- mode: result.mode,
166
- browserWsEndpoint: result.wsEndpoint,
167
- activeTargetId: result.activeTargetId,
168
- warnings: warnings.length ? warnings : void 0
169
- });
170
- } catch (error) {
171
- return failure(serializeError(error).message, "launch_failed");
172
436
  }
173
437
  }
174
438
  });
175
439
  }
176
- async function waitForExtension(relay, timeoutMs) {
440
+ var buildExtensionMissingMessage = (reason) => {
441
+ return [
442
+ reason,
443
+ "Connect the extension: open the Chrome extension popup and click Connect, then retry.",
444
+ "Tip: If the popup says Connected, it may be connected to a different relay instance/port than this tool expects.",
445
+ "Legend: ext=extension websocket, handshake=extension handshake, ops=active /ops client, cdp=active /cdp client, pairing=token required.",
446
+ "",
447
+ "Other options (explicit):",
448
+ "- Managed (headed): npx opendevbrowser launch --no-extension",
449
+ "- Managed (headless): npx opendevbrowser launch --no-extension --headless",
450
+ "- Legacy extension relay: npx opendevbrowser launch --extension-legacy",
451
+ "- CDPConnect (default port): npx opendevbrowser connect --cdp-port 9222",
452
+ "- CDPConnect (explicit WS): npx opendevbrowser connect --ws-endpoint ws://127.0.0.1:9222/devtools/browser/<id>",
453
+ "Note: CDPConnect requires Chrome started with --remote-debugging-port=9222."
454
+ ].join("\n");
455
+ };
456
+ var buildManagedFailureMessage = (error) => {
457
+ const detail = serializeError(error).message;
458
+ return [
459
+ `Managed session failed: ${detail}`,
460
+ "",
461
+ "Final option (explicit):",
462
+ "- CDPConnect (default port): npx opendevbrowser connect --cdp-port 9222",
463
+ "- CDPConnect (explicit WS): npx opendevbrowser connect --ws-endpoint ws://127.0.0.1:9222/devtools/browser/<id>"
464
+ ].join("\n");
465
+ };
466
+ var MIN_WAIT_TIMEOUT_MS = 3e3;
467
+ var WAIT_MIN_DELAY_MS = 250;
468
+ var WAIT_MAX_DELAY_MS = 2e3;
469
+ function clampWaitTimeout(timeoutMs) {
470
+ if (!Number.isFinite(timeoutMs)) {
471
+ return MIN_WAIT_TIMEOUT_MS;
472
+ }
473
+ return Math.max(timeoutMs, MIN_WAIT_TIMEOUT_MS);
474
+ }
475
+ async function waitForExtensionHandshake(relay, observedPort, timeoutMs) {
177
476
  const start = Date.now();
477
+ let delay = WAIT_MIN_DELAY_MS;
178
478
  while (Date.now() - start < timeoutMs) {
179
- if (relay.status().extensionConnected) {
479
+ if (relay?.status().extensionHandshakeComplete) {
480
+ return true;
481
+ }
482
+ const observedStatus = await fetchRelayObservedStatus(observedPort);
483
+ if (observedStatus?.extensionHandshakeComplete) {
180
484
  return true;
181
485
  }
182
- await new Promise((resolve) => setTimeout(resolve, 500));
486
+ await new Promise((resolve) => setTimeout(resolve, delay));
487
+ delay = Math.min(delay * 2, WAIT_MAX_DELAY_MS);
183
488
  }
184
489
  return false;
185
490
  }
491
+ function resolveObservedPort(relayStatus, configPort) {
492
+ const relayPort = relayStatus?.port;
493
+ if (isValidPort(relayPort)) return relayPort;
494
+ if (isValidPort(configPort)) return configPort;
495
+ return null;
496
+ }
497
+ function isValidPort(port) {
498
+ return typeof port === "number" && Number.isInteger(port) && port > 0 && port <= 65535;
499
+ }
500
+ function shortInstanceId(value) {
501
+ if (!value) return "?";
502
+ return value.slice(0, 8);
503
+ }
504
+ function formatRelayUrl(relayUrl) {
505
+ return relayUrl ?? "null";
506
+ }
507
+ function formatLocalStatus(status) {
508
+ return [
509
+ "local(instance=",
510
+ shortInstanceId(status?.instanceId),
511
+ " port=",
512
+ typeof status?.port === "number" ? String(status.port) : "?",
513
+ " ext=",
514
+ String(Boolean(status?.extensionConnected)),
515
+ " handshake=",
516
+ String(Boolean(status?.extensionHandshakeComplete)),
517
+ " ops=",
518
+ String(Boolean(status?.opsConnected)),
519
+ " cdp=",
520
+ String(Boolean(status?.cdpConnected)),
521
+ " pairing=",
522
+ String(Boolean(status?.pairingRequired)),
523
+ ")"
524
+ ].join("");
525
+ }
526
+ function formatObservedStatus(status, port) {
527
+ const label = port ?? "?";
528
+ if (!status) {
529
+ return `observed@${label}=none`;
530
+ }
531
+ return [
532
+ "observed@",
533
+ label,
534
+ "=instance=",
535
+ shortInstanceId(status.instanceId),
536
+ " ext=",
537
+ String(Boolean(status.extensionConnected)),
538
+ " handshake=",
539
+ String(Boolean(status.extensionHandshakeComplete)),
540
+ " ops=",
541
+ String(Boolean(status.opsConnected)),
542
+ " cdp=",
543
+ String(Boolean(status.cdpConnected)),
544
+ " pairing=",
545
+ String(Boolean(status.pairingRequired))
546
+ ].join("");
547
+ }
548
+ function buildRelayNotReadyDiagnostics(baseReason, detail) {
549
+ const localExt = Boolean(detail.relayStatus?.extensionConnected);
550
+ const observedExt = Boolean(detail.observedStatus?.extensionConnected);
551
+ let hint = "none";
552
+ if (detail.relayUrl === null) {
553
+ hint = "relayUrl_null";
554
+ } else if (detail.observedStatus && !localExt && observedExt) {
555
+ hint = "possible_mismatch";
556
+ } else if (detail.relayStatus?.instanceId && detail.observedStatus?.instanceId && detail.relayStatus.instanceId !== detail.observedStatus.instanceId) {
557
+ hint = "possible_mismatch";
558
+ }
559
+ const diagnostics = [
560
+ "Diagnostics: relayUrl=",
561
+ formatRelayUrl(detail.relayUrl),
562
+ " ",
563
+ formatLocalStatus(detail.relayStatus),
564
+ " ",
565
+ formatObservedStatus(detail.observedStatus, detail.observedPort),
566
+ " hint=",
567
+ hint
568
+ ].join("");
569
+ return { message: [baseReason, diagnostics].join("\n"), hint };
570
+ }
571
+ async function maybeRetryHubMismatch(hint, attempted, deps) {
572
+ if (attempted) return false;
573
+ if (hint !== "possible_mismatch") return false;
574
+ if (!deps.ensureHub) return false;
575
+ await deps.ensureHub();
576
+ await deps.relay?.refresh?.();
577
+ return true;
578
+ }
579
+ async function fetchRelayObservedStatus(port) {
580
+ if (!isValidPort(port)) return null;
581
+ if (typeof fetch !== "function") return null;
582
+ const controller = new AbortController();
583
+ const timeoutId = setTimeout(() => controller.abort(), 500);
584
+ try {
585
+ const response = await fetch(`http://127.0.0.1:${port}/status`, { signal: controller.signal });
586
+ if (!response.ok) return null;
587
+ const payload = await response.json();
588
+ if (!payload || typeof payload.instanceId !== "string") return null;
589
+ return {
590
+ instanceId: payload.instanceId,
591
+ running: Boolean(payload.running),
592
+ port: typeof payload.port === "number" ? payload.port : void 0,
593
+ extensionConnected: Boolean(payload.extensionConnected),
594
+ extensionHandshakeComplete: Boolean(payload.extensionHandshakeComplete),
595
+ cdpConnected: Boolean(payload.cdpConnected),
596
+ opsConnected: Boolean(payload.opsConnected),
597
+ pairingRequired: Boolean(payload.pairingRequired)
598
+ };
599
+ } catch {
600
+ return null;
601
+ } finally {
602
+ clearTimeout(timeoutId);
603
+ }
604
+ }
186
605
 
187
606
  // src/tools/connect.ts
188
607
  import { tool as tool2 } from "@opencode-ai/plugin";
189
608
  var z2 = tool2.schema;
609
+ function normalizeRelayEndpoint(wsEndpoint, path, allowBase) {
610
+ if (!wsEndpoint) return null;
611
+ try {
612
+ const url = new URL(wsEndpoint);
613
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return null;
614
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return null;
615
+ if (!url.port || !/^\d+$/.test(url.port)) return null;
616
+ const normalizedPath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
617
+ if (!allowBase && normalizedPath === "") return null;
618
+ if (normalizedPath && normalizedPath !== `/${path}`) return null;
619
+ return `${url.protocol}//${url.hostname}:${url.port}/${path}`;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
190
624
  function createConnectTool(deps) {
191
625
  return tool2({
192
- description: "Connect to an existing Chrome CDP endpoint.",
626
+ description: "Connect to an existing Chrome CDP endpoint or extension relay.",
193
627
  args: {
194
628
  wsEndpoint: z2.string().optional().describe("Full WebSocket endpoint to connect to"),
195
629
  host: z2.string().optional().describe("Host for /json/version lookup"),
196
- port: z2.number().int().optional().describe("Port for /json/version lookup")
630
+ port: z2.number().int().optional().describe("Port for /json/version lookup"),
631
+ extensionLegacy: z2.boolean().optional().describe("Use legacy extension relay (/cdp) instead of ops")
197
632
  },
198
633
  async execute(args) {
199
634
  try {
200
- const relayUrl = deps.relay?.getCdpUrl();
201
- const useRelay = Boolean(relayUrl && args.wsEndpoint === relayUrl);
202
- const result = useRelay && relayUrl ? await deps.manager.connectRelay(relayUrl) : await deps.manager.connect({
203
- wsEndpoint: args.wsEndpoint,
204
- host: args.host,
205
- port: args.port
206
- });
635
+ await deps.relay?.refresh?.();
636
+ const wsEndpoint = args.wsEndpoint;
637
+ const extensionLegacy = args.extensionLegacy === true;
638
+ const hasExplicitCdp = Boolean(wsEndpoint || args.host || args.port);
639
+ const relayUrl = extensionLegacy ? deps.relay?.getCdpUrl() ?? null : deps.relay?.getOpsUrl?.() ?? null;
640
+ const normalizedOpsEndpoint = normalizeRelayEndpoint(wsEndpoint, "ops", true);
641
+ const normalizedLegacyEndpoint = normalizeRelayEndpoint(wsEndpoint, "cdp", extensionLegacy);
642
+ if (normalizedLegacyEndpoint && !extensionLegacy) {
643
+ return failure("Legacy extension relay (/cdp) requires extensionLegacy=true.", "extension_legacy_required");
644
+ }
645
+ const relayEndpoint = relayUrl && wsEndpoint === relayUrl ? relayUrl : extensionLegacy ? normalizedLegacyEndpoint ?? normalizedOpsEndpoint : normalizedOpsEndpoint;
646
+ let result;
647
+ if (relayEndpoint || !hasExplicitCdp && relayUrl) {
648
+ result = await deps.manager.connectRelay(relayEndpoint ?? relayUrl ?? "");
649
+ } else {
650
+ if (!hasExplicitCdp) {
651
+ return failure("Extension relay not available. Connect the extension or pass wsEndpoint/host/port.", "extension_not_connected");
652
+ }
653
+ result = await deps.manager.connect({
654
+ wsEndpoint,
655
+ host: args.host,
656
+ port: args.port
657
+ });
658
+ }
207
659
  return ok({
208
660
  sessionId: result.sessionId,
209
661
  mode: result.mode,
@@ -244,6 +696,13 @@ import { readFileSync } from "fs";
244
696
  import { dirname, join } from "path";
245
697
  import { fileURLToPath } from "url";
246
698
  import { tool as tool4 } from "@opencode-ai/plugin";
699
+
700
+ // src/utils/hub-enabled.ts
701
+ var isHubEnabled = (config) => {
702
+ return config.relayToken !== false && config.relayPort > 0;
703
+ };
704
+
705
+ // src/tools/status.ts
247
706
  var z4 = tool4.schema;
248
707
  function getPackageVersion() {
249
708
  try {
@@ -285,17 +744,45 @@ async function fetchLatestVersion(packageName) {
285
744
  }
286
745
  function createStatusTool(deps) {
287
746
  return tool4({
288
- description: "Get status of a browser session.",
747
+ description: "Get daemon or session status.",
289
748
  args: {
290
- sessionId: z4.string().describe("Session id")
749
+ sessionId: z4.string().optional().describe("Session id (required when hub is disabled)")
291
750
  },
292
751
  async execute(args) {
293
752
  try {
294
- const status = await deps.manager.status(args.sessionId);
295
- const extensionPath = deps.getExtensionPath?.() ?? null;
296
753
  const config = deps.config.get();
754
+ const hubEnabled = isHubEnabled(config);
755
+ const extensionPath = deps.getExtensionPath?.() ?? null;
297
756
  const version = getPackageVersion();
298
757
  let updateHint;
758
+ let sessionStatus = null;
759
+ if (hubEnabled) {
760
+ const daemonStatus = await fetchDaemonStatusFromMetadata();
761
+ if (!daemonStatus) {
762
+ return failure("Daemon not running. Start with `npx opendevbrowser serve`.", "status_failed");
763
+ }
764
+ if (args.sessionId) {
765
+ sessionStatus = await deps.manager.status(args.sessionId);
766
+ }
767
+ if (config.checkForUpdates && version) {
768
+ const latest = await fetchLatestVersion("opendevbrowser");
769
+ if (latest && latest !== version) {
770
+ updateHint = `Update available: ${version} -> ${latest}`;
771
+ }
772
+ }
773
+ return ok({
774
+ ...sessionStatus ?? {},
775
+ daemon: daemonStatus,
776
+ hubEnabled: true,
777
+ extensionPath: extensionPath ?? void 0,
778
+ version,
779
+ updateHint
780
+ });
781
+ }
782
+ if (!args.sessionId) {
783
+ return failure("Missing sessionId for status.", "status_failed");
784
+ }
785
+ sessionStatus = await deps.manager.status(args.sessionId);
299
786
  if (config.checkForUpdates && version) {
300
787
  const latest = await fetchLatestVersion("opendevbrowser");
301
788
  if (latest && latest !== version) {
@@ -303,10 +790,10 @@ function createStatusTool(deps) {
303
790
  }
304
791
  }
305
792
  return ok({
306
- mode: status.mode,
307
- activeTargetId: status.activeTargetId,
308
- url: status.url,
309
- title: status.title,
793
+ mode: sessionStatus.mode,
794
+ activeTargetId: sessionStatus.activeTargetId,
795
+ url: sessionStatus.url,
796
+ title: sessionStatus.title,
310
797
  extensionPath: extensionPath ?? void 0,
311
798
  version,
312
799
  updateHint
@@ -599,18 +1086,103 @@ function createClickTool(deps) {
599
1086
  });
600
1087
  }
601
1088
 
602
- // src/tools/type.ts
1089
+ // src/tools/hover.ts
603
1090
  import { tool as tool16 } from "@opencode-ai/plugin";
604
1091
  var z16 = tool16.schema;
605
- function createTypeTool(deps) {
1092
+ function createHoverTool(deps) {
606
1093
  return tool16({
1094
+ description: "Hover over an element by ref.",
1095
+ args: {
1096
+ sessionId: z16.string().describe("Active browser session id"),
1097
+ ref: z16.string().describe("Element ref from snapshot")
1098
+ },
1099
+ async execute(args) {
1100
+ try {
1101
+ const result = await deps.manager.hover(args.sessionId, args.ref);
1102
+ return ok(result);
1103
+ } catch (error) {
1104
+ return failure(serializeError(error).message, "hover_failed");
1105
+ }
1106
+ }
1107
+ });
1108
+ }
1109
+
1110
+ // src/tools/press.ts
1111
+ import { tool as tool17 } from "@opencode-ai/plugin";
1112
+ var z17 = tool17.schema;
1113
+ function createPressTool(deps) {
1114
+ return tool17({
1115
+ description: "Press a keyboard key, optionally focusing a ref first.",
1116
+ args: {
1117
+ sessionId: z17.string().describe("Active browser session id"),
1118
+ key: z17.string().describe("Keyboard key to press, e.g. Enter or ArrowDown"),
1119
+ ref: z17.string().optional().describe("Optional element ref to focus first")
1120
+ },
1121
+ async execute(args) {
1122
+ try {
1123
+ const result = await deps.manager.press(args.sessionId, args.key, args.ref);
1124
+ return ok(result);
1125
+ } catch (error) {
1126
+ return failure(serializeError(error).message, "press_failed");
1127
+ }
1128
+ }
1129
+ });
1130
+ }
1131
+
1132
+ // src/tools/check.ts
1133
+ import { tool as tool18 } from "@opencode-ai/plugin";
1134
+ var z18 = tool18.schema;
1135
+ function createCheckTool(deps) {
1136
+ return tool18({
1137
+ description: "Check a checkbox or toggle by ref.",
1138
+ args: {
1139
+ sessionId: z18.string().describe("Active browser session id"),
1140
+ ref: z18.string().describe("Element ref from snapshot")
1141
+ },
1142
+ async execute(args) {
1143
+ try {
1144
+ const result = await deps.manager.check(args.sessionId, args.ref);
1145
+ return ok(result);
1146
+ } catch (error) {
1147
+ return failure(serializeError(error).message, "check_failed");
1148
+ }
1149
+ }
1150
+ });
1151
+ }
1152
+
1153
+ // src/tools/uncheck.ts
1154
+ import { tool as tool19 } from "@opencode-ai/plugin";
1155
+ var z19 = tool19.schema;
1156
+ function createUncheckTool(deps) {
1157
+ return tool19({
1158
+ description: "Uncheck a checkbox or toggle by ref.",
1159
+ args: {
1160
+ sessionId: z19.string().describe("Active browser session id"),
1161
+ ref: z19.string().describe("Element ref from snapshot")
1162
+ },
1163
+ async execute(args) {
1164
+ try {
1165
+ const result = await deps.manager.uncheck(args.sessionId, args.ref);
1166
+ return ok(result);
1167
+ } catch (error) {
1168
+ return failure(serializeError(error).message, "uncheck_failed");
1169
+ }
1170
+ }
1171
+ });
1172
+ }
1173
+
1174
+ // src/tools/type.ts
1175
+ import { tool as tool20 } from "@opencode-ai/plugin";
1176
+ var z20 = tool20.schema;
1177
+ function createTypeTool(deps) {
1178
+ return tool20({
607
1179
  description: "Type text into a referenced input.",
608
1180
  args: {
609
- sessionId: z16.string().describe("Session id"),
610
- ref: z16.string().describe("Element ref"),
611
- text: z16.string().describe("Text to type"),
612
- clear: z16.boolean().optional().describe("Clear before typing"),
613
- submit: z16.boolean().optional().describe("Press Enter after typing")
1181
+ sessionId: z20.string().describe("Session id"),
1182
+ ref: z20.string().describe("Element ref"),
1183
+ text: z20.string().describe("Text to type"),
1184
+ clear: z20.boolean().optional().describe("Clear before typing"),
1185
+ submit: z20.boolean().optional().describe("Press Enter after typing")
614
1186
  },
615
1187
  async execute(args) {
616
1188
  try {
@@ -630,15 +1202,15 @@ function createTypeTool(deps) {
630
1202
  }
631
1203
 
632
1204
  // src/tools/select.ts
633
- import { tool as tool17 } from "@opencode-ai/plugin";
634
- var z17 = tool17.schema;
1205
+ import { tool as tool21 } from "@opencode-ai/plugin";
1206
+ var z21 = tool21.schema;
635
1207
  function createSelectTool(deps) {
636
- return tool17({
1208
+ return tool21({
637
1209
  description: "Select options in a referenced select element.",
638
1210
  args: {
639
- sessionId: z17.string().describe("Session id"),
640
- ref: z17.string().describe("Element ref"),
641
- values: z17.array(z17.string()).describe("Values to select")
1211
+ sessionId: z21.string().describe("Session id"),
1212
+ ref: z21.string().describe("Element ref"),
1213
+ values: z21.array(z21.string()).describe("Values to select")
642
1214
  },
643
1215
  async execute(args) {
644
1216
  try {
@@ -652,15 +1224,15 @@ function createSelectTool(deps) {
652
1224
  }
653
1225
 
654
1226
  // src/tools/scroll.ts
655
- import { tool as tool18 } from "@opencode-ai/plugin";
656
- var z18 = tool18.schema;
1227
+ import { tool as tool22 } from "@opencode-ai/plugin";
1228
+ var z22 = tool22.schema;
657
1229
  function createScrollTool(deps) {
658
- return tool18({
1230
+ return tool22({
659
1231
  description: "Scroll the page or a referenced element.",
660
1232
  args: {
661
- sessionId: z18.string().describe("Session id"),
662
- dy: z18.number().describe("Scroll delta in pixels"),
663
- ref: z18.string().optional().describe("Optional element ref to scroll")
1233
+ sessionId: z22.string().describe("Session id"),
1234
+ dy: z22.number().describe("Scroll delta in pixels"),
1235
+ ref: z22.string().optional().describe("Optional element ref to scroll")
664
1236
  },
665
1237
  async execute(args) {
666
1238
  try {
@@ -673,16 +1245,37 @@ function createScrollTool(deps) {
673
1245
  });
674
1246
  }
675
1247
 
1248
+ // src/tools/scroll_into_view.ts
1249
+ import { tool as tool23 } from "@opencode-ai/plugin";
1250
+ var z23 = tool23.schema;
1251
+ function createScrollIntoViewTool(deps) {
1252
+ return tool23({
1253
+ description: "Scroll an element into view by ref.",
1254
+ args: {
1255
+ sessionId: z23.string().describe("Active browser session id"),
1256
+ ref: z23.string().describe("Element ref from snapshot")
1257
+ },
1258
+ async execute(args) {
1259
+ try {
1260
+ const result = await deps.manager.scrollIntoView(args.sessionId, args.ref);
1261
+ return ok(result);
1262
+ } catch (error) {
1263
+ return failure(serializeError(error).message, "scroll_into_view_failed");
1264
+ }
1265
+ }
1266
+ });
1267
+ }
1268
+
676
1269
  // src/tools/dom_get_html.ts
677
- import { tool as tool19 } from "@opencode-ai/plugin";
678
- var z19 = tool19.schema;
1270
+ import { tool as tool24 } from "@opencode-ai/plugin";
1271
+ var z24 = tool24.schema;
679
1272
  function createDomGetHtmlTool(deps) {
680
- return tool19({
1273
+ return tool24({
681
1274
  description: "Get outerHTML for a referenced element.",
682
1275
  args: {
683
- sessionId: z19.string().describe("Session id"),
684
- ref: z19.string().describe("Element ref"),
685
- maxChars: z19.number().int().optional().describe("Max characters")
1276
+ sessionId: z24.string().describe("Session id"),
1277
+ ref: z24.string().describe("Element ref"),
1278
+ maxChars: z24.number().int().optional().describe("Max characters")
686
1279
  },
687
1280
  async execute(args) {
688
1281
  try {
@@ -704,15 +1297,15 @@ function createDomGetHtmlTool(deps) {
704
1297
  }
705
1298
 
706
1299
  // src/tools/dom_get_text.ts
707
- import { tool as tool20 } from "@opencode-ai/plugin";
708
- var z20 = tool20.schema;
1300
+ import { tool as tool25 } from "@opencode-ai/plugin";
1301
+ var z25 = tool25.schema;
709
1302
  function createDomGetTextTool(deps) {
710
- return tool20({
1303
+ return tool25({
711
1304
  description: "Get inner text for a referenced element.",
712
1305
  args: {
713
- sessionId: z20.string().describe("Session id"),
714
- ref: z20.string().describe("Element ref"),
715
- maxChars: z20.number().int().optional().describe("Max characters")
1306
+ sessionId: z25.string().describe("Session id"),
1307
+ ref: z25.string().describe("Element ref"),
1308
+ maxChars: z25.number().int().optional().describe("Max characters")
716
1309
  },
717
1310
  async execute(args) {
718
1311
  try {
@@ -733,21 +1326,127 @@ function createDomGetTextTool(deps) {
733
1326
  });
734
1327
  }
735
1328
 
1329
+ // src/tools/get_attr.ts
1330
+ import { tool as tool26 } from "@opencode-ai/plugin";
1331
+ var z26 = tool26.schema;
1332
+ function createGetAttrTool(deps) {
1333
+ return tool26({
1334
+ description: "Get a DOM attribute value by ref.",
1335
+ args: {
1336
+ sessionId: z26.string().describe("Active browser session id"),
1337
+ ref: z26.string().describe("Element ref from snapshot"),
1338
+ name: z26.string().describe("Attribute name, e.g. href or aria-label")
1339
+ },
1340
+ async execute(args) {
1341
+ try {
1342
+ const result = await deps.manager.domGetAttr(args.sessionId, args.ref, args.name);
1343
+ return ok(result);
1344
+ } catch (error) {
1345
+ return failure(serializeError(error).message, "get_attr_failed");
1346
+ }
1347
+ }
1348
+ });
1349
+ }
1350
+
1351
+ // src/tools/get_value.ts
1352
+ import { tool as tool27 } from "@opencode-ai/plugin";
1353
+ var z27 = tool27.schema;
1354
+ function createGetValueTool(deps) {
1355
+ return tool27({
1356
+ description: "Get the input value for an element by ref.",
1357
+ args: {
1358
+ sessionId: z27.string().describe("Active browser session id"),
1359
+ ref: z27.string().describe("Element ref from snapshot")
1360
+ },
1361
+ async execute(args) {
1362
+ try {
1363
+ const result = await deps.manager.domGetValue(args.sessionId, args.ref);
1364
+ return ok(result);
1365
+ } catch (error) {
1366
+ return failure(serializeError(error).message, "get_value_failed");
1367
+ }
1368
+ }
1369
+ });
1370
+ }
1371
+
1372
+ // src/tools/is_visible.ts
1373
+ import { tool as tool28 } from "@opencode-ai/plugin";
1374
+ var z28 = tool28.schema;
1375
+ function createIsVisibleTool(deps) {
1376
+ return tool28({
1377
+ description: "Check if an element is visible by ref.",
1378
+ args: {
1379
+ sessionId: z28.string().describe("Active browser session id"),
1380
+ ref: z28.string().describe("Element ref from snapshot")
1381
+ },
1382
+ async execute(args) {
1383
+ try {
1384
+ const result = await deps.manager.domIsVisible(args.sessionId, args.ref);
1385
+ return ok(result);
1386
+ } catch (error) {
1387
+ return failure(serializeError(error).message, "is_visible_failed");
1388
+ }
1389
+ }
1390
+ });
1391
+ }
1392
+
1393
+ // src/tools/is_enabled.ts
1394
+ import { tool as tool29 } from "@opencode-ai/plugin";
1395
+ var z29 = tool29.schema;
1396
+ function createIsEnabledTool(deps) {
1397
+ return tool29({
1398
+ description: "Check if an element is enabled by ref.",
1399
+ args: {
1400
+ sessionId: z29.string().describe("Active browser session id"),
1401
+ ref: z29.string().describe("Element ref from snapshot")
1402
+ },
1403
+ async execute(args) {
1404
+ try {
1405
+ const result = await deps.manager.domIsEnabled(args.sessionId, args.ref);
1406
+ return ok(result);
1407
+ } catch (error) {
1408
+ return failure(serializeError(error).message, "is_enabled_failed");
1409
+ }
1410
+ }
1411
+ });
1412
+ }
1413
+
1414
+ // src/tools/is_checked.ts
1415
+ import { tool as tool30 } from "@opencode-ai/plugin";
1416
+ var z30 = tool30.schema;
1417
+ function createIsCheckedTool(deps) {
1418
+ return tool30({
1419
+ description: "Check if an element is checked by ref.",
1420
+ args: {
1421
+ sessionId: z30.string().describe("Active browser session id"),
1422
+ ref: z30.string().describe("Element ref from snapshot")
1423
+ },
1424
+ async execute(args) {
1425
+ try {
1426
+ const result = await deps.manager.domIsChecked(args.sessionId, args.ref);
1427
+ return ok(result);
1428
+ } catch (error) {
1429
+ return failure(serializeError(error).message, "is_checked_failed");
1430
+ }
1431
+ }
1432
+ });
1433
+ }
1434
+
736
1435
  // src/tools/run.ts
737
- import { tool as tool21 } from "@opencode-ai/plugin";
738
- var z21 = tool21.schema;
739
- var stepSchema = z21.object({
740
- action: z21.string().describe("Action name"),
741
- args: z21.record(z21.string(), z21.unknown()).optional().describe("Action arguments")
1436
+ import { tool as tool31 } from "@opencode-ai/plugin";
1437
+ var z31 = tool31.schema;
1438
+ var stepSchema = z31.object({
1439
+ action: z31.string().describe("Action name"),
1440
+ args: z31.record(z31.string(), z31.unknown()).optional().describe("Action arguments")
742
1441
  });
743
1442
  function createRunTool(deps) {
744
- return tool21({
1443
+ return tool31({
745
1444
  description: "Run multiple actions in a single tool call.",
746
1445
  args: {
747
- sessionId: z21.string().describe("Session id"),
748
- steps: z21.array(stepSchema).describe("Steps to execute"),
749
- stopOnError: z21.boolean().optional().describe("Stop when a step fails"),
750
- maxSnapshotChars: z21.number().int().optional().describe("Default maxChars for snapshot steps")
1446
+ sessionId: z31.string().describe("Session id"),
1447
+ steps: z31.array(stepSchema).describe("Steps to execute"),
1448
+ stopOnError: z31.boolean().optional().describe("Stop when a step fails"),
1449
+ maxSnapshotChars: z31.number().int().optional().describe("Default maxChars for snapshot steps")
751
1450
  },
752
1451
  async execute(args) {
753
1452
  try {
@@ -780,13 +1479,13 @@ function normalizeSteps(steps, maxSnapshotChars) {
780
1479
  }
781
1480
 
782
1481
  // src/tools/prompting_guide.ts
783
- import { tool as tool22 } from "@opencode-ai/plugin";
784
- var z22 = tool22.schema;
1482
+ import { tool as tool32 } from "@opencode-ai/plugin";
1483
+ var z32 = tool32.schema;
785
1484
  function createPromptingGuideTool(deps) {
786
- return tool22({
1485
+ return tool32({
787
1486
  description: "Return best-practice prompting guidance for OpenDevBrowser.",
788
1487
  args: {
789
- topic: z22.string().optional().describe("Optional topic for guidance")
1488
+ topic: z32.string().optional().describe("Optional topic for guidance")
790
1489
  },
791
1490
  async execute(args) {
792
1491
  try {
@@ -800,19 +1499,19 @@ function createPromptingGuideTool(deps) {
800
1499
  }
801
1500
 
802
1501
  // src/tools/console_poll.ts
803
- import { tool as tool23 } from "@opencode-ai/plugin";
804
- var z23 = tool23.schema;
1502
+ import { tool as tool33 } from "@opencode-ai/plugin";
1503
+ var z33 = tool33.schema;
805
1504
  function createConsolePollTool(deps) {
806
- return tool23({
1505
+ return tool33({
807
1506
  description: "Poll console events for the active target.",
808
1507
  args: {
809
- sessionId: z23.string().describe("Session id"),
810
- sinceSeq: z23.number().int().optional().describe("Sequence to resume from"),
811
- max: z23.number().int().optional().describe("Max events to return")
1508
+ sessionId: z33.string().describe("Session id"),
1509
+ sinceSeq: z33.number().int().optional().describe("Sequence to resume from"),
1510
+ max: z33.number().int().optional().describe("Max events to return")
812
1511
  },
813
1512
  async execute(args) {
814
1513
  try {
815
- const result = deps.manager.consolePoll(
1514
+ const result = await deps.manager.consolePoll(
816
1515
  args.sessionId,
817
1516
  args.sinceSeq,
818
1517
  args.max ?? 50
@@ -826,19 +1525,19 @@ function createConsolePollTool(deps) {
826
1525
  }
827
1526
 
828
1527
  // src/tools/network_poll.ts
829
- import { tool as tool24 } from "@opencode-ai/plugin";
830
- var z24 = tool24.schema;
1528
+ import { tool as tool34 } from "@opencode-ai/plugin";
1529
+ var z34 = tool34.schema;
831
1530
  function createNetworkPollTool(deps) {
832
- return tool24({
1531
+ return tool34({
833
1532
  description: "Poll network events for the active target.",
834
1533
  args: {
835
- sessionId: z24.string().describe("Session id"),
836
- sinceSeq: z24.number().int().optional().describe("Sequence to resume from"),
837
- max: z24.number().int().optional().describe("Max events to return")
1534
+ sessionId: z34.string().describe("Session id"),
1535
+ sinceSeq: z34.number().int().optional().describe("Sequence to resume from"),
1536
+ max: z34.number().int().optional().describe("Max events to return")
838
1537
  },
839
1538
  async execute(args) {
840
1539
  try {
841
- const result = deps.manager.networkPoll(
1540
+ const result = await deps.manager.networkPoll(
842
1541
  args.sessionId,
843
1542
  args.sinceSeq,
844
1543
  args.max ?? 50
@@ -852,13 +1551,13 @@ function createNetworkPollTool(deps) {
852
1551
  }
853
1552
 
854
1553
  // src/tools/clone_page.ts
855
- import { tool as tool25 } from "@opencode-ai/plugin";
856
- var z25 = tool25.schema;
1554
+ import { tool as tool35 } from "@opencode-ai/plugin";
1555
+ var z35 = tool35.schema;
857
1556
  function createClonePageTool(deps) {
858
- return tool25({
1557
+ return tool35({
859
1558
  description: "Export the active page as a React component and CSS bundle.",
860
1559
  args: {
861
- sessionId: z25.string().describe("Active browser session id")
1560
+ sessionId: z35.string().describe("Active browser session id")
862
1561
  },
863
1562
  async execute(args) {
864
1563
  try {
@@ -872,14 +1571,14 @@ function createClonePageTool(deps) {
872
1571
  }
873
1572
 
874
1573
  // src/tools/clone_component.ts
875
- import { tool as tool26 } from "@opencode-ai/plugin";
876
- var z26 = tool26.schema;
1574
+ import { tool as tool36 } from "@opencode-ai/plugin";
1575
+ var z36 = tool36.schema;
877
1576
  function createCloneComponentTool(deps) {
878
- return tool26({
1577
+ return tool36({
879
1578
  description: "Export a selected element subtree as a React component and CSS bundle.",
880
1579
  args: {
881
- sessionId: z26.string().describe("Active browser session id"),
882
- ref: z26.string().describe("Element ref from snapshot")
1580
+ sessionId: z36.string().describe("Active browser session id"),
1581
+ ref: z36.string().describe("Element ref from snapshot")
883
1582
  },
884
1583
  async execute(args) {
885
1584
  try {
@@ -893,13 +1592,13 @@ function createCloneComponentTool(deps) {
893
1592
  }
894
1593
 
895
1594
  // src/tools/perf.ts
896
- import { tool as tool27 } from "@opencode-ai/plugin";
897
- var z27 = tool27.schema;
1595
+ import { tool as tool37 } from "@opencode-ai/plugin";
1596
+ var z37 = tool37.schema;
898
1597
  function createPerfTool(deps) {
899
- return tool27({
1598
+ return tool37({
900
1599
  description: "Fetch lightweight performance metrics from the active page.",
901
1600
  args: {
902
- sessionId: z27.string().describe("Active browser session id")
1601
+ sessionId: z37.string().describe("Active browser session id")
903
1602
  },
904
1603
  async execute(args) {
905
1604
  try {
@@ -913,14 +1612,14 @@ function createPerfTool(deps) {
913
1612
  }
914
1613
 
915
1614
  // src/tools/screenshot.ts
916
- import { tool as tool28 } from "@opencode-ai/plugin";
917
- var z28 = tool28.schema;
1615
+ import { tool as tool38 } from "@opencode-ai/plugin";
1616
+ var z38 = tool38.schema;
918
1617
  function createScreenshotTool(deps) {
919
- return tool28({
1618
+ return tool38({
920
1619
  description: "Capture a screenshot of the active page.",
921
1620
  args: {
922
- sessionId: z28.string().describe("Active browser session id"),
923
- path: z28.string().optional().describe("Optional output file path")
1621
+ sessionId: z38.string().describe("Active browser session id"),
1622
+ path: z38.string().optional().describe("Optional output file path")
924
1623
  },
925
1624
  async execute(args) {
926
1625
  try {
@@ -933,10 +1632,67 @@ function createScreenshotTool(deps) {
933
1632
  });
934
1633
  }
935
1634
 
1635
+ // src/tools/annotate.ts
1636
+ import { tool as tool39 } from "@opencode-ai/plugin";
1637
+ var z39 = tool39.schema;
1638
+ var screenshotModeSchema = z39.enum(["visible", "full", "none"]);
1639
+ var transportSchema = z39.enum(["auto", "direct", "relay"]);
1640
+ function createAnnotateTool(deps) {
1641
+ return tool39({
1642
+ description: "Request interactive annotations via direct (CDP) or relay transport.",
1643
+ args: {
1644
+ sessionId: z39.string().describe("Session id"),
1645
+ transport: transportSchema.optional().describe("auto | direct | relay (default: auto)"),
1646
+ targetId: z39.string().optional().describe("Optional target id for direct mode"),
1647
+ tabId: z39.number().int().optional().describe("Optional Chrome tab id for relay mode"),
1648
+ url: z39.string().optional().describe("Optional URL to open before annotating"),
1649
+ screenshotMode: screenshotModeSchema.optional().describe("visible | full | none (default: visible)"),
1650
+ debug: z39.boolean().optional().describe("Include debug metadata"),
1651
+ context: z39.string().optional().describe("Optional context for the annotator"),
1652
+ timeoutMs: z39.number().int().optional().describe("Timeout in milliseconds")
1653
+ },
1654
+ async execute(args) {
1655
+ try {
1656
+ const transport = args.transport ?? "auto";
1657
+ if (transport === "relay") {
1658
+ const status = await deps.manager.status(args.sessionId);
1659
+ if (status.mode !== "extension") {
1660
+ return failure("Annotations require extension mode (relay).", "annotate_requires_extension");
1661
+ }
1662
+ }
1663
+ const response = await deps.annotationManager.requestAnnotation({
1664
+ sessionId: args.sessionId,
1665
+ transport,
1666
+ targetId: args.targetId,
1667
+ tabId: args.tabId,
1668
+ url: args.url,
1669
+ screenshotMode: args.screenshotMode ?? "visible",
1670
+ debug: args.debug ?? false,
1671
+ context: args.context,
1672
+ timeoutMs: args.timeoutMs
1673
+ });
1674
+ if (response.status !== "ok" || !response.payload) {
1675
+ const message2 = response.error?.message ?? "Annotation failed.";
1676
+ const code = response.error?.code ?? "annotate_failed";
1677
+ return failure(message2, code);
1678
+ }
1679
+ const { message, details, screenshots } = await buildAnnotateResult(response.payload);
1680
+ return ok({
1681
+ message,
1682
+ details,
1683
+ screenshots
1684
+ });
1685
+ } catch (error) {
1686
+ return failure(serializeError(error).message, "annotate_failed");
1687
+ }
1688
+ }
1689
+ });
1690
+ }
1691
+
936
1692
  // src/tools/skill_list.ts
937
- import { tool as tool29 } from "@opencode-ai/plugin";
1693
+ import { tool as tool40 } from "@opencode-ai/plugin";
938
1694
  function createSkillListTool(deps) {
939
- return tool29({
1695
+ return tool40({
940
1696
  description: "List available skills from OpenCode skill directories (compatibility wrapper)",
941
1697
  args: {},
942
1698
  async execute() {
@@ -952,14 +1708,14 @@ function createSkillListTool(deps) {
952
1708
  }
953
1709
 
954
1710
  // src/tools/skill_load.ts
955
- import { tool as tool30 } from "@opencode-ai/plugin";
956
- var z29 = tool30.schema;
1711
+ import { tool as tool41 } from "@opencode-ai/plugin";
1712
+ var z40 = tool41.schema;
957
1713
  function createSkillLoadTool(deps) {
958
- return tool30({
1714
+ return tool41({
959
1715
  description: "Load a specific skill by name from OpenCode skill directories (compatibility wrapper)",
960
1716
  args: {
961
- name: z29.string().describe("Name of the skill to load (e.g., 'login-automation', 'form-testing')"),
962
- topic: z29.string().optional().describe("Optional topic to filter the skill content")
1717
+ name: z40.string().describe("Name of the skill to load (e.g., 'login-automation', 'form-testing')"),
1718
+ topic: z40.string().optional().describe("Optional topic to filter the skill content")
963
1719
  },
964
1720
  async execute(args) {
965
1721
  try {
@@ -975,44 +1731,74 @@ function createSkillLoadTool(deps) {
975
1731
 
976
1732
  // src/tools/index.ts
977
1733
  function createTools(deps) {
1734
+ const wrap = (definition) => {
1735
+ if (!deps.ensureHub) return definition;
1736
+ return {
1737
+ ...definition,
1738
+ execute: async (args, context) => {
1739
+ try {
1740
+ await deps.ensureHub?.();
1741
+ } catch {
1742
+ }
1743
+ return definition.execute(args, context);
1744
+ }
1745
+ };
1746
+ };
978
1747
  return {
979
- opendevbrowser_launch: createLaunchTool(deps),
980
- opendevbrowser_connect: createConnectTool(deps),
981
- opendevbrowser_disconnect: createDisconnectTool(deps),
982
- opendevbrowser_status: createStatusTool(deps),
983
- opendevbrowser_targets_list: createTargetsListTool(deps),
984
- opendevbrowser_target_use: createTargetUseTool(deps),
985
- opendevbrowser_target_new: createTargetNewTool(deps),
986
- opendevbrowser_target_close: createTargetCloseTool(deps),
987
- opendevbrowser_page: createPageTool(deps),
988
- opendevbrowser_list: createListTool(deps),
989
- opendevbrowser_close: createCloseTool(deps),
990
- opendevbrowser_goto: createGotoTool(deps),
991
- opendevbrowser_wait: createWaitTool(deps),
992
- opendevbrowser_snapshot: createSnapshotTool(deps),
993
- opendevbrowser_click: createClickTool(deps),
994
- opendevbrowser_type: createTypeTool(deps),
995
- opendevbrowser_select: createSelectTool(deps),
996
- opendevbrowser_scroll: createScrollTool(deps),
997
- opendevbrowser_dom_get_html: createDomGetHtmlTool(deps),
998
- opendevbrowser_dom_get_text: createDomGetTextTool(deps),
999
- opendevbrowser_run: createRunTool(deps),
1000
- opendevbrowser_prompting_guide: createPromptingGuideTool(deps),
1001
- opendevbrowser_console_poll: createConsolePollTool(deps),
1002
- opendevbrowser_network_poll: createNetworkPollTool(deps),
1003
- opendevbrowser_clone_page: createClonePageTool(deps),
1004
- opendevbrowser_clone_component: createCloneComponentTool(deps),
1005
- opendevbrowser_perf: createPerfTool(deps),
1006
- opendevbrowser_screenshot: createScreenshotTool(deps),
1007
- opendevbrowser_skill_list: createSkillListTool(deps),
1008
- opendevbrowser_skill_load: createSkillLoadTool(deps)
1748
+ opendevbrowser_launch: wrap(createLaunchTool(deps)),
1749
+ opendevbrowser_connect: wrap(createConnectTool(deps)),
1750
+ opendevbrowser_disconnect: wrap(createDisconnectTool(deps)),
1751
+ opendevbrowser_status: wrap(createStatusTool(deps)),
1752
+ opendevbrowser_targets_list: wrap(createTargetsListTool(deps)),
1753
+ opendevbrowser_target_use: wrap(createTargetUseTool(deps)),
1754
+ opendevbrowser_target_new: wrap(createTargetNewTool(deps)),
1755
+ opendevbrowser_target_close: wrap(createTargetCloseTool(deps)),
1756
+ opendevbrowser_page: wrap(createPageTool(deps)),
1757
+ opendevbrowser_list: wrap(createListTool(deps)),
1758
+ opendevbrowser_close: wrap(createCloseTool(deps)),
1759
+ opendevbrowser_goto: wrap(createGotoTool(deps)),
1760
+ opendevbrowser_wait: wrap(createWaitTool(deps)),
1761
+ opendevbrowser_snapshot: wrap(createSnapshotTool(deps)),
1762
+ opendevbrowser_click: wrap(createClickTool(deps)),
1763
+ opendevbrowser_hover: wrap(createHoverTool(deps)),
1764
+ opendevbrowser_press: wrap(createPressTool(deps)),
1765
+ opendevbrowser_check: wrap(createCheckTool(deps)),
1766
+ opendevbrowser_uncheck: wrap(createUncheckTool(deps)),
1767
+ opendevbrowser_type: wrap(createTypeTool(deps)),
1768
+ opendevbrowser_select: wrap(createSelectTool(deps)),
1769
+ opendevbrowser_scroll: wrap(createScrollTool(deps)),
1770
+ opendevbrowser_scroll_into_view: wrap(createScrollIntoViewTool(deps)),
1771
+ opendevbrowser_dom_get_html: wrap(createDomGetHtmlTool(deps)),
1772
+ opendevbrowser_dom_get_text: wrap(createDomGetTextTool(deps)),
1773
+ opendevbrowser_get_attr: wrap(createGetAttrTool(deps)),
1774
+ opendevbrowser_get_value: wrap(createGetValueTool(deps)),
1775
+ opendevbrowser_is_visible: wrap(createIsVisibleTool(deps)),
1776
+ opendevbrowser_is_enabled: wrap(createIsEnabledTool(deps)),
1777
+ opendevbrowser_is_checked: wrap(createIsCheckedTool(deps)),
1778
+ opendevbrowser_run: wrap(createRunTool(deps)),
1779
+ opendevbrowser_prompting_guide: wrap(createPromptingGuideTool(deps)),
1780
+ opendevbrowser_console_poll: wrap(createConsolePollTool(deps)),
1781
+ opendevbrowser_network_poll: wrap(createNetworkPollTool(deps)),
1782
+ opendevbrowser_clone_page: wrap(createClonePageTool(deps)),
1783
+ opendevbrowser_clone_component: wrap(createCloneComponentTool(deps)),
1784
+ opendevbrowser_perf: wrap(createPerfTool(deps)),
1785
+ opendevbrowser_screenshot: wrap(createScreenshotTool(deps)),
1786
+ opendevbrowser_annotate: wrap(createAnnotateTool(deps)),
1787
+ opendevbrowser_skill_list: wrap(createSkillListTool(deps)),
1788
+ opendevbrowser_skill_load: wrap(createSkillLoadTool(deps))
1009
1789
  };
1010
1790
  }
1011
1791
 
1012
1792
  // src/index.ts
1013
1793
  var OpenDevBrowserPlugin = async ({ directory, worktree }) => {
1014
1794
  const core = createOpenDevBrowserCore({ directory, worktree });
1015
- const { config, configStore, manager, runner, skills, relay, ensureRelay, cleanup, getExtensionPath } = core;
1795
+ const { config, configStore, skills, ensureRelay, cleanup, getExtensionPath } = core;
1796
+ let relay = core.relay;
1797
+ let manager = core.manager;
1798
+ let runner = core.runner;
1799
+ let annotationManager = core.annotationManager;
1800
+ let hubStop = null;
1801
+ let daemonClient = null;
1016
1802
  const skillNudgeState = createSkillNudgeState();
1017
1803
  const continuityNudgeState = createContinuityNudgeState();
1018
1804
  console.info(
@@ -1023,12 +1809,89 @@ var OpenDevBrowserPlugin = async ({ directory, worktree }) => {
1023
1809
  } catch (error) {
1024
1810
  console.warn("Extension extraction failed:", error instanceof Error ? error.message : error);
1025
1811
  }
1026
- process.on("SIGINT", cleanup);
1027
- process.on("SIGTERM", cleanup);
1028
- process.on("beforeExit", cleanup);
1029
- await ensureRelay(config.relayPort);
1812
+ const toolDeps = {
1813
+ manager,
1814
+ annotationManager,
1815
+ runner,
1816
+ config: configStore,
1817
+ skills,
1818
+ relay,
1819
+ getExtensionPath
1820
+ };
1821
+ const bindRemote = () => {
1822
+ if (!daemonClient) {
1823
+ daemonClient = new DaemonClient({ autoRenew: true });
1824
+ }
1825
+ manager = new RemoteManager(daemonClient);
1826
+ relay = new RemoteRelay(daemonClient);
1827
+ annotationManager.setRelay(relay);
1828
+ annotationManager.setBrowserManager(manager);
1829
+ runner = new ScriptRunner(manager);
1830
+ toolDeps.manager = manager;
1831
+ toolDeps.relay = relay;
1832
+ toolDeps.runner = runner;
1833
+ };
1834
+ const ensureHub = async () => {
1835
+ const currentConfig = configStore.get();
1836
+ if (!isHubEnabled(currentConfig)) {
1837
+ return;
1838
+ }
1839
+ if (!daemonClient) {
1840
+ daemonClient = new DaemonClient({ autoRenew: true });
1841
+ }
1842
+ const deadline = Date.now() + 2e3;
1843
+ let attempt = 0;
1844
+ let lastError = null;
1845
+ while (attempt < 2 && Date.now() < deadline) {
1846
+ attempt += 1;
1847
+ const status = await fetchDaemonStatusFromMetadata(currentConfig);
1848
+ if (status?.ok) {
1849
+ bindRemote();
1850
+ await relay?.refresh?.();
1851
+ return;
1852
+ }
1853
+ try {
1854
+ const { stop } = await startDaemon({ config: currentConfig, directory, worktree });
1855
+ hubStop = stop;
1856
+ } catch (error) {
1857
+ lastError = error instanceof Error ? error : new Error(String(error));
1858
+ }
1859
+ if (Date.now() < deadline) {
1860
+ await new Promise((resolve) => setTimeout(resolve, 500));
1861
+ }
1862
+ }
1863
+ if (lastError) {
1864
+ throw lastError;
1865
+ }
1866
+ throw new Error("Hub daemon unavailable.");
1867
+ };
1868
+ toolDeps.ensureHub = ensureHub;
1869
+ const hubEnabled = isHubEnabled(config);
1870
+ if (hubEnabled) {
1871
+ bindRemote();
1872
+ try {
1873
+ await ensureHub();
1874
+ } catch (error) {
1875
+ const message = error instanceof Error ? error.message : String(error);
1876
+ console.warn(`[opendevbrowser] Hub daemon unavailable: ${message}`);
1877
+ }
1878
+ } else {
1879
+ await ensureRelay(config.relayPort);
1880
+ }
1881
+ const cleanupAll = () => {
1882
+ if (hubStop) {
1883
+ hubStop().catch(() => {
1884
+ });
1885
+ }
1886
+ daemonClient?.releaseBinding().catch(() => {
1887
+ });
1888
+ cleanup();
1889
+ };
1890
+ process.on("SIGINT", cleanupAll);
1891
+ process.on("SIGTERM", cleanupAll);
1892
+ process.on("beforeExit", cleanupAll);
1030
1893
  return {
1031
- tool: createTools({ manager, runner, config: configStore, skills, relay, getExtensionPath }),
1894
+ tool: createTools(toolDeps),
1032
1895
  "chat.message": async (_input, output) => {
1033
1896
  const config2 = configStore.get();
1034
1897
  if (output.message.role !== "user") return;