witbitz-code 1.7.0__tar.gz → 1.7.1__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 (104) hide show
  1. {witbitz_code-1.7.0/src/witbitz_code.egg-info → witbitz_code-1.7.1}/PKG-INFO +1 -1
  2. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/pyproject.toml +1 -1
  3. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/auto_runner.py +68 -2
  5. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/cli.py +2 -1
  6. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/connector.py +7 -4
  7. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/uninstall.py +1 -1
  8. {witbitz_code-1.7.0 → witbitz_code-1.7.1/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  9. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_auto.py +111 -0
  10. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_uninstall.py +1 -1
  11. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/LICENSE +0 -0
  12. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/README.md +0 -0
  13. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/setup.cfg +0 -0
  14. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/__main__.py +0 -0
  15. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/_js.py +0 -0
  16. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/account.py +0 -0
  17. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/asks.py +0 -0
  18. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/attachments.py +0 -0
  19. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/auto.py +0 -0
  20. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/codekeys.py +0 -0
  21. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/codetools.py +0 -0
  22. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/compress.py +0 -0
  23. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/computers.py +0 -0
  24. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/confidential.py +0 -0
  25. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/confidential_models.json +0 -0
  26. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/devicelink.py +0 -0
  27. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/dirs.py +0 -0
  28. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/folders.py +0 -0
  29. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/gateway.py +0 -0
  30. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  31. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  32. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/images.py +0 -0
  33. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/keys.py +0 -0
  34. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/link.py +0 -0
  35. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/mkdir.py +0 -0
  36. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/models.py +0 -0
  37. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/net.py +0 -0
  38. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/offered_models.json +0 -0
  39. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  40. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  41. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  42. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  43. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/outputs.py +0 -0
  44. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/pair.py +0 -0
  45. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/pairings.py +0 -0
  46. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/payers.py +0 -0
  47. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/plugins.py +0 -0
  48. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/policy.py +0 -0
  49. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/ports.py +0 -0
  50. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/qr.py +0 -0
  51. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/receipt.py +0 -0
  52. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/relay.py +0 -0
  53. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/seccheck.py +0 -0
  54. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/seen.py +0 -0
  55. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/tinfoil_read.py +0 -0
  56. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/tools_catalog.json +0 -0
  57. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/tools_probe.py +0 -0
  58. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/toolsetup.py +0 -0
  59. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/ui.py +0 -0
  60. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/ui_static/app.css +0 -0
  61. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/ui_static/app.js +0 -0
  62. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/ui_static/index.html +0 -0
  63. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/uitools.py +0 -0
  64. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/winagent.py +0 -0
  65. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/winbg.py +0 -0
  66. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/winconsole.py +0 -0
  67. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/wizard.py +0 -0
  68. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code/wslstart.py +0 -0
  69. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
  70. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  71. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  72. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/requires.txt +0 -0
  73. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/top_level.txt +0 -0
  74. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_asks.py +0 -0
  75. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_attachments.py +0 -0
  76. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_cli.py +0 -0
  77. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_codekeys.py +0 -0
  78. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_codetools.py +0 -0
  79. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_confidential.py +0 -0
  80. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_connector_e2e.py +0 -0
  81. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_devicelink.py +0 -0
  82. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_dirs.py +0 -0
  83. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_files_and_compress.py +0 -0
  84. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_folders.py +0 -0
  85. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_images.py +0 -0
  86. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_outputs.py +0 -0
  87. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_pair_flows.py +0 -0
  88. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_plugins.py +0 -0
  89. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_policy.py +0 -0
  90. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_ports.py +0 -0
  91. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_ports_win.py +0 -0
  92. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_registry.py +0 -0
  93. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_relay_codec.py +0 -0
  94. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_seccheck.py +0 -0
  95. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_setup.py +0 -0
  96. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_tools_probe.py +0 -0
  97. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_toolsetup.py +0 -0
  98. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_ui.py +0 -0
  99. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_uitools.py +0 -0
  100. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_winagent.py +0 -0
  101. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_winbg.py +0 -0
  102. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_winconsole.py +0 -0
  103. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_windows_files.py +0 -0
  104. {witbitz_code-1.7.0 → witbitz_code-1.7.1}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.0
3
+ Version: 1.7.1
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.7.0"
7
+ version = "1.7.1"
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.7.0"
7
+ __version__ = "1.7.1"
@@ -22,6 +22,7 @@ import asyncio
22
22
  import math
23
23
  import os
24
24
  import re
25
+ import secrets
25
26
  import stat
26
27
  import time
27
28
  from collections.abc import Awaitable, Callable
@@ -42,6 +43,15 @@ MAX_USER_MESSAGES = 6
42
43
  MAX_DETAIL = 300
43
44
  PARENT_TTL_MS = 10 * 60_000
44
45
  AGENTS_TTL_MS = 60_000
46
+ # THE REVIEWER'S MODEL (the owner, 2026-10-13: "I am not sure if the auto mode classifier is working correctly"). Reviewed as
47
+ # an OpenCode session with the session's own model, a review carried OpenCode's whole agent prompt (~35k chars) ahead of ours
48
+ # and the model's full reasoning: DeepSeek V4.1 Flash went from a 5 s median to 30 s timeouts, and every ask fell to the
49
+ # person. Where the computer runs the model proxy (`model_url`), the policy is now asked DIRECTLY, of a fixed fast confidential
50
+ # model, with a backup. Measured on 8 cases: GPT-OSS 120B 8/8 in 2.5–3.5 s, DeepSeek V4.1 Flash 8/8 in 5–16 s. Any failure is
51
+ # still the person's. Held equal to tools/code-auto-runner.mjs.
52
+ REVIEW_MODELS = ("openai/gpt-oss-120b", "deepseek/deepseek-v4.1-flash")
53
+ REVIEW_TIMEOUT_MS = 45_000 # the whole review, backup included
54
+ FIRST_TRY_MS = 20_000 # the first model's share, so a hung one leaves the backup time to answer
45
55
  MAX_DEPTH = 4 # parent links followed looking for the session in Auto
46
56
  REFUSAL_SUFFIX = "(Witbitz Auto mode refused this — try a narrower or safer step.)"
47
57
  _UNKNOWN = object() # OpenCode could not say who a session's parent is (not cached)
@@ -107,11 +117,15 @@ def scratch_on_disk(paths: list[str], directory: str = SCRATCH_DIR) -> bool:
107
117
 
108
118
  class AutoRunner:
109
119
  def __init__(self, *, base: str, auth: Callable[[], dict], client: httpx.AsyncClient, state_path: Path | None,
110
- log_path: Path | None, poll_ms: float = 1000, review_timeout_ms: float = 30_000, home: str | None = None,
120
+ log_path: Path | None, poll_ms: float = 1000, review_timeout_ms: float = REVIEW_TIMEOUT_MS, home: str | None = None,
111
121
  on_verdict: Callable[[dict], Any] = lambda v: None, log: Callable[[str], Any] = lambda m: None,
112
122
  now: Callable[[], int] = _now_ms, scratch_check: Callable[[list[str]], bool] = scratch_on_disk,
113
123
  list_asks: Callable[[str], Awaitable[list | None]] | None = None,
114
- on_review: Callable[[str, bool], Any] | None = None) -> None:
124
+ on_review: Callable[[str, bool], Any] | None = None, model_url: str = "",
125
+ review_models: tuple[str, ...] | list[str] = REVIEW_MODELS, first_try_ms: float = FIRST_TRY_MS) -> None:
126
+ self._model_url = re.sub(r"/+\Z", "", model_url or "")
127
+ self._review_models = list(review_models)
128
+ self._first_try_ms = first_try_ms
115
129
  self._root = re.sub(r"/+\Z", "", base or "")
