witbitz-code 1.9.4__tar.gz → 1.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {witbitz_code-1.9.4/src/witbitz_code.egg-info → witbitz_code-1.10.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/browse.py +3 -2
  5. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/cli.py +50 -29
  6. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/link.py +6 -1
  7. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/net.py +19 -3
  8. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/pair.py +21 -10
  9. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/pairings.py +75 -5
  10. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui.py +81 -52
  11. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.js +22 -17
  12. {witbitz_code-1.9.4 → witbitz_code-1.10.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  13. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_devicelink.py +22 -0
  14. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_pair_flows.py +111 -2
  15. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_ui.py +21 -11
  16. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/LICENSE +0 -0
  17. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/README.md +0 -0
  18. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/setup.cfg +0 -0
  19. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/__main__.py +0 -0
  20. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/_js.py +0 -0
  21. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/account.py +0 -0
  22. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/asks.py +0 -0
  23. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/attachments.py +0 -0
  24. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/auto.py +0 -0
  25. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/auto_runner.py +0 -0
  26. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/browsetree.py +0 -0
  27. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/codekeys.py +0 -0
  28. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/codetools.py +0 -0
  29. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/compress.py +0 -0
  30. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/computers.py +0 -0
  31. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/confidential.py +0 -0
  32. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/confidential_models.json +0 -0
  33. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/connector.py +0 -0
  34. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/devicelink.py +0 -0
  35. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/dirs.py +0 -0
  36. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/folders.py +0 -0
  37. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/gateway.py +0 -0
  38. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  39. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  40. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/images.py +0 -0
  41. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/keys.py +0 -0
  42. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/mkdir.py +0 -0
  43. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/models.py +0 -0
  44. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/offered_models.json +0 -0
  45. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  46. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  47. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  48. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  49. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  50. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  51. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  52. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  53. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  54. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/outputs.py +0 -0
  55. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/payers.py +0 -0
  56. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/plugins.py +0 -0
  57. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/policy.py +0 -0
  58. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ports.py +0 -0
  59. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/qr.py +0 -0
  60. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/receipt.py +0 -0
  61. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/relay.py +0 -0
  62. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/remind.py +0 -0
  63. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/seccheck.py +0 -0
  64. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/seen.py +0 -0
  65. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/selfupdate.py +0 -0
  66. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/tinfoil_read.py +0 -0
  67. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/tools_catalog.json +0 -0
  68. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/tools_probe.py +0 -0
  69. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/toolsetup.py +0 -0
  70. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.css +0 -0
  71. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/index.html +0 -0
  72. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/uitools.py +0 -0
  73. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/uninstall.py +0 -0
  74. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/winagent.py +0 -0
  75. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/winbg.py +0 -0
  76. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/winconsole.py +0 -0
  77. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/wizard.py +0 -0
  78. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code/wslstart.py +0 -0
  79. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  80. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  81. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  82. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  83. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  84. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_asks.py +0 -0
  85. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_attachments.py +0 -0
  86. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_auto.py +0 -0
  87. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_browse.py +0 -0
  88. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_browsetree.py +0 -0
  89. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_cli.py +0 -0
  90. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_codekeys.py +0 -0
  91. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_codetools.py +0 -0
  92. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_confidential.py +0 -0
  93. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_connector_e2e.py +0 -0
  94. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_dirs.py +0 -0
  95. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_files_and_compress.py +0 -0
  96. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_folders.py +0 -0
  97. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_images.py +0 -0
  98. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_outputs.py +0 -0
  99. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_plugins.py +0 -0
  100. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_policy.py +0 -0
  101. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_ports.py +0 -0
  102. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_ports_win.py +0 -0
  103. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_registry.py +0 -0
  104. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_relay_codec.py +0 -0
  105. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_remind.py +0 -0
  106. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_seccheck.py +0 -0
  107. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_selfupdate.py +0 -0
  108. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_setup.py +0 -0
  109. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_tools_probe.py +0 -0
  110. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_toolsetup.py +0 -0
  111. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_uitools.py +0 -0
  112. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_uninstall.py +0 -0
  113. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_winagent.py +0 -0
  114. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_winbg.py +0 -0
  115. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_winconsole.py +0 -0
  116. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_windows_files.py +0 -0
  117. {witbitz_code-1.9.4 → witbitz_code-1.10.0}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.9.4
3
+ Version: 1.10.0
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.9.4"
7
+ version = "1.10.0"
8
8
  description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.9.4"
7
+ __version__ = "1.10.0"
@@ -125,7 +125,7 @@ class LocalBrowser:
125
125
 
126
126
 
127
127
  async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Callable[[str], Any] = print,
128
- node: str | None = None) -> LocalBrowser | None:
128
+ node: str | None = None, profile: Path | None = None) -> LocalBrowser | None:
129
129
  """Start it; → a LocalBrowser (env for OpenCode, probe for the hello), or None when this computer has no node."""
