witbitz-code 1.7.4__tar.gz → 1.8.0__tar.gz

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 (114) hide show
  1. {witbitz_code-1.7.4/src/witbitz_code.egg-info → witbitz_code-1.8.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/__init__.py +1 -1
  4. witbitz_code-1.8.0/src/witbitz_code/browse.py +145 -0
  5. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/cli.py +18 -3
  6. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/codetools.py +1 -1
  7. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/connector.py +27 -4
  8. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/devicelink.py +4 -0
  9. witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +248 -0
  10. witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +223 -0
  11. witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +1395 -0
  12. witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browser.js +168 -0
  13. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/relay.py +10 -0
  14. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/selfupdate.py +5 -4
  15. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui.py +4 -3
  16. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui_static/app.js +4 -4
  17. {witbitz_code-1.7.4 → witbitz_code-1.8.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  18. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/SOURCES.txt +6 -0
  19. witbitz_code-1.8.0/tests/test_browse.py +43 -0
  20. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_devicelink.py +3 -1
  21. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_selfupdate.py +14 -1
  22. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_ui.py +28 -1
  23. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/LICENSE +0 -0
  24. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/README.md +0 -0
  25. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/setup.cfg +0 -0
  26. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/__main__.py +0 -0
  27. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/_js.py +0 -0
  28. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/account.py +0 -0
  29. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/asks.py +0 -0
  30. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/attachments.py +0 -0
  31. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/auto.py +0 -0
  32. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/auto_runner.py +0 -0
  33. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/codekeys.py +0 -0
  34. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/compress.py +0 -0
  35. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/computers.py +0 -0
  36. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/confidential.py +0 -0
  37. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/confidential_models.json +0 -0
  38. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/dirs.py +0 -0
  39. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/folders.py +0 -0
  40. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/gateway.py +0 -0
  41. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  42. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  43. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/images.py +0 -0
  44. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/keys.py +0 -0
  45. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/link.py +0 -0
  46. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/mkdir.py +0 -0
  47. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/models.py +0 -0
  48. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/net.py +0 -0
  49. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/offered_models.json +0 -0
  50. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  51. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  52. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  53. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  54. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/outputs.py +0 -0
  55. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/pair.py +0 -0
  56. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/pairings.py +0 -0
  57. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/payers.py +0 -0
  58. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/plugins.py +0 -0
  59. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/policy.py +0 -0
  60. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ports.py +0 -0
  61. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/qr.py +0 -0
  62. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/receipt.py +0 -0
  63. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/remind.py +0 -0
  64. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/seccheck.py +0 -0
  65. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/seen.py +0 -0
  66. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/tinfoil_read.py +0 -0
  67. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/tools_catalog.json +0 -0
  68. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/tools_probe.py +0 -0
  69. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/toolsetup.py +0 -0
  70. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui_static/app.css +0 -0
  71. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui_static/index.html +0 -0
  72. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/uitools.py +0 -0
  73. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/uninstall.py +0 -0
  74. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/winagent.py +0 -0
  75. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/winbg.py +0 -0
  76. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/winconsole.py +0 -0
  77. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/wizard.py +0 -0
  78. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/wslstart.py +0 -0
  79. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  80. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  81. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  82. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  83. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_asks.py +0 -0
  84. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_attachments.py +0 -0
  85. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_auto.py +0 -0
  86. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_cli.py +0 -0
  87. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_codekeys.py +0 -0
  88. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_codetools.py +0 -0
  89. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_confidential.py +0 -0
  90. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_connector_e2e.py +0 -0
  91. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_dirs.py +0 -0
  92. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_files_and_compress.py +0 -0
  93. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_folders.py +0 -0
  94. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_images.py +0 -0
  95. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_outputs.py +0 -0
  96. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_pair_flows.py +0 -0
  97. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_plugins.py +0 -0
  98. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_policy.py +0 -0
  99. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_ports.py +0 -0
  100. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_ports_win.py +0 -0
  101. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_registry.py +0 -0
  102. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_relay_codec.py +0 -0
  103. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_remind.py +0 -0
  104. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_seccheck.py +0 -0
  105. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_setup.py +0 -0
  106. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_tools_probe.py +0 -0
  107. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_toolsetup.py +0 -0
  108. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_uitools.py +0 -0
  109. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_uninstall.py +0 -0
  110. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_winagent.py +0 -0
  111. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_winbg.py +0 -0
  112. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_winconsole.py +0 -0
  113. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_windows_files.py +0 -0
  114. {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.4
3
+ Version: 1.8.0
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.7.4"
7
+ version = "1.8.0"
8
8
  description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.7.4"
7
+ __version__ = "1.8.0"
@@ -0,0 +1,145 @@
1
+ """The agent's browser on this computer — the twin of tools/browse-local-serve.mjs (2026-10-19; the owner: "I want to add the
2
+ browsing with agent capability in local code too" → "Both stages. Default.").
3
+
4
+ ON by default; `serve --no-browser` or WB_BROWSE_DISABLE=1 turns it off (and removes the agent's browser tools). The browser
5
+ programs are JavaScript (the cloud computer's own: browse-local, the agent's CLI, the publisher), so they run with this
6
+ computer's `node` — as the media tools do; without node the agent works without a browser. Each pairing gets a viewer key
7
+ derived from its secret (relay.browse_key = codeRelay.js deriveBrowseKey), so the app can Watch and Take over.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import os
14
+ import re
15
+ import shutil
16
+ import socket
17
+ import subprocess
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ import httpx
22
+
23
+ from . import plugins
24
+ from .relay import browse_key
25
+
26
+ BROWSE_DIR = "witbitz-browse"
27
+ PLUGIN_REL = "plugins/witbitz-browser.js"
28
+ FILES = { # installed path (under OpenCode's config folder) → the packaged file (tools/browse-embed.mjs writes them)
29
+ f"{BROWSE_DIR}/browse-local.mjs": "witbitz-browse-local.mjs",
30
+ f"{BROWSE_DIR}/browse-cdp.mjs": "witbitz-browse-cdp.mjs",
31
+ f"{BROWSE_DIR}/browse-publisher.mjs": "witbitz-browse-publisher.mjs",
32
+ PLUGIN_REL: "witbitz-browser.js",
33
+ }
34
+
35
+
36
+ def disabled(args: list[str], env: dict | None = None) -> bool:
37
+ env = os.environ if env is None else env
38
+ return "--no-browser" in args or bool(re.fullmatch(r"(?i)1|true|yes", str(env.get("WB_BROWSE_DISABLE", ""))))
39
+
40
+
41
+ def install(config_dir: Path | None = None) -> dict[str, Path]:
42
+ """The browser files into OpenCode's config folder (an identical file is left alone); → their paths by file name."""
43
+ root = Path(config_dir) if config_dir else plugins.opencode_config_dir()
44
+ out = {}
45
+ for rel, name in FILES.items():
46
+ target = root / rel
47
+ text = plugins.packaged(name)
48
+ try:
49
+ same = target.read_text(encoding="utf-8") == text
50
+ except (OSError, UnicodeDecodeError):
51
+ same = False
52
+ if not same:
53
+ target.parent.mkdir(parents=True, exist_ok=True)
54
+ target.write_text(text, encoding="utf-8", newline="")
55
+ out[Path(rel).name] = target
56
+ return out
57
+
58
+
59
+ def remove(config_dir: Path | None = None) -> None:
60
+ """Turned off: the agent's browser tools go, so OpenCode never offers a tool that can only fail."""
61
+ root = Path(config_dir) if config_dir else plugins.opencode_config_dir()
62
+ try:
63
+ (root / PLUGIN_REL).unlink()
64
+ except FileNotFoundError:
65
+ pass
66
+
67
+
68
+ def node_major(node: str) -> int:
69
+ try:
70
+ v = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=10).stdout.strip()
71
+ return int(v.lstrip("v").split(".")[0])
72
+ except (OSError, ValueError, subprocess.SubprocessError):
73
+ return 0
74
+
75
+
76
+ def _free_port() -> int:
77
+ with socket.socket() as s:
78
+ s.bind(("127.0.0.1", 0))
79
+ return s.getsockname()[1]
80
+
81
+
82
+ class LocalBrowser:
83
+ def __init__(self, proc: asyncio.subprocess.Process, ctl: int, env: dict, http: httpx.AsyncClient) -> None:
84
+ self.proc, self.ctl, self.env, self._http = proc, ctl, env, http
85
+ self.last: dict | None = None
86
+ self._task: asyncio.Task | None = None
87
+
88
+ def probe(self) -> dict | None:
89
+ """The browser's last /status — synchronous: the connector's hello must never wait on it."""
90
+ return self.last
91
+
92
+ async def _poll(self) -> None:
93
+ try:
94
+ r = await self._http.get(f"http://127.0.0.1:{self.ctl}/status", timeout=1.5)
95
+ self.last = r.json() if r.status_code == 200 else None
96
+ except (httpx.HTTPError, ValueError):
97
+ self.last = None
98
+
99
+ async def _every(self) -> None:
100
+ while True:
101
+ await asyncio.sleep(2)
102
+ await self._poll()
103
+
104
+ async def stop(self) -> None:
105
+ if self._task:
106
+ self._task.cancel()
107
+ if self.proc.returncode is None:
108
+ try:
109
+ self.proc.terminate()
110
+ await asyncio.wait_for(self.proc.wait(), 5)
111
+ except (ProcessLookupError, asyncio.TimeoutError):
112
+ try:
113
+ self.proc.kill()
114
+ except ProcessLookupError:
115
+ pass
116
+ await self._http.aclose()
117
+
118
+
119
+ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Callable[[str], Any] = print,
120
+ node: str | None = None) -> LocalBrowser | None:
121
+ """Start it; → a LocalBrowser (env for OpenCode, probe for the hello), or None when this computer has no node."""
122
+ node = node or shutil.which("node")
123
+ if not node:
124
+ log("witbitz-code: no node on this computer — the agent works without a browser (install Node.js 22+ to add one)")
125
+ return None
126
+ paths = await asyncio.to_thread(install, config_dir)
127
+ watchable = node_major(node) >= 22 # the publisher needs a global WebSocket
128
+ keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
129
+ ctl, gate = _free_port(), _free_port()
130
+ env = {**os.environ, "WB_BROWSE_CTL_PORT": str(ctl), "WB_BROWSE_GATE_PORT": str(gate), "WB_BROWSE_KEYS": ",".join(keys),
131
+ "WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid())}
132
+ proc = await asyncio.create_subprocess_exec(node, str(paths["browse-local.mjs"]), env=env, stdin=asyncio.subprocess.DEVNULL,
133
+ stdout=asyncio.subprocess.DEVNULL)
134
+ b = LocalBrowser(proc, ctl, {"WB_BROWSE_NODE": node, "WB_BROWSE_CDP_CLI": str(paths["browse-cdp.mjs"]),
135
+ "WB_BROWSE_CTL": f"http://127.0.0.1:{ctl}", "WB_BROWSE_CDP": f"http://127.0.0.1:{gate}"},
136
+ httpx.AsyncClient())
137
+ for _ in range(20): # its control port answers within a moment
138
+ await b._poll()
139
+ if b.last is not None:
140
+ break
141
+ await asyncio.sleep(0.25)
142
+ b._task = asyncio.ensure_future(b._every())
143
+ log("witbitz-code: the agent's browser is ready on this computer"
144
+ + (" — watch it from the Code section" if watchable else " (node is older than 22: the app cannot watch it)") + "; off: serve --no-browser")
145
+ return b
@@ -26,7 +26,7 @@ import httpx
26
26
  from . import __version__
27
27
  from . import pair as pair_cmd
28
28
  from . import pairings as pp
29
- from . import codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winbg, winconsole, wizard, wslstart
29
+ from . import browse as browse_mod, codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winbg, winconsole, wizard, wslstart
30
30
  from ._js import to_number, truthy
31
31
  from .connector import pairings_for_port, start_connector
32
32
  from .payers import SessionPayers
@@ -41,7 +41,7 @@ HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from t
41
41
  start when I log in (Windows and WSL); it serves while it runs. --background (Windows):
42
42
  no window — the page's Quit stops it
43
43
  pair [--name <name>] show a QR code; scan it in Spaces (Settings → Privacy & security → Add a device)
44
- serve [--port <n>] [--no-opencode]
44
+ serve [--port <n>] [--no-opencode] [--no-browser]
45
45
  start OpenCode on 127.0.0.1 with your account's keys, and the connector
46
46
  status list this computer's pairings and their coding keys
47
47
  rotate [--account <email>] replace the pairing secret(s) without a scan, then restart serve
@@ -217,6 +217,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
217
217
  stop = stop or _Stop()
218
218
  child = None
219
219
  proxy = None
220
+ browser = None
220
221
  live: dict = {"connector": None}
221
222
  keys_http = httpx.AsyncClient(timeout=30)
222
223
  try:
@@ -245,6 +246,17 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
245
246
  _err(f"witbitz-code: OpenCode is already running on 127.0.0.1:{port}, started some other way — it cannot use your account's keys.")
246
247
  _err(" Close it, then run witbitz-code serve again (it starts OpenCode itself).")
247
248
  return 1
249
+ # THE AGENT'S BROWSER (browse.py): ON unless --no-browser / WB_BROWSE_DISABLE=1 — started BEFORE OpenCode, whose
250
+ # plugin reads where it is; watched from the Code section like a cloud computer's.
251
+ if browse_mod.disabled(args):
252
+ browse_mod.remove()
253
+ else:
254
+ try:
255
+ browser = await browse_mod.start(pairings, log=_err)
256
+ except OSError as e:
257
+ _err(f"witbitz-code: the agent's browser could not start ({e}) — the agent works without it")
258
+ if browser:
259
+ tools_env = {**tools_env, **browser.env}
248
260
  if "--no-opencode" not in args:
249
261
  exe = wizard.find_opencode()
250
262
  if not exe:
@@ -262,7 +274,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
262
274
  if child is not None:
263
275
  _err("witbitz-code: model calls are paid by each account's own coding keys; confidential models are enforced (min_privacy + verified receipts)")
264
276
  # Auto mode's reviewer asks the policy of the model proxy directly (auto_runner.REVIEW_MODELS), billed like the session.
265
- connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else "")
277
+ connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else "",
278
+ browse_probe=browser.probe if browser else None)
266
279
  live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
