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.
Files changed (117) hide show
  1. {witbitz_code-1.9.5/src/witbitz_code.egg-info → witbitz_code-1.10.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/browse.py +3 -2
  5. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/cli.py +49 -28
  6. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/pair.py +17 -8
  7. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/pairings.py +68 -3
  8. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui.py +81 -52
  9. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.js +22 -17
  10. {witbitz_code-1.9.5 → witbitz_code-1.10.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  11. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_pair_flows.py +53 -2
  12. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_ui.py +21 -11
  13. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/LICENSE +0 -0
  14. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/README.md +0 -0
  15. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/setup.cfg +0 -0
  16. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/__main__.py +0 -0
  17. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/_js.py +0 -0
  18. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/account.py +0 -0
  19. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/asks.py +0 -0
  20. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/attachments.py +0 -0
  21. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/auto.py +0 -0
  22. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/auto_runner.py +0 -0
  23. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/browsetree.py +0 -0
  24. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/codekeys.py +0 -0
  25. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/codetools.py +0 -0
  26. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/compress.py +0 -0
  27. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/computers.py +0 -0
  28. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/confidential.py +0 -0
  29. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/confidential_models.json +0 -0
  30. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/connector.py +0 -0
  31. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/devicelink.py +0 -0
  32. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/dirs.py +0 -0
  33. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  36. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  37. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/images.py +0 -0
  38. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/keys.py +0 -0
  39. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/link.py +0 -0
  40. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/mkdir.py +0 -0
  41. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/models.py +0 -0
  42. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/net.py +0 -0
  43. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/offered_models.json +0 -0
  44. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  45. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  46. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  47. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  48. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  49. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  50. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  51. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  52. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  53. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/outputs.py +0 -0
  54. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/payers.py +0 -0
  55. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/plugins.py +0 -0
  56. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/policy.py +0 -0
  57. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ports.py +0 -0
  58. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/qr.py +0 -0
  59. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/receipt.py +0 -0
  60. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/relay.py +0 -0
  61. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/remind.py +0 -0
  62. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/seccheck.py +0 -0
  63. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/seen.py +0 -0
  64. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/selfupdate.py +0 -0
  65. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/tinfoil_read.py +0 -0
  66. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/tools_catalog.json +0 -0
  67. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/tools_probe.py +0 -0
  68. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/toolsetup.py +0 -0
  69. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/app.css +0 -0
  70. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/ui_static/index.html +0 -0
  71. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/uitools.py +0 -0
  72. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/uninstall.py +0 -0
  73. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/winagent.py +0 -0
  74. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/winbg.py +0 -0
  75. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/winconsole.py +0 -0
  76. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/wizard.py +0 -0
  77. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code/wslstart.py +0 -0
  78. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  79. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  80. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  81. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  82. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  83. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_asks.py +0 -0
  84. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_attachments.py +0 -0
  85. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_auto.py +0 -0
  86. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_browse.py +0 -0
  87. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_browsetree.py +0 -0
  88. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_cli.py +0 -0
  89. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_codekeys.py +0 -0
  90. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_codetools.py +0 -0
  91. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_confidential.py +0 -0
  92. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_connector_e2e.py +0 -0
  93. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_devicelink.py +0 -0
  94. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_dirs.py +0 -0
  95. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_files_and_compress.py +0 -0
  96. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_folders.py +0 -0
  97. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_images.py +0 -0
  98. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_outputs.py +0 -0
  99. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_plugins.py +0 -0
  100. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_policy.py +0 -0
  101. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_ports.py +0 -0
  102. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_ports_win.py +0 -0
  103. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_registry.py +0 -0
  104. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_relay_codec.py +0 -0
  105. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_remind.py +0 -0
  106. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_seccheck.py +0 -0
  107. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_selfupdate.py +0 -0
  108. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_setup.py +0 -0
  109. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_tools_probe.py +0 -0
  110. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_toolsetup.py +0 -0
  111. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_uitools.py +0 -0
  112. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_uninstall.py +0 -0
  113. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_winagent.py +0 -0
  114. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_winbg.py +0 -0
  115. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_winconsole.py +0 -0
  116. {witbitz_code-1.9.5 → witbitz_code-1.10.0}/tests/test_windows_files.py +0 -0
  117. {witbitz_code-1.9.5 → 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.5
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.5"
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.5"
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
@@ -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
@@ -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 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]
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.get("account") or "")["doc"])
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 UNDEFINED, origin=account.get("origin") 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)
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. 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."""
159
162
  cur = norm_pairings(doc)
160
- 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)]
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
- self.serve_task: asyncio.Task | None = None
145
- self.serve_stop: _StopFlag | None = None
146
- self.serve_port = 0
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 = sorted({wizard.port_of(p) for p in self.pairings()})
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
- t = self.serve_task
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
- if t.done():
183
+ port = min(self.runs)
184
+ if not self._running():
174
185
  return {"state": "stopped", "why": (self.serve_exit or {}).get("why", "")}
175
- conn = self.live.get("connector")
176
- servers = getattr(conn, "servers", None) if conn else None
177
- if not servers:
178
- return {"state": "starting", "port": self.serve_port}
179
- states = [getattr(s, "relay_state", "") for s in servers]
180
- return {"state": "online" if states and all(s == "open" for s in states) else "connecting", "port": self.serve_port}
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.serve_task and not self.serve_task.done():
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
- port, owners = self.port(), []
237
- for q in (port, port + 100):
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 q in (el["port"], el["port"] + 100) if await self._is_listening(q)]:
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 Start (takeover) stops it
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
- el = await self.elsewhere(fresh=True)
274
- if el and not takeover:
275
- return {"ok": False, "why": "already running in another window", "elsewhere": el}
276
- if el:
277
- await self._stop_elsewhere(el)
278
- self.serve_stop, self.serve_exit, self.live = _StopFlag(), None, {}
279
- self.serve_port = self.port()
280
- self.serve_task = asyncio.ensure_future(self._serve_run(self.serve_port))
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, port: int) -> int:
307
+ async def _serve_run(self, run: dict) -> int:
284
308
  try:
285
- code = await self._serve(["--port", str(port)], stop=self.serve_stop, live_out=self.live)
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
- t = self.serve_task
297
- if t is None or t.done():
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
- self.serve_stop.event.set()
303
- try:
304
- await asyncio.wait_for(asyncio.shield(t), 25)
305
- except asyncio.TimeoutError:
306
- t.cancel()
307
- except Exception: # noqa: BLE001
308
- pass
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
- ps = sorted({wizard.port_of(p) for p in self.pairings()})
314
- if len(ps) == 1:
315
- return ps[0]
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
- code = await self._pair(["--unpair", *(["--account", account] if account else [])], out=note, err=note)
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,