116
130
  # The review session's model calls are billed to the account that turned Auto on (payers.py): told when the
117
131
  # review opens and closes, since nothing from the Spaces app ever posts into it.
@@ -291,8 +305,60 @@ class AutoRunner:
291
305
  return []
292
306
  return allowed_folders(data.get("permission")) if ok and isinstance(data, dict) else []
293
307
 
308
+ async def _ask_model(self, m: str, prompt: dict, rid: str) -> tuple[dict | None, str]:
309
+ body = {"model": m, "messages": [{"role": "system", "content": prompt["system"]}, {"role": "user", "content": prompt["text"]}],
310
+ "max_tokens": 1500, "reasoning_effort": "low", "stream": False}
311
+ r = await self._client.post(f"{self._model_url}/chat/completions", headers={"content-type": "application/json", "x-session-id": rid},
312
+ content=_js.utf8(_js.stringify(body)))
313
+ try:
314
+ data = _js.parse(r.text)
315
+ except ValueError:
316
+ data = None
317
+ choices = data.get("choices") if isinstance(data, dict) and isinstance(data.get("choices"), list) else []
318
+ msg = choices[0].get("message") if choices and isinstance(choices[0], dict) and isinstance(choices[0].get("message"), dict) else {}
319
+ text = msg.get("content") if 200 <= r.status_code < 300 and isinstance(msg.get("content"), str) else ""
320
+ verdict = parse_verdict(text) if text else None
321
+ if verdict:
322
+ return verdict, ""
323
+ return None, f"answered {r.status_code}" if not 200 <= r.status_code < 300 else "no verdict in its answer" if text else "no answer"
324
+
325
+ async def _review_direct(self, prompt: dict) -> tuple[dict | None, str, str]:
326
+ """Ask the policy of the review models in turn, through the computer's model proxy (billed like the session: on_review)."""
327
+ rid = "ses_review_" + secrets.token_urlsafe(9)
328
+ until = time.monotonic() + self._review_timeout_ms / 1000
329
+ notes: list[str] = []
330
+ if self._on_review:
331
+ self._on_review(rid, True)
332
+ try:
333
+ for i, m in enumerate(self._review_models):
334
+ left = until - time.monotonic()
335
+ if left <= 0:
336
+ break
337
+ limit = left if i == len(self._review_models) - 1 else min(left, self._first_try_ms / 1000)
338
+ try:
339
+ verdict, why = await asyncio.wait_for(self._ask_model(m, prompt, rid), limit)
340
+ except asyncio.TimeoutError:
341
+ verdict, why = None, "did not answer in time"
342
+ except asyncio.CancelledError:
343
+ raise
344
+ except Exception:
345
+ verdict, why = None, "could not be reached"
346
+ if verdict:
347
+ return verdict, f"trustedrouter/{m}", ""
348
+ notes.append(f"{m}: {why}")
349
+ finally:
350
+ if self._on_review:
351
+ self._on_review(rid, False)
352
+ first = self._review_models[0] if self._review_models else ""
353
+ secs = math.floor(self._review_timeout_ms / 1000 + 0.5) or 1
354
+ return None, f"trustedrouter/{first}", f"the reviewer did not answer ({'; '.join(notes) or f'nothing within {secs} s'})"
355
+
294
356
  async def _review(self, req: dict, directory: str, owner: str | None = None, folders: list[str] | None = None) -> tuple[dict | None, str, str]:
295
357
  model, user_messages, tools = await self._session_context(owner or req["sessionID"], directory)
358
+ if self._model_url and self._review_models:
359
+ call = req.get("tool") if isinstance(req.get("tool"), dict) else {}
360
+ tool = tools.get(call["callID"], "") if isinstance(call.get("callID"), str) else ""
361
+ return await self._review_direct(reviewer_prompt(req, directory, user_messages, tool, folders or []))
296
362
  if not model:
297
363
  return None, "", "the session has no model to review with yet"
298
364
  label = f"{_js.js_string(model['providerID'])}/{_js.js_string(model['modelID'])}"
@@ -261,7 +261,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
261
261
  return 0
262
262
  if child is not None:
263
263
  _err("witbitz-code: model calls are paid by each account's own coding keys; confidential models are enforced (min_privacy + verified receipts)")
264
- connector = await start_connector(pairings, payers=payers)
264
+ # Auto mode's reviewer asks the policy of the model proxy directly (auto_runner.REVIEW_MODELS), billed like the session.
265
+ connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else "")
265
266
  live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
266
267
  live["connector"] = connector
267
268
  if live_out is not None:
@@ -157,7 +157,8 @@ class PairingServer:
157
157
  max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
158
158
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
159
159
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
160
- notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> None:
160
+ notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
161
+ model_url: str = "") -> None:
161
162
  self.pairing = pairing
162
163
  self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
163
164
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
@@ -212,7 +213,8 @@ class PairingServer:
212
213
  self.auto = AutoRunner(base=self.base, auth=self._auth, client=client, poll_ms=auto_poll_ms, log=log,
213
214
  state_path=auto_dir / f"auto-{safe_id}.json", log_path=auto_dir / "auto-log.jsonl",
214
215
  on_verdict=lambda v: self.peer.send({"t": "autoverdict", **v, "ts": int(time.time() * 1000)}),
215
- list_asks=self._pending_asks, on_review=self._own_review)
216
+ list_asks=self._pending_asks, on_review=self._own_review,
217
+ model_url=model_url) # the reviewer asks the model proxy directly (auto_runner.REVIEW_MODELS)
216
218
 