267
280
  live["connector"] = connector
268
281
  if live_out is not None:
@@ -281,6 +294,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
281
294
  finally:
282
295
  if child is not None:
283
296
  await _stop_opencode(child)
297
+ if browser is not None:
298
+ await browser.stop()
284
299
  if proxy is not None:
285
300
  await proxy.close()
286
301
  await keys_http.aclose()
@@ -39,7 +39,7 @@ Tools you may expect from other setups, and what stands in for them here — do
39
39
  - todoread → todowrite returns the whole list on every write.
40
40
  - multiedit / patch → edit, one file at a time, or apply_patch where your model is offered it.
41
41
  - websearch → web_search (witbitz-media): confidential, answered by a search agent inside a hardware enclave; then webfetch a source for its full text.
42
- - browser → browser_open / browser_navigate / browser_snapshot / browser_click / browser_type / browser_key / browser_scroll / browser_back / browser_screenshot, when this computer offers them; act from a snapshot's numbered refs, not from a screenshot.
42
+ - browser → browser_open / browser_navigate / browser_snapshot / browser_click / browser_type / browser_key / browser_scroll / browser_back / browser_screenshot / browser_close, when this computer offers them; close it when you are done; act from a snapshot's numbered refs, not from a screenshot.
43
43
  - lsp is here when the config enables a language server for the file type (Python: pyright); otherwise run the project's own typecheck or lint.
