witbitz-code 1.3.6__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 (85) hide show
  1. {witbitz_code-1.3.6/src/witbitz_code.egg-info → witbitz_code-1.3.8}/PKG-INFO +5 -1
  2. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/README.md +4 -0
  3. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/pyproject.toml +1 -1
  4. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/cli.py +10 -1
  6. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/pair.py +20 -0
  7. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/pairings.py +7 -1
  8. witbitz_code-1.3.8/src/witbitz_code/ports.py +167 -0
  9. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/tools_probe.py +26 -4
  10. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/toolsetup.py +14 -2
  11. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/uninstall.py +37 -2
  12. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/winagent.py +6 -1
  13. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/wizard.py +39 -3
  14. {witbitz_code-1.3.6 → witbitz_code-1.3.8/src/witbitz_code.egg-info}/PKG-INFO +5 -1
  15. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/SOURCES.txt +1 -0
  16. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_pair_flows.py +27 -0
  17. witbitz_code-1.3.8/tests/test_ports_win.py +109 -0
  18. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_setup.py +51 -7
  19. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_tools_probe.py +28 -0
  20. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_toolsetup.py +22 -1
  21. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_winagent.py +11 -0
  22. witbitz_code-1.3.6/src/witbitz_code/ports.py +0 -87
  23. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/LICENSE +0 -0
  24. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/setup.cfg +0 -0
  25. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/__main__.py +0 -0
  26. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/_js.py +0 -0
  27. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/account.py +0 -0
  28. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/asks.py +0 -0
  29. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/attachments.py +0 -0
  30. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/auto.py +0 -0
  31. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/auto_runner.py +0 -0
  32. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/codekeys.py +0 -0
  33. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/codetools.py +0 -0
  34. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/compress.py +0 -0
  35. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/computers.py +0 -0
  36. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/confidential.py +0 -0
  37. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/confidential_models.json +0 -0
  38. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/connector.py +0 -0
  39. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/devicelink.py +0 -0
  40. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/folders.py +0 -0
  41. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/gateway.py +0 -0
  42. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/keys.py +0 -0
  43. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/link.py +0 -0
  44. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/models.py +0 -0
  45. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/net.py +0 -0
  46. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/offered_models.json +0 -0
  47. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  48. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  49. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  50. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  51. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/outputs.py +0 -0
  52. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/payers.py +0 -0
  53. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/plugins.py +0 -0
  54. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/policy.py +0 -0
  55. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/qr.py +0 -0
  56. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/receipt.py +0 -0
  57. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/relay.py +0 -0
  58. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/seen.py +0 -0
  59. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/tinfoil_read.py +0 -0
  60. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/tools_catalog.json +0 -0
  61. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/winconsole.py +0 -0
  62. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  63. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  64. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/requires.txt +0 -0
  65. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/top_level.txt +0 -0
  66. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_asks.py +0 -0
  67. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_attachments.py +0 -0
  68. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_auto.py +0 -0
  69. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_cli.py +0 -0
  70. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_codekeys.py +0 -0
  71. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_codetools.py +0 -0
  72. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_confidential.py +0 -0
  73. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_connector_e2e.py +0 -0
  74. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_devicelink.py +0 -0
  75. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_files_and_compress.py +0 -0
  76. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_folders.py +0 -0
  77. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_outputs.py +0 -0
  78. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_plugins.py +0 -0
  79. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_policy.py +0 -0
  80. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_ports.py +0 -0
  81. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_registry.py +0 -0
  82. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_relay_codec.py +0 -0
  83. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_uninstall.py +0 -0
  84. {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_winconsole.py +0 -0
  85. {witbitz_code-1.3.6 → 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.6
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.6"
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.6"
7
+ __version__ = "1.3.8"
@@ -156,11 +156,20 @@ 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:
162
166
  _err(f"witbitz-code: no pairing uses OpenCode on port {port} — pair with: witbitz-code pair --port {port}")
163
167
  return 1
168
+ # Windows: a tool installed since this window opened (setup's Tools step, winget) is on the registry's PATH, not yet on
169
+ # this process's — read it, so the agent's OpenCode finds it (= witbitz-code.mjs serve)
170
+ reg = toolsetup.registry_path()
171
+ if reg:
172
+ os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
164
173
  # Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
165
174
  # written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
166
175
  # running picks them up at its next restart.
@@ -306,7 +315,7 @@ async def setup(args: list[str]) -> int:
306
315
  d = wizard.Deps(
307
316
  say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
308
317
  port=port, port_explicit="--port" in args,
309
- 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),
310
319
  keys_state=keys_state,
311
320
  run_tools=lambda plan: toolsetup.run_plan(plan, say=_err), refresh_path=refresh_path,
312
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) ───────────────────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,167 @@
1
+ """Who is listening on a port of this computer — this user's processes only (= tools/witbitz-code.mjs portOwners).
2
+
3
+ setup asks it before starting: an earlier witbitz-code still running (`pipx uninstall` does not stop it) holds OpenCode's
4
+ port and the model proxy's (port + 100), and keeps answering the phone with the keys it started with (the owner,
5
+ 2026-10-08: "could not start the model proxy … address already in use", then "Invalid API key"). Linux reads /proc; macOS
6
+ asks lsof; Windows asks netstat and tasklist (_win_owners, = tools/code-ports-win.mjs — before 1.3.7 Windows knew nobody and
7
+ setup called the person's own `witbitz-code serve` "a program that is not yours"); elsewhere nothing is known.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+
21
+ def port_owners(port: int) -> list[dict]:
22
+ """[{pid, cmd}] of this user's processes listening on 127.0.0.1/::1 `port` (never this process)."""
23
+ if sys.platform == "win32":
24
+ return win_owners(port)
25
+ if Path("/proc/net/tcp").exists():
26
+ return _proc_owners(port)
27
+ if shutil.which("lsof"):
28
+ return _lsof_owners(port)
29
+ return []
30
+
31
+
32
+ def _proc_owners(port: int) -> list[dict]:
33
+ inodes: set[str] = set()
34
+ for f in ("/proc/net/tcp", "/proc/net/tcp6"):
35
+ try:
36
+ lines = Path(f).read_text().splitlines()[1:]
37
+ except OSError:
38
+ continue
39
+ for line in lines:
40
+ c = line.split()
41
+ if len(c) >= 10 and c[3] == "0A" and int(c[1].rsplit(":", 1)[-1], 16) == port: # 0A = LISTEN
42
+ inodes.add(c[9])
43
+ if not inodes:
44
+ return []
45
+ hits = []
46
+ for p in os.listdir("/proc"):
47
+ if not p.isdigit() or int(p) == os.getpid():
48
+ continue
49
+ try:
50
+ fds = os.listdir(f"/proc/{p}/fd")
51
+ except OSError:
52
+ continue # not this user's
53
+ owns = False
54
+ for fd in fds:
55
+ try:
56
+ link = os.readlink(f"/proc/{p}/fd/{fd}")
57
+ except OSError:
58
+ continue
59
+ if link.startswith("socket:[") and link[8:-1] in inodes:
60
+ owns = True
61
+ break
62
+ if not owns:
63
+ continue
64
+ try:
65
+ cmd = " ".join(x for x in Path(f"/proc/{p}/cmdline").read_bytes().decode("utf-8", "replace").split("\0") if x)
66
+ except OSError:
67
+ cmd = ""
68
+ hits.append({"pid": int(p), "cmd": cmd})
69
+ return hits
70
+
71
+
72
+ def _lsof_owners(port: int) -> list[dict]:
73
+ try:
74
+ out = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], capture_output=True, text=True, timeout=10).stdout
75
+ except (OSError, subprocess.SubprocessError):
76
+ return []
77
+ hits = []
78
+ for pid in sorted({int(x) for x in out.split() if x.isdigit()}):
79
+ if pid == os.getpid():
80
+ continue
81
+ try:
82
+ cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True, timeout=5).stdout.strip()
83
+ except (OSError, subprocess.SubprocessError):
84
+ cmd = ""
85
+ hits.append({"pid": pid, "cmd": cmd})
86
+ return hits
87
+
88
+
89
+ def on_wsl() -> bool:
90
+ try:
91
+ return "microsoft" in Path("/proc/version").read_text().lower()
92
+ except OSError:
93
+ return False
94
+
95
+
96
+ def parse_netstat_listeners(text: str, port: int) -> list[int]:
97
+ """The process ids listening on TCP `port`, from `netstat -ano` output. A listening socket's foreign address is 0.0.0.0:0
98
+ or [::]:0 — the State column is translated on some Windows languages, the addresses are not."""
99
+ pids: set[int] = set()
100
+ for line in (text or "").splitlines():
101
+ c = line.split()
102
+ if len(c) < 5 or c[0].upper() != "TCP" or not c[1].endswith(f":{port}") or c[2] not in ("0.0.0.0:0", "[::]:0"):
103
+ continue
104
+ if c[-1].isdigit() and int(c[-1]) > 0:
105
+ pids.add(int(c[-1]))
106
+ return sorted(pids)
107
+
108
+
109
+ def parse_tasklist_row(text: str) -> dict | None:
110
+ """One CSV row of `tasklist /V /FO CSV /NH` → {image, pid, user}, or None."""
111
+ line = next((ln for ln in (text or "").splitlines() if ln.strip().startswith('"')), None)
112
+ if line is None:
113
+ return None
114
+ cells = [m.replace('""', '"') for m in re.findall(r'"((?:[^"]|"")*)"', line)]
115
+ if len(cells) < 7 or not cells[1].isdigit():
116
+ return None
117
+ return {"image": cells[0], "pid": int(cells[1]), "user": cells[6]}
118
+
119
+
120
+ def _run_text(argv: list[str]) -> str:
121
+ try:
122
+ return subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=15).stdout or ""
123
+ except (OSError, subprocess.SubprocessError):
124
+ return ""
125
+
126
+
127
+ def win_owners(port: int, *, run: Callable[[list[str]], str] = _run_text, user: str | None = None, self_pid: int | None = None) -> list[dict]:
128
+ """[{pid, cmd}] of this user's processes listening on `port` on Windows: netstat for the ids, tasklist for each one's
129
+ program and user (another user's process says N/A and is left out)."""
130
+ user = os.environ.get("USERNAME", "") if user is None else user
131
+ self_pid = os.getpid() if self_pid is None else self_pid
132
+ hits = []
133
+ for pid in parse_netstat_listeners(run(["netstat", "-ano"]), port):
134
+ if pid == self_pid:
135
+ continue
136
+ row = parse_tasklist_row(run(["tasklist", "/V", "/FO", "CSV", "/NH", "/FI", f"PID eq {pid}"]))
137
+ if row and row["pid"] == pid and (not user or row["user"].rsplit("\\", 1)[-1].lower() == user.lower()):
138
+ hits.append({"pid": pid, "cmd": row["image"]})
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()
@@ -30,6 +30,28 @@ _EXTRA_HOME = [".local/bin", ".cargo/bin", "bin"]
30
30
  # Windows: under %LOCALAPPDATA%\Witbitz — what the Windows installer (code.ps1) fills and witbitz-code serve gives the agent:
31
31
  # its Git (Git Bash, the agent's shell), its own Python, and what setup's Tools step puts there (Poppler). = code-tools-probe.mjs
32
32
  _WIN_WITBITZ = ["bin", "git\\cmd", "git\\bin", "agent-python\\Scripts", "poppler\\bin"]
33
+ # Windows: tools whose installers do not add themselves to PATH (measured 2026-10-09 on the owner's laptop: winget installed
34
+ # Tesseract (UB-Mannheim) and qpdf, setup's re-check still found neither) — their usual folders. = code-tools-probe.mjs
35
+ _WIN_PROGRAM_ROOTS = ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
36
+
37
+
38
+ def _listdir(d: str) -> list[str]:
39
+ try:
40
+ return os.listdir(d)
41
+ except OSError:
42
+ return []
43
+
44
+
45
+ def win_program_dirs(env: Mapping[str, str], listdir: Callable[[str], list[str]] = _listdir) -> list[str]:
46
+ """Tesseract-OCR and qpdf <version>\\bin under Program Files (all three) and %LOCALAPPDATA%\\Programs."""
47
+ roots = [(env.get(k) or "").rstrip("\\/") for k in _WIN_PROGRAM_ROOTS]
48
+ local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
49
+ roots = list(dict.fromkeys([r for r in roots + ([f"{local}\\Programs"] if local else []) if r]))
50
+ out: list[str] = []
51
+ for r in roots:
52
+ out.append(f"{r}\\Tesseract-OCR")
53
+ out += [f"{r}\\{n}\\bin" for n in sorted(listdir(r)) if n.lower().startswith("qpdf")]
54
+ return out
33
55
 