217
219
  # ── lifecycle ─────────────────────────────────────────────────────────────────────────────────────────────────────
218
220
  async def start(self) -> None:
@@ -880,7 +882,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
880
882
  max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
881
883
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
882
884
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
883
- notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> Connector:
885
+ notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
886
+ model_url: str = "") -> Connector:
884
887
  """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
885
888
  request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
886
889
  client = client or local_client()
@@ -895,7 +898,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
895
898
  s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
896
899
  max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
897
900
  attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
898
- notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers)
901
+ notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url)
899
902
  await s.start()
900
903
  servers.append(s)
901
904
  return Connector(servers, client, payers)
@@ -302,7 +302,7 @@ def remove_opencode_program(info: dict) -> dict:
302
302
  return {"ok": False, "why": f"{info['path']} is still there", "said": said} if os.path.exists(info["path"]) else {"ok": True, "said": said}
303
303
 
304
304
 
305
- WINDOWS_REINSTALL = "irm https://app.witbitz.chat/code.ps1 | iex"
305
+ WINDOWS_REINSTALL = "the install lines at https://witbitz.chat/code (or Code in your Spaces app)" # app-neutral: each app serves its own code.ps1
306
306
 
307
307
 
308
308
  def windows_installer_root() -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.0
3
+ Version: 1.7.1
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
@@ -700,3 +700,114 @@ def test_auto_switches_on_for_a_network_share(tmp_path):
700
700
  async def body(oc, runner, verdicts, make, client):
701
701
  assert runner.set_auto("ses_main", _UNC, True)
702
702
  with_runner(tmp_path, body)
703
+
704
+
705
+ # ── THE DIRECT REVIEWER (2026-10-13; = tools/code-auto-runner.test.mjs): with the computer's model proxy, the policy is asked
706
+ # of a fixed confidential model, then a backup — never through an OpenCode session.
707
+ ALLOW = '{"decision":"allow","severity":10,"rule":"allow:tests","reason":"the user asked for tests"}'
708
+
709
+
710
+ class FakeProxy:
711
+ def __init__(self, answers: dict) -> None:
712
+ self.seen: list[dict] = []
713
+ outer = self
714
+
715
+ class H(BaseHTTPRequestHandler):
716
+ def log_message(self, *a: Any) -> None:
717
+ pass
718
+
719
+ def do_POST(self) -> None:
720
+ b = json.loads(self.rfile.read(int(self.headers.get("content-length") or 0)))
721
+ outer.seen.append({"path": self.path, "sid": self.headers.get("x-session-id"), "body": b})
722
+ a = answers.get(b["model"], {"status": 404})
723
+ if a.get("delay"):
724
+ time.sleep(a["delay"])
725
+ data = json.dumps({"choices": [{"message": {"role": "assistant", "content": a["text"]}}]} if "text" in a
726
+ else {"error": {"message": "provider error"}}).encode()
727
+ try:
728
+ self.send_response(a.get("status", 200))
729
+ self.send_header("content-type", "application/json")
730
+ self.send_header("content-length", str(len(data)))
731
+ self.end_headers()
732
+ self.wfile.write(data)
733
+ except OSError:
734
+ pass
735
+
736
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
737
+ self.httpd.daemon_threads = True
738
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
739
+ self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/v1"
740
+
741
+ def close(self) -> None:
742
+ self.httpd.shutdown()
743
+ self.httpd.server_close()
744
+
745
+
746
+ def direct_world(tmp_path: Path, answers: dict, check, **extra: Any) -> None:
747
+ px = FakeProxy(answers)
748
+ oc = LoopOpenCode()
749
+ verdicts: list[dict] = []
750
+ billed: list = []
751
+
752
+ async def go():
753
+ async with httpx.AsyncClient(trust_env=False) as client:
754
+ runner = AutoRunner(base=oc.url, auth=lambda: {"authorization": "Basic x"}, client=client, state_path=tmp_path / "auto.json",
755
+ log_path=tmp_path / "auto-log.jsonl", poll_ms=30, home="/home/u", on_verdict=verdicts.append,
756
+ on_review=lambda rid, start: billed.append((rid, start)), model_url=px.url, **extra)
757
+ runner.start()
758
+ try:
759
+ runner.set_auto("ses_main", DIR, True)
760
+ oc.ask(permission="bash", patterns=["npm test"], metadata={"command": "npm test"})
761
+ await check(oc, px, verdicts, billed)
762
+ finally:
763
+ runner.stop()
764
+
765
+ try:
766
+ run(go(), timeout=60)
767
+ finally:
768
+ oc.close()
769
+ px.close()
770
+
771
+
772
+ def test_direct_gpt_oss_is_asked_directly_no_opencode_session_low_effort_billed_like_the_session(tmp_path):
773
+ async def check(oc, px, verdicts, billed):
774
+ assert await until(lambda: oc.replies(), 6)
775
+ assert oc.replies()[0]["body"] == {"reply": "once"}
776
+ assert oc.calls("POST", "/session") == [], "no review session"
777
+ c = px.seen[0]
778
+ assert c["path"] == "/v1/chat/completions"
779
+ assert (c["body"]["model"], c["body"]["reasoning_effort"], c["body"]["stream"]) == ("openai/gpt-oss-120b", "low", False)
780
+ assert "HARD DENY" in c["body"]["messages"][0]["content"]
781
+ assert "npm test" in c["body"]["messages"][1]["content"] and "please run the test suite" in c["body"]["messages"][1]["content"]
782
+ assert c["sid"].startswith("ses_review_")
783
+ assert billed == [(c["sid"], True), (c["sid"], False)]
784
+ assert await until(lambda: verdicts, 2)
785
+ assert verdicts[0]["stage"] == "reviewer"
786
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"text": ALLOW}}, check)
787
+
788
+
789
+ def test_direct_the_backup_answers_when_gpt_oss_fails_and_the_log_names_it(tmp_path):
790
+ async def check(oc, px, verdicts, billed):
791
+ assert await until(lambda: oc.replies(), 6)
792
+ assert [s["body"]["model"] for s in px.seen] == ["openai/gpt-oss-120b", "deepseek/deepseek-v4.1-flash"]
793
+ assert await until(lambda: log_lines(tmp_path), 2)
794
+ assert log_lines(tmp_path)[0]["model"] == "trustedrouter/deepseek/deepseek-v4.1-flash"
795
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"status": 502}, "deepseek/deepseek-v4.1-flash": {"text": ALLOW}}, check)
796
+
797
+
798
+ def test_direct_a_first_model_that_hangs_leaves_the_backup_its_time(tmp_path):
799
+ async def check(oc, px, verdicts, billed):
800
+ assert await until(lambda: oc.replies(), 6)
801
+ assert oc.replies()[0]["body"] == {"reply": "once"} and len(px.seen) == 2
802
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"text": ALLOW, "delay": 2}, "deepseek/deepseek-v4.1-flash": {"text": ALLOW}}, check,
803
+ first_try_ms=150, review_timeout_ms=1500)
804
+
805
+
806
+ def test_direct_both_failing_leaves_it_for_the_person_saying_why(tmp_path):
807
+ async def check(oc, px, verdicts, billed):
808
+ assert await until(lambda: verdicts, 6)
809
+ assert verdicts[0]["action"] == "ask"
810
+ assert "gpt-oss-120b: answered 502; deepseek/deepseek-v4.1-flash: no verdict in its answer" in verdicts[0]["reason"]
811
+ await asyncio.sleep(0.2)
812
+ assert oc.replies() == []
813
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"status": 502}, "deepseek/deepseek-v4.1-flash": {"text": "sure, fine"}}, check)
@@ -223,7 +223,7 @@ def test_under_the_windows_installer_the_summary_says_what_that_uninstaller_does
223
223
  assert r["done"]
224
224
  for wrong in ("pip uninstall", "pipx", "OpenCode is still installed", "still running in a terminal window"):
225
225
  assert wrong not in said, f"{wrong!r} does not apply to the Windows installer:\n{said}"
226
- assert "irm https://app.witbitz.chat/code.ps1 | iex" in said, said
226
+ assert "https://witbitz.chat/code" in said and "app.witbitz.chat" not in said, said
227
227
  assert ("package",) not in f.done, "the Windows uninstaller removes the program itself"
228
228
 
229
229
 
File without changes
File without changes
File without changes