witbitz-code 1.9.5__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.
- {witbitz_code-1.9.5/src/witbitz_code.egg-info → witbitz_code-1.10.0}/PKG-INFO +1 -1
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/pyproject.toml +1 -1
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/browse.py +3 -2
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/cli.py +49 -28
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/pair.py +17 -8
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/pairings.py +68 -3
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui.py +81 -52
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.js +22 -17
- {witbitz_code-1.9.5 → witbitz_code-1.10.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_pair_flows.py +53 -2
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_ui.py +21 -11
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/LICENSE +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/README.md +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/setup.cfg +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_browse.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_images.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_selfupdate.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_wslstart.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.
|
|
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"
|
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 == "--
|
|
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
|
|
@@ -198,11 +201,12 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
198
201
|
return 0
|
|
199
202
|
|
|
200
203
|
if args.unpair:
|
|
201
|
-
targets = [p for p in doc["pairings"] if
|
|
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]
|
|
202
206
|
if not targets:
|
|
203
207
|
err(f"witbitz-code: no pairing{' for ' + args.account if args.account else ''} on this computer")
|
|
204
208
|
return 1
|
|
205
|
-
if len(targets) > 1 and not args.account:
|
|
209
|
+
if len(targets) > 1 and not args.account and not args.computer:
|
|
206
210
|
names = ", ".join(str(p.get("account")) for p in targets)
|
|
207
211
|
err(f"witbitz-code: this computer is paired with {len(targets)} accounts ({names}) — pass --account <email>")
|
|
208
212
|
return 1
|
|
@@ -220,7 +224,7 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
220
224
|
continue
|
|
221
225
|
if entry:
|
|
222
226
|
codekeys.forget(p["computerId"])
|
|
223
|
-
_forget_payer(p["computerId"])
|
|
227
|
+
_forget_payer(p["computerId"], p)
|
|
224
228
|
r = await acct.unpublish_computer(call=net.call_for(call, p.get("origin")), idx=p["idx"], computer_id=p["computerId"])
|
|
225
229
|
if not r["ok"]:
|
|
226
230
|
ok = False
|
|
@@ -228,7 +232,7 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
228
232
|
continue
|
|
229
233
|
note = " (it was already removed)" if r.get("noop") else ""
|
|
230
234
|
out(f"witbitz-code: ✓ removed \"{p.get('name')}\" from {p.get('account')}{note} — every device drops it on its next sync")
|
|
231
|
-
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), p
|
|
235
|
+
pp.write_pairings(pp.remove_pairings(pp.read_pairings(), computer_id=p["computerId"])["doc"]) # this one only
|
|
232
236
|
out(f"witbitz-code: restart the connector ({START_HINT}) so it stops answering on the old channel.")
|
|
233
237
|
return 0 if ok else 1
|
|
234
238
|
|
|
@@ -261,8 +265,10 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
261
265
|
if not account:
|
|
262
266
|
err("witbitz-code: pairing did not complete — nothing changed.")
|
|
263
267
|
return 1
|
|
268
|
+
port = None if args.opencode_url else pp.next_port(doc, account["idx"]["room"]) # another account: its own OpenCode
|
|
264
269
|
u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
|
|
265
|
-
opencode_url=args.opencode_url or
|
|
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)
|
|
266
272
|
# THIS COMPUTER'S CODING KEYS from the account (codekeys.py; docs/code-account-keys.md) — the only way it gets model
|
|
267
273
|
# keys. Asked with the sign-in the scan carried, before anything is published: an account that cannot give keys (not
|
|
268
274
|
# invited, too many computers, …) leaves nothing behind.
|
|
@@ -310,7 +316,10 @@ async def _keys_line(p: dict, keys: Any) -> str:
|
|
|
310
316
|
return f"coding keys: live · models this month ${used} of ${m.get('modelsCapUsd', '?')} · tools up to ${m.get('toolsCapUsd', '?')}"
|
|
311
317
|
|
|
312
318
|
|
|
313
|
-
def _forget_payer(computer_id: str) -> None:
|
|
319
|
+
def _forget_payer(computer_id: str, p: dict | None = None) -> None:
|
|
314
320
|
from .payers import SessionPayers # noqa: PLC0415
|
|
315
321
|
|
|
316
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
|
|
@@ -154,14 +156,77 @@ def upsert_pairing(doc: Any, *, idx: dict, account: str = "", name: str = "", re
|
|
|
154
156
|
"sharedWith": [p["account"] if truthy(p.get("account")) else "(another account)" for p in others]}
|
|
155
157
|
|
|
156
158
|
|
|
157
|
-
def remove_pairings(doc: Any, account: str = "") -> dict:
|
|
158
|
-
"""Drop the entries matching `account` (email) — or every entry when `account` is empty
|
|
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."""
|
|
159
162
|
cur = norm_pairings(doc)
|
|
160
|
-
gone = [p for p in cur["pairings"] if
|
|
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)]
|
|
161
165
|
kept = [p for p in cur["pairings"] if not any(p is g for g in gone)]
|
|
162
166
|
return {"doc": {"v": 1, "pairings": kept}, "removed": gone}
|
|
163
167
|
|
|
164
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
|
+
|
|
165
230
|
# ── files ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
166
231
|
def read_pairings(path: Path | None = None) -> dict:
|
|
167
232
|
try:
|
|
@@ -141,11 +141,10 @@ class Manager:
|
|
|
141
141
|
self._login_spec, self._login_state, self._login_enable, self._login_disable = login_spec, login_state, login_enable, login_disable
|
|
142
142
|
self.label = label or computer_label()
|
|
143
143
|
self.log: deque = log if log is not None else deque(maxlen=60)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
self.
|
|
147
|
-
self.serve_exit: dict | None = None
|
|
148
|
-
self.live: dict = {}
|
|
144
|
+
# ONE SERVE PER PAIRED PORT (the owner, 2026-10-22: each account its own OpenCode — "they should be separate"):
|
|
145
|
+
# port → {task, stop, live, exit}. A computer paired once runs one, as before.
|
|
146
|
+
self.runs: dict[int, dict] = {}
|
|
147
|
+
self.serve_exit: dict | None = None # the last run that ended (its why shows while nothing serves)
|
|
149
148
|
self.pair_task: asyncio.Task | None = None
|
|
150
149
|
self.pair_state: dict = {"state": "idle"}
|
|
151
150
|
self.qr_svg = ""
|
|
@@ -163,21 +162,39 @@ class Manager:
|
|
|
163
162
|
return []
|
|
164
163
|
|
|
165
164
|
def port(self) -> int:
|
|
166
|
-
ps =
|
|
165
|
+
ps = self.ports()
|
|
167
166
|
return ps[0] if len(ps) == 1 else 4096
|
|
168
167
|
|
|
168
|
+
def ports(self) -> list[int]:
|
|
169
|
+
"""Every port a pairing here uses — one OpenCode each."""
|
|
170
|
+
return sorted({wizard.port_of(p) for p in self.pairings()})
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def serve_task(self) -> asyncio.Task | None:
|
|
174
|
+
"""The first run's task (a computer paired once has one)."""
|
|
175
|
+
return next(iter(self.runs.values()), {}).get("task")
|
|
176
|
+
|
|
177
|
+
def _running(self) -> list[dict]:
|
|
178
|
+
return [r for r in self.runs.values() if not r["task"].done()]
|
|
179
|
+
|
|
169
180
|
def serving(self) -> dict:
|
|
170
|
-
|
|
171
|
-
if t is None:
|
|
181
|
+
if not self.runs:
|
|
172
182
|
return {"state": "stopped", **({"why": self.serve_exit["why"]} if self.serve_exit and self.serve_exit.get("why") else {})}
|
|
173
|
-
|
|
183
|
+
port = min(self.runs)
|
|
184
|
+
if not self._running():
|
|
174
185
|
return {"state": "stopped", "why": (self.serve_exit or {}).get("why", "")}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
186
|
+
states = []
|
|
187
|
+
for r in self.runs.values():
|
|
188
|
+
if r["task"].done():
|
|
189
|
+
states.append("stopped")
|
|
190
|
+
continue
|
|
191
|
+
conn = r["live"].get("connector")
|
|
192
|
+
servers = getattr(conn, "servers", None) if conn else None
|
|
193
|
+
states.append("starting" if not servers else
|
|
194
|
+
("online" if all(getattr(x, "relay_state", "") == "open" for x in servers) else "connecting"))
|
|
195
|
+
if "starting" in states:
|
|
196
|
+
return {"state": "starting", "port": port}
|
|
197
|
+
return {"state": "online" if all(x == "online" for x in states) else "connecting", "port": port}
|
|
181
198
|
|
|
182
199
|
async def keys(self, p: dict) -> str:
|
|
183
200
|
if not self._keys_line:
|
|
@@ -209,7 +226,8 @@ class Manager:
|
|
|
209
226
|
ps = self.pairings()
|
|
210
227
|
paired = []
|
|
211
228
|
for p in ps:
|
|
212
|
-
paired.append({"name": p.get("name") or "", "account": p.get("account") or "", "port": wizard.port_of(p), "keys": await self.keys(p)
|
|
229
|
+
paired.append({"name": p.get("name") or "", "account": p.get("account") or "", "port": wizard.port_of(p), "keys": await self.keys(p),
|
|
230
|
+
"computer": p.get("computerId") or "", "app": pp.app_label(p)})
|
|
213
231
|
pair = {k: v for k, v in self.pair_state.items() if k != "log"}
|
|
214
232
|
serving = self.serving()
|
|
215
233
|
if serving["state"] == "stopped" and ps:
|
|
@@ -228,13 +246,14 @@ class Manager:
|
|
|
228
246
|
page serves nothing — `witbitz-code setup` serving in its own window, a login-started copy, an earlier serve (the owner,
|
|
229
247
|
2026-10-09: the installer's setup was serving and the page said Stopped; its Start then failed "held by python.exe").
|
|
230
248
|
Another user's program, or WSL's copy (Windows cannot name it), is not listed: Start then fails and says why."""
|
|
231
|
-
if self.
|
|
249
|
+
if self._running():
|
|
232
250
|
return None
|
|
233
251
|
hit = self._elsewhere_cache
|
|
234
252
|
if hit and not fresh and time.monotonic() - hit[0] < 5:
|
|
235
253
|
return hit[1]
|
|
236
|
-
|
|
237
|
-
|
|
254
|
+
ports, owners = self.ports() or [self.port()], []
|
|
255
|
+
port = ports[0]
|
|
256
|
+
for q in [x for pt in ports for x in (pt, pt + 100)]:
|
|
238
257
|
if not await self._is_listening(q):
|
|
239
258
|
continue
|
|
240
259
|
try:
|
|
@@ -256,7 +275,7 @@ class Manager:
|
|
|
256
275
|
said.append(f"{'stopped' if ok else 'could not stop'} {o['cmd'] or 'a program'} (process {o['pid']})")
|
|
257
276
|
self.log.append("witbitz-code: " + said[-1])
|
|
258
277
|
for _ in range(30):
|
|
259
|
-
if not [q for
|
|
278
|
+
if not [q for pt in (self.ports() or [el["port"]]) for q in (pt, pt + 100) if await self._is_listening(q)]:
|
|
260
279
|
break
|
|
261
280
|
await asyncio.sleep(0.2)
|
|
262
281
|
self._elsewhere_cache = None
|
|
@@ -264,57 +283,66 @@ class Manager:
|
|
|
264
283
|
|
|
265
284
|
# ── serving ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
266
285
|
async def start_serving(self, takeover: bool = False) -> dict:
|
|
267
|
-
"""Serve from this page. Another copy of witbitz-code already serving here: the page's
|
|
268
|
-
and runs here instead; the launch's own start leaves it alone (the page then shows where it runs)."""
|
|
269
|
-
if self.serve_task and not self.serve_task.done():
|
|
270
|
-
return {"ok": True}
|
|
286
|
+
"""Serve from this page — one serve per paired port. Another copy of witbitz-code already serving here: the page's
|
|
287
|
+
Start (takeover) stops it and runs here instead; the launch's own start leaves it alone (the page then shows where it runs)."""
|
|
271
288
|
if not self.pairings():
|
|
272
289
|
return {"ok": False, "why": "pair this computer first"}
|
|
273
|
-
|
|
274
|
-
if
|
|
275
|
-
return {"ok":
|
|
276
|
-
if
|
|
277
|
-
await self.
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
wanted = [pt for pt in self.ports() if not (pt in self.runs and not self.runs[pt]["task"].done())]
|
|
291
|
+
if not wanted:
|
|
292
|
+
return {"ok": True}
|
|
293
|
+
if not self._running():
|
|
294
|
+
el = await self.elsewhere(fresh=True)
|
|
295
|
+
if el and not takeover:
|
|
296
|
+
return {"ok": False, "why": "already running in another window", "elsewhere": el}
|
|
297
|
+
if el:
|
|
298
|
+
await self._stop_elsewhere(el)
|
|
299
|
+
self.runs = {}
|
|
300
|
+
self.serve_exit = None
|
|
301
|
+
for pt in wanted:
|
|
302
|
+
run = {"port": pt, "stop": _StopFlag(), "live": {}, "exit": None}
|
|
303
|
+
run["task"] = asyncio.ensure_future(self._serve_run(run))
|
|
304
|
+
self.runs[pt] = run
|
|
281
305
|
return {"ok": True}
|
|
282
306
|
|
|
283
|
-
async def _serve_run(self,
|
|
307
|
+
async def _serve_run(self, run: dict) -> int:
|
|
284
308
|
try:
|
|
285
|
-
code = await self._serve(["--port", str(port)], stop=
|
|
309
|
+
code = await self._serve(["--port", str(run["port"])], stop=run["stop"], live_out=run["live"])
|
|
286
310
|
except asyncio.CancelledError:
|
|
287
311
|
raise
|
|
288
312
|
except Exception as e: # noqa: BLE001
|
|
289
|
-
self.serve_exit = {"code": 1, "why": str(e)[:500]}
|
|
313
|
+
run["exit"] = self.serve_exit = {"code": 1, "why": str(e)[:500]}
|
|
290
314
|
return 1
|
|
291
315
|
last = next((ln for ln in reversed(self.log) if ln.startswith("witbitz-code:")), "")
|
|
292
|
-
self.serve_exit = {"code": code, "why": "" if code == 0 else last.replace("witbitz-code: ", "", 1)}
|
|
316
|
+
run["exit"] = self.serve_exit = {"code": code, "why": "" if code == 0 else last.replace("witbitz-code: ", "", 1)}
|
|
293
317
|
return code
|
|
294
318
|
|
|
295
319
|
async def stop_serving(self) -> dict:
|
|
296
|
-
|
|
297
|
-
if
|
|
320
|
+
running = self._running()
|
|
321
|
+
if not running:
|
|
322
|
+
self.runs = {}
|
|
298
323
|
el = await self.elsewhere(fresh=True)
|
|
299
324
|
if el:
|
|
300
325
|
return {"ok": True, "said": await self._stop_elsewhere(el)}
|
|
301
326
|
return {"ok": True}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
327
|
+
for r in running:
|
|
328
|
+
r["stop"].event.set()
|
|
329
|
+
for r in running:
|
|
330
|
+
try:
|
|
331
|
+
await asyncio.wait_for(asyncio.shield(r["task"]), 25)
|
|
332
|
+
except asyncio.TimeoutError:
|
|
333
|
+
r["task"].cancel()
|
|
334
|
+
except Exception: # noqa: BLE001
|
|
335
|
+
pass
|
|
309
336
|
return {"ok": True}
|
|
310
337
|
|
|
311
338
|
# ── pairing ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
312
339
|
async def _pair_port(self) -> int:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
340
|
+
"""A free port for the next pairing's OpenCode — never one a pairing here uses: each account runs its own OpenCode
|
|
341
|
+
(the owner, 2026-10-22: one account's sessions must not show in another's). Serving is stopped while a code is up,
|
|
342
|
+
so a paired port is skipped by the list, not by listening."""
|
|
343
|
+
used = set(self.ports())
|
|
316
344
|
for p in range(4096, 4196): # OpenCode's port and its proxy's (+100) — WSL and Windows share them on one laptop
|
|
317
|
-
if not await self._is_listening(p) and not await self._is_listening(p + 100):
|
|
345
|
+
if p not in used and not await self._is_listening(p) and not await self._is_listening(p + 100):
|
|
318
346
|
return p
|
|
319
347
|
return 4096
|
|
320
348
|
|
|
@@ -365,7 +393,7 @@ class Manager:
|
|
|
365
393
|
await self.start_serving()
|
|
366
394
|
return {"ok": True}
|
|
367
395
|
|
|
368
|
-
async def unpair(self, account: str = "") -> dict:
|
|
396
|
+
async def unpair(self, account: str = "", computer: str = "") -> dict:
|
|
369
397
|
await self.stop_serving()
|
|
370
398
|
said: list[str] = []
|
|
371
399
|
|
|
@@ -375,7 +403,8 @@ class Manager:
|
|
|
375
403
|
said.append(line)
|
|
376
404
|
self.log.append(line[:300])
|
|
377
405
|
|
|
378
|
-
|
|
406
|
+
which = ["--computer", computer] if computer else (["--account", account] if account else [])
|
|
407
|
+
code = await self._pair(["--unpair", *which], out=note, err=note)
|
|
379
408
|
self._keys_cache.clear()
|
|
380
409
|
if code != 0:
|
|
381
410
|
if self.pairings():
|
|
@@ -548,7 +577,7 @@ class Server:
|
|
|
548
577
|
"/api/serve": lambda: self.m.start_serving(takeover=True) if data.get("on") else self.m.stop_serving(),
|
|
549
578
|
"/api/pair": self.m.start_pairing,
|
|
550
579
|
"/api/pair/cancel": self.m.cancel_pairing,
|
|
551
|
-
"/api/unpair": lambda: self.m.unpair(str(data.get("account") or "")),
|
|
580
|
+
"/api/unpair": lambda: self.m.unpair(str(data.get("account") or ""), str(data.get("computer") or "")),
|
|
552
581
|
"/api/login": lambda: self.m.set_login(bool(data.get("on"))),
|
|
553
582
|
"/api/quit": self.m.quit,
|
|
554
583
|
"/api/update": self.m.update,
|