witbitz-code 1.8.0__tar.gz → 1.9.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 (117) hide show
  1. {witbitz_code-1.8.0/src/witbitz_code.egg-info → witbitz_code-1.9.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/browse.py +47 -5
  5. witbitz_code-1.9.0/src/witbitz_code/browsetree.py +117 -0
  6. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +9 -5
  7. witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +303 -0
  8. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui.py +6 -3
  9. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.css +1 -0
  10. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.js +23 -0
  11. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/index.html +9 -0
  12. {witbitz_code-1.8.0 → witbitz_code-1.9.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  13. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/SOURCES.txt +3 -0
  14. witbitz_code-1.9.0/tests/test_browsetree.py +131 -0
  15. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/LICENSE +0 -0
  16. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/README.md +0 -0
  17. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/setup.cfg +0 -0
  18. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/__main__.py +0 -0
  19. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/_js.py +0 -0
  20. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/account.py +0 -0
  21. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/asks.py +0 -0
  22. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/attachments.py +0 -0
  23. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/auto.py +0 -0
  24. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/auto_runner.py +0 -0
  25. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/cli.py +0 -0
  26. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/codekeys.py +0 -0
  27. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/codetools.py +0 -0
  28. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/compress.py +0 -0
  29. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/computers.py +0 -0
  30. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/confidential.py +0 -0
  31. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/confidential_models.json +0 -0
  32. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/connector.py +0 -0
  33. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/devicelink.py +0 -0
  34. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/dirs.py +0 -0
  35. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/folders.py +0 -0
  36. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/gateway.py +0 -0
  37. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  38. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  39. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/images.py +0 -0
  40. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/keys.py +0 -0
  41. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/link.py +0 -0
  42. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/mkdir.py +0 -0
  43. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/models.py +0 -0
  44. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/net.py +0 -0
  45. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/offered_models.json +0 -0
  46. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  47. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  48. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  49. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  50. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  51. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  52. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  53. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/outputs.py +0 -0
  54. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/pair.py +0 -0
  55. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/pairings.py +0 -0
  56. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/payers.py +0 -0
  57. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/plugins.py +0 -0
  58. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/policy.py +0 -0
  59. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ports.py +0 -0
  60. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/qr.py +0 -0
  61. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/receipt.py +0 -0
  62. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/relay.py +0 -0
  63. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/remind.py +0 -0
  64. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/seccheck.py +0 -0
  65. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/seen.py +0 -0
  66. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/selfupdate.py +0 -0
  67. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/tinfoil_read.py +0 -0
  68. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/tools_catalog.json +0 -0
  69. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/tools_probe.py +0 -0
  70. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/toolsetup.py +0 -0
  71. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/uitools.py +0 -0
  72. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/uninstall.py +0 -0
  73. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/winagent.py +0 -0
  74. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/winbg.py +0 -0
  75. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/winconsole.py +0 -0
  76. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/wizard.py +0 -0
  77. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/wslstart.py +0 -0
  78. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  79. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  80. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  81. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  82. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_asks.py +0 -0
  83. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_attachments.py +0 -0
  84. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_auto.py +0 -0
  85. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_browse.py +0 -0
  86. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_cli.py +0 -0
  87. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_codekeys.py +0 -0
  88. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_codetools.py +0 -0
  89. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_confidential.py +0 -0
  90. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_connector_e2e.py +0 -0
  91. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_devicelink.py +0 -0
  92. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_dirs.py +0 -0
  93. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_files_and_compress.py +0 -0
  94. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_folders.py +0 -0
  95. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_images.py +0 -0
  96. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_outputs.py +0 -0
  97. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_pair_flows.py +0 -0
  98. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_plugins.py +0 -0
  99. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_policy.py +0 -0
  100. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_ports.py +0 -0
  101. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_ports_win.py +0 -0
  102. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_registry.py +0 -0
  103. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_relay_codec.py +0 -0
  104. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_remind.py +0 -0
  105. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_seccheck.py +0 -0
  106. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_selfupdate.py +0 -0
  107. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_setup.py +0 -0
  108. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_tools_probe.py +0 -0
  109. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_toolsetup.py +0 -0
  110. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_ui.py +0 -0
  111. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_uitools.py +0 -0
  112. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_uninstall.py +0 -0
  113. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_winagent.py +0 -0
  114. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_winbg.py +0 -0
  115. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_winconsole.py +0 -0
  116. {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_windows_files.py +0 -0
  117. {witbitz_code-1.8.0 → witbitz_code-1.9.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.8.0
3
+ Version: 1.9.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.8.0"
7
+ version = "1.9.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.8.0"
7
+ __version__ = "1.9.0"
@@ -5,11 +5,16 @@ ON by default; `serve --no-browser` or WB_BROWSE_DISABLE=1 turns it off (and rem
5
5
  programs are JavaScript (the cloud computer's own: browse-local, the agent's CLI, the publisher), so they run with this
6
6
  computer's `node` — as the media tools do; without node the agent works without a browser. Each pairing gets a viewer key
7
7
  derived from its secret (relay.browse_key = codeRelay.js deriveBrowseKey), so the app can Watch and Take over.
8
+
9
+ On Linux with the CLOUD COMPUTER'S browser installed (browsetree.py — the page's card, or `witbitz-browse-tree.mjs install`)
10
+ it is that one instead: the image's own Chromium, KasmVNC, PulseAudio and publisher under bwrap, sealed to the FIRST
11
+ pairing's key (the lab build seals to one). WB_BROWSE_TREE=0 keeps the laptop browser.
8
12
  """
9
13
 
10
14
  from __future__ import annotations
11
15
 
12
16
  import asyncio
17
+ import json
13
18
  import os
14
19
  import re
15
20
  import shutil
@@ -20,8 +25,8 @@ from typing import Any, Callable
20
25
 
21
26
  import httpx
22
27
 
23
- from . import plugins
24
- from .relay import browse_key
28
+ from . import browsetree, plugins
29
+ from .relay import RELAY_URL, browse_key
25
30
 
26
31
  BROWSE_DIR = "witbitz-browse"
27
32
  PLUGIN_REL = "plugins/witbitz-browser.js"
@@ -80,13 +85,16 @@ def _free_port() -> int:
80
85
 
81
86
 
82
87
  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
88
+ def __init__(self, proc: asyncio.subprocess.Process, ctl: int, env: dict, http: httpx.AsyncClient, tree: str = "") -> None:
89
+ self.proc, self.ctl, self.env, self._http, self.tree = proc, ctl, env, http, tree
85
90
  self.last: dict | None = None
86
91
  self._task: asyncio.Task | None = None
87
92
 
88
93
  def probe(self) -> dict | None:
89
- """The browser's last /status — synchronous: the connector's hello must never wait on it."""
94
+ """The browser's last /status — synchronous: the connector's hello must never wait on it. The cloud computer's
95
+ browser answers as in the enclave; `local`/`cast` tell the app to watch it as this computer's, `tree` which image."""
96
+ if self.last is not None and self.tree:
97
+ return {**self.last, "local": True, "cast": True, "tree": self.tree}
90
98
  return self.last
91
99
 
92
100
  async def _poll(self) -> None:
@@ -124,6 +132,10 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
124
132
  log("witbitz-code: no node on this computer — the agent works without a browser (install Node.js 22+ to add one)")
125
133
  return None
126
134
  paths = await asyncio.to_thread(install, config_dir)
135
+ if browsetree.use_tree():
136
+ b = await _start_tree(node, paths, pairings, config_dir, log)
137
+ if b:
138
+ return b
127
139
  watchable = node_major(node) >= 22 # the publisher needs a global WebSocket
128
140
  keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
129
141
  ctl, gate = _free_port(), _free_port()
@@ -143,3 +155,33 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
143
155
  log("witbitz-code: the agent's browser is ready on this computer"
144
156
  + (" — watch it from the Code section" if watchable else " (node is older than 22: the app cannot watch it)") + "; off: serve --no-browser")
145
157
  return b
158
+
159
+
160
+ async def _start_tree(node: str, paths: dict, pairings: list[dict], config_dir: Path | None, log: Callable[[str], Any]) -> LocalBrowser | None:
161
+ """The cloud computer's browser when it is installed here; None → the laptop browser."""
162
+ key = next((browse_key(p["secret"]) for p in pairings if p.get("secret")), "")
163
+ tree = await asyncio.to_thread(browsetree.script, config_dir)
164
+ st = await asyncio.to_thread(browsetree.read_status, node, tree)
165
+ if not key or not st.get("supported") or not st.get("installed"):
166
+ return None
167
+ proc = await asyncio.create_subprocess_exec(node, str(tree), "run", "--relay", RELAY_URL, "--parent", str(os.getpid()),
168
+ env={**os.environ, "WB_BROWSE_KEY": key}, stdin=asyncio.subprocess.DEVNULL,
169
+ stdout=asyncio.subprocess.PIPE)
170
+ try:
171
+ assert proc.stdout
172
+ ready = json.loads(await asyncio.wait_for(proc.stdout.readline(), 40))
173
+ except (ValueError, asyncio.TimeoutError, AssertionError):
174
+ ready = {}
175
+ if not ready.get("ready"):
176
+ log(f"witbitz-code: the cloud computer's browser did not start ({ready.get('error') or 'no answer'}) — using the laptop browser")
177
+ if proc.returncode is None:
178
+ proc.kill()
179
+ return None
180
+ env = {k: str(v) for k, v in ready.items() if k.startswith("WB_BROWSE_")}
181
+ env["WB_BROWSE_CDP_CLI"] = str(paths["browse-cdp.mjs"])
182
+ b = LocalBrowser(proc, int(env["WB_BROWSE_CTL"].rsplit(":", 1)[1]), env, httpx.AsyncClient(), tree=str(ready.get("tag") or ""))
183
+ await b._poll()
184
+ b._task = asyncio.ensure_future(b._every())
185
+ if st.get("current") is False:
186
+ log(f"witbitz-code: this release pins the cloud browser {st['pinned']['tag']} — update it on the witbitz-code page")
187
+ return b
@@ -0,0 +1,117 @@
1
+ """THE CLOUD COMPUTER'S BROWSER ON THIS LINUX COMPUTER — the page's card and serve's choice (the owner, 2026-10-19: "why isnt
2
+ it part of the pipx install? and why isnt it installed from the witbitz code webpage?").
3
+
4
+ The program is `witbitz-browse-tree.mjs` (tools/browse-tree/cli.mjs, bundled with this release's pin by tools/browse-embed.mjs)
5
+ run with this computer's node: `status` says what is pinned and installed, `install --json` downloads the pinned image's tree
6
+ (~650 MB, both sha256s checked before anything is unpacked) with progress lines, `run` starts it under bwrap. Nothing is
7
+ downloaded until the person presses Install; a release that pins a newer image shows it as an update.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any, Callable
21
+
22
+ from . import plugins
23
+
24
+ SCRIPT_REL = "witbitz-browse/browse-tree.mjs"
25
+ PACKAGED = "witbitz-browse-tree.mjs"
26
+
27
+
28
+ def script(config_dir: Path | None = None) -> Path:
29
+ """The program in OpenCode's config folder, beside the other browser files (an identical file is left alone)."""
30
+ target = (Path(config_dir) if config_dir else plugins.opencode_config_dir()) / SCRIPT_REL
31
+ text = plugins.packaged(PACKAGED)
32
+ try:
33
+ same = target.read_text(encoding="utf-8") == text
34
+ except (OSError, UnicodeDecodeError):
35
+ same = False
36
+ if not same:
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ target.write_text(text, encoding="utf-8", newline="")
39
+ return target
40
+
41
+
42
+ def read_status(node: str, path: Path) -> dict:
43
+ """`status` → {linux, needs, supported, pinned, installed, current, why}; {error} when it could not be asked."""
44
+ try:
45
+ r = subprocess.run([node, str(path), "status"], capture_output=True, text=True, timeout=20, check=False) # noqa: S603
46
+ return json.loads(r.stdout.strip().splitlines()[-1])
47
+ except (OSError, ValueError, IndexError, subprocess.SubprocessError) as e:
48
+ return {"error": f"could not check ({str(e)[:80]})"}
49
+
50
+
51
+ class Tree:
52
+ """The page's card: {shown, state: idle|installing|done|failed, step, pct, error, ...status}. Outside reaches are injected."""
53
+
54
+ def __init__(self, *, node: Callable[[], str | None] = lambda: shutil.which("node"),
55
+ script_path: Callable[[], Path] = script, read: Callable[[str, Path], dict] = read_status,
56
+ platform: str = sys.platform) -> None:
57
+ self._node, self._script, self._read, self._platform = node, script_path, read, platform
58
+ self.state, self.step, self.pct, self.error, self.tag = "idle", "", 0.0, "", ""
59
+ self.task: asyncio.Task | None = None
60
+ self._cache: tuple[float, dict] | None = None
61
+
62
+ def status(self) -> dict:
63
+ if not self._platform.startswith("linux"):
64
+ return {"shown": False}
65
+ live = {"state": self.state, "step": self.step, "pct": self.pct, "error": self.error, "tag": self.tag}
66
+ if self._cache and time.monotonic() - self._cache[0] < 15 and self.state != "installing":
67
+ return {**self._cache[1], **live}
68
+ node = self._node()
69
+ if not node:
70
+ st = {"shown": True, "supported": False, "needs": ["Node.js 22.15+"]}
71
+ else:
72
+ try:
73
+ st = {"shown": True, **self._read(node, self._script())}
74
+ except OSError as e:
75
+ st = {"shown": True, "error": f"could not check ({str(e)[:80]})"}
76
+ self._cache = (time.monotonic(), st)
77
+ return {**st, **live}
78
+
79
+ async def install(self) -> dict:
80
+ if self.task and not self.task.done():
81
+ return {"ok": True}
82
+ node = self._node()
83
+ if not node or not self._platform.startswith("linux"):
84
+ return {"ok": False, "why": "the cloud computer's browser needs Linux and Node.js"}
85
+ self.state, self.step, self.pct, self.error = "installing", "download", 0.0, ""
86
+ self.task = asyncio.ensure_future(self._install(node))
87
+ return {"ok": True}
88
+
89
+ async def _install(self, node: str) -> None:
90
+ try:
91
+ proc = await asyncio.create_subprocess_exec(node, str(self._script()), "install", "--json", stdin=asyncio.subprocess.DEVNULL,
92
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
93
+ assert proc.stdout
94
+ async for raw in proc.stdout:
95
+ try:
96
+ m: dict[str, Any] = json.loads(raw)
97
+ except ValueError:
98
+ continue
99
+ if m.get("error"):
100
+ self.error = str(m["error"])[:300]
101
+ elif m.get("ok"):
102
+ self.tag = str(m.get("tag") or "")
103
+ else:
104
+ self.step, self.pct = str(m.get("step") or ""), float(m.get("pct") or 0)
105
+ await proc.wait()
106
+ if proc.returncode and not self.error:
107
+ self.error = f"the install stopped ({proc.returncode})"
108
+ except (OSError, asyncio.CancelledError) as e:
109
+ self.error = self.error or f"could not run it ({str(e)[:120]})"
110
+ finally:
111
+ self._cache = None
112
+ self.state = "failed" if self.error else "done"
113
+
114
+
115
+ def use_tree(env: dict | None = None, platform: str = sys.platform) -> bool:
116
+ env = os.environ if env is None else env
117
+ return platform.startswith("linux") and str(env.get("WB_BROWSE_TREE", "")) != "0"
@@ -945,6 +945,7 @@ var tellControl = async (path, body) => {
945
945
  }
946
946
  };
947
947
  var said = null;
948
+ var holderVid = null;
948
949
  var tellTheViewerWhoHasTheControls = async (why) => {
949
950
  let st = null;
950
951
  try {
@@ -954,16 +955,17 @@ var tellTheViewerWhoHasTheControls = async (why) => {
954
955
  }
955
956
  held = !!st.takeover;
956
957
  if (!held && cast && cast.incognito && cast.incognito()) closeIncognito("the controls lapsed");
958
+ if (!held) holderVid = null;
957
959
  if (!held && cast && cast.controller()) {
958
960
  for (const v of castViewers.values()) v.driving = false;
959
961
  cast.setController(null).then(assign).catch(() => {
960
962
  });
961
963
  }
962
- const now = JSON.stringify({ on: !!st.takeover, web: !!st.web });
964
+ const now = JSON.stringify({ on: !!st.takeover, web: !!st.web, holder: holderVid });
963
965
  if (why === "poll" && now === said) return;
964
966
  said = now;
965
967
  if (relay && relay.readyState === 1) {
966
- relay.send(await sealCtl(key, { wb: "takeover", on: !!st.takeover, web: !!st.web, audio: !!PULSE, ack: true, cast: !!CDP, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()) }));
968
+ relay.send(await sealCtl(key, { wb: "takeover", on: !!st.takeover, web: !!st.web, ...st.takeover && holderVid ? { holder: holderVid } : {}, audio: !!PULSE, ack: true, cast: !!CDP, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()) }));
967
969
  }
968
970
  log(
969
971
  why === "poll" ? "the state changed \u2014" : "a viewer arrived \u2014",
@@ -1327,12 +1329,13 @@ function connectRelay() {
1327
1329
  if (!c.on) await closeIncognito("the controls were given back");
1328
1330
  await tellControl("/takeover", { on: !!c.on });
1329
1331
  held = !!c.on;
1332
+ holderVid = c.on && typeof c.vid === "string" ? c.vid.slice(0, 64) : null;
1330
1333
  log("take-over", c.on ? "ON \u2014 the agent's CDP session is being closed" : "released");
1331
1334
  if (cast) await cast.setController(c.on ? norm(c.view) || largestView() : null).catch(() => {
1332
1335
  });
1333
1336
  for (const [vid, v] of castViewers) v.driving = !!c.on && vid === c.vid;
1334
1337
  if (cast) assign();
1335
- if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "takeover", on: !!c.on, audio: !!PULSE, ack: true, cast: !!cast, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()), close: true }));
1338
+ if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "takeover", on: !!c.on, ...holderVid ? { holder: holderVid } : {}, audio: !!PULSE, ack: true, cast: !!cast, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()), close: true }));
1336
1339
  } else if (c.wb === "incognito" && cast) {
1337
1340
  if (c.on && !held) {
1338
1341
  log("incognito: refused \u2014 nobody holds the controls");
@@ -1357,12 +1360,13 @@ function connectRelay() {
1357
1360
  if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "site", mode }));