130
130
  node = node or shutil.which("node")
131
131
  if not node:
@@ -140,7 +140,8 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
140
140
  keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
141
141
  ctl, gate = _free_port(), _free_port()
142
142
  env = {**os.environ, "WB_BROWSE_CTL_PORT": str(ctl), "WB_BROWSE_GATE_PORT": str(gate), "WB_BROWSE_KEYS": ",".join(keys),
143
- "WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid())}
143
+ "WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid()),
144
+ **({"WB_BROWSE_PROFILE": str(profile)} if profile is not None else {})} # one Chromium profile per account's OpenCode
144
145
  proc = await asyncio.create_subprocess_exec(node, str(paths["browse-local.mjs"]), env=env, stdin=asyncio.subprocess.DEVNULL,
145
146
  stdout=asyncio.subprocess.DEVNULL)
146
147
  b = LocalBrowser(proc, ctl, {"WB_BROWSE_NODE": node, "WB_BROWSE_CDP_CLI": str(paths["browse-cdp.mjs"]),
@@ -124,7 +124,7 @@ OPENCODE_TR_PLACEHOLDER = "witbitz-account-keys"
124
124
 
125
125
 
126
126
  async def _start_opencode(exe: str, port: int, media: dict | None = None, tools: tuple[dict, dict] | None = None,
127
- extra: dict | None = None) -> asyncio.subprocess.Process:
127
+ extra: dict | None = None, store: Path | None = None) -> asyncio.subprocess.Process:
128
128
  password = pp.read_env_password()
129
129
  # Subagents ask like their parent, and a refused call does not end the turn (policy.py) — merged by OpenCode over the
130
130
  # user's own config, which is never written.
@@ -134,6 +134,9 @@ async def _start_opencode(exe: str, port: int, media: dict | None = None, tools:
134
134
  # The Tinfoil key is witbitz-code's (the confidential proxy reads it). Handed to OpenCode it would be in every shell the
135
135
  # agent runs, and OpenCode would list Tinfoil as a plain provider whose attestation nothing checks (as the Node one).
136
136
  env.pop("TINFOIL_API_KEY", None)
137
+ if store is not None: # this account's own sessions (pairings.store_dir) — OpenCode keeps its data under XDG_DATA_HOME
138
+ (store / "data").mkdir(parents=True, exist_ok=True)
139
+ env["XDG_DATA_HOME"] = str(store / "data")
137
140
  # The resolved path, not "opencode": on Windows the npm install is opencode.cmd, which a bare name does not find.
138
141
  # Its own process group there, so stopping it can take the whole tree (cmd.exe → node) with it.
139
142
  extra = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if sys.platform == "win32" else {}
@@ -154,6 +157,9 @@ async def _stop_opencode(child: asyncio.subprocess.Process) -> None:
154
157
  await child.wait()
155
158
 
156
159
 
160
+ _SETUP_LOCK = asyncio.Lock()
161
+
162
+
157
163
  async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = None) -> int:
158
164
  """`stop` and `live_out`: the page's manager (ui.py) runs serve inside itself — it stops it with its own flag instead
159
165
  of a signal, and reads the running connector (each pairing's relay state) from live_out."""
@@ -167,6 +173,15 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
167
173
  paired_ports = sorted({wizard.port_of(p) for p in everything})
168
174
  if "--port" not in args and len(paired_ports) == 1:
169
175
  port = paired_ports[0]
176
+ elif "--port" not in args and len(paired_ports) > 1:
177
+ # Several accounts, each its own OpenCode (the owner, 2026-10-22): serve them all here, one Ctrl-C stops them all;
178
+ # one that ends stops the others (a half-served computer reads as working).
179
+ stop = stop or _Stop()
180
+ runs = [asyncio.ensure_future(serve([*args, "--port", str(pt)], stop=stop)) for pt in paired_ports]
181
+ done, _ = await asyncio.wait(runs, return_when=asyncio.FIRST_COMPLETED)
182
+ stop.event.set()
183
+ codes = await asyncio.gather(*runs, return_exceptions=True)
184
+ return next((c for c in codes if isinstance(c, int) and c), 0) if all(isinstance(c, int) for c in codes) else 1
170
185
  # One OpenCode per port, one connector per OpenCode: two `serve`s (one per account) must never both answer a channel.
171
186
  pairings = pairings_for_port(everything, port)
172
187
  if not pairings:
@@ -174,30 +189,33 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
174
189
  return 1
175
190
  # Windows: a tool installed since this window opened (setup's Tools step, winget) is on the registry's PATH, not yet on
176
191
  # this process's — read it, so the agent's OpenCode finds it (= witbitz-code.mjs serve)
177
- reg = toolsetup.registry_path()
178
- if reg:
179
- os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
180
- # Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
181
- # written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
182
- # running picks them up at its next restart.
183
- try:
184
- if plugins.install():
185
- _err(f"witbitz-code: installed OpenCode plugins (project notes, progress updates) in {plugins.opencode_config_dir()}")
186
- except OSError as e:
187
- _err(f"witbitz-code: could not install the OpenCode plugins in {plugins.opencode_config_dir()} ({e}) — the agent works without progress updates or notes")
188
- # The media tools (plugins.py media_config) — one MCP server of image/PDF/video/audio tools, run with this computer's node.
189
- media, note = plugins.media_config()
190
- if note:
191
- _err(note)
192
- # Structure (codetools.py): OpenCode's lsp tool, ast-grep fetched once (pinned), and the hint that names it.
193
- tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
194
- if note:
195
- _err(note)
196
- # On Windows: UTF-8 Python, Git Bash, the agent's own Python and the note naming them (winagent.py; measured).
197
- w_add, w_instr, w_note = await asyncio.to_thread(winagent.windows_agent, plugins.opencode_config_dir())
198
- if w_note:
199
- _err(w_note)
200
- tools_env, tools_config = winagent.apply(tools_env, tools_config, w_add, w_instr)
192
+ # The setup every OpenCode shares (plugins, tools, the Windows agent) — one serve at a time: the page runs one serve per
193
+ # account's OpenCode, and two writing the same plugin folder or fetching the same tool at once would collide.
194
+ async with _SETUP_LOCK:
195
+ reg = toolsetup.registry_path()
196
+ if reg:
197
+ os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
198
+ # Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
199
+ # written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
200
+ # running picks them up at its next restart.
201
+ try:
202
+ if plugins.install():
203
+ _err(f"witbitz-code: installed OpenCode plugins (project notes, progress updates) in {plugins.opencode_config_dir()}")
204
+ except OSError as e:
205
+ _err(f"witbitz-code: could not install the OpenCode plugins in {plugins.opencode_config_dir()} ({e}) — the agent works without progress updates or notes")
206
+ # The media tools (plugins.py media_config) — one MCP server of image/PDF/video/audio tools, run with this computer's node.
207
+ media, note = plugins.media_config()
208
+ if note:
209
+ _err(note)
210
+ # Structure (codetools.py): OpenCode's lsp tool, ast-grep fetched once (pinned), and the hint that names it.
211
+ tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
212
+ if note:
213
+ _err(note)
214
+ # On Windows: UTF-8 Python, Git Bash, the agent's own Python and the note naming them (winagent.py; measured).
215
+ w_add, w_instr, w_note = await asyncio.to_thread(winagent.windows_agent, plugins.opencode_config_dir())
216
+ if w_note:
217
+ _err(w_note)
218
+ tools_env, tools_config = winagent.apply(tools_env, tools_config, w_add, w_instr)
201
219
  # THIS COMPUTER'S CODING KEYS (codekeys.py; docs/code-account-keys.md): the only model keys witbitz-code uses. A pairing
202
220
  # made before them is not served — every call of its account would be refused.
203
221
  unkeyed = [p for p in pairings if not codekeys.for_computer(p.get("computerId") or "")]
@@ -207,7 +225,10 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
207
225
  if not pairings:
208
226
  return 1
209
227
  live_keys = codekeys.LiveKeys([p["computerId"] for p in pairings])
210
- payers = SessionPayers()
228
+ # THIS ACCOUNT'S STORE (pairings.store_dir): its own OpenCode sessions, browser profile and session owners — or the
229
+ # default ones for the account that had them first. Pairings sharing a port (made before 2026-10-22) share the first's.
230
+ store = pp.store_dir(pairings[0], everything)
231
+ payers = SessionPayers(store / "session-payers.json") if store is not None else SessionPayers()
211
232
  offered = models.offered()
212
233
 
213
234
  def credential_for(req: Any) -> dict | None:
@@ -252,7 +273,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
252
273
  browse_mod.remove()
253
274
  else:
254
275
  try:
255
- browser = await browse_mod.start(pairings, log=_err)
276
+ browser = await browse_mod.start(pairings, log=_err, profile=(store / "browse-profile") if store is not None else None)
256
277
  except OSError as e:
257
278
  _err(f"witbitz-code: the agent's browser could not start ({e}) — the agent works without it")
258
279
  if browser:
@@ -264,7 +285,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
264
285
  _err(" npm install -g opencode-ai or curl -fsSL https://opencode.ai/install | bash")
265
286
  return 1
266
287
  _err(f"witbitz-code: starting OpenCode on 127.0.0.1:{port}")
267
- child = await _start_opencode(exe, port, media, (tools_env, tools_config), route)
288
+ child = await _start_opencode(exe, port, media, (tools_env, tools_config), route, store)
268
289
  for _ in range(40):
269
290
  if stop.event.is_set() or child.returncode is not None or await is_listening(port):
270
291
  break
@@ -384,7 +405,7 @@ async def uninstall(args: list[str]) -> int:
384
405
  return {"ok": False, "why": "could not give this computer's coding keys back"}
385
406
  if entry:
386
407
  codekeys.forget(p["computerId"])
387
- r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
408
+ r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
388
409
  if r.get("ok"):
389
410
  pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
390
411
  return r
@@ -156,6 +156,7 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
156
156
  out(f" waiting up to {round(wait_s)}s for your device to seal the account to this computer…\n")
157
157
  until = time.monotonic() + wait_s
158
158
  warned = False
159
+ found = ""
159
160
  while time.monotonic() < until:
160
161
  await asyncio.sleep(poll_s)
161
162
  r = None
@@ -168,7 +169,7 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
168
169
  err(f"witbitz-code: note — {origin} has no device-link relay; ignoring it.")
169
170
  warned = True
170
171
  if isinstance(rr, dict) and truthy(rr.get("ok")):
171
- r = rr
172
+ r, found = rr, origin
172
173
  break
173
174
  if r is None:
174
175
  continue # 404 on all = not sealed yet
@@ -184,6 +185,10 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
184
185
  if not account:
185
186
  err("witbitz-code: the reply carried no account key.")
186
187
  return None
188
+ # THE APP IT CAME FROM (the owner, 2026-10-22: a second account paired from the hosted app → "computers read refused
189
+ # (404: not_found)"): the relay that held the reply is that app's own address, and its rooms answer only their own app
190
+ # since 2026-10-18 — so this account's calls speak as it (pairings.json `origin`, net.call_for).
191
+ account["origin"] = found
187
192
  via = "link" if account["idx"] else ""
188
193
  if not account["idx"]:
189
194
  out("witbitz-code: no index pointer in the link — recovering it from your account backup… ", end="")
@@ -4,6 +4,7 @@ retried or cached)."""
4
4
  from __future__ import annotations
5
5
 
6
6
  import os
7
+ import re
7
8
  from collections.abc import Awaitable, Callable
8
9
  from dataclasses import dataclass
9
10
  from typing import Any
@@ -31,10 +32,24 @@ def _json_of(r: httpx.Response) -> Any:
31
32
  return _js.parse(r.content.decode("utf-8-sig", "replace")) # fetch's r.json(): BOM dropped, then JSON.parse
32
33
 
33
34
 
34
- def make_api_call(client: httpx.AsyncClient) -> ApiCall:
35
- """POST {op:'state', …} to the account API, as the Spaces app would (same Origin)."""
35
+ def app_origin(o: Any) -> str:
36
+ """A pairing's app address, when it is one: https and nothing but scheme://host. Else '' (the default app)."""
37
+ return o if isinstance(o, str) and re.fullmatch(r"https://[A-Za-z0-9.-]+(:[0-9]+)?", o) else ""
38
+
39
+
40
+ def call_for(call: Any, origin: Any) -> Any:
41
+ """The same account call, speaking as the app `origin` pairs through (its rooms answer only their own app's addresses —
42
+ the hosted app's since 2026-10-18). A call without that ability (a test's fake) is returned unchanged."""
43
+ o = app_origin(origin)
44
+ f = getattr(call, "for_origin", None)
45
+ return f(o) if o and callable(f) else call
46
+
47
+
48
+ def make_api_call(client: httpx.AsyncClient, origin: str | None = None) -> ApiCall:
49
+ """POST {op:'state', …} to the account API, as the Spaces app would (same Origin). `origin`: the app this account was
50
+ paired from (a pairing's `origin`); none → RC_ORIGIN, else the regular app."""
36
51
  api = os.environ.get("RC_BASE") or API