34
56
 
35
57
  def _is_executable(path: str) -> bool:
@@ -40,26 +62,26 @@ def _is_executable(path: str) -> bool:
40
62
  return stat.S_ISREG(st.st_mode) and os.access(path, os.X_OK)
41
63
 
42
64
 
43
- def search_dirs(*, env: Mapping[str, str], platform: str, home: str) -> list[str]:
65
+ def search_dirs(*, env: Mapping[str, str], platform: str, home: str, listdir: Callable[[str], list[str]] = _listdir) -> list[str]:
44
66
  """The folders searched, in order, without repeats."""
45
67
  win = platform == "win32"
46
68
  raw = env.get("PATH") or env.get("Path") or ""
47
69
  from_path = [d for d in raw.split(";" if win else ":") if d]
48
70
  if win:
49
71
  local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
50
- extra = [f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []
72
+ extra = ([f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []) + win_program_dirs(env, listdir)
51
73
  else:
52
74
  extra = _EXTRA_POSIX + ([f"{home.rstrip('/')}/{d}" for d in _EXTRA_HOME] if home else [])
53
75
  return list(dict.fromkeys(from_path + extra))
54
76
 
55
77
 
56
78
  def probe_tools(*, env: Mapping[str, str] | None = None, platform: str | None = None, home: str | None = None,
57
- is_executable: Callable[[str], bool] = _is_executable) -> dict:
79
+ is_executable: Callable[[str], bool] = _is_executable, listdir: Callable[[str], list[str]] = _listdir) -> dict:
58
80
  env = os.environ if env is None else env
59
81
  platform = sys.platform if platform is None else platform
60
82
  home = str(Path.home()) if home is None else home
61
83
  win = platform == "win32"
62
- dirs = search_dirs(env=env, platform=platform, home=home)
84
+ dirs = search_dirs(env=env, platform=platform, home=home, listdir=listdir)
63
85
  exts = [""] + [e.lower() for e in (env.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD").split(";") if e] if win else [""]
64
86
 
65
87
  def join(d: str, b: str) -> str:
@@ -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 …")
@@ -150,8 +150,43 @@ def opencode_holders(data_dir: str) -> list[dict]:
150
150
  return hits
151
151
 
152
152
 
153
+ def _win_alive(pid: int) -> bool:
154
+ """Windows: is `pid` still running? (OpenProcess + GetExitCodeProcess; access denied = another user's, still running.)"""
155
+ import ctypes # noqa: PLC0415 — Windows only
156
+
157
+ k = ctypes.windll.kernel32 # type: ignore[attr-defined]
158
+ h = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
159
+ if not h:
160
+ return k.GetLastError() == 5 # ERROR_ACCESS_DENIED
161
+ try:
162
+ code = ctypes.c_ulong()
163
+ return bool(k.GetExitCodeProcess(h, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
164
+ finally:
165
+ k.CloseHandle(h)
166
+
167
+
168
+ def win_stop_process(pid: int, *, run: Callable[[list[str]], Any] | None = None, alive: Callable[[int], bool] = _win_alive,
169
+ sleep: Callable[[float], None] = time.sleep) -> bool:
170
+ """Windows: taskkill /T /F (the process and what it started — a witbitz-code serve's OpenCode goes with it), then wait up
171
+ to 5 s for it to be gone. Never os.kill(pid, 0): on Windows signal 0 is CTRL_C_EVENT, a Ctrl-C to the console's
172
+ processes — setup's own window included — not a probe."""
173
+ run = run or (lambda argv: subprocess.run(argv, capture_output=True, check=False, timeout=30))
174
+ try:
175
+ run(["taskkill", "/PID", str(pid), "/T", "/F"])
176
+ except (OSError, subprocess.SubprocessError):
177
+ pass
178
+ for _ in range(50):
179
+ if not alive(pid):
180
+ return True
181
+ sleep(0.1)
182
+ return not alive(pid)
183
+
184
+
153
185
  def stop_process(pid: int) -> bool:
154
- """SIGTERM, up to 5 s, then SIGKILL. Gone = no such process, or a zombie (exited, not yet reaped)."""
186
+ """SIGTERM, up to 5 s, then SIGKILL. Gone = no such process, or a zombie (exited, not yet reaped). Windows: win_stop_process."""
187
+ if sys.platform == "win32":
188
+ return win_stop_process(pid)
189
+
155
190
  def gone() -> bool:
156
191
  try:
157
192
  os.kill(pid, 0)
@@ -170,7 +205,7 @@ def stop_process(pid: int) -> bool:
170
205
  if gone():
171
206
  return True
172
207
  try:
173
- os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM)) # Windows has no SIGKILL
208
+ os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
174
209
  except OSError:
175
210
  pass
176
211
  time.sleep(0.3)
@@ -19,6 +19,8 @@ import sys
19
19
  from pathlib import Path
20
20
  from typing import Callable
21
21
 
22
+ from . import tools_probe
23
+
22
24
  INSTRUCTIONS_NAME = "witbitz-windows.md"
23
25
 
24
26
  GIT_BASH = """# This computer
@@ -70,7 +72,7 @@ def _write_once(f: Path, text: str) -> None:
70
72
 
71
73
 
72
74
  def windows_agent(config_dir: Path, env: dict | None = None, platform: str | None = None,
73
- exists: Callable[[str], bool] = os.path.exists) -> tuple[dict, str | None, str]:
75
+ exists: Callable[[str], bool] = os.path.exists, listdir: Callable[[str], list] | None = None) -> tuple[dict, str | None, str]:
74
76
  """→ (env additions — PATH_PREPEND is a list for apply(), the instructions file or None, a line for the log or "")."""
75
77
  platform = sys.platform if platform is None else platform
76
78
  if platform != "win32":
@@ -84,6 +86,9 @@ def windows_agent(config_dir: Path, env: dict | None = None, platform: str | Non
84
86
  # Poppler from setup's Tools step (toolsetup.py: winget has no package, so a pinned release into Witbitz\poppler\bin)
85
87
  poppler = ntpath.join(_witbitz_dir(env), "poppler", "bin")
86
88
  prepend = [d for d in (py, poppler if exists(ntpath.join(poppler, "pdftotext.exe")) else None) if d]
89
+ # Tesseract and qpdf: their installers leave PATH alone (tools_probe.win_program_dirs — setup's probe looks there too)
90
+ programs = tools_probe.win_program_dirs(env, listdir) if listdir else tools_probe.win_program_dirs(env)
91
+ prepend += [d for d in programs if exists(ntpath.join(d, "tesseract.exe")) or exists(ntpath.join(d, "qpdf.exe"))]
87
92
  if prepend:
88
93
  add["PATH_PREPEND"] = prepend
89
94
  f = Path(config_dir) / INSTRUCTIONS_NAME
@@ -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.6
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
@@ -70,6 +70,7 @@ tests/test_pair_flows.py
70
70
  tests/test_plugins.py
71
71
  tests/test_policy.py
72
72
  tests/test_ports.py
73
+ tests/test_ports_win.py
73
74
  tests/test_registry.py
74
75
  tests/test_relay_codec.py
75
76
  tests/test_setup.py
@@ -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"