witbitz-code 1.5.1__tar.gz → 1.6.1__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.1/src/witbitz_code.egg-info → witbitz_code-1.6.1}/PKG-INFO +1 -1
  2. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/pyproject.toml +1 -1
  3. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/cli.py +14 -5
  5. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui.py +25 -5
  6. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui_static/app.css +2 -0
  7. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui_static/app.js +42 -1
  8. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui_static/index.html +12 -1
  9. witbitz_code-1.6.1/src/witbitz_code/uitools.py +108 -0
  10. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/uninstall.py +75 -5
  11. witbitz_code-1.6.1/src/witbitz_code/winbg.py +97 -0
  12. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/wizard.py +15 -0
  13. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/wslstart.py +62 -9
  14. {witbitz_code-1.5.1 → witbitz_code-1.6.1/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  15. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
  16. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_setup.py +22 -0
  17. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_ui.py +20 -0
  18. witbitz_code-1.6.1/tests/test_uitools.py +71 -0
  19. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_uninstall.py +41 -3
  20. witbitz_code-1.6.1/tests/test_winbg.py +71 -0
  21. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_wslstart.py +25 -0
  22. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/LICENSE +0 -0
  23. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/README.md +0 -0
  24. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/setup.cfg +0 -0
  25. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/__main__.py +0 -0
  26. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/_js.py +0 -0
  27. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/account.py +0 -0
  28. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/asks.py +0 -0
  29. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/attachments.py +0 -0
  30. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/auto.py +0 -0
  31. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/auto_runner.py +0 -0
  32. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/codekeys.py +0 -0
  33. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/codetools.py +0 -0
  34. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/compress.py +0 -0
  35. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/computers.py +0 -0
  36. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/confidential.py +0 -0
  37. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/confidential_models.json +0 -0
  38. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/connector.py +0 -0
  39. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/devicelink.py +0 -0
  40. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/dirs.py +0 -0
  41. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/folders.py +0 -0
  42. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/gateway.py +0 -0
  43. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/keys.py +0 -0
  44. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/link.py +0 -0
  45. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/mkdir.py +0 -0
  46. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/models.py +0 -0
  47. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/net.py +0 -0
  48. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/offered_models.json +0 -0
  49. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  50. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  51. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  52. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  53. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/outputs.py +0 -0
  54. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/pair.py +0 -0
  55. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/pairings.py +0 -0
  56. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/payers.py +0 -0
  57. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/plugins.py +0 -0
  58. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/policy.py +0 -0
  59. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ports.py +0 -0
  60. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/qr.py +0 -0
  61. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/receipt.py +0 -0
  62. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/relay.py +0 -0
  63. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/seen.py +0 -0
  64. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/tinfoil_read.py +0 -0
  65. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/tools_catalog.json +0 -0
  66. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/tools_probe.py +0 -0
  67. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/toolsetup.py +0 -0
  68. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/winagent.py +0 -0
  69. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/winconsole.py +0 -0
  70. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  71. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  72. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/requires.txt +0 -0
  73. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/top_level.txt +0 -0
  74. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_asks.py +0 -0
  75. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_attachments.py +0 -0
  76. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_auto.py +0 -0
  77. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_cli.py +0 -0
  78. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_codekeys.py +0 -0
  79. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_codetools.py +0 -0
  80. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_confidential.py +0 -0
  81. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_connector_e2e.py +0 -0
  82. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_devicelink.py +0 -0
  83. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_dirs.py +0 -0
  84. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_files_and_compress.py +0 -0
  85. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_folders.py +0 -0
  86. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_outputs.py +0 -0
  87. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_pair_flows.py +0 -0
  88. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_plugins.py +0 -0
  89. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_policy.py +0 -0
  90. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_ports.py +0 -0
  91. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_ports_win.py +0 -0
  92. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_registry.py +0 -0
  93. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_relay_codec.py +0 -0
  94. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_tools_probe.py +0 -0
  95. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_toolsetup.py +0 -0
  96. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_winagent.py +0 -0
  97. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_winconsole.py +0 -0
  98. {witbitz_code-1.5.1 → witbitz_code-1.6.1}/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.1
3
+ Version: 1.6.1
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.1"
7
+ version = "1.6.1"
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.1"
7
+ __version__ = "1.6.1"
@@ -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, uninstall, 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
 
@@ -123,9 +123,10 @@ class Manager:
123
123
  login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
124
124
  login_disable: Callable[[dict], bool] = wslstart.startup_disable,
125
125
  port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
126
- label: dict | None = None, log: deque | None = None) -> None:
126
+ tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None) -> None:
127
127
  self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