44
44
  """
45
45
 
@@ -160,8 +160,12 @@ class PairingServer:
160
160
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
161
161
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
162
162
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
163
- model_url: str = "", remind_times: dict | None = None) -> None:
163
+ model_url: str = "", remind_times: dict | None = None,
164
+ browse_probe: Callable[[], dict | None] | None = None) -> None:
164
165
  self.pairing = pairing
166
+ # THE AGENT'S BROWSER ON THIS COMPUTER (browse.py): its /status rides the hello; a change is announced within 2 s
167
+ self._browse_probe = browse_probe
168
+ self._browse_said = "" # the last browse report the page was told, as JSON
165
169
  self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
166
170
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
167
171
  self._payers = payers
@@ -227,6 +231,8 @@ class PairingServer:
227
231
  self._spawn(self._every(SWEEP_EVERY_S, self._sweep))
228
232
  self._spawn(self._every(self._remind_rt.get("every_s") or REMIND_EVERY_S, lambda: self._spawn(self._remind_tick())))
229
233
  self._spawn(self._every(HELLO_EVERY_S, lambda: self.hello() if self.peer.peers >= 2 else None))
234
+ if self._browse_probe:
235
+ self._spawn(self._every(2, self._browse_changed))
230
236
  self._spawn(self._watch_asks())
231
237
  self._spawn(self._every(self._asks_reconcile_s, lambda: self._spawn(self._reconcile_asks()) if self._asks_live else None))
232
238
  self.auto.start()
@@ -290,12 +296,27 @@ class PairingServer:
290
296
  except Exception as e:
291
297
  self._log(f"witbitz-code: {self.name} · {e}")
292
298
 
299
+ def _browse_report(self) -> dict | None:
300
+ try:
301
+ r = self._browse_probe() if self._browse_probe else None
302
+ except Exception: # noqa: BLE001 — a probe that throws reports nothing
303
+ r = None
304
+ return r if isinstance(r, dict) else None
305
+
306
+ def _browse_changed(self) -> None:
307
+ now = json.dumps(self._browse_report(), sort_keys=True)
308
+ if now != self._browse_said and self.peer.peers >= 2:
309
+ self.hello()
310
+
293
311
  def hello(self) -> asyncio.Future | None:
294
312
  if not self.nonce:
295
313
  return None # no hello before this socket has its nonce
314
+ browse = self._browse_report()
315
+ self._browse_said = json.dumps(browse, sort_keys=True)
296
316
  # caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
297
317
  return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
298
- "k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], "auto": self.auto.sessions()})
318
+ "k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], "auto": self.auto.sessions(),
319
+ **({"browse": browse} if browse else {})})
299
320
 
300
321
  def _auth(self) -> dict:
301
322
  pw = self.pairing.get("password") or read_env_password(Path(self.pairing["envFile"]) if self.pairing.get("envFile") else None)
@@ -941,7 +962,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
941
962
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
942
963
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
943
964
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
944
- model_url: str = "", remind_times: dict | None = None) -> Connector:
965
+ model_url: str = "", remind_times: dict | None = None,
966
+ browse_probe: Callable[[], dict | None] | None = None) -> Connector:
945
967
  """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