37
- origin = os.environ.get("RC_ORIGIN") or ORIGIN
52
+ origin = app_origin(origin) or os.environ.get("RC_ORIGIN") or ORIGIN
38
53
 
39
54
  async def call(body: dict) -> ApiReply:
40
55
  r = await client.post(api, content=_js.stringify(body).encode("utf-8"),
@@ -45,6 +60,7 @@ def make_api_call(client: httpx.AsyncClient) -> ApiCall:
45
60
  j = None
46
61
  return ApiReply(r.status_code, j)
47
62
 
63
+ call.for_origin = lambda o: make_api_call(client, o) # type: ignore[attr-defined]
48
64
  return call
49
65
 
50
66
 
@@ -46,6 +46,7 @@ class Args:
46
46
  status: bool = False
47
47
  name: str = ""
48
48
  account: str = ""
49
+ computer: str = ""
49
50
  opencode_url: str = ""
50
51
 
51
52
 
@@ -63,9 +64,11 @@ def parse_args(argv: list[str]) -> Args:
63
64
  a.unpair = True
64
65
  elif x == "--status":
65
66
  a.status = True
66
- elif x in ("--name", "--account", "--opencode-url", "--port"):
67
+ elif x in ("--name", "--account", "--computer", "--opencode-url", "--port"):
67
68
  i += 1
68
- if x == "--name":
69
+ if x == "--computer": # one pairing by its computer id (the page's Unpair on a row)
70
+ a.computer = nxt
71
+ elif x == "--name":
69
72
  a.name = nxt
70
73
  elif x == "--account":
71
74
  a.account = nxt
@@ -94,6 +97,7 @@ def ensure_local_password(out: Callable[[str], Any] = _out) -> None:
94
97
 
95
98
 
96
99
  async def _publish_entry(call: net.ApiCall, e: dict, out: Callable, err: Callable) -> bool:
100
+ call = net.call_for(call, e.get("origin")) # this account's app (the hosted app's rooms refuse the regular one)
97
101
  r = await acct.publish_computer(call=call, idx=e["idx"], computer_id=e["computerId"], name=e["name"], relay=e["relay"],
98
102
  secret=e["secret"])
99
103
  who = e["account"] or "account"
@@ -197,11 +201,12 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
197
201
  return 0
198
202
 
199
203
  if args.unpair:
200
- targets = [p for p in doc["pairings"] if not args.account or p.get("account") == args.account]
204
+ targets = [p for p in doc["pairings"] if p.get("computerId") == args.computer] if args.computer else \
205
+ [p for p in doc["pairings"] if not args.account or p.get("account") == args.account]
201
206
  if not targets:
202
207
  err(f"witbitz-code: no pairing{' for ' + args.account if args.account else ''} on this computer")
203
208
  return 1
204
- if len(targets) > 1 and not args.account:
209
+ if len(targets) > 1 and not args.account and not args.computer:
205
210
  names = ", ".join(str(p.get("account")) for p in targets)
206
211
  err(f"witbitz-code: this computer is paired with {len(targets)} accounts ({names}) — pass --account <email>")
207
212
  return 1
@@ -219,15 +224,15 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
219
224
  continue
220
225
  if entry:
221
226
  codekeys.forget(p["computerId"])
222
- _forget_payer(p["computerId"])
223
- r = await acct.unpublish_computer(call=call, idx=p["idx"], computer_id=p["computerId"])
227
+ _forget_payer(p["computerId"], p)
228
+ r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
224
229
  if not r["ok"]:
225
230
  ok = False
226
231
  err(f"witbitz-code: ✖ {p.get('account')}: {r['why']} — kept the local pairing so you can retry")
227
232
  continue
228
233
  note = " (it was already removed)" if r.get("noop") else ""
229
234
  out(f"witbitz-code: ✓ removed \"{p.get('name')}\" from {p.get('account')}{note} — every device drops it on its next sync")
230
- pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p.get("account") or "")["doc"])
235
+ pp.write_pairings(pp.remove_pairings(pp.read_pairings(), computer_id=p["computerId"])["doc"]) # this one only
231
236
  out(f"witbitz-code: restart the connector ({START_HINT}) so it stops answering on the old channel.")