128
128
  self._load, self._keys_line = load_pairings, keys_line
129
+ self.tools = tools or uitools.Tools()
129
130
  self._owners = port_owners or ports.port_owners
130
131
  self._stop_pid = stop_pid or uninstall.stop_process
131
132
  self._elsewhere_cache: tuple[float, dict | None] | None = None
@@ -143,6 +144,8 @@ class Manager:
143
144
  self._keys_cache: dict[str, tuple[float, str]] = {}
144
145
  self._login_cache: tuple[float, dict] | None = None
145
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
146
149
 
147
150
  # ── reading ────────────────────────────────────────────────────────────────────────────────────────────────────────
148
151
  def pairings(self) -> list:
@@ -206,7 +209,8 @@ class Manager:
206
209
  if el:
207
210
  serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
208
211
  return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
209
- "login": self.login(), "busy": self.busy, "log": list(self.log)[-14:]}
212
+ "login": self.login(), "busy": self.busy, "background": self.background,
213
+ "tools": await asyncio.to_thread(self.tools.status), "log": list(self.log)[-14:]}
210
214
 
211
215
  async def elsewhere(self, fresh: bool = False) -> dict | None:
212
216
  """{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
@@ -381,6 +385,13 @@ class Manager:
381
385
  self._login_cache = None
382
386
  return {"ok": bool(ok), **({"why": why} if not ok else {})}
383
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
+
384
395
  async def shutdown(self) -> None:
385
396
  if self.pair_task and not self.pair_task.done():
386
397
  self.pair_task.cancel()
@@ -518,6 +529,8 @@ class Server:
518
529
  "/api/pair/cancel": self.m.cancel_pairing,
519
530
  "/api/unpair": lambda: self.m.unpair(str(data.get("account") or "")),
520
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,
521
534
  }
522
535
  act = actions.get(path)
523
536
  if not act:
@@ -567,22 +580,29 @@ async def run(args: list[str], *, serve: Callable[..., Awaitable[int]], is_liste
567
580
  if not no_browser:
568
581
  open_browser(url)
569
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
570
587
  log: deque = deque(maxlen=60)
571
588
  tee = _Tee(sys.stderr, log)
572
589
  old_err = sys.stderr
573
590
  sys.stderr = tee
574
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
575
595
  srv = Server(m)
576
596
  try:
577
597
  await srv.start()
578
598
  pp.write_private(lock_path(), json.dumps({"port": srv.port, "token": srv.token, "pid": os.getpid()}) + "\n")
579
599
  print(f"witbitz-code: {m.label['label']} — open {srv.url}", file=sys.stderr, flush=True)
580
- 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)
581
602
  if m.pairings():
582
603
  await m.start_serving()
583
604
  if not no_browser and not open_browser(srv.url):
584
605
  print("witbitz-code: open the address above in your browser", file=sys.stderr, flush=True)
585
- stop = asyncio.Event()
586
606
  loop = asyncio.get_running_loop()
587
607
  import signal # noqa: PLC0415
588
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; }
@@ -43,6 +43,7 @@ function elsewhereText(sv) {
43
43
  function render(s) {
44
44
  state = s
45
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'
46
47
  $('version').textContent = s.version
47
48
  const paired = s.paired.length > 0
48
49
  const run = s.serving.state
@@ -91,6 +92,11 @@ function render(s) {
91
92
  $('login').disabled = busy || !lg.available
92
93
  $('login').checked = !!lg.on
93
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)
94
100
 
95
101
  $('log').textContent = (s.log || []).join('\n')
96
102
  }
@@ -105,7 +111,8 @@ async function refresh() {
105
111
  } catch (e) {
106
112
  $('pill').textContent = 'Not running'
107
113
  $('pill').className = 'pill bad'
108
- $('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.'
109
116
  }
110
117
  }
111
118
 
@@ -131,7 +138,41 @@ function accountOf() {
131
138
  return pick || null
132
139
  }
133
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
+
134
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.')
135
176
  $('pair').onclick = () => act('/api/pair', {})
136
177
  $('start').onclick = () => act('/api/serve', { on: true })
137
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"
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
23
23
  from pathlib import Path
24
24
  from typing import Any
25
25
 
26
- from . import keys, plugins
26
+ from . import keys, plugins, ports
27
27
  from . import pairings as pp
28
28
  from .wizard import find_opencode
29
29
 
@@ -138,6 +138,8 @@ def opencode_holders(data_dir: str) -> list[dict]:
138
138
  cmd = ""
139
139
  hits.append({"pid": int(p), "cmd": cmd})
140
140
  return hits
141
+ if sys.platform == "win32": # no /proc, no lsof: every opencode.exe of this user's (the owner, 2026-10-09: the background
142
+ return win_opencode_processes() # copy's OpenCode held opencode.db and the delete died "WinError 32")
141
143
  files = [os.path.join(data_dir, f) for f in ("opencode.db", "opencode.db-wal", "opencode.db-shm") if os.path.exists(os.path.join(data_dir, f))]
142
144
  if not files or not shutil.which("lsof"):
143
145
  return hits
@@ -150,6 +152,55 @@ def opencode_holders(data_dir: str) -> list[dict]:
150
152
  return hits
151
153
 
152
154
 
155
+ def win_opencode_processes(*, run: Callable[[list[str]], str] | None = None, user: str | None = None) -> list[dict]:
156
+ """Windows: this user's opencode.exe processes (tasklist, one CSV row each)."""
157
+ run = run or ports._run_text
158
+ user = os.environ.get("USERNAME", "") if user is None else user
159
+ hits = []
160
+ for line in run(["tasklist", "/V", "/FO", "CSV", "/NH", "/FI", "IMAGENAME eq opencode.exe"]).splitlines():
161
+ row = ports.parse_tasklist_row(line)
162
+ if row and (not user or row["user"].rsplit("\\", 1)[-1].lower() == user.lower()):
163
+ hits.append({"pid": row["pid"], "cmd": row["image"]})
164
+ return hits
165
+
166
+
167
+ def running_witbitz(*, lock: Path | None = None, pairings: Callable[[], list] | None = None,
168
+ owners: Callable[[int], list] | None = None, alive: Callable[[int], bool] | None = None) -> list[dict]:
169
+ """What runs witbitz-code here now: the page's manager (its ui.json names it — in the background it has no window to
170
+ close) and whatever holds each paired OpenCode port or its model proxy's (port + 100): a serve, its OpenCode."""
171
+ lock = lock or pp.pairings_path().parent / "ui.json"
172
+ pairings = pairings or (lambda: pp.load_pairings(log=lambda _m: None))
173
+ owners = owners or ports.port_owners
174
+ alive = alive or _alive
175
+ found: list[dict] = []
176
+ try:
177
+ pid = json.loads(lock.read_text(encoding="utf-8")).get("pid")
178
+ if isinstance(pid, int) and pid != os.getpid() and alive(pid):
179
+ found.append({"pid": pid, "cmd": "the Witbitz Code page"})
180
+ except (OSError, ValueError, AttributeError):
181
+ pass
182
+ for p in pairings():
183
+ tail = str(p.get("opencodeUrl") or "").rsplit(":", 1)[-1].rstrip("/")
184
+ port = int(tail) if tail.isdigit() else 0
185
+ for q in ([port, port + 100] if port else []):
186
+ for o in owners(q):
187
+ if o["pid"] != os.getpid() and all(o["pid"] != x["pid"] for x in found):
188
+ found.append({"pid": int(o["pid"]), "cmd": str(o.get("cmd") or "")[:70]})
189
+ return found
190
+
191
+
192
+ def _alive(pid: int) -> bool:
193
+ if sys.platform == "win32":
194
+ return _win_alive(pid)
195
+ try:
196
+ os.kill(pid, 0)
197
+ return True
198
+ except ProcessLookupError:
199
+ return False
200
+ except PermissionError:
201
+ return False # another user's: not ours
202
+
203
+
153
204
  def _win_alive(pid: int) -> bool:
154
205
  """Windows: is `pid` still running? (OpenProcess + GetExitCodeProcess; access denied = another user's, still running.)"""
155
206
  import ctypes # noqa: PLC0415 — Windows only
@@ -287,6 +338,7 @@ class Deps:
287
338
  sessions: Callable[[], dict] = None # type: ignore[assignment]
288
339
  delete_sessions: Callable[[], None] = None # type: ignore[assignment]
289
340
  opencode_processes: Callable[[], list] = lambda: opencode_holders(str(keys.auth_path().parent))
341
+ running: Callable[[], list] = running_witbitz
290
342
  stop_process: Callable[[int], bool] = stop_process
291
343
  opencode: Callable[[], dict] = opencode_here
292
344
  remove_opencode_program: Callable[[dict], dict] = remove_opencode_program
@@ -294,6 +346,7 @@ class Deps:
294
346
  remove_package: Callable[[dict], Any] = remove_package # may be async: the CLI closes its network client first
295
347
  login_start: Callable[[], Any] = lambda: __import__("witbitz_code.wslstart", fromlist=["startup_here"]).startup_here() # WSL only
296
348
  login_start_disable: Callable[[dict], bool] = lambda spec: __import__("witbitz_code.wslstart", fromlist=["startup_disable"]).startup_disable(spec)
