witbitz-code 1.3.7__tar.gz → 1.3.8__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 (84) hide show
  1. {witbitz_code-1.3.7/src/witbitz_code.egg-info → witbitz_code-1.3.8}/PKG-INFO +5 -1
  2. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/README.md +4 -0
  3. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/pyproject.toml +1 -1
  4. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/cli.py +5 -1
  6. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/pair.py +20 -0
  7. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/pairings.py +7 -1
  8. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/ports.py +28 -0
  9. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/toolsetup.py +14 -2
  10. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/wizard.py +39 -3
  11. {witbitz_code-1.3.7 → witbitz_code-1.3.8/src/witbitz_code.egg-info}/PKG-INFO +5 -1
  12. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_pair_flows.py +27 -0
  13. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_ports_win.py +26 -0
  14. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_setup.py +51 -7
  15. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_toolsetup.py +22 -1
  16. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/LICENSE +0 -0
  17. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/setup.cfg +0 -0
  18. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/__main__.py +0 -0
  19. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/_js.py +0 -0
  20. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/account.py +0 -0
  21. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/asks.py +0 -0
  22. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/attachments.py +0 -0
  23. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/auto.py +0 -0
  24. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/auto_runner.py +0 -0
  25. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/codekeys.py +0 -0
  26. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/codetools.py +0 -0
  27. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/compress.py +0 -0
  28. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/computers.py +0 -0
  29. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/confidential.py +0 -0
  30. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/confidential_models.json +0 -0
  31. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/connector.py +0 -0
  32. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/devicelink.py +0 -0
  33. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/keys.py +0 -0
  36. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/link.py +0 -0
  37. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/models.py +0 -0
  38. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/net.py +0 -0
  39. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/offered_models.json +0 -0
  40. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  41. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  42. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  43. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  44. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/outputs.py +0 -0
  45. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/payers.py +0 -0
  46. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/plugins.py +0 -0
  47. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/policy.py +0 -0
  48. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/qr.py +0 -0
  49. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/receipt.py +0 -0
  50. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/relay.py +0 -0
  51. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/seen.py +0 -0
  52. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/tinfoil_read.py +0 -0
  53. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/tools_catalog.json +0 -0
  54. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/tools_probe.py +0 -0
  55. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/uninstall.py +0 -0
  56. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/winagent.py +0 -0
  57. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code/winconsole.py +0 -0
  58. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  59. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  60. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  61. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/requires.txt +0 -0
  62. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/top_level.txt +0 -0
  63. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_asks.py +0 -0
  64. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_attachments.py +0 -0
  65. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_auto.py +0 -0
  66. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_cli.py +0 -0
  67. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_codekeys.py +0 -0
  68. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_codetools.py +0 -0
  69. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_confidential.py +0 -0
  70. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_connector_e2e.py +0 -0
  71. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_devicelink.py +0 -0
  72. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_files_and_compress.py +0 -0
  73. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_folders.py +0 -0
  74. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_outputs.py +0 -0
  75. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_plugins.py +0 -0
  76. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_policy.py +0 -0
  77. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_ports.py +0 -0
  78. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_registry.py +0 -0
  79. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_relay_codec.py +0 -0
  80. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_tools_probe.py +0 -0
  81. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_uninstall.py +0 -0
  82. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_winagent.py +0 -0
  83. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/tests/test_winconsole.py +0 -0
  84. {witbitz_code-1.3.7 → witbitz_code-1.3.8}/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.3.7
3
+ Version: 1.3.8
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
@@ -88,6 +88,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
88
88
 
89
89
  `pair`, `rotate` and `unpair` also accept `--dry-run`.
90
90
 
91
+ **Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
92
+ one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
93
+ "<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
94
+
91
95
  **More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
92
96
  channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
93
97
  all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
@@ -62,6 +62,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
62
62
 
63
63
  `pair`, `rotate` and `unpair` also accept `--dry-run`.
64
64
 
65
+ **Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
66
+ one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
67
+ "<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
68
+
65
69
  **More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
66
70
  channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
67
71
  all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.3.7"
7
+ version = "1.3.8"
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.3.7"
7
+ __version__ = "1.3.8"
@@ -156,6 +156,10 @@ async def serve(args: list[str]) -> int:
156
156
  if not everything:
157
157
  _err("witbitz-code: this computer is not paired yet — run: witbitz-code setup")
158
158
  return 1
159
+ # No --port: the port this computer is paired for (setup moves a pairing off a port WSL's or Windows' copy holds)
160
+ paired_ports = sorted({wizard.port_of(p) for p in everything})
161
+ if "--port" not in args and len(paired_ports) == 1:
162
+ port = paired_ports[0]
159
163
  # One OpenCode per port, one connector per OpenCode: two `serve`s (one per account) must never both answer a channel.
160
164
  pairings = pairings_for_port(everything, port)
161
165
  if not pairings:
@@ -311,7 +315,7 @@ async def setup(args: list[str]) -> int:
311
315
  d = wizard.Deps(
312
316
  say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
313
317
  port=port, port_explicit="--port" in args,
314
- pair=lambda p: pair_cmd.main(["--port", str(p)]),
318
+ pair=lambda p: pair_cmd.main(["--port", str(p)]), rename=lambda p, name: pair_cmd.rename(p, name),
315
319
  keys_state=keys_state,
316
320
  run_tools=lambda plan: toolsetup.run_plan(plan, say=_err), refresh_path=refresh_path,
317
321
  is_listening=is_listening, serve_here=lambda p: serve(["--port", str(p)]),
@@ -124,6 +124,26 @@ async def _link_by_qr(link_fn: LinkFn | None, out: Callable, err: Callable) -> d
124
124
  return account
125
125
 
126
126
 
127
+ async def rename(pairing: dict, name: str, *, call: net.ApiCall | None = None, out: Callable[[str], Any] = _out,
128
+ err: Callable[[str], Any] = _err) -> bool:
129
+ """A pairing's label: in the account's computers (devices show it at their next sync), then in the pairings file. No scan."""
130
+ client = None
131
+ if call is None:
132
+ client = net.new_client()
133
+ call = net.make_api_call(client)
134
+ try:
135
+ if not await _publish_entry(call, {**pairing, "name": name}, out, err):
136
+ return False
137
+ finally:
138
+ if client is not None:
139
+ await client.aclose()
140
+ doc = pp.read_pairings()
141
+ doc["pairings"] = [({**x, "name": name} if isinstance(x, dict) and x.get("computerId") == pairing.get("computerId") else x)
142
+ for x in doc.get("pairings") or []]
143
+ pp.write_pairings(doc)
144
+ return True
145
+
146
+
127
147
  async def main(argv: list[str], *, call: net.ApiCall | None = None, link_fn: LinkFn | None = None,
128
148
  out: Callable[[str], Any] = _out, err: Callable[[str], Any] = _err, keys: Any = None) -> int:
129
149
  """pair / --status / --unpair / --rotate. Returns the exit code. `keys`: the coding-key calls (codekeys.mint /
@@ -91,7 +91,13 @@ def new_computer_id() -> str:
91
91
 
92
92
 
93
93
  def hostname() -> str:
94
- return socket.gethostname()
94
+ """The label a new pairing gets: this computer's name — on WSL "<name> (WSL)": WSL calls itself by the Windows computer's
95
+ name, and the Windows side and the Linux side are two computers in the account (the owner, 2026-10-09: "one running in
96
+ WSL and one in windows … noted as separate devices")."""
97
+ from .ports import on_wsl # noqa: PLC0415
98
+
99
+ h = socket.gethostname()
100
+ return f"{h} (WSL)" if on_wsl() else h
95
101
 
96
102
 
97
103
  # ── the pairings doc (pure) ───────────────────────────────────────────────────────────────────────────────────────────
@@ -137,3 +137,31 @@ def win_owners(port: int, *, run: Callable[[list[str]], str] = _run_text, user:
137
137
  if row and row["pid"] == pid and (not user or row["user"].rsplit("\\", 1)[-1].lower() == user.lower()):
138
138
  hits.append({"pid": pid, "cmd": row["image"]})
139
139
  return hits
140
+
141
+
142
+ def _run_status(argv: list[str], env: dict | None = None) -> tuple[int, str]:
143
+ try:
144
+ r = subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=15, env=env)
145
+ return r.returncode, r.stdout or ""
146
+ except (OSError, subprocess.SubprocessError):
147
+ return -1, ""
148
+
149
+
150
+ def wsl_running(run: Callable[[list[str]], tuple[int, str]] | None = None) -> list[str]:
151
+ """Windows: the WSL distros running now. WSL_UTF8=1, or wsl.exe answers in UTF-16; a distro name has no spaces, so a
152
+ message line ("There are no running distributions.") is never taken for one."""
153
+ run = run or (lambda argv: _run_status(argv, {**os.environ, "WSL_UTF8": "1"}))
154
+ code, out = run(["wsl", "-l", "--running", "-q"])
155
+ if code != 0:
156
+ return []
157
+ return [n for n in (ln.strip() for ln in out.replace("\x00", "").splitlines()) if re.fullmatch(r"[A-Za-z0-9._-]+", n)]
158
+
159
+
160
+ def held_in_wsl(port: int, *, platform: str | None = None, run: Callable[[list[str]], str] = _run_text,
161
+ wsl: Callable[[], list[str]] = wsl_running) -> list[str]:
162
+ """Windows: the running WSL distros, when `port` answers but no Windows program listens on it — Linux and Windows share
163
+ localhost ports (WSL's mirrored networking) and Windows cannot name the Linux program (the owner's laptop, 2026-10-09:
164
+ netstat showed nothing on 4096, UbuntuD was running an earlier witbitz-code serve). [] off Windows."""
165
+ if (sys.platform if platform is None else platform) != "win32" or parse_netstat_listeners(run(["netstat", "-ano"]), port):
166
+ return []
167
+ return wsl()
@@ -119,6 +119,15 @@ def install_release(step: dict, *, client: httpx.Client | None = None, dest: str
119
119
  return str(to)
120
120
 
121
121
 
122
+ # winget's "no newer version" (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE, 0x8A15002B) and "already installed"
123
+ # (0x8A150061): the tool is HERE (the owner's laptop, 2026-10-09: Pandoc and ImageMagick "ended with 2316632107"). = runPlan
124
+ WINGET_ALREADY = (0x8A15002B, 0x8A150061)
125
+
126
+
127
+ def already_installed(argv: list[str], code: int) -> bool:
128
+ return argv[:1] == ["winget"] and code != -1 and (code & 0xFFFFFFFF) in WINGET_ALREADY
129
+
130
+
122
131
  def run_plan(steps: list[dict], *, say: Callable[[str], Any] = print, run: Callable[[list[str]], int] = run_inherit,
123
132
  release: Callable[[dict], str] = install_release) -> dict:
124
133
  """Carry out the plan → {done, failed, auto, none} (tool ids). Says each step as it goes. = code-tools-install.mjs runPlan"""
@@ -127,8 +136,11 @@ def run_plan(steps: list[dict], *, say: Callable[[str], Any] = print, run: Calla
127
136
  if s["kind"] == "cmd":
128
137
  say(f" $ {s['argv'][2] if s['argv'][0] == 'sh' else ' '.join(s['argv'])}")
129
138
  code = run(s["argv"])
130
- (out["done"] if code == 0 else out["failed"]).extend(s["ids"])
131
- if code != 0:
139
+ here = code != 0 and already_installed(s["argv"], code)
140
+ (out["done"] if code == 0 or here else out["failed"]).extend(s["ids"])
141
+ if here:
142
+ say(f" ✓ {', '.join(label(i) for i in s['ids'])}: already installed")
143
+ elif code != 0:
132
144
  say(f" ✖ {', '.join(label(i) for i in s['ids'])}: that {'could not start' if code == -1 else f'ended with {code}'}")
133
145
  elif s["kind"] == "release":
134
146
  say(f" Downloading {label(s['id'])} ({s['mb']} MB) from its publisher, checked against its SHA-256 …")
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import asyncio
15
15
  import os
16
16
  import shutil
17
+ import socket
17
18
  import subprocess
18
19
  import sys
19
20
  from collections.abc import Awaitable, Callable
@@ -75,6 +76,9 @@ class Deps:
75
76
  forget_keys: Callable[[dict], Any] = lambda p: codekeys.forget(p.get("computerId") or "")
76
77
  is_listening: Callable[[int], Awaitable[bool]] = None # type: ignore[assignment]
77
78
  port_owners: Callable[[int], list] = ports.port_owners # [{pid, cmd}] — this user's processes on a port (ports.py)
79
+ held_in_wsl: Callable[[int], list] = ports.held_in_wsl # Windows: running WSL distros when no Windows program holds the port
80
+ bare_host: Callable[[], str] = socket.gethostname # the computer's own name (WSL reports Windows')
81
+ rename: Callable[[dict, str], Awaitable[bool]] | None = None # pair.rename: a pairing's label, here and in the account
78
82
  stop_process: Callable[[int], bool] = lambda pid: __import__("witbitz_code.uninstall", fromlist=["stop_process"]).stop_process(pid)
79
83
  wsl: bool = field(default_factory=ports.on_wsl)
80
84
  # setup's Tools step (toolsetup.py): what is here, carrying out the plan, and PATH after installs (Windows: the registry)
@@ -158,6 +162,11 @@ async def run_setup(d: Deps) -> dict:
158
162
  d.say(f" ✖ This computer is paired, but for OpenCode on another port. Run setup again with that port: witbitz-code setup --port {d.port_of(everything[0])}"
159
163
  if everything else " ✖ Pairing did not finish. Run setup again to retry.")
160
164
  return {**result, "stopped": "pair"}
165
+ if d.wsl and d.rename: # WSL calls itself by the Windows computer's name: two computers in the account, one name (1.3.7 and before)
166
+ for p in [x for x in mine if x.get("name") == d.bare_host()]:
167
+ if await d.rename(p, f"{p['name']} (WSL)"):
168
+ d.say(f' ✓ Renamed this computer "{p["name"]} (WSL)" — Windows on the same machine keeps "{p["name"]}"')
169
+ mine = on(d.all_pairings(), port)
161
170
  d.say(f" ✓ Paired: {named(mine)}{f' (OpenCode on port {port})' if port != 4096 else ''}")
162
171
  result["paired"] = True
163
172
 
@@ -201,9 +210,24 @@ async def run_setup(d: Deps) -> dict:
201
210
  if all(o["pid"] != x["pid"] for x in owners):
202
211
  owners.append(o)
203
212
  if not owners:
204
- d.say(f" Port {busy[0]} is taken by a program that is not yours{' — on WSL, another Linux distro on this computer shares its ports' if d.wsl else ''}.")
205
- d.say(f" Run setup with another port: witbitz-code setup --port {port + 1}")
206
- return {**result, "stopped": "busy"}
213
+ # Not this person's program here — on Windows most likely the witbitz-code in WSL (Linux and Windows share these
214
+ # ports; the owner, 2026-10-09: "I want one running in WSL and one in windows"). Both can run: this one moves.
215
+ free = await _free_port(d, port + 1)
216
+ distros = d.held_in_wsl(busy[0])
217
+ if distros:
218
+ d.say(f" Port {busy[0]} is used inside WSL (Linux on this computer — running: {', '.join(distros)}), most likely by the")
219
+ d.say(" witbitz-code there: Windows and WSL share these ports. Both can run — this one just needs its own.")
220
+ else:
221
+ d.say(f" Port {busy[0]} is taken by a program that is not yours{' — on WSL, Windows and the other Linux distros on this computer share its ports' if d.wsl else ''}.")
222
+ if not _yes(d.ask(f" Use port {free} (and {free + 100} for the model proxy) here instead? [Y/n] ")):
223
+ d.say(f" Stopped. To use another port later: witbitz-code setup --port {free}"
224
+ + (" — or stop WSL's copy: wsl --shutdown" if distros else ""))
225
+ return {**result, "stopped": "busy"}
226
+ for p in on(d.all_pairings(), port):
227
+ d.move_pairing(p, free)
228
+ d.say(f" ✓ This computer now uses port {free} — `witbitz-code serve` remembers it")
229
+ port = free
230
+ continue
207
231
  listed = "; ".join(f"{(o.get('cmd') or 'a program')[:70]} (process {o['pid']})" for o in owners)
208
232
  d.say(f" Already running here: {listed}.")
209
233
  d.say(" That is an earlier witbitz-code or an OpenCode started some other way — it would keep answering your phone with")
@@ -240,8 +264,20 @@ def _describe(step: dict) -> str:
240
264
  return f" {toolsetup.label(step['id'])}: nothing here installs it" + (f" ({step['note']})" if step.get("note") else "")
241
265
 
242
266
 
267
+ async def _free_port(d: Deps, start: int) -> int:
268
+ """The first port from `start` that is free together with its model proxy's (port + 100)."""
269
+ for p in range(max(start, 1024), 65436):
270
+ if not await d.is_listening(p) and not await d.is_listening(p + 100):
271
+ return p
272
+ return start
273
+
274
+
243
275
  async def _tools_step(d: Deps) -> str:
244
276
  """→ 'here' | 'installed' | 'partly' | 'skipped' | 'unavailable' | 'failed' (an install that breaks never ends setup)."""
277
+ try: # Windows: what an earlier setup installed is on the registry's PATH, not yet on this window's — read it FIRST, or
278
+ d.refresh_path() # winget is asked again for tools that are here (the owner's laptop, 2026-10-09: Pandoc, ImageMagick)
279
+ except Exception: # noqa: BLE001
280
+ pass
245
281
  try:
246
282
  report = d.probe_tools()
247
283
  except Exception: # noqa: BLE001 — a probe that fails never stops setup
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.7
3
+ Version: 1.3.8
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
@@ -88,6 +88,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
88
88
 
89
89
  `pair`, `rotate` and `unpair` also accept `--dry-run`.
90
90
 
91
+ **Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
92
+ one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
93
+ "<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
94
+
91
95
  **More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
92
96
  channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
93
97
  all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
@@ -274,3 +274,30 @@ def test_status_output_is_json_free_of_secrets_even_for_js_written_files():
274
274
  run(pair.main(["--status"], **cap.kw()))
275
275
  assert SECRET not in cap.text() and "MK" not in cap.text()
276
276
  assert json.loads(pp.pairings_path().read_text())["pairings"][0]["secret"] == SECRET
277
+
278
+
279
+ def test_rename_relabels_the_computer_in_the_account_and_here_with_no_scan(monkeypatch):
280
+ # WSL's pairing and Windows' were two "AlonASUS" in Code (the owner, 2026-10-09); setup renames WSL's "… (WSL)"
281
+ api = MockApi(docs={"computers": with_computer(None, "cmp_wsl_1", {"name": "AlonASUS", "relay": RELAY, "secret": SECRET}, 500)})
282
+ entry = {"account": "a@x.com", "idx": IDX, "computerId": "cmp_wsl_1", "secret": SECRET, "name": "AlonASUS", "relay": RELAY,
283
+ "opencodeUrl": "http://127.0.0.1:4096"}
284
+ pp.write_pairings({"v": 1, "pairings": [entry]})
285
+ assert run(pair.rename(entry, "AlonASUS (WSL)", call=api.call, out=lambda m: None, err=lambda m: None)) is True
286
+ reg = norm_registry(api.plain("computers"))
287
+ assert reg["computers"]["cmp_wsl_1"]["name"] == "AlonASUS (WSL)" and reg["computers"]["cmp_wsl_1"]["secret"] == SECRET
288
+ assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["AlonASUS (WSL)"]
289
+ refused = MockApi(read_status=500)
290
+ assert run(pair.rename({**entry, "name": "AlonASUS (WSL)"}, "x", call=refused.call, out=lambda m: None, err=lambda m: None)) is False
291
+ assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["AlonASUS (WSL)"], "nothing renamed here when the account refused"
292
+
293
+
294
+ def test_a_new_pairing_on_wsl_is_named_for_wsl(monkeypatch):
295
+ import socket
296
+
297
+ from witbitz_code import ports
298
+
299
+ monkeypatch.setattr(socket, "gethostname", lambda: "AlonASUS")
300
+ monkeypatch.setattr(ports, "on_wsl", lambda: True)
301
+ assert pp.hostname() == "AlonASUS (WSL)"
302
+ monkeypatch.setattr(ports, "on_wsl", lambda: False)
303
+ assert pp.hostname() == "AlonASUS"
@@ -81,3 +81,29 @@ def test_stopping_on_windows_is_taskkill_of_the_tree_and_never_os_kill(monkeypat
81
81
  monkeypatch.setattr(sys, "platform", "win32")
82
82
  monkeypatch.setattr(uninstall, "win_stop_process", lambda pid: stopped.append(pid) or True)
83
83
  assert uninstall.stop_process(5150) is True and stopped == [5150]
84
+
85
+
86
+ def test_wsl_is_named_only_when_no_windows_program_holds_the_port():
87
+ wsl = lambda: ["UbuntuD"] # noqa: E731
88
+ assert ports.held_in_wsl(4096, platform="win32", run=lambda a: NETSTAT, wsl=wsl) == [], "opencode.exe holds 4096 here"
89
+ assert ports.held_in_wsl(4097, platform="win32", run=lambda a: NETSTAT, wsl=wsl) == ["UbuntuD"]
90
+ assert ports.held_in_wsl(4097, platform="linux", run=lambda a: NETSTAT, wsl=wsl) == []
91
+
92
+
93
+ def test_running_distros_are_read_whatever_wsl_prints():
94
+ assert ports.wsl_running(lambda a: (0, "UbuntuD\r\nDebian\r\n")) == ["UbuntuD", "Debian"]
95
+ assert ports.wsl_running(lambda a: (0, "U\x00b\x00u\x00n\x00t\x00u\x00D\x00\r\x00\n\x00")) == ["UbuntuD"], "UTF-16 read as text"
96
+ assert ports.wsl_running(lambda a: (0, "There are no running distributions.\r\n")) == []
97
+ assert ports.wsl_running(lambda a: (1, "UbuntuD")) == [], "an error answer names nothing"
98
+
99
+
100
+ @requires_node
101
+ def test_the_javascript_names_wsl_the_same_way():
102
+ js = run_node("""
103
+ import { heldInWsl, wslRunning } from '@TOOLS@/code-ports-win.mjs'
104
+ OUT({ held: [4096, 4097].map((p) => heldInWsl(p, { platform: 'win32', run: () => INPUT.netstat, wsl: () => ['UbuntuD'] })),
105
+ off: heldInWsl(4097, { platform: 'linux', run: () => INPUT.netstat, wsl: () => ['UbuntuD'] }),
106
+ names: INPUT.outs.map(([status, out]) => wslRunning({ run: () => ({ status, out }) })) })
107
+ """, {"netstat": NETSTAT, "outs": [[0, "UbuntuD\r\nDebian\r\n"], [0, "U\x00b\x00u\x00n\x00t\x00u\x00D\x00\r\x00\n\x00"], [0, "There are no running distributions.\r\n"], [1, "UbuntuD"]]})
108
+ assert js["held"] == [[], ["UbuntuD"]] and js["off"] == []
109
+ assert js["names"] == [["UbuntuD", "Debian"], ["UbuntuD"], [], []]
@@ -101,6 +101,9 @@ class Fake:
101
101
  self._oc, self._pairings, self._keyed, self._gives = opencode, list(pairings or []), set(keyed), pair_gives_keys
102
102
  self._answers, self._listening = list(answers), listening
103
103
 
104
+ def _move(self, p, port):
105
+ self._pairings = [({**x, "opencodeUrl": f"http://127.0.0.1:{port}"} if x.get("computerId") == p.get("computerId") else x) for x in self._pairings]
106
+
104
107
  def deps(self, **over):
105
108
  async def pair(port):
106
109
  self.paired.append(port)
@@ -109,19 +112,19 @@ class Fake:
109
112
  if self._gives:
110
113
  self._keyed.add("c")
111
114
  async def listening(port):
112
- return self._listening
115
+ return self._listening(port) if callable(self._listening) else self._listening
113
116
  async def serve_here(port):
114
117
  self.served.append(port)
115
118
  return 0
116
119
  return wizard.Deps(
117
120
  say=self.said.append, ask=lambda q: self._answers.pop(0) if self._answers else "", secret=lambda q: self.asked_secret.append(q) or "",
118
121
  find_opencode=lambda: "/usr/bin/opencode" if self._oc else "", can_install_opencode=lambda: False,
119
- all_pairings=lambda: list(self._pairings), pair=pair, move_pairing=lambda p, port: None,
122
+ all_pairings=lambda: list(self._pairings), pair=pair, move_pairing=self._move,
120
123
  code_keys=lambda p: {"caps": {"modelsUsd": 50, "toolsUsd": 5}} if p.get("computerId") in self._keyed else None,
121
124
  serve_here=serve_here,
122
125
  # never this machine's real processes: the real lookup finds whatever listens on 4096 HERE (an OpenCode does)
123
126
  # and never this machine's tools: nothing is missing, nothing is installed (the Tools step has its own tests)
124
- **{"is_listening": listening, "port_owners": lambda port: [], "stop_process": lambda pid: False, "wsl": False,
127
+ **{"is_listening": listening, "port_owners": lambda port: [], "held_in_wsl": lambda port: [], "stop_process": lambda pid: False, "wsl": False,
125
128
  "probe_tools": lambda: {"tools": {}, "platform": {"os": "", "pm": ""}}, "run_tools": lambda plan: {}, "refresh_path": lambda: None, **over})
126
129
 
127
130
 
@@ -166,12 +169,19 @@ def test_paired_for_another_port_uses_it():
166
169
  assert r["port"] == 4097 and f.served == [4097] and f.paired == []
167
170
 
168
171
 
169
- def test_a_port_taken_by_a_program_that_is_not_yours_stops_setup_with_another_port_to_use():
170
- f = Fake(listening=True)
172
+ DESK = {"name": "desk", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}
173
+ HELD = lambda p: p in (4096, 4196) # noqa: E731 — OpenCode's port and the proxy's, held by someone else
174
+
175
+
176
+ def test_a_port_taken_by_a_program_that_is_not_yours_moves_this_computer_to_a_free_pair_and_serves():
177
+ f = Fake(pairings=[DESK], keyed={"c"}, listening=HELD, answers=[""])
171
178
  r = run(wizard.run_setup(f.deps()))
172
- assert r["stopped"] == "busy" and f.served == []
179
+ assert f.served == [4097] and wizard.port_of(f._pairings[0]) == 4097, "the pairing moves — no new scan"
173
180
  assert any("Port 4096 is taken by a program that is not yours" in line for line in f.said)
174
- assert any("witbitz-code setup --port 4097" in line for line in f.said)
181
+ assert any("now uses port 4097" in line for line in f.said) and "stopped" not in r
182
+ no = Fake(pairings=[DESK], keyed={"c"}, listening=HELD, answers=["n"])
183
+ assert run(wizard.run_setup(no.deps()))["stopped"] == "busy" and no.served == []
184
+ assert any("witbitz-code setup --port 4097" in line for line in no.said)
175
185
 
176
186
 
177
187
  def test_an_earlier_witbitz_code_still_running_is_named_and_stopped_both_ports_then_this_one_serves():
@@ -222,3 +232,37 @@ def test_keys_that_cannot_be_checked_are_not_taken_for_a_removal_and_setup_says_
222
232
  r = run(wizard.run_setup(f.deps(keys_state=keys_state, forget_keys=lambda p: f._keyed.discard(p["computerId"]))))
223
233
  assert f.paired == [] and r["keys"] and f.served == [4096]
224
234
  assert any("Could not check them with your account just now — the signature does not match this computer (401)" in line for line in f.said)
235
+
236
+
237
+ def test_windows_and_wsl_each_run_their_own_the_windows_one_moves_off_the_port_wsl_holds():
238
+ # the owner's laptop, 2026-10-09: 4096 answered, netstat showed no Windows program, UbuntuD ran witbitz-code serve —
239
+ # "I want one running in WSL and one in windows"
240
+ f = Fake(pairings=[DESK], keyed={"c"}, listening=HELD, answers=[""])
241
+ run(wizard.run_setup(f.deps(held_in_wsl=lambda port: ["UbuntuD"])))
242
+ assert f.served == [4097]
243
+ assert any("Port 4096 is used inside WSL (Linux on this computer — running: UbuntuD)" in line for line in f.said)
244
+ assert not any("not yours" in line for line in f.said)
245
+ no = Fake(pairings=[DESK], keyed={"c"}, listening=HELD, answers=["n"])
246
+ assert run(wizard.run_setup(no.deps(held_in_wsl=lambda port: ["UbuntuD"])))["stopped"] == "busy"
247
+ assert any("wsl --shutdown" in line for line in no.said)
248
+
249
+
250
+ def test_on_wsl_a_pairing_named_like_the_windows_computer_is_renamed_once():
251
+ # WSL calls itself by the Windows computer's name: Windows' and WSL's pairings were two "AlonASUS" in Code
252
+ f = Fake(pairings=[DESK], keyed={"c"})
253
+ renamed = []
254
+
255
+ async def rename(p, name):
256
+ renamed.append((p["computerId"], name))
257
+ f._pairings = [{**x, "name": name} for x in f._pairings]
258
+ return True
259
+
260
+ run(wizard.run_setup(f.deps(wsl=True, bare_host=lambda: "desk", rename=rename)))
261
+ assert renamed == [("c", "desk (WSL)")] and any('Renamed this computer "desk (WSL)"' in line for line in f.said)
262
+ assert any('✓ Paired: "desk (WSL)"' in line for line in f.said)
263
+ again = Fake(pairings=[{**DESK, "name": "desk (WSL)"}], keyed={"c"})
264
+ run(wizard.run_setup(again.deps(wsl=True, bare_host=lambda: "desk", rename=rename)))
265
+ assert len(renamed) == 1, "a renamed one is left alone"
266
+ off = Fake(pairings=[DESK], keyed={"c"})
267
+ run(wizard.run_setup(off.deps(wsl=False, bare_host=lambda: "desk", rename=rename)))
268
+ assert len(renamed) == 1, "Windows keeps the computer's own name"
@@ -127,7 +127,7 @@ def step(tools: Tools, answers: list[str]) -> tuple[str, list[str]]:
127
127
  def test_setup_asks_once_installs_mechanically_and_checks_again():
128
128
  t = Tools(["ffmpeg", "poppler", "tesseract", "whisper"], {"os": "win32", "pm": "winget"})
129
129
  r, said = step(t, [""])
130
- assert r == "installed" and t.refreshed == 1
130
+ assert r == "installed" and t.refreshed == 2, "once before the first look, once after the installs"
131
131
  assert [s["kind"] for s in t.plans[0]] == ["cmd", "release", "cmd"], "Whisper is not asked for on Windows: it comes by itself"
132
132
  assert any("Missing here: ffmpeg, Poppler, Tesseract." in s for s in said) and any("42 MB download" in s for s in said)
133
133
 
@@ -203,3 +203,24 @@ def test_no_module_uses_a_name_it_never_defined():
203
203
  for f in sorted(Path(witbitz_code.__file__).parent.glob("*.py")):
204
204
  pyflakes.api.check(f.read_text(encoding="utf-8"), str(f), rep)
205
205
  assert rep.found == []
206
+
207
+
208
+ def test_tools_an_earlier_setup_installed_are_seen_before_anything_is_asked():
209
+ # the owner's laptop, 2026-10-09: this window's PATH predated the installs, so Pandoc and ImageMagick were "missing"
210
+ # and winget was asked again; the registry's PATH (refresh_path) had them all along
211
+ t = Tools(["pandoc", "imagemagick", "jq"], {"os": "win32", "pm": "winget"})
212
+ said: list[str] = []
213
+ d = wizard.Deps(say=said.append, ask=lambda q: "", secret=lambda q: "", probe_tools=t.probe, run_tools=t.run,
214
+ refresh_path=lambda: t.missing.difference_update({"pandoc", "imagemagick"}))
215
+ assert asyncio.run(wizard._tools_step(d)) == "installed"
216
+ assert [i for st in t.plans[0] for i in (st.get("ids") or [st.get("id")])] == ["jq"]
217
+
218
+
219
+ def test_wingets_already_installed_is_done_not_a_failure():
220
+ said: list[str] = []
221
+ codes = {"Pandoc": 2316632107, "ImageMagick": -1978335189, "qpdf": 1} # 0x8A15002B unsigned, then signed; a real failure
222
+ steps = [{"kind": "cmd", "ids": [i], "argv": ["winget", "install", "--id", n]} for i, n in (("pandoc", "Pandoc"), ("imagemagick", "ImageMagick"), ("qpdf", "qpdf"))]
223
+ out = ts.run_plan(steps, say=said.append, run=lambda argv: codes[argv[-1]])
224
+ assert out["done"] == ["pandoc", "imagemagick"] and out["failed"] == ["qpdf"]
225
+ assert " ✓ Pandoc: already installed" in said and " ✓ ImageMagick: already installed" in said
226
+ assert ts.run_plan([{"kind": "cmd", "ids": ["jq"], "argv": ["brew", "install", "jq"]}], say=said.append, run=lambda a: 2316632107)["failed"] == ["jq"], "only winget's code means that"
File without changes
File without changes