232
237
  return 0 if ok else 1
233
238
 
@@ -242,7 +247,8 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
242
247
  cur, ok = doc, True
243
248
  for p in to_rotate:
244
249
  u = pp.upsert_pairing(cur, account=p.get("account") or "", idx=p["idx"], name=p.get("name") or "",
245
- relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED), rotate=True)
250
+ relay=p.get("relay", UNDEFINED), opencode_url=p.get("opencodeUrl", UNDEFINED),
251
+ origin=p.get("origin", UNDEFINED), rotate=True)
246
252
  if await _publish_entry(call, u["entry"], out, err):
247
253
  cur = u["doc"]
248
254
  pp.write_pairings(cur)
@@ -259,8 +265,10 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
259
265
  if not account:
260
266
  err("witbitz-code: pairing did not complete — nothing changed.")
261
267
  return 1
268
+ port = None if args.opencode_url else pp.next_port(doc, account["idx"]["room"]) # another account: its own OpenCode
262
269
  u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
263
- opencode_url=args.opencode_url or UNDEFINED)
270
+ opencode_url=args.opencode_url or (f"http://127.0.0.1:{port}" if port else UNDEFINED),
271
+ origin=account.get("origin") or UNDEFINED)
264
272
  # THIS COMPUTER'S CODING KEYS from the account (codekeys.py; docs/code-account-keys.md) — the only way it gets model