946
968
  request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
947
969
  client = client or local_client()
@@ -956,7 +978,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
956
978
  s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
957
979
  max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
958
980
  attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
959
- notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times)
981
+ notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times,
982
+ browse_probe=browse_probe)
960
983
  await s.start()
961
984
  servers.append(s)
962
985
  return Connector(servers, client, payers)
@@ -116,6 +116,9 @@ def read_link_payload(p: Any) -> dict | None:
116
116
  anchor = {"method": a["method"], "email": a["email"]}
117
117
  if isinstance(token, str) and token:
118
118
  anchor["token"] = token
119
+ kp = a.get("kp")
120
+ if isinstance(kp, dict) and truthy(kp.get("priv")) and truthy(kp.get("pub")):
121
+ anchor["kp"] = {"priv": kp["priv"], "pub": kp["pub"]}
119
122
  if truthy(a.get("exp")):
120
123
  anchor["exp"] = a["exp"]
121
124
  rel = p.get("rel")
@@ -128,6 +131,7 @@ def read_link_payload(p: Any) -> dict | None:
128
131
  if isinstance(rel, dict) and isinstance(rel.get("token"), str) and rel["token"] else None,
129
132
  "idx": _ptr(p.get("idx")),
130
133
  "pipes": _ptr(p.get("pipes")),
