witbitz-code 1.5.0__tar.gz → 1.6.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 (98) hide show
  1. {witbitz_code-1.5.0/src/witbitz_code.egg-info → witbitz_code-1.6.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/cli.py +14 -5
  5. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui.py +88 -10
  6. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.css +2 -0
  7. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.js +52 -3
  8. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/index.html +12 -1
  9. witbitz_code-1.6.0/src/witbitz_code/uitools.py +108 -0
  10. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/uninstall.py +3 -0
  11. witbitz_code-1.6.0/src/witbitz_code/winbg.py +97 -0
  12. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/wizard.py +15 -0
  13. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/wslstart.py +62 -9
  14. {witbitz_code-1.5.0 → witbitz_code-1.6.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  15. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
  16. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_setup.py +22 -0
  17. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_ui.py +71 -0
  18. witbitz_code-1.6.0/tests/test_uitools.py +71 -0
  19. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_uninstall.py +5 -3
  20. witbitz_code-1.6.0/tests/test_winbg.py +71 -0
  21. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_wslstart.py +25 -0
  22. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/LICENSE +0 -0
  23. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/README.md +0 -0
  24. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/setup.cfg +0 -0
  25. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/__main__.py +0 -0
  26. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/_js.py +0 -0
  27. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/account.py +0 -0
  28. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/asks.py +0 -0
  29. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/attachments.py +0 -0
  30. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/auto.py +0 -0
  31. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/auto_runner.py +0 -0
  32. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/codekeys.py +0 -0
  33. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/codetools.py +0 -0
  34. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/compress.py +0 -0
  35. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/computers.py +0 -0
  36. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/confidential.py +0 -0
  37. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/confidential_models.json +0 -0
  38. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/connector.py +0 -0
  39. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/devicelink.py +0 -0
  40. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/dirs.py +0 -0
  41. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/folders.py +0 -0
  42. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/gateway.py +0 -0
  43. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/keys.py +0 -0
  44. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/link.py +0 -0
  45. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/mkdir.py +0 -0
  46. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/models.py +0 -0
  47. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/net.py +0 -0
  48. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/offered_models.json +0 -0
  49. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  50. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  51. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  52. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  53. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/outputs.py +0 -0
  54. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/pair.py +0 -0
  55. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/pairings.py +0 -0
  56. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/payers.py +0 -0
  57. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/plugins.py +0 -0
  58. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/policy.py +0 -0
  59. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ports.py +0 -0
  60. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/qr.py +0 -0
  61. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/receipt.py +0 -0
  62. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/relay.py +0 -0
  63. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/seen.py +0 -0
  64. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/tinfoil_read.py +0 -0
  65. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/tools_catalog.json +0 -0
  66. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/tools_probe.py +0 -0
  67. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/toolsetup.py +0 -0
  68. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/winagent.py +0 -0
  69. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/winconsole.py +0 -0
  70. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  71. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  72. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  73. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  74. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_asks.py +0 -0
  75. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_attachments.py +0 -0
  76. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_auto.py +0 -0
  77. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_cli.py +0 -0
  78. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_codekeys.py +0 -0
  79. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_codetools.py +0 -0
  80. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_confidential.py +0 -0
  81. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_connector_e2e.py +0 -0
  82. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_devicelink.py +0 -0
  83. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_dirs.py +0 -0
  84. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_files_and_compress.py +0 -0
  85. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_folders.py +0 -0
  86. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_outputs.py +0 -0
  87. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_pair_flows.py +0 -0
  88. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_plugins.py +0 -0
  89. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_policy.py +0 -0
  90. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_ports.py +0 -0
  91. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_ports_win.py +0 -0
  92. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_registry.py +0 -0
  93. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_relay_codec.py +0 -0
  94. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_tools_probe.py +0 -0
  95. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_toolsetup.py +0 -0
  96. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_winagent.py +0 -0
  97. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_winconsole.py +0 -0
  98. {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_windows_files.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.5.0
3
+ Version: 1.6.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.5.0"
7
+ version = "1.6.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.5.0"
7
+ __version__ = "1.6.0"
@@ -26,17 +26,20 @@ 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, winconsole, wizard
29
+ from . import 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
33
33
 
34
34
  HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from the Spaces Code section, end-to-end encrypted.
35
35
 
36
- setup [--port <n>] start here: installs OpenCode if needed, pairs this computer — which gives it its own
37
- keys from your Spaces account — then serves
38
- ui [--no-browser] the same in a page in your browser: status, pair / unpair / pair again, start and stop,
39
- start when I log in (Windows and WSL); it serves while its window is open
36
+ setup [--port <n>] [--here] start here: installs OpenCode if needed, pairs this computer — which gives it its own
37
+ keys from your Spaces account — then serves. Windows and WSL: after OpenCode, the page
38
+ opens and does the rest, in the background (--here: every step in this terminal)
39
+ ui [--no-browser] [--background]
40
+ the same in a page in your browser: status, pair / unpair / pair again, start and stop,
41
+ start when I log in (Windows and WSL); it serves while it runs. --background (Windows):
42
+ no window — the page's Quit stops it
40
43
  pair [--name <name>] show a QR code; scan it in Spaces (Settings → Back up & recovery → Add a device)
41
44
  serve [--port <n>] [--no-opencode]
42
45
  start OpenCode on 127.0.0.1 with your account's keys, and the connector
@@ -327,6 +330,8 @@ async def setup(args: list[str]) -> int:
327
330
  keys_state=keys_state,
328
331
  run_tools=lambda plan: toolsetup.run_plan(plan, say=_err), refresh_path=refresh_path,
329
332
  is_listening=is_listening, serve_here=lambda p: serve(["--port", str(p)]),
333
+ background_ui=None if "--here" in args else (lambda: winbg.launch_ui()) if sys.platform == "win32"
334
+ else (lambda: wslstart.launch_background()) if ports.on_wsl() else None,
330
335
  )
331
336
  try:
332
337
  r = await wizard.run_setup(d)
@@ -392,6 +397,10 @@ async def ui_command(args: list[str]) -> int:
392
397
  start when I log in. It runs serve inside itself; --no-browser serves without opening the page (a login start)."""
393
398
  from . import net, ui # noqa: PLC0415
394
399
 
400
+ if "--background" in args: # Windows: hand off to a windowless copy (winbg.py) and return — this window may close
401
+ ok, why = winbg.launch_ui([a for a in args if a != "--background"])
402
+ _err("witbitz-code: running in the background — it opens in your browser" if ok else f"witbitz-code: could not start it in the background: {why}")
403
+ return 0 if ok else 1
395
404
  http = net.new_client()
396
405
  keys = pair_cmd._KeyCalls(http)
397
406
  try:
@@ -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, wizard, wslstart
38
+ from . import __version__, devicelink, link, net, ports, qr, uitools, uninstall, winbg, wizard, wslstart
39
39
  from . import pair as pair_cmd
40
40
  from . import pairings as pp
41
41
 
@@ -91,7 +91,7 @@ class _Tee(io.TextIOBase):
91
91
  while "\n" in self._part:
92
92
  line, self._part = self._part.split("\n", 1)
93
93
  if line.strip() and "\x1b[" not in line:
94
- self.sink.append(line.rstrip()[:300])
94
+ self.sink.append(line.rstrip()[:600])
95
95
  return len(s)
96
96
 
97
97
  def flush(self) -> None:
@@ -122,9 +122,14 @@ class Manager:
122
122
  login_state: Callable[[dict], str] = wslstart.startup_state,
123
123
  login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
124
124
  login_disable: Callable[[dict], bool] = wslstart.startup_disable,
125
- label: dict | None = None, log: deque | None = None) -> None:
125
+ port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
126
+ tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None) -> None:
126
127
  self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
127
128
  self._load, self._keys_line = load_pairings, keys_line
129
+ self.tools = tools or uitools.Tools()
130
+ self._owners = port_owners or ports.port_owners
131
+ self._stop_pid = stop_pid or uninstall.stop_process
132
+ self._elsewhere_cache: tuple[float, dict | None] | None = None
128
133
  self._login_spec, self._login_state, self._login_enable, self._login_disable = login_spec, login_state, login_enable, login_disable
129
134
  self.label = label or computer_label()
130
135
  self.log: deque = log if log is not None else deque(maxlen=60)
@@ -139,6 +144,8 @@ class Manager:
139
144
  self._keys_cache: dict[str, tuple[float, str]] = {}
140
145
  self._login_cache: tuple[float, dict] | None = None
141
146
  self.busy = "" # the action running now (the page greys its buttons)
147
+ self.background = False # Windows, no window (winbg.py): the page's Quit is the only way to stop it
148
+ self.quit_event: asyncio.Event | None = None
142
149
 
143
150
  # ── reading ────────────────────────────────────────────────────────────────────────────────────────────────────────
144
151
  def pairings(self) -> list:
@@ -196,15 +203,67 @@ class Manager:
196
203
  for p in ps:
197
204
  paired.append({"name": p.get("name") or "", "account": p.get("account") or "", "port": wizard.port_of(p), "keys": await self.keys(p)})
198
205
  pair = {k: v for k, v in self.pair_state.items() if k != "log"}
199
- return {"version": __version__, "computer": self.label, "paired": paired, "serving": self.serving(), "pair": pair,
200
- "login": self.login(), "busy": self.busy, "log": list(self.log)[-14:]}
206
+ serving = self.serving()
207
+ if serving["state"] == "stopped" and ps:
208
+ el = await self.elsewhere()
209
+ if el:
210
+ serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
211
+ return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
212
+ "login": self.login(), "busy": self.busy, "background": self.background,
213
+ "tools": await asyncio.to_thread(self.tools.status), "log": list(self.log)[-14:]}
214
+
215
+ async def elsewhere(self, fresh: bool = False) -> dict | None:
216
+ """{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
217
+ page serves nothing — `witbitz-code setup` serving in its own window, a login-started copy, an earlier serve (the owner,
218
+ 2026-10-09: the installer's setup was serving and the page said Stopped; its Start then failed "held by python.exe").
219
+ Another user's program, or WSL's copy (Windows cannot name it), is not listed: Start then fails and says why."""
220
+ if self.serve_task and not self.serve_task.done():
221
+ return None
222
+ hit = self._elsewhere_cache
223
+ if hit and not fresh and time.monotonic() - hit[0] < 5:
224
+ return hit[1]
225
+ port, owners = self.port(), []
226
+ for q in (port, port + 100):
227
+ if not await self._is_listening(q):
228
+ continue
229
+ try:
230
+ found = await asyncio.to_thread(self._owners, q)
231
+ except Exception: # noqa: BLE001 — a reading, never a failure of the page
232
+ found = []
233
+ for o in found:
234
+ if all(o["pid"] != x["pid"] for x in owners):
235
+ owners.append({"pid": int(o["pid"]), "cmd": str(o.get("cmd") or "")[:80]})
236
+ out = {"port": port, "owners": owners} if owners else None
237
+ self._elsewhere_cache = (time.monotonic(), out)
238
+ return out
239
+
240
+ async def _stop_elsewhere(self, el: dict) -> list[str]:
241
+ """Stop what elsewhere() named, then wait (≤ 6 s) for both ports to come free. The lines that say what happened."""
242
+ said = []
243
+ for o in el["owners"]:
244
+ ok = await asyncio.to_thread(self._stop_pid, o["pid"])
245
+ said.append(f"{'stopped' if ok else 'could not stop'} {o['cmd'] or 'a program'} (process {o['pid']})")
246
+ self.log.append("witbitz-code: " + said[-1])
247
+ for _ in range(30):
248
+ if not [q for q in (el["port"], el["port"] + 100) if await self._is_listening(q)]:
249
+ break
250
+ await asyncio.sleep(0.2)
251
+ self._elsewhere_cache = None
252
+ return said
201
253
 
202
254
  # ── serving ────────────────────────────────────────────────────────────────────────────────────────────────────────
203
- async def start_serving(self) -> dict:
255
+ async def start_serving(self, takeover: bool = False) -> dict:
256
+ """Serve from this page. Another copy of witbitz-code already serving here: the page's Start (takeover) stops it
257
+ and runs here instead; the launch's own start leaves it alone (the page then shows where it runs)."""
204
258
  if self.serve_task and not self.serve_task.done():
205
259
  return {"ok": True}
206
260
  if not self.pairings():
207
261
  return {"ok": False, "why": "pair this computer first"}
262
+ el = await self.elsewhere(fresh=True)
263
+ if el and not takeover:
264
+ return {"ok": False, "why": "already running in another window", "elsewhere": el}
265
+ if el:
266
+ await self._stop_elsewhere(el)
208
267
  self.serve_stop, self.serve_exit, self.live = _StopFlag(), None, {}
209
268
  self.serve_port = self.port()
210
269
  self.serve_task = asyncio.ensure_future(self._serve_run(self.serve_port))
@@ -216,7 +275,7 @@ class Manager:
216
275
  except asyncio.CancelledError:
217
276
  raise
218
277
  except Exception as e: # noqa: BLE001
219
- self.serve_exit = {"code": 1, "why": str(e)[:200]}
278
+ self.serve_exit = {"code": 1, "why": str(e)[:500]}
220
279
  return 1
221
280
  last = next((ln for ln in reversed(self.log) if ln.startswith("witbitz-code:")), "")
222
281
  self.serve_exit = {"code": code, "why": "" if code == 0 else last.replace("witbitz-code: ", "", 1)}
@@ -225,6 +284,9 @@ class Manager:
225
284
  async def stop_serving(self) -> dict:
226
285
  t = self.serve_task
227
286
  if t is None or t.done():
287
+ el = await self.elsewhere(fresh=True)
288
+ if el:
289
+ return {"ok": True, "said": await self._stop_elsewhere(el)}
228
290
  return {"ok": True}
229
291
  self.serve_stop.event.set()
230
292
  try:
@@ -323,6 +385,13 @@ class Manager:
323
385
  self._login_cache = None
324
386
  return {"ok": bool(ok), **({"why": why} if not ok else {})}
325
387
 
388
+ async def quit(self) -> dict:
389
+ """Stop serving and end this manager (the page's Quit — in the background there is no window to close)."""
390
+ await self.stop_serving()
391
+ if self.quit_event:
392
+ asyncio.get_running_loop().call_later(0.3, self.quit_event.set) # after this answer reaches the page
393
+ return {"ok": True}
394
+
326
395
  async def shutdown(self) -> None:
327
396
  if self.pair_task and not self.pair_task.done():
328
397
  self.pair_task.cancel()
@@ -455,11 +524,13 @@ class Server:
455
524
  return 400, {"content-type": "application/json"}, b'{"error":"not json"}'
456
525
  data = data if isinstance(data, dict) else {}
457
526
  actions: dict[str, Callable[[], Awaitable[dict]]] = {
458
- "/api/serve": lambda: self.m.start_serving() if data.get("on") else self.m.stop_serving(),
527
+ "/api/serve": lambda: self.m.start_serving(takeover=True) if data.get("on") else self.m.stop_serving(),
459
528
  "/api/pair": self.m.start_pairing,
460
529
  "/api/pair/cancel": self.m.cancel_pairing,
461
530
  "/api/unpair": lambda: self.m.unpair(str(data.get("account") or "")),
462
531
  "/api/login": lambda: self.m.set_login(bool(data.get("on"))),
532
+ "/api/quit": self.m.quit,
533
+ "/api/tools/install": self.m.tools.install,
463
534
  }
464
535
  act = actions.get(path)
465
536
  if not act:
@@ -509,22 +580,29 @@ async def run(args: list[str], *, serve: Callable[..., Awaitable[int]], is_liste
509
580
  if not no_browser:
510
581
  open_browser(url)
511
582
  return 0
583
+ background = winbg.prepare(lock_path().parent / "ui.log") # Windows, pythonw: a log file, windowless children
584
+ if "--hidden" in args and not background: # WSL, started by a hidden wsl.exe (wslstart.py): no window either
585
+ winbg.log_to(lock_path().parent / "ui.log")
586
+ background = True
512
587
  log: deque = deque(maxlen=60)
513
588
  tee = _Tee(sys.stderr, log)
514
589
  old_err = sys.stderr
515
590
  sys.stderr = tee
516
591
  m = Manager(serve=serve, is_listening=is_listening, keys_line=keys_line, log=log)
592
+ m.background = background
593
+ stop = asyncio.Event()
594
+ m.quit_event = stop
517
595
  srv = Server(m)
518
596
  try:
519
597
  await srv.start()
520
598
  pp.write_private(lock_path(), json.dumps({"port": srv.port, "token": srv.token, "pid": os.getpid()}) + "\n")
521
599
  print(f"witbitz-code: {m.label['label']} — open {srv.url}", file=sys.stderr, flush=True)
522
- print("witbitz-code: keep this window open (closing it stops witbitz-code). Ctrl-C stops it.", file=sys.stderr, flush=True)
600
+ print("witbitz-code: running in the background — Quit on the page stops it" if background else
601
+ "witbitz-code: keep this window open (closing it stops witbitz-code). Ctrl-C stops it.", file=sys.stderr, flush=True)
523
602
  if m.pairings():
524
603
  await m.start_serving()
525
604
  if not no_browser and not open_browser(srv.url):
526
605
  print("witbitz-code: open the address above in your browser", file=sys.stderr, flush=True)
527
- stop = asyncio.Event()
528
606
  loop = asyncio.get_running_loop()
529
607
  import signal # noqa: PLC0415
530
608
  for sig in (signal.SIGINT, signal.SIGTERM):
@@ -49,3 +49,5 @@ pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospa
49
49
  footer { font-size: 13px; text-align: center; }
50
50
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
51
51
  .actions:not(:has(button:not([hidden]))) { display: none; }
52
+ button.small { font-size: 13px; padding: 2px 10px; margin-left: 8px; }
53
+ pre.cmd { background: var(--off-bg); color: var(--text); padding: 8px 10px; border-radius: 8px; }
@@ -31,11 +31,19 @@ const PILL = {
31
31
  connecting: ['Connecting…', 'connecting', 'reaching the relay'],
32
32
  starting: ['Starting…', 'starting', 'starting OpenCode'],
33
33
  stopped: ['Stopped', '', 'not reachable from Spaces'],
34
+ elsewhere: ['Running in another window', 'online', ''],
35
+ }
36
+
37
+ // Another copy of witbitz-code serves this computer (setup's window, a login-started one): say which, so Start/Stop make sense.
38
+ function elsewhereText(sv) {
39
+ const who = (sv.owners || []).map((o) => `${o.cmd || 'a program'} (process ${o.pid})`).join(', ')
40
+ return `witbitz-code is running in another window${who ? ' — ' + who : ''}. Run it here to manage it from this page, or Stop it.`
34
41
  }
35
42
 
36
43
  function render(s) {
37
44
  state = s
38
45
  $('computer').textContent = s.computer.label + (s.computer.kind && !s.computer.label.includes(s.computer.kind) ? ' · ' + s.computer.kind : '')
46
+ document.title = s.computer.kind && s.computer.kind.startsWith('WSL') ? `Witbitz Code (${s.computer.kind})` : 'Witbitz Code'
39
47
  $('version').textContent = s.version
40
48
  const paired = s.paired.length > 0
41
49
  const run = s.serving.state
@@ -43,7 +51,7 @@ function render(s) {
43
51
  const pill = $('pill')
44
52
  pill.textContent = label
45
53
  pill.className = 'pill ' + cls
46
- $('stateText').textContent = run === 'stopped' && s.serving.why ? s.serving.why : hint + (s.serving.port && s.serving.port !== 4096 ? ` · port ${s.serving.port}` : '')
54
+ $('stateText').textContent = run === 'elsewhere' ? elsewhereText(s.serving) : run === 'stopped' && s.serving.why ? s.serving.why : hint + (s.serving.port && s.serving.port !== 4096 ? ` · port ${s.serving.port}` : '')
47
55
 
48
56
  const list = $('paired')
49
57
  list.textContent = ''
@@ -58,7 +66,8 @@ function render(s) {
58
66
  const waiting = s.pair.state === 'waiting'
59
67
  const busy = !!s.busy
60
68
  show($('pair'), !paired && !waiting)
61
- show($('start'), paired && run === 'stopped' && !waiting)
69
+ show($('start'), paired && (run === 'stopped' || run === 'elsewhere') && !waiting)
70
+ $('start').textContent = run === 'elsewhere' ? 'Run it here' : 'Start'
62
71
  show($('stop'), paired && run !== 'stopped')
63
72
  show($('pairAgain'), paired && !waiting)
64
73
  show($('unpair'), paired && !waiting)
@@ -83,6 +92,11 @@ function render(s) {
83
92
  $('login').disabled = busy || !lg.available
84
93
  $('login').checked = !!lg.on
85
94
  if (!lg.available && lg.why) $('loginNote').textContent = 'Not available: ' + lg.why
95
+ else if (s.background) $('loginNote').textContent = 'Starts in the background when you log in and keeps this computer reachable.'
96
+ // Windows, no window (winbg.py): nothing to keep open — Quit is how it stops
97
+ $('footerText').textContent = s.background ? 'runs in the background — Start menu → Witbitz Code opens this page' : 'keep its window open — closing it stops witbitz-code'
98
+ show($('quit'), !!s.background)
99
+ renderTools(s.tools, paired)
86
100
 
87
101
  $('log').textContent = (s.log || []).join('\n')
88
102
  }
@@ -97,7 +111,8 @@ async function refresh() {
97
111
  } catch (e) {
98
112
  $('pill').textContent = 'Not running'
99
113
  $('pill').className = 'pill bad'
100
- $('stateText').textContent = 'Witbitz Code’s window was closed — open it again from the Start menu.'
114
+ $('stateText').textContent = quitting || (state && state.background) ? 'Witbitz Code is not running — open it again from the Start menu.'
115
+ : 'Witbitz Code’s window was closed — open it again from the Start menu.'
101
116
  }
102
117
  }
103
118
 
@@ -123,7 +138,41 @@ function accountOf() {
123
138
  return pick || null
124
139
  }
125
140
 
141
+ // setup's Tools step, on the page (uitools.py): what is missing, what Install runs, its progress.
142
+ function renderTools(t, paired) {
143
+ if (!t) return show($('toolsCard'), false)
144
+ const missing = t.missing || []
145
+ const running = t.state === 'installing'
146
+ show($('toolsCard'), paired && (missing.length > 0 || running || t.state === 'done' || !!t.error))
147
+ $('toolsText').textContent = t.error ? t.error
148
+ : running ? 'Installing…'
149
+ : missing.length ? `Missing here: ${missing.join(', ')}. The agent uses them to work with your files.`
150
+ : 'Everything the agent uses for these is here ✓'
151
+ const steps = t.steps || []
152
+ show($('toolsSteps'), !running && missing.length > 0 && steps.length > 0)
153
+ $('toolsSteps').textContent = steps.join('\n')
154
+ show($('toolsInstall'), !running && !!t.installable)
155
+ $('toolsInstall').disabled = !!state.busy
156
+ show($('toolsAdmin'), !!t.admin && !running)
157
+ show($('toolsAdminCmd'), !!t.admin && !running)
158
+ $('toolsAdminCmd').textContent = t.admin || ''
159
+ const lines = t.lines || []
160
+ show($('toolsLog'), lines.length > 0)
161
+ $('toolsLog').textContent = lines.join('\n')
162
+ }
163
+
164
+ let quitting = false
165
+ async function quit() {
166
+ if (!window.confirm('Quit Witbitz Code? Code in Spaces will not reach this computer until you open Witbitz Code from the Start menu again.')) return
167
+ quitting = true
168
+ try { await api('/api/quit', {}) } catch { /* it is going away */ }
169
+ note('Witbitz Code stopped. Start menu → Witbitz Code starts it again.', 'bad')
170
+ for (const id of ['pair', 'start', 'stop', 'pairAgain', 'unpair', 'quit']) $(id).disabled = true
171
+ }
172
+
126
173
  document.addEventListener('DOMContentLoaded', () => {
174
+ $('quit').onclick = quit
175
+ $('toolsInstall').onclick = () => act('/api/tools/install', {}, 'Install them now? Windows may ask once to allow an installer.')
127
176
  $('pair').onclick = () => act('/api/pair', {})
128
177
  $('start').onclick = () => act('/api/serve', { on: true })
129
178
  $('stop').onclick = () => act('/api/serve', { on: false }, 'Stop? Code in Spaces will not reach this computer until you start it again.')
@@ -41,6 +41,16 @@
41
41
  <div class="actions"><button id="pairCancel" type="button" class="ghost">Cancel</button><button id="pairRetry" type="button" hidden>Show a new code</button></div>
42
42
  </section>
43
43
 
44
+ <section class="card" id="toolsCard" hidden>
45
+ <h2>Tools for audio, video, PDFs and scans</h2>
46
+ <p id="toolsText" class="muted"></p>
47
+ <pre id="toolsSteps" class="cmd" hidden></pre>
48
+ <div class="actions"><button id="toolsInstall" type="button" hidden>Install them</button></div>
49
+ <p id="toolsAdmin" class="muted" hidden>These need your password — run this in a terminal:</p>
50
+ <pre id="toolsAdminCmd" class="cmd" hidden></pre>
51
+ <pre id="toolsLog" class="cmd" hidden></pre>
52
+ </section>
53
+
44
54
  <section class="card" id="loginCard" hidden>
45
55
  <label class="switch-row" for="login">
46
56
  <span>
@@ -56,7 +66,8 @@
56
66
  <pre id="log"></pre>
57
67
  </details>
58
68
 
59
- <footer class="muted">witbitz-code <span id="version"></span> · keep its window open — closing it stops witbitz-code</footer>
69
+ <footer class="muted">witbitz-code <span id="version"></span> · <span id="footerText">keep its window open — closing it stops witbitz-code</span>
70
+ <button id="quit" type="button" class="ghost small" hidden>Quit</button></footer>
60
71
  </main>
61
72
  </body>
62
73
  </html>
@@ -0,0 +1,108 @@
1
+ """The page's Tools card (ui.py): what the agent uses for audio, video, PDFs and scans, and one Install button — setup's
2
+ Tools step without a terminal (the owner, 2026-10-09: "the installer still asks its questions in the terminal - why the
3
+ terminal?"). The same plan as setup (toolsetup.install_plan); the same runner (toolsetup.run_plan), with each command's
4
+ output kept for the page instead of a terminal. A step that needs an administrator's password (Linux's apt/dnf/pacman
5
+ line) is never run from here: the page shows it for the person to run in a terminal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import subprocess
13
+ import time
14
+ from collections import deque
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from . import toolsetup, tools_probe
19
+
20
+
21
+ def run_captured(argv: list[str], log: Callable[[str], Any]) -> int:
22
+ """A command with no terminal: stdin closed, its last lines kept → its exit code (-1: it could not start)."""
23
+ try:
24
+ r = subprocess.run(argv, check=False, stdin=subprocess.DEVNULL, capture_output=True, text=True, # noqa: S603 — argv from install_plan
25
+ errors="replace", timeout=1800)
26
+ except (OSError, ValueError, subprocess.SubprocessError) as e:
27
+ log(f" ✖ could not start it ({str(e)[:120]})")
28
+ return -1
29
+ for line in [ln.strip() for ln in (r.stdout + "\n" + r.stderr).splitlines() if ln.strip()][-4:]:
30
+ log(" " + line[:200])
31
+ return r.returncode
32
+
33
+
34
+ def refresh_path() -> None:
35
+ """Windows: what winget just installed is on the registry's PATH, not yet on this process's."""
36
+ reg = toolsetup.registry_path()
37
+ if reg:
38
+ os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
39
+
40
+
41
+ def describe(step: dict) -> str:
42
+ if step["kind"] == "cmd":
43
+ return " ".join(step["argv"][:4]) if step["argv"][0] != "sh" else step["argv"][2]
44
+ if step["kind"] == "release":
45
+ return f"{toolsetup.label(step['id'])}: a {step['mb']} MB download from its publisher, checked against its SHA-256"
46
+ return ""
47
+
48
+
49
+ class Tools:
50
+ """{state: idle|installing|done, missing, steps, admin, lines}. Everything outside is injected for tests."""
51
+
52
+ def __init__(self, *, probe: Callable[[], dict] = tools_probe.probe_tools,
53
+ run_plan: Callable[..., dict] = toolsetup.run_plan, run: Callable[[list[str], Callable], int] = run_captured,
54
+ refresh: Callable[[], None] = refresh_path) -> None:
55
+ self._probe, self._run_plan, self._run, self._refresh = probe, run_plan, run, refresh
56
+ self.state = "idle"
57
+ self.lines: deque = deque(maxlen=40)
58
+ self.task: asyncio.Task | None = None
59
+ self._refreshed = False
60
+ self._cache: tuple[float, tuple] | None = None # the page asks every few seconds; a probe reads PATH's folders
61
+
62
+ def _plan(self) -> tuple[list[dict], list[dict]]:
63
+ if not self._refreshed: # once: a tool an earlier install put on PATH must not read as missing
64
+ self._refreshed = True
65
+ try:
66
+ self._refresh()
67
+ except Exception: # noqa: BLE001
68
+ pass
69
+ if self._cache and time.monotonic() - self._cache[0] < 15:
70
+ return self._cache[1]
71
+ report = self._probe()
72
+ missing = toolsetup.missing_tools(report)
73
+ plan = toolsetup.install_plan([t["id"] for t in missing], report.get("platform") or {}) if missing else []
74
+ self._cache = (time.monotonic(), (missing, plan))
75
+ return missing, plan
76
+
77
+ def status(self) -> dict:
78
+ try:
79
+ missing, plan = self._plan()
80
+ except Exception as e: # noqa: BLE001 — a reading, never a failure of the page
81
+ return {"state": self.state, "error": f"could not check ({str(e)[:80]})", "missing": [], "lines": list(self.lines)}
82
+ runnable = [s for s in plan if s["kind"] in ("cmd", "release") and not s.get("admin")]
83
+ admin = next((s["argv"][2] for s in plan if s.get("admin")), "")
84
+ return {"state": self.state, "missing": [t["label"] for t in missing], "steps": [describe(s) for s in runnable],
85
+ "admin": admin, "installable": bool(runnable), "lines": list(self.lines)}
86
+
87
+ async def install(self) -> dict:
88
+ if self.task and not self.task.done():
89
+ return {"ok": True}
90
+ _missing, plan = self._plan()
91
+ steps = [s for s in plan if not s.get("admin")]
92
+ if not any(s["kind"] in ("cmd", "release") for s in steps):
93
+ return {"ok": False, "why": "nothing here can install them from this page"}
94
+ self.state, self._refreshed = "installing", False
95
+ self.lines.clear()
96
+ self.task = asyncio.ensure_future(asyncio.to_thread(self._install, steps))
97
+ return {"ok": True}
98
+
99
+ def _install(self, steps: list[dict]) -> None:
100
+ say = self.lines.append
101
+ try:
102
+ self._run_plan(steps, say=say, run=lambda argv: self._run(argv, say))
103
+ self._refresh()
104
+ except Exception as e: # noqa: BLE001
105
+ say(f" ✖ {str(e)[:160]}")
106
+ finally:
107
+ self._cache = None
108
+ self.state = "done"
@@ -294,6 +294,7 @@ class Deps:
294
294
  remove_package: Callable[[dict], Any] = remove_package # may be async: the CLI closes its network client first
295
295
  login_start: Callable[[], Any] = lambda: __import__("witbitz_code.wslstart", fromlist=["startup_here"]).startup_here() # WSL only
296
296
  login_start_disable: Callable[[dict], bool] = lambda spec: __import__("witbitz_code.wslstart", fromlist=["startup_disable"]).startup_disable(spec)
297
+ menu_remove: Callable[[], bool] = lambda: __import__("witbitz_code.wslstart", fromlist=["menu_remove"]).menu_remove() # WSL only
297
298
 
298
299
 
299
300
  async def run_uninstall(d: Deps) -> dict:
@@ -393,6 +394,8 @@ async def run_uninstall(d: Deps) -> dict:
393
394
  spec = d.login_start() # WSL: the Windows Startup shortcut setup made (wslstart.py)
394
395
  if spec and d.login_start_disable(spec):
395
396
  d.say(f"✓ Removed the Windows Startup shortcut \"{spec['name'][:-4]}\" — it no longer starts when you log in")
397
+ if spec and d.menu_remove():
398
+ d.say(f"✓ Removed \"{spec['name'][:-4]}\" from the Start menu")
396
399
  d.remove_path(d.code_dir)
397
400
  d.say(f"✓ Deleted {d.code_dir}")
398
401
  pkg_gone = False
@@ -0,0 +1,97 @@
1
+ """Windows: witbitz-code's page manager (`witbitz-code ui`) runs in the BACKGROUND — no terminal window that stops it when
2
+ closed (the owner, 2026-10-09: "currently it opens a terminal and it depends on it. can this be run in the background?").
3
+
4
+ It runs under pythonw.exe, which has no console. Two things follow, both handled here at start:
5
+ - stdout/stderr are None — what it says goes to a log file beside the pairings (ui.log, cut at 1 MB);
6
+ - a console program it starts (OpenCode, netstat, tasklist, taskkill, winget …) would each open a NEW window — every child
7
+ is started with CREATE_NO_WINDOW instead (one patch of subprocess.Popen, which asyncio's Windows subprocesses use too; a
8
+ child of such a process inherits its windowless console, so OpenCode's own children stay hidden as well).
9
+ Nothing here runs off Windows, or in a process that has a console (a `witbitz-code ui` typed into a terminal).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ CREATE_NO_WINDOW = 0x08000000
21
+ DETACHED_PROCESS = 0x00000008
22
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
23
+ LOG_MAX = 1024 * 1024
24
+
25
+
26
+ def has_console(platform: str | None = None) -> bool:
27
+ if (sys.platform if platform is None else platform) != "win32":
28
+ return True
29
+ try:
30
+ import ctypes # noqa: PLC0415 — Windows only
31
+
32
+ return bool(ctypes.windll.kernel32.GetConsoleWindow()) # type: ignore[attr-defined]
33
+ except Exception: # noqa: BLE001
34
+ return True
35
+
36
+
37
+ def pythonw(executable: str | None = None) -> Path | None:
38
+ """pythonw.exe beside this Python (a uv tool's venv has both in Scripts), or None."""
39
+ p = Path(executable or sys.executable).with_name("pythonw.exe")
40
+ return p if p.exists() else None
41
+
42
+
43
+ def quiet_children(popen: type = subprocess.Popen) -> bool:
44
+ """Every child from now on starts with CREATE_NO_WINDOW (unless it asked for a console or detachment itself)."""
45
+ if getattr(popen, "_witbitz_quiet", False):
46
+ return False
47
+ original = popen.__init__
48
+
49
+ def __init__(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
50
+ flags = kwargs.get("creationflags", 0) or 0
51
+ if not flags & (DETACHED_PROCESS | 0x00000010): # 0x10 = CREATE_NEW_CONSOLE: the caller wants a window
52
+ kwargs["creationflags"] = flags | CREATE_NO_WINDOW
53
+ original(self, *args, **kwargs)
54
+
55
+ popen.__init__ = __init__
56
+ popen._witbitz_quiet = True
57
+ return True
58
+
59
+
60
+ def log_to(path: Path) -> None:
61
+ """stdout/stderr → `path` (appended; started over past LOG_MAX), line-buffered so the page's tee sees each line."""
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ try:
64
+ if path.stat().st_size > LOG_MAX:
65
+ path.unlink()
66
+ except OSError:
67
+ pass
68
+ f = open(path, "a", encoding="utf-8", errors="replace", buffering=1) # noqa: SIM115 — lives as long as the process
69
+ sys.stdout = f
70
+ sys.stderr = f
71
+
72
+
73
+ def prepare(log_path: Path, *, platform: str | None = None, console: Callable[[], bool] | None = None) -> bool:
74
+ """In a windowless Windows process: log to a file and keep every child windowless. → whether it is in the background."""
75
+ if (sys.platform if platform is None else platform) != "win32":
76
+ return False
77
+ if (console or has_console)():
78
+ return False
79
+ if sys.stderr is None or sys.stdout is None:
80
+ log_to(log_path)
81
+ quiet_children()
82
+ return True
83
+
84
+
85
+ def launch_ui(extra: list[str] | None = None, *, popen: Callable[..., object] = subprocess.Popen,
86
+ executable: str | None = None) -> tuple[bool, str]:
87
+ """Start `witbitz-code ui` in the background (pythonw, detached — closing this window does not stop it). → (ok, why)."""
88
+ w = pythonw(executable)
89
+ if not w:
90
+ return False, "pythonw.exe is not beside this Python"
91
+ try:
92
+ popen([str(w), "-m", "witbitz_code", "ui", *(extra or [])], cwd=os.path.expanduser("~"),
93
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True,
94
+ creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
95
+ except OSError as e:
96
+ return False, str(e)[:120]
97
+ return True, ""