265
273
  # keys. Asked with the sign-in the scan carried, before anything is published: an account that cannot give keys (not
266
274
  # invited, too many computers, …) leaves nothing behind.
@@ -308,7 +316,10 @@ async def _keys_line(p: dict, keys: Any) -> str:
308
316
  return f"coding keys: live · models this month ${used} of ${m.get('modelsCapUsd', '?')} · tools up to ${m.get('toolsCapUsd', '?')}"
309
317
 
310
318
 
311
- def _forget_payer(computer_id: str) -> None:
319
+ def _forget_payer(computer_id: str, p: dict | None = None) -> None:
312
320
  from .payers import SessionPayers # noqa: PLC0415
313
321
 
314
322
  SessionPayers().forget_computer(computer_id)
323
+ store = pp.store_dir(p, pp.read_pairings()["pairings"]) if p else None
324
+ if store is not None and (store / "session-payers.json").exists(): # its own store's record too (cli.serve)
325
+ SessionPayers(store / "session-payers.json").forget_computer(computer_id)
@@ -11,6 +11,8 @@ One entry per (computer, account) — several accounts can pair the same compute
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import hashlib
15
+ import json
14
16
  import os
15
17
  import re
16
18
  import secrets
@@ -114,8 +116,8 @@ def norm_pairings(doc: Any) -> dict:
114
116
 