134
+ "msgs": _ptr(p.get("msgs")), # the message room (deviceLink.js since b27a27d0)
131
135
  "anchor": anchor,
132
136
  }
133
137
 
@@ -0,0 +1,248 @@
1
+ // browse-cdp.mjs — what the AGENT does to its browser, over CDP (docs/cloud-computer.md § 12).
2
+ //
3
+ // node browse-cdp.mjs <op> ['{"json":"args"}'] → one JSON object on stdout
4
+ //
5
+ // It talks to the CDP GATE (browse.go), never to Chromium directly, so the enclave can close the agent's session
6
+ // the moment the owner takes the controls — and a tool that runs during a take-over fails plainly rather than
7
+ // quietly reading a screen someone is typing a password into.
8
+ //
9
+ // ★ ACCESSIBILITY, NOT PIXELS. `snapshot` returns the page's accessibility tree as numbered refs, and `click`
10
+ // takes one of those refs. The agent never needs a screenshot to act, which keeps its context small and its
11
+ // actions deterministic — and it means the model is working from what a screen reader would say, not from what a
12
+ // vision model guesses a JPEG shows. A screenshot is available, for a human to look at, not for the agent to aim by.
13
+ //
14
+ // No dependency: Node's own fetch and WebSocket. There is no npm in the enclave and no network to reach one.
15
+ const CTL = process.env.WB_BROWSE_CTL || 'http://127.0.0.1:9224'
16
+ const GATE = process.env.WB_BROWSE_CDP || 'http://127.0.0.1:9223'
17
+ const out = (o) => { process.stdout.write(JSON.stringify(o) + '\n'); process.exit(o.error ? 1 : 0) }
18
+
19
+ class CDP {
20
+ constructor (ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
21
+ static async attach () {
22
+ let targets
23
+ try {
24
+ // ★ The gate answers /json with an OBJECT when it has no browser to ask ({ error }) or when the browser did not
25
+ // answer it — a model read "targets.find is not a function" for that (the owner's agent, 2026-10-06). Three quick
26
+ // tries cover a browser that is still coming up; an object after that is the gate's own sentence.
27
+ for (let i = 0; ; i++) {
28
+ targets = await (await fetch(GATE + '/json')).json()
29
+ if (Array.isArray(targets)) break
30
+ if (i >= 2) throw new Error((targets && targets.error) || 'the browser gate answered without a target list')
31
+ await new Promise((r) => setTimeout(r, 300))
32
+ }
33
+ } catch (e) {
34
+ // ★★ SAY WHICH ONE. This used to answer "it may be closed, or the owner has the controls" — two causes,
35
+ // neither checked, and on 2026-09-21 BOTH were false: no browser had ever been started. The agent
36
+ // faithfully passed that on, and the owner went hunting in the viewer for a take-over that did not exist
37
+ // ("Try opening example.com" → "it may have been closed or taken over on your side").
38
+ // The control surface knows exactly which case this is and is already reachable from here, so ask it.
39
+ // An absent capability and a withheld one must never read the same — the third time that lesson has been
40
+ // paid for in this subsystem (the plugin that loaded zero tools, the row that was silent when not running).
41
+ let st = null
42
+ try { st = await (await fetch(CTL + '/status')).json() } catch { /* the control surface is gone too */ }
43
+ if (st && !st.offered) throw new Error('this cloud computer has no browser — it was created without one')
44
+ if (st && st.takeover) throw new Error('the owner has the controls — the agent cannot drive this browser until they give them back')
45
+ if (st && !st.running) throw new Error('no browser is open yet — call browser_open first, then navigate')
46
+ throw new Error('the browser is not reachable, and its control surface did not answer either: ' + String(e).slice(0, 80))
47
+ }
48
+ const t = targets.find((x) => x.type === 'page') || targets[0]
49
+ if (!t) throw new Error('the browser has no page open')
50
+ const ws = new WebSocket(t.webSocketDebuggerUrl)
51
+ await new Promise((res, rej) => {
52
+ const timer = setTimeout(() => rej(new Error('CDP did not answer')), 10000)
53
+ ws.onopen = () => { clearTimeout(timer); res() }
54
+ ws.onerror = () => { clearTimeout(timer); rej(new Error('the CDP gate refused the connection — the owner may have the controls')) }
55
+ })
56
+ const c = new CDP(ws)
57
+ ws.onmessage = (ev) => {
58
+ const m = JSON.parse(ev.data)
59
+ if (m.id && c.pending.has(m.id)) { c.pending.get(m.id)(m); c.pending.delete(m.id) }
60
+ }
61
+ return c
62
+ }
63
+ send (method, params = {}) {
64
+ return new Promise((res, rej) => {
65
+ const i = ++this.id
66
+ const timer = setTimeout(() => rej(new Error(method + ' timed out')), 30000)
67
+ this.pending.set(i, (m) => { clearTimeout(timer); m.error ? rej(new Error(method + ': ' + m.error.message)) : res(m.result) })
68
+ this.ws.send(JSON.stringify({ id: i, method, params }))
69
+ })
70
+ }
71
+ close () { try { this.ws.close() } catch { /* already gone */ } }
72
+ }
73
+
74
+ // The roles worth showing a model: everything it can act on, plus the text that tells it where it is. A full AX
75
+ // tree of a real page is thousands of nodes and most of them are scaffolding.
76
+ const USEFUL = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'menuitem',
77
+ 'tab', 'switch', 'slider', 'option', 'heading', 'StaticText', 'image', 'listitem', 'alert', 'dialog'])
78
+
79
+ async function snapshot (c, limit = 120) {
80
+ await c.send('Accessibility.enable')
81
+ const { nodes } = await c.send('Accessibility.getFullAXTree', { depth: -1 })
82
+ const lines = []
83
+ const refs = {}
84
+ let n = 0
85
+ for (const node of nodes) {
86
+ if (node.ignored) continue
87
+ const role = node.role?.value
88
+ const name = (node.name?.value || '').replace(/\s+/g, ' ').trim()
89
+ if (!role || !USEFUL.has(role)) continue
90
+ if (!name && role !== 'textbox' && role !== 'searchbox') continue
91
+ n++
92
+ if (n > limit) break
93
+ const ref = String(n)
94
+ refs[ref] = node.backendDOMNodeId
95
+ const value = node.properties?.find((p) => p.name === 'value')?.value?.value
96
+ lines.push(`[${ref}] ${role}${name ? ' "' + name.slice(0, 120) + '"' : ''}${value ? ' = "' + String(value).slice(0, 60) + '"' : ''}`)
97
+ }
98
+ return { lines, refs, truncated: n > limit }
99
+ }
100
+
101
+ // The refs are only meaningful next to the snapshot that made them, so they travel with it rather than living in
102
+ // a cache that a page navigation would quietly invalidate.
103
+ const REFS_FILE = (process.env.TMPDIR || '/tmp') + '/wb-browse-refs.json'
104
+ const { writeFileSync, readFileSync } = await import('node:fs')
105
+
106
+ // ★ A MISSING refs file is the SAME situation as a stale ref: the agent has not taken a snapshot yet. Letting
107
+ // readFileSync throw turned that into "ENOENT: no such file or directory", which tells a model nothing it can act
108
+ // on — its own test caught this.
109
+ function loadRefs () {
110
+ try { return JSON.parse(readFileSync(REFS_FILE, 'utf8')) } catch { return {} }
111
+ }
112
+
113
+ // ★ ENTER CARRIES ITS CHARACTER. A keyDown without `text` is a key with no keypress: Chromium moves no form on it, so
114
+ // Enter in a search box never submitted (the owner's agent, 2026-10-07; measured: no submit as shipped, a submit with
115
+ // "\r"). keyDown with the text is what a real keyboard sends.
116
+ async function pressEnter (c) {
117
+ await c.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', unmodifiedText: '\r' })
118
+ await c.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 })
119
+ }
120
+
121
+ async function boxOf (c, backendNodeId) {
122
+ let model
123
+ try { ({ model } = await c.send('DOM.getBoxModel', { backendNodeId })) } catch (e) { throw new Error('that ref is stale — the page changed since the snapshot that made it (' + String(e.message || e).slice(0, 80) + '); take a snapshot first') }
124
+ const q = model.content
125
+ return { x: (q[0] + q[2] + q[4] + q[6]) / 4, y: (q[1] + q[3] + q[5] + q[7]) / 4 }
126
+ }
127
+
128
+ const op = process.argv[2]
129
+ let args = {}
130
+ try { args = process.argv[3] ? JSON.parse(process.argv[3]) : {} } catch { out({ error: 'arguments must be JSON' }) }
131
+
132
+ try {
133
+ if (op === 'open') {
134
+ const r = await fetch(CTL + '/open', { method: 'POST' })
135
+ const j = await r.json()
136
+ out(j.error ? { error: j.error } : { ok: true, ...j })
137
+ }
138
+ if (op === 'status') out(await (await fetch(CTL + '/status')).json())
139
+ // CLOSE: the browser and everything in it — its tabs, its logins — are gone; the next open starts a fresh one. Refused
140
+ // while the owner holds the controls (browse.go closeCaller): the agent does not pull the screen from a person.
141
+ if (op === 'close') {
142
+ const r = await fetch(CTL + '/close', { method: 'POST' })
143
+ const j = await r.json().catch(() => ({}))
144
+ out(j.error ? { error: j.error } : { ok: true, closed: true })
145
+ }
146
+ // CLEAR (the owner, 2026-10-19: "a tool that can clear browsing history and cookies"): a close IS a clear — it wipes the
147
+ // whole profile, history, cookies, cache and every site's storage (browse.go stopBrowseLocked / forgetBrowser) — and a
148
+ // fresh browser opens after it, so the agent can go on. ⛑ /close answers before it tears down, so this waits until the
149
+ // browser reports it is gone before opening again. Refused, like close, while the owner holds the controls.
150
+ if (op === 'clear') {
151
+ const r = await fetch(CTL + '/close', { method: 'POST' })
152
+ const j = await r.json().catch(() => ({}))
153
+ if (j.error) out({ error: j.error })
154
+ for (let i = 0; i < 40; i++) {
155
+ await new Promise((res) => setTimeout(res, 250))
156
+ const st = await (await fetch(CTL + '/status')).json().catch(() => null)
157
+ if (st && !st.running) break
158
+ }
159
+ const r2 = await fetch(CTL + '/open', { method: 'POST' })
160
+ const j2 = await r2.json().catch(() => ({}))
161
+ out(j2.error ? { error: 'cleared, but the browser did not open again: ' + j2.error } : { ok: true, cleared: true })
162
+ }
163
+
164
+ // ★ NAVIGATE OPENS THE BROWSER when none is open (the owner's agent, 2026-10-07: "it needed browser_open before
165
+ // navigation"). Only a browser this computer OFFERS and that is not in the owner's hands: a computer made without one
166
+ // and a take-over still answer with their own sentences (attach, below).
167
+ let opened = false
168
+ if (op === 'navigate' && args.url) {
169
+ let st = null
170
+ try { st = await (await fetch(CTL + '/status')).json() } catch { /* attach says what is wrong */ }
171
+ if (st && st.offered && !st.running && !st.takeover) {
172
+ const j = await (await fetch(CTL + '/open', { method: 'POST' })).json().catch((e) => ({ error: String(e) }))
173
+ if (j.error) out({ error: 'could not open the browser to navigate: ' + String(j.error).slice(0, 200) })
174
+ opened = true
175
+ }
176
+ }
177
+
178
+ const c = await CDP.attach()
179
+ await c.send('Page.enable')
180
+ await c.send('DOM.enable')
181
+
182
+ if (op === 'navigate') {
183
+ if (!args.url) out({ error: 'navigate needs a url' })
184
+ await c.send('Page.navigate', { url: args.url })
185
+ await new Promise((r) => setTimeout(r, args.waitMs ?? 1200))
186
+ } else if (op === 'click') {
187
+ const id = loadRefs()[String(args.ref)]
188
+ if (!id) out({ error: 'no such ref — take a snapshot first, refs belong to the snapshot that made them' })
189
+ const { x, y } = await boxOf(c, id)
190
+ for (const type of ['mousePressed', 'mouseReleased']) {
191
+ await c.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 })
192
+ }
193
+ await new Promise((r) => setTimeout(r, args.waitMs ?? 600))
194
+ } else if (op === 'type') {
195
+ if (args.ref != null) {
196
+ const id = loadRefs()[String(args.ref)]
197
+ if (!id) out({ error: 'no such ref — take a snapshot first, refs belong to the snapshot that made them' })
198
+ const { x, y } = await boxOf(c, id)
199
+ for (const type of ['mousePressed', 'mouseReleased']) {
200
+ await c.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 })
201
+ }
202
+ }
203
+ await c.send('Input.insertText', { text: String(args.text ?? '') })
204
+ if (args.enter) {
205
+ await pressEnter(c)
206
+ await new Promise((r) => setTimeout(r, args.waitMs ?? 1000))
207
+ }
208
+ } else if (op === 'key') {
209
+ const key = String(args.key || 'Enter')
210
+ const codes = { Enter: 13, Tab: 9, Escape: 27, Backspace: 8, ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39 }
211
+ if (key === 'Enter') await pressEnter(c)
212
+ else {
213
+ for (const type of ['keyDown', 'keyUp']) {
214
+ await c.send('Input.dispatchKeyEvent', { type, key, code: key, windowsVirtualKeyCode: codes[key] ?? 0 })
215
+ }
216
+ }
217
+ await new Promise((r) => setTimeout(r, 400))
218
+ } else if (op === 'back') {
219
+ const h = await c.send('Page.getNavigationHistory')
220
+ const prev = h.entries[h.currentIndex - 1]
221
+ if (!prev) out({ error: 'nothing to go back to' })
222
+ await c.send('Page.navigateToHistoryEntry', { entryId: prev.id })
223
+ await new Promise((r) => setTimeout(r, 900))
224
+ } else if (op === 'scroll') {
225
+ await c.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 200, y: 400, deltaX: 0, deltaY: Number(args.dy ?? 400) })
226
+ await new Promise((r) => setTimeout(r, 400))
227
+ } else if (op === 'screenshot') {
228
+ const { data } = await c.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 })
229
+ const path = args.path || ((process.env.TMPDIR || '/tmp') + '/wb-browse-' + Date.now() + '.jpg')
230
+ writeFileSync(path, Buffer.from(data, 'base64'))
231
+ const { lines } = await snapshot(c, 0).catch(() => ({ lines: [] }))
232
+ c.close()
233
+ out({ ok: true, path, note: 'a picture for a person to look at; act from a snapshot, not from this' })
234
+ } else if (op !== 'snapshot') {
235
+ out({ error: 'unknown op: ' + op })
236
+ }
237
+
238
+ // every op ends by saying where the browser now is, so the agent never has to ask separately
239
+ const snap = await snapshot(c, args.limit ?? 120)
240
+ writeFileSync(REFS_FILE, JSON.stringify(snap.refs))
241
+ const { root } = await c.send('DOM.getDocument', { depth: 0 })
242
+ const title = await c.send('Runtime.evaluate', { expression: 'document.title', returnByValue: true })
243
+ .then((r) => r.result?.value || '').catch(() => '')
244
+ c.close()
245
+ out({ ok: true, ...(opened ? { opened: true } : {}), url: root.documentURL, title, elements: snap.lines, truncated: snap.truncated })
246
+ } catch (e) {
247
+ out({ error: String(e && e.message ? e.message : e).slice(0, 300) })
248
+ }