349
+ menu_remove: Callable[[], bool] = lambda: __import__("witbitz_code.wslstart", fromlist=["menu_remove"]).menu_remove() # WSL only
297
350
 
298
351
 
299
352
  async def run_uninstall(d: Deps) -> dict:
@@ -331,6 +384,12 @@ async def run_uninstall(d: Deps) -> dict:
331
384
  how = {"installer": "from OpenCode's installer, in ~/.opencode", "npm": "with npm", "brew": "with Homebrew"}.get(oc["kind"])
332
385
  remove_oc = ask(d.remove_opencode, f"Also remove OpenCode itself (installed {how})? [y/N] ") if oc["kind"] not in ("none", "other") else False
333
386
 
387
+ # The running witbitz-code first — the page's manager in the background, a serve, their OpenCode: it holds the files
388
+ # about to go (Windows refuses to delete an open file) and would keep answering the phone. Continue already said yes.
389
+ for p in d.running():
390
+ d.say(f"✓ Stopped the running witbitz-code: {p['cmd'] or 'a program'} (process {p['pid']})" if d.stop_process(p["pid"])
391
+ else f"✖ Could not stop {p['cmd'] or 'a program'} (process {p['pid']}) — close it, then run uninstall again")
392
+
334
393
  # Sessions: an OpenCode that has its database open would write it back — never delete under it.
335
394
  while remove_sessions:
336
395
  procs = d.opencode_processes()
@@ -375,8 +434,11 @@ async def run_uninstall(d: Deps) -> dict:
375
434
  d.remove_path(notes["root"])
376
435
  d.say(f"✓ Deleted {notes['root']}")
377
436
  if remove_sessions:
378
- d.delete_sessions()
379
- d.say(f"✓ Deleted the OpenCode sessions in {sessions['dir']} (provider logins kept)")
437
+ stuck = d.delete_sessions() or []
438
+ if stuck:
439
+ d.say(f"✖ Could not delete {', '.join(stuck[:4])} in {sessions['dir']} — another program still has it open; close OpenCode and delete it yourself")
440
+ else:
441
+ d.say(f"✓ Deleted the OpenCode sessions in {sessions['dir']} (provider logins kept)")
380
442
  oc_gone = False
381
443
  if remove_oc:
382
444
  try:
@@ -393,6 +455,8 @@ async def run_uninstall(d: Deps) -> dict:
393
455
  spec = d.login_start() # WSL: the Windows Startup shortcut setup made (wslstart.py)
394
456
  if spec and d.login_start_disable(spec):
395
457
  d.say(f"✓ Removed the Windows Startup shortcut \"{spec['name'][:-4]}\" — it no longer starts when you log in")
458
+ if spec and d.menu_remove():
459
+ d.say(f"✓ Removed \"{spec['name'][:-4]}\" from the Start menu")
396
460
  d.remove_path(d.code_dir)
397
461
  d.say(f"✓ Deleted {d.code_dir}")
398
462
  pkg_gone = False
@@ -451,12 +515,18 @@ def sessions_info() -> dict:
451
515
  return {"dir": str(d), "exists": exists}
452
516
 
453
517
 
454
- def delete_sessions() -> None:
518
+ def delete_sessions() -> list[str]:
519
+ """→ the names that could not be deleted (Windows: a file another program still has open), never a crash."""
455
520
  d = keys.auth_path().parent
521
+ left = []
456
522
  for f in d.iterdir():
457
523
  if f.name == "auth.json":
458
524
  continue
459
- shutil.rmtree(f, ignore_errors=True) if f.is_dir() and not f.is_symlink() else f.unlink(missing_ok=True)
525
+ try:
526
+ shutil.rmtree(f, ignore_errors=True) if f.is_dir() and not f.is_symlink() else f.unlink(missing_ok=True)
527
+ except OSError:
528
+ left.append(f.name)
529
+ return left
460
530
 
461
531
 
462
532
  def saved_keys() -> list[str]:
@@ -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, ""