115
117
 
116
118
  def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", relay: Any = UNDEFINED,
117
- opencode_url: Any = UNDEFINED, rotate: bool = False, mint_id: Callable[[], str] = new_computer_id,
118
- mint_secret: Callable[[], str] = new_relay_secret) -> dict:
119
+ opencode_url: Any = UNDEFINED, origin: Any = UNDEFINED, rotate: bool = False,
120
+ mint_id: Callable[[], str] = new_computer_id, mint_secret: Callable[[], str] = new_relay_secret) -> dict:
119
121
  """Add this computer for an account, or refresh the existing entry (same id + secret unless `rotate`). Pure.
120
122
  → {doc, entry, isNew, sharedWith}: sharedWith names the other accounts that reach the same OpenCode."""
121
123
  relay = RELAY_URL if relay is UNDEFINED else relay
@@ -138,6 +140,11 @@ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", re
138
140
  # An OpenCode asked for (`pair --port 4097`) wins; not asked → the entry keeps the one it had (tools/opencode-pair.mjs).
139
141
  "opencodeUrl": pick(None if opencode_url is UNDEFINED else opencode_url, "opencodeUrl", DEFAULT_OPENCODE_URL),
140
142
  }
143
+ # The app this account pairs through (its rooms answer only their own app — net.call_for). Absent = the regular app, so an
144
+ # entry written before 2026-10-22 (or by an older tool) reads as before, byte for byte.
145
+ app = pick(None if origin is UNDEFINED else origin, "origin", "")
146
+ if truthy(app):
147
+ entry["origin"] = app
141
148
  pairings = list(cur["pairings"])