1358
1361
  } else if (c.wb === "hello" && c.cast && typeof c.vid === "string" && CDP) {
1359
1362
  const known = castViewers.has(c.vid);
1360
- castViewers.set(c.vid.slice(0, 64), { view: norm(c.view), at: Date.now(), driving: !!(held && c.driving) });
1363
+ const mine = held && c.driving && (!holderVid || c.vid.slice(0, 64) === holderVid);
1364
+ castViewers.set(c.vid.slice(0, 64), { view: norm(c.view), at: Date.now(), driving: !!mine });
1361
1365
  if (castViewers.size > 8) castViewers.delete(castViewers.keys().next().value);
1362
1366
  if (!known) log("cast: a viewer said hello \u2014", JSON.stringify(norm(c.view)));
1363
1367
  if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "cast", on: true, close: true }));
1364
1368
  await startCast();
1365
- if (held && cast && c.driving) await cast.setController(norm(c.view)).catch(() => {
1369
+ if (mine && cast) await cast.setController(norm(c.view)).catch(() => {
1366
1370
  });
1367
1371
  assign();
1368
1372
  } else if (c.wb === "in" && cast) {
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ // GENERATED by tools/browse-embed.mjs from tools/browse-tree/cli.mjs and its pin — do not edit.
3
+
4
+ // tools/browse-tree/pin.json
5
+ var pin_default = {
6
+ _: "THE CLOUD COMPUTER'S BROWSER FOR LINUX that this release installs (tools/browse-tree; make.sh writes it). The files are public at <base><tag>/; both sha256s are checked before anything is unpacked.",
7
+ tag: "p2v",
8
+ base: "https://drwev5vxt2t2e.cloudfront.net/browse-tree/",
9
+ pcr0: "87162d8648af94daec37f9d022c43546f6dc5c9018b7225ee5ccaf3cecd1c7924c7681f0ca48fddb84f5d0a2f987f719",
10
+ commit: "e303d54e2295c148c5177b2216eca21de6b6f3a3",
11
+ tree: {
12
+ file: "tree.tar.zst",
13
+ sha256: "1fceee1d2ddbda8a6ec9f792755a960a877af6342645b8a29fbbf94b28b9536e",
14
+ size: 664712218
15
+ },
16
+ lab: {
17
+ file: "ccvm-lab.bin",
18
+ sha256: "2d15b2e7c93b9626eccf8fa0d728c6e4385aadbbeba538ab45f7123937aed38b",
19
+ size: 11908726
20
+ }
21
+ };
22
+
23
+ // tools/browse-tree/run.mjs
24
+ import { spawn, spawnSync } from "node:child_process";
25
+ import { existsSync, readFileSync, readlinkSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+ var TREE_HOME = () => process.env.WB_BROWSE_TREE_HOME || join(homedir(), ".witbitz", "browse-tree");
29
+ var CTL_PORT = 9224;
30
+ var GATE_PORT = 9223;
31
+ function findTree({ home = TREE_HOME(), platform = process.platform } = {}) {
32
+ if (platform !== "linux") return { why: "the cloud computer's browser runs on Linux only" };
33
+ const cur = join(home, "current");
34
+ if (!existsSync(cur)) return { why: "no cloud browser tree installed (witbitz-code browser install, or node tools/browse-tree/cli.mjs install)" };
35
+ let dir;
36
+ try {
37
+ dir = join(home, readlinkSync(cur));
38
+ } catch {
39
+ dir = cur;
40
+ }
41
+ const m = (() => {
42
+ try {
43
+ return JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
44
+ } catch {
45
+ return null;
46
+ }
47
+ })();
48
+ if (!m || !existsSync(join(dir, "tree", "usr", "bin", "chromium")) || !existsSync(join(dir, "ccvm-lab"))) return { why: `the tree in ${dir} is incomplete \u2014 install it again` };
49
+ if (spawnSync("bwrap", ["--version"], { stdio: "ignore" }).status !== 0) return { why: "bwrap is not installed (apt install bubblewrap)" };
50
+ return { dir, tree: join(dir, "tree"), lab: join(dir, "ccvm-lab"), manifest: m };
51
+ }
52
+ function bwrapArgs({ tree, lab, key, relay }) {
53
+ const env = {
54
+ PATH: "/usr/local/bin:/usr/bin:/bin",
55
+ HOME: "/tmp",
56
+ // the image's own paths (browse_paths.go) — the lab build reads them from here instead of compiling them in
57
+ WB_LAB_ROOT: "/",
58
+ WB_LAB_XKASM: "/usr/bin/Xkasmvnc",
59
+ WB_LAB_CHROMIUM: "/usr/bin/chromium",
60
+ WB_LAB_NODE: "/usr/local/bin/node",
61
+ WB_LAB_PUBLISHER: "/opt/witbitz/browse-publisher.mjs",
62
+ WB_LAB_KASM_WWW: "/usr/share/kasmvnc/www",
63
+ WB_LAB_PULSE: "/usr/bin/pulseaudio",
64
+ WB_LAB_PAREC: "/usr/bin/parec",
65
+ WB_LAB_RRWEB: "/opt/witbitz/rrweb.umd.min.cjs",
66
+ WB_LAB_LOGS: "/tmp",
67
+ WB_LAB_SCRATCH: "/tmp/wb",
68
+ WB_LAB_KEY: key,
69
+ WB_LAB_RELAY: relay
70
+ };
71
+ return [
72
+ "--ro-bind",
73
+ tree,
74
+ "/",
75
+ "--dev",
76
+ "/dev",
77
+ "--proc",
78
+ "/proc",
79
+ "--tmpfs",
80
+ "/tmp",
81
+ "--tmpfs",
82
+ "/run",
83
+ "--ro-bind",
84
+ "/etc/resolv.conf",
85
+ "/run/resolvconf/resolv.conf",
86
+ // the image's /etc/resolv.conf is a link to here
87
+ "--ro-bind",
88
+ lab,
89
+ "/tmp/ccvm-lab",
90
+ "--unshare-pid",
91
+ "--die-with-parent",
92
+ "--clearenv",
93
+ ...Object.entries(env).flatMap(([k, v]) => ["--setenv", k, String(v)]),
94
+ "/tmp/ccvm-lab",
95
+ "browselab"
96
+ ];
97
+ }
98
+ async function startTreeBrowser({ found, key, relay, log = console.error, fetchImpl = fetch, cdpCli = "", node = process.execPath } = {}) {
99
+ const child = spawn("bwrap", bwrapArgs({ tree: found.tree, lab: found.lab, key, relay }), { stdio: ["ignore", "ignore", "pipe"], detached: false });
100
+ let tail = "";
101
+ child.stderr.on("data", (d) => {
102
+ tail = (tail + d).slice(-2e3);
103
+ });
104
+ child.on("exit", (code) => log(`witbitz-code: the cloud browser stopped (${code})${tail ? " \u2014 " + tail.trim().split("\n").pop() : ""}`));
105
+ let last = null;
106
+ const poll = async () => {
107
+ try {
108
+ last = await (await fetchImpl(`http://127.0.0.1:${CTL_PORT}/status`, { signal: AbortSignal.timeout(1500) })).json();
109
+ } catch {
110
+ last = null;
111
+ }
112
+ };
113
+ for (let i = 0; i < 20 && !last; i++) {
114
+ await new Promise((r) => setTimeout(r, 250));
115
+ await poll();
116
+ }
117
+ const timer = setInterval(poll, 2e3);
118
+ timer.unref();
119
+ const m = found.manifest || {};
120
+ log(`witbitz-code: the agent's browser is the CLOUD COMPUTER's (${m.tag || "?"}, PCR0 ${String(m.pcr0 || "").slice(0, 16)}\u2026) \u2014 its own Chromium, KasmVNC, PulseAudio and publisher, run here with no enclave, one user and this computer's network; watch it from the Code section`);
121
+ return {
122
+ tag: m.tag || "",
123
+ env: { WB_BROWSE_NODE: node, ...cdpCli ? { WB_BROWSE_CDP_CLI: cdpCli } : {}, WB_BROWSE_CTL: `http://127.0.0.1:${CTL_PORT}`, WB_BROWSE_CDP: `http://127.0.0.1:${GATE_PORT}` },
124
+ // `local`: the app watches a computer's browser through a key it derives from the pairing (opencodeApp.browseCloudOf), as
125
+ // for the laptop browser; `cast`: the publisher screencasts, as there; `tree`: which image this is, for whoever debugs it
126
+ probe: () => last ? { ...last, local: true, cast: true, tree: m.tag || "" } : null,
127
+ stop: () => {
128
+ clearInterval(timer);
129
+ try {
130
+ child.kill();
131
+ } catch {
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ // tools/browse-tree/install.mjs
138
+ import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
139
+ import { createHash } from "node:crypto";
140
+ import { chmodSync, copyFileSync, createReadStream, createWriteStream, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
141
+ import { join as join2 } from "node:path";
142
+ import { Readable, Transform } from "node:stream";
143
+ import { pipeline } from "node:stream/promises";
144
+ import zlib from "node:zlib";
145
+ var sha256File = (file) => new Promise((resolve, reject) => {
146
+ const h = createHash("sha256");
147
+ createReadStream(file).on("data", (d) => h.update(d)).on("end", () => resolve(h.digest("hex"))).on("error", reject);
148
+ });
149
+ async function download(url, dest, onBytes, fetchImpl) {
150
+ const r = await fetchImpl(url);
151
+ if (!r.ok || !r.body) throw new Error(`${url} answered ${r.status}`);
152
+ const h = createHash("sha256");
153
+ const tap = new Transform({ transform(chunk, _e, cb) {
154
+ h.update(chunk);
155
+ onBytes(chunk.length);
156
+ cb(null, chunk);
157
+ } });
158
+ await pipeline(Readable.fromWeb(r.body), tap, createWriteStream(dest));
159
+ return h.digest("hex");
160
+ }
161
+ async function unpack(file, dir) {
162
+ const tar = spawn2("tar", ["-x", "--no-same-owner", "-C", dir], { stdio: ["pipe", "ignore", "pipe"] });
163
+ let err = "";
164
+ tar.stderr.on("data", (d) => {
165
+ err = (err + d).slice(-500);
166
+ });
167
+ const done = new Promise((resolve, reject) => tar.on("close", (c) => c === 0 ? resolve() : reject(new Error("tar: " + (err.trim() || "exit " + c)))));
168
+ if (typeof zlib.createZstdDecompress === "function") await pipeline(createReadStream(file), zlib.createZstdDecompress(), tar.stdin);
169
+ else {
170
+ const z = spawn2("zstd", ["-dc", file], { stdio: ["ignore", "pipe", "inherit"] });
171
+ z.on("error", () => tar.kill());
172
+ await pipeline(z.stdout, tar.stdin);
173
+ }
174
+ await done;
175
+ }
176
+ async function installTree({ pin, from = "", home = TREE_HOME(), onProgress = () => {
177
+ }, fetchImpl = fetch } = {}) {
178
+ if (process.platform !== "linux") throw new Error("the cloud computer's browser runs on Linux only");
179
+ if (spawnSync2("tar", ["--version"], { stdio: "ignore" }).status !== 0) throw new Error("tar is not installed");
180
+ if (typeof zlib.createZstdDecompress !== "function" && spawnSync2("zstd", ["--version"], { stdio: "ignore" }).status !== 0) throw new Error("this Node has no zstd and the zstd program is not installed \u2014 update Node to 22.15+, or install zstd");
181
+ const want = from ? JSON.parse(readFileSync2(join2(from, "manifest.json"), "utf8")) : pin;
182
+ if (!want || !/^p\d\w*$/.test(String(want.tag || "")) || !want.tree || !want.lab) throw new Error("no browser is pinned in this release");
183
+ mkdirSync(home, { recursive: true });
184
+ const stage = join2(home, ".incoming");
185
+ rmSync(stage, { recursive: true, force: true });
186
+ mkdirSync(stage, { recursive: true });
187
+ try {
188
+ const parts = [["tree", "tree.tar.zst"], ["lab", "ccvm-lab"]].map(([k, f]) => [k, f, want[k] && want[k].file || f]);
189
+ const total = parts.reduce((n, [k, , src]) => n + Number(want[k] && want[k].size || (from ? statSync(join2(from, src)).size : 0)), 0);
190
+ let done = 0;
191
+ for (const [k, f, src] of parts) {
192
+ let got;
193
+ if (from) {
194
+ copyFileSync(join2(from, src), join2(stage, f));
195
+ got = await sha256File(join2(stage, f));
196
+ done += statSync(join2(stage, f)).size;
197
+ onProgress({ step: "download", done, total });
198
+ } else got = await download(`${want.base}${want.tag}/${src}`, join2(stage, f), (n) => {
199
+ done += n;
200
+ onProgress({ step: "download", done, total });
201
+ }, fetchImpl);
202
+ onProgress({ step: "check", done, total });
203
+ if (got !== want[k].sha256) throw new Error(`${f} does not match what this release pins (${got.slice(0, 12)}\u2026 \u2260 ${String(want[k].sha256).slice(0, 12)}\u2026) \u2014 nothing was installed`);
204
+ }
205
+ const dir = join2(home, want.tag), next = dir + ".new";
206
+ rmSync(next, { recursive: true, force: true });
207
+ mkdirSync(join2(next, "tree"), { recursive: true });
208
+ onProgress({ step: "unpack", done, total });
209
+ await unpack(join2(stage, "tree.tar.zst"), join2(next, "tree"));
210
+ if (!existsSync2(join2(next, "tree", "usr", "bin", "chromium"))) throw new Error("the unpacked tree has no Chromium");
211
+ copyFileSync(join2(stage, "ccvm-lab"), join2(next, "ccvm-lab"));
212
+ chmodSync(join2(next, "ccvm-lab"), 493);
213
+ const { _: _note, base: _base, ...record } = want;
214
+ writeFileSync(join2(next, "manifest.json"), JSON.stringify(record, null, 1) + "\n");
215
+ rmSync(dir, { recursive: true, force: true });
216
+ renameSync(next, dir);
217
+ const cur = join2(home, "current");
218
+ try {
219
+ unlinkSync(cur);
220
+ } catch {
221
+ }
222
+ symlinkSync(want.tag, cur);
223
+ onProgress({ step: "done", done: total, total });
224
+ return { tag: want.tag, dir };
225
+ } finally {
226
+ rmSync(stage, { recursive: true, force: true });
227
+ }
228
+ }
229
+ var has = (cmd2) => spawnSync2(cmd2, ["--version"], { stdio: "ignore" }).status === 0;
230
+ function treeStatus(pin) {
231
+ const linux = process.platform === "linux";
232
+ const needs = !linux ? ["Linux"] : [
233
+ ...has("bwrap") ? [] : ["bwrap (apt install bubblewrap)"],
234
+ ...has("tar") ? [] : ["tar"],
235
+ ...typeof zlib.createZstdDecompress === "function" || has("zstd") ? [] : ["Node 22.15+ or the zstd program"]
236
+ ];
237
+ const found = linux ? findTree() : { why: "Linux only" };
238
+ const installed = found.manifest ? { tag: found.manifest.tag, pcr0: found.manifest.pcr0 } : null;
239
+ return {
240
+ linux,
241
+ needs,
242
+ supported: linux && !needs.length,
243
+ pinned: { tag: pin.tag, pcr0: pin.pcr0, bytes: Number(pin.tree.size || 0) + Number(pin.lab.size || 0) },
244
+ installed,
245
+ current: !!(installed && installed.tag === pin.tag),
246
+ why: found.why || ""
247
+ };
248
+ }
249
+
250
+ // tools/browse-tree/cli.mjs
251
+ var arg = (name) => {
252
+ const a = process.argv, i = a.indexOf(name);
253
+ return i >= 0 ? a[i + 1] : "";
254
+ };
255
+ var say = (o) => process.stdout.write(JSON.stringify(o) + "\n");
256
+ var cmd = process.argv[2];
257
+ if (cmd === "status") say(treeStatus(pin_default));
258
+ else if (cmd === "install") {
259
+ const json = process.argv.includes("--json");
260
+ let last = -1;
261
+ try {
262
+ const r = await installTree({ pin: pin_default, from: arg("--from"), onProgress: (p) => {
263
+ const pct = p.total ? Math.floor(p.done * 1e3 / p.total) / 10 : 0;
264
+ if (pct === last && p.step === "download") return;
265
+ last = pct;
266
+ if (json) say({ ...p, pct });
267
+ else process.stderr.write(`\r${p.step} ${pct}% `);
268
+ } });
269
+ if (json) say({ ok: true, tag: r.tag });
270
+ else console.error(`
271
+ \u2713 ${r.tag}'s browser is installed \u2014 witbitz-code serve uses it`);
272
+ } catch (e) {
273
+ const m = String(e && e.message || e);
274
+ if (json) say({ error: m });
275
+ else console.error("\n\u2717 " + m);
276
+ process.exit(1);
277
+ }
278
+ } else if (cmd === "run") {
279
+ const found = findTree();
280
+ if (!found.dir) {
281
+ say({ error: found.why });
282
+ process.exit(1);
283
+ }
284
+ const b = await startTreeBrowser({ found, key: arg("--key") || process.env.WB_BROWSE_KEY || "", relay: arg("--relay"), log: (m) => console.error(m) });
285
+ say({ ready: true, tag: b.tag, ...b.env });
286
+ const parent = Number(arg("--parent")) || 0;
287
+ const bye = () => {
288
+ b.stop();
289
+ process.exit(0);
290
+ };
291
+ process.on("SIGTERM", bye);
292
+ process.on("SIGINT", bye);
293
+ if (parent) setInterval(() => {
294
+ try {
295
+ process.kill(parent, 0);
296
+ } catch {
297
+ bye();
298
+ }
299
+ }, 2e3);
300
+ } else if (cmd) {
301
+ console.error("usage: status | install [--json] | run --key <k> --relay <url> [--parent <pid>]");
302
+ process.exit(2);
303
+ }
@@ -35,7 +35,7 @@ from importlib import resources
35
35
  from pathlib import Path
36
36
  from typing import Any, Awaitable, Callable
37
37
 
38
- from . import __version__, devicelink, link, net, ports, qr, seccheck, selfupdate, uitools, uninstall, winbg, wizard, wslstart
38
+ from . import __version__, browsetree, devicelink, link, net, ports, qr, seccheck, selfupdate, uitools, uninstall, winbg, wizard, wslstart
39
39
  from . import pair as pair_cmd
40
40
  from . import pairings as pp
41
41
 
@@ -127,11 +127,12 @@ class Manager:
127
127
  login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
128
128
  login_disable: Callable[[dict], bool] = wslstart.startup_disable,
129
129
  port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
130
- tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None,
130
+ tools: uitools.Tools | None = None, tree: browsetree.Tree | None = None, label: dict | None = None, log: deque | None = None,
131
131
  security: seccheck.SecurityCheck | None = None, updates: selfupdate.Updates | None = None) -> None:
132
132
  self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
133
133
  self._load, self._keys_line = load_pairings, keys_line
134
134
  self.tools = tools or uitools.Tools()
135
+ self.tree = tree or browsetree.Tree()
135
136
  self.security = security or seccheck.SecurityCheck()
136
137
  self.updates = updates or selfupdate.Updates(fetch_text=_fetch_text)
137
138
  self._owners = port_owners or ports.port_owners
@@ -219,7 +220,8 @@ class Manager:
219
220
  asyncio.ensure_future(self.updates.check(fresh=fresh))
220
221
  return {"version": __version__, "update": self.updates.status(), "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
221
222
  "login": self.login(), "busy": self.busy, "background": self.background,
222
- "tools": await asyncio.to_thread(self.tools.status), "security": self.security.peek(), "log": list(self.log)[-14:]}
223
+ "tools": await asyncio.to_thread(self.tools.status),
224
+ "tree": await asyncio.to_thread(self.tree.status), "security": self.security.peek(), "log": list(self.log)[-14:]}
223
225
 
224
226
  async def elsewhere(self, fresh: bool = False) -> dict | None:
225
227
  """{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
@@ -549,6 +551,7 @@ class Server:
549
551
  "/api/quit": self.m.quit,
550
552
  "/api/update": self.m.update,
551
553
  "/api/tools/install": self.m.tools.install,
554
+ "/api/tree/install": self.m.tree.install,
552
555
  "/api/security/fix": lambda: self.m.security.fix(str(data.get("id") or "")),
553
556
  }
554
557
  act = actions.get(path)
@@ -59,3 +59,4 @@ p.small, div.small { font-size: 12.5px; }
59
59
  .sec-list li.todo .sec-head span { font-weight: 600; }
60
60
  .sec-list li.ok { opacity: .7; }
61
61
  .sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
62
+ progress { width: 100%; height: 8px; margin-top: 10px; accent-color: var(--accent); }