142
149
  if i >= 0:
143
150
  pairings[i] = entry
@@ -149,14 +156,77 @@ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", re
149
156
  "sharedWith": [p["account"] if truthy(p.get("account")) else "(another account)" for p in others]}
150
157
 
151
158
 
152
- def remove_pairings(doc: Any, account: str = "") -> dict:
153
- """Drop the entries matching `account` (email) — or every entry when `account` is empty. Pure."""
159
+ def remove_pairings(doc: Any, account: str = "", computer_id: str = "") -> dict:
160
+ """Drop the entries matching `account` (email) — or every entry when `account` is empty; with `computer_id`, only that
161
+ pairing (one email can be paired twice: once from each app — the owner, 2026-10-22). Pure."""
154
162
  cur = norm_pairings(doc)
155
- gone = [p for p in cur["pairings"] if not truthy(account) or strict_eq(p.get("account", UNDEFINED), account)]
163
+ gone = [p for p in cur["pairings"] if strict_eq(p.get("computerId", UNDEFINED), computer_id)] if truthy(computer_id) else \
164
+ [p for p in cur["pairings"] if not truthy(account) or strict_eq(p.get("account", UNDEFINED), account)]
156
165
  kept = [p for p in cur["pairings"] if not any(p is g for g in gone)]
157
166
  return {"doc": {"v": 1, "pairings": kept}, "removed": gone}
158
167
 
159
168
 
169
+ # The hosted ("vaulted") app's origins (the tenant t_a7f1); any other origin, or none, is the regular app.
170
+ VAULTED_ORIGINS = ("https://spaces.witbitz.chat", "https://app.witbitz.chat", "https://witbitz-vault-prod.pages.dev",
171
+ "https://witbitz-spaces-vaulted.pages.dev")
172
+
173
+
174
+ def app_label(p: Any) -> str:
175
+ """Which app a pairing belongs to, in words — two pairings of one email differ only in this (the owner, 2026-10-22)."""
176
+ return "vaulted app" if isinstance(p, dict) and p.get("origin") in VAULTED_ORIGINS else "regular app"
177
+
178
+
179
+ # EACH ACCOUNT ITS OWN OPENCODE (the owner, 2026-10-22: one account's sessions showed in the other's — "they should be
180
+ # separate"). A pairing's account + app name its store: OpenCode's data (sessions), the agent's browser profile and who pays
181
+ # for each session. The store OpenCode used before (its default) stays with the account that had it — the first one this
182
+ # computer saw, recorded once in stores.json — so nothing anyone had moves; every other account gets a folder of its own,
183
+ # and pairing the same account again finds it again.
184
+ def store_key(p: Any) -> str:
185
+ p = p if isinstance(p, dict) else {}
186
+ return hashlib.sha256(f"{p.get('account') or ''}\n{p.get('origin') or ''}".encode()).hexdigest()[:16]
187
+
188
+
189
+ def next_port(doc: Any, room: str) -> int | None:
190
+ """The OpenCode port for a NEW pairing of `room`'s account: None = keep (already paired, or the first one here → 4096);
191
+ else the first port no other pairing uses — each account its own OpenCode (= the page's _pair_port)."""
192
+ cur = norm_pairings(doc)["pairings"]
193
+ if not cur or any(p["idx"]["room"] == room for p in cur):
194
+ return None
195
+ used = set()
196
+ for p in cur:
197
+ m = re.search(r":(\d+)/*$", str(p.get("opencodeUrl") or "http://127.0.0.1:4096"))
198
+ used.add(int(m.group(1)) if m else 4096)
199
+ return next((n for n in range(4096, 4196) if n not in used), None)
200
+
201
+
202
+ def stores_path() -> Path:
203
+ return pairings_path().parent / "stores.json"
204
+
205
+
206
+ def default_store_owner(pairings: list[dict]) -> str:
207
+ """The store key that keeps OpenCode's default data — read, or (first time) the oldest pairing's, written once."""
208
+ try:
209
+ doc = json.loads(stores_path().read_text(encoding="utf-8"))
210
+ if isinstance(doc, dict) and isinstance(doc.get("default"), str) and doc["default"]:
211
+ return doc["default"]
212
+ except (OSError, ValueError):
213
+ pass
214
+ if not pairings:
215
+ return ""
216
+ key = store_key(pairings[0])
217
+ try:
218
+ write_private(stores_path(), json.dumps({"v": 1, "default": key}))
219
+ except OSError:
220
+ pass
221
+ return key
222
+
223
+
224
+ def store_dir(p: Any, pairings: list[dict]) -> Path | None:
225
+ """None = OpenCode's default store (the first account's); else this account's own folder."""
226
+ key = store_key(p)
227
+ return None if key == default_store_owner(pairings) else pairings_path().parent / "accounts" / key
228
+
229
+
160
230
  # ── files ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
161
231
  def read_pairings(path: Path | None = None) -> dict:
162
232
  try: