witbitz-code 1.2.0__py3-none-any.whl

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.
@@ -0,0 +1,818 @@
1
+ """The Code section's end on YOUR computer — tools/opencode-connector.mjs.
2
+
3
+ OpenCode stays on 127.0.0.1. This process dials OUT to the sealed relay once per pairing and does for the Code page what a
4
+ browser on the same machine would do: it calls OpenCode's HTTP API (with the local password, which never leaves this
5
+ machine) and streams OpenCode's events back — every frame sealed with keys derived from the pairing secret, so the relay
6
+ forwards bytes it cannot read.
7
+
8
+ It serves ONLY the calls the Code page makes (relay.allowed_request): OpenCode can run shell commands here, so a leaked
9
+ secret must not unlock more than the page itself can do.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import base64
16
+ import codecs
17
+ import hashlib
18
+ import json
19
+ import math
20
+ import os
21
+ import re
22
+ import sys
23
+ import time
24
+ import urllib.parse
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Any
29
+ from urllib.parse import urlsplit
30
+
31
+ import httpx
32
+
33
+ from . import _js
34
+ from .asks import ASK_FRAME, AskBook
35
+ from .folders import FOLDER_ROUTE, folder_route
36
+ from .attachments import (ATTACHMENT_ROUTE, MAX_FILE_BYTES, attachment_rule, default_root, prune_attachments,
37
+ remove_session_attachments, serve_attachment, stage_message_body)
38
+ from .auto_runner import AutoRunner
39
+ from .outputs import OUTPUT_ROUTE, serve_output
40
+ from .plugins import notes_plugin_path as default_notes_plugin, notes_root as default_notes_root, notes_rules
41
+ from .seen import SEEN_ROUTE, merge_seen, norm_seen
42
+ from .pairings import DEFAULT_OPENCODE_URL, hostname, read_env_password
43
+ from .relay import RELAY_URL, Connect, RelayPeer, allowed_event_path, allowed_request, project_response
44
+ from .tools_probe import probe_tools
45
+
46
+ VERSION = "1" # the hello protocol version, as the JS connector sends it
47
+ REQUEST_TIMEOUT_MS = 30_000
48
+ HELLO_EVERY_S = 20.0 # the page counts the computer online while a hello arrived in the last 30 s
49
+ SUB_TTL_S = 75.0 # a page re-subscribes every 30 s; a client that stops renewing is dropped
50
+ SWEEP_EVERY_S = 15.0
51
+ ASKS_RECONCILE_MS = 30_000 # how often recorded asks are checked against which sessions still run (asks.py)
52
+ MAX_RESPONSE = 30 * 1024 * 1024 # bytes; below the page's 32 MiB reassembly cap: refuse while reading, not by a timeout
53
+ START_HINT = "witbitz-code serve"
54
+ _TOKEN = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+") # an HTTP method fetch() would accept
55
+ _URI_SAFE = "-_.!~*'()" # encodeURIComponent
56
+
57
+
58
+ def default_auto_dir() -> Path:
59
+ """Auto mode's state (per pairing) and its verdict log — the JS connector's AUTO_DIR."""
60
+ return Path(os.environ.get("WITBITZ_CODE_AUTO_DIR") or Path.home() / ".witbitz" / "code")
61
+
62
+
63
+ def _round(x: float) -> int:
64
+ return math.floor(x + 0.5) # Math.round, not banker's rounding
65
+
66
+
67
+ def sse_reader(on_data: Callable[[str], Any]) -> Callable[[str], None]:
68
+ """Split an SSE text stream into `data:` payloads (one per event, multi-line data joined) — the JS sseReader,
69
+ including what it does not handle (a bare \\r line ending), so both connectors deliver the same events."""
70
+ buf = ""
71
+ sep = re.compile(r"\r?\n\r?\n")
72
+
73
+ def feed(chunk: str) -> None:
74
+ nonlocal buf
75
+ buf += chunk
76
+ while True:
77
+ m = sep.search(buf)
78
+ if not m:
79
+ return
80
+ block, buf = buf[: m.start()], buf[m.end():]
81
+ lines = re.split(r"\r?\n", block)
82
+ data = "\n".join(re.sub(r"\A ", "", ln[5:]) for ln in lines if ln.startswith("data:"))
83
+ if data:
84
+ on_data(data)
85
+
86
+ return feed
87
+
88
+
89
+ def pairings_for_port(pairings: list[dict], port: Any) -> list[dict]:
90
+ """The pairings served by the OpenCode on `port`. Two connectors (one OpenCode per account) must never both answer
91
+ the same channel — every event would arrive twice — so each serves only the pairings that point at its own port."""
92
+ return [p for p in pairings if _url_port(p.get("opencodeUrl") or DEFAULT_OPENCODE_URL) == _js.to_number(port)]
93
+
94
+
95
+ _DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443, "ftp": 21}
96
+
97
+
98
+ def _url_port(url: Any) -> float:
99
+ """new URL(url).port, which is '' for the scheme's own default port; '' counts as 443 for https:, else 80."""
100
+ try:
101
+ u = urlsplit(_js.trim(_js.js_string(url)))
102
+ if not u.scheme or not u.netloc:
103
+ return math.nan
104
+ port = u.port
105
+ except ValueError:
106
+ return math.nan
107
+ if port is None or port == _DEFAULT_PORTS.get(u.scheme.lower()):
108
+ return 443 if u.scheme.lower() == "https" else 80 # u.port || (https: ? 443 : 80) — ws/wss/ftp count as 80
109
+ return port
110
+
111
+
112
+ @dataclass
113
+ class _Sub:
114
+ clients: dict # client id → last renewed (monotonic s); the stream is shared, interest is per device
115
+ buffer: list = field(default_factory=list)
116
+ task: asyncio.Task | None = None
117
+ timer: asyncio.TimerHandle | None = None
118
+
119
+
120
+ class _Inflight:
121
+ def __init__(self) -> None:
122
+ self.aborted = False
123
+ self.timed_out = False
124
+ self.task: asyncio.Task | None = None
125
+
126
+ def abort(self) -> None:
127
+ self.aborted = True
128
+ if self.task is not None:
129
+ self.task.cancel()
130
+
131
+ def time_out(self) -> None:
132
+ self.timed_out = True
133
+ self.abort()
134
+
135
+
136
+ _ALL = object() # internal only: close a stream for every client (nobody watching, the sweep emptied it, stopping)
137
+
138
+
139
+ class _TooLarge(Exception):
140
+ pass
141
+
142
+
143
+ def _client_id(c: Any) -> str:
144
+ return _js.utf16_slice(c, 0, 64) if isinstance(c, str) and c else "_"
145
+
146
+
147
+ class PairingServer:
148
+ """One pairing: one RelayPeer on its channel, serving one local OpenCode."""
149
+
150
+ def __init__(self, pairing: dict, *, client: httpx.AsyncClient, flush_ms: float, log: Callable[[str], Any],
151
+ connect: Connect | None = None, request_timeout_ms: float = REQUEST_TIMEOUT_MS, max_senders: int = 64,
152
+ max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
153
+ attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
154
+ asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
155
+ notes_plugin_path: Path | None = None) -> None:
156
+ self.pairing = pairing
157
+ # PROJECT NOTES (plugins.py): where the plugin keeps a project's notes, and the file whose presence turns the rule on
158
+ self._notes_root = notes_root or default_notes_root()
159
+ self._notes_plugin = Path(notes_plugin_path) if notes_plugin_path else default_notes_plugin()
160
+ # WHAT IS WAITING (asks.py): OpenCode's GET /permission breaks for a whole folder on one malformed ask and keeps a
161
+ # stopped turn's asks — every folder's asks are followed on /global/event, for Auto and for the page's card recovery.
162
+ self._asks = AskBook()
163
+ self._asks_live = False
164
+ self._asks_epoch = 0 # one per connection of the stream
165
+ self._asks_complete: dict[str, int] = {} # directory → the epoch its whole list was read in: the book knows every ask since
166
+ self._asks_tried: dict[str, float] = {} # directory → when a whole read was last tried for a folder not known in full
167
+ self._asks_suspects: dict[str, set[str]] = {} # directory → ask ids whose session was not running at the last check
168
+ self._asks_reconcile_s = asks_reconcile_ms / 1000
169
+ # Attachments, the Claude Code way (docs/code-attachments.md): files a turn carries are saved here, per session.
170
+ self._attach_root = Path(attach_root) if attach_root else default_root()
171
+ self._attach_max = attach_max_file_bytes
172
+ self._ruled: set[str] = set()
173
+ self._pruned_at = time.time()
174
+ self.name = pairing.get("name") if _js.truthy(pairing.get("name")) else hostname()
175
+ self.base = re.sub(r"/+\Z", "", _js.js_string(pairing.get("opencodeUrl") or DEFAULT_OPENCODE_URL))
176
+ self._client = client
177
+ self._flush_s = flush_ms / 1000
178
+ self._timeout_ms = request_timeout_ms
179
+ self._max_response = max_response_bytes
180
+ self._log = log
181
+ self._inflight: dict[str, _Inflight] = {}
182
+ self._subs: dict[str, _Sub] = {}
183
+ self._tasks: set[asyncio.Task] = set()
184
+ self._stopping = False
185
+ # ★ REPLAY ACROSS RESTARTS. The frame layer drops a replay only while this process remembers the sender, so a
186
+ # sealed request recorded earlier (by anyone on the path — the relay included) could be re-sent after a restart
187
+ # and run again. Each socket therefore announces a fresh random nonce inside its sealed hello, and a request
188
+ # must carry the CURRENT one: a recording from before a restart or reconnect carries a nonce nobody accepts.
189
+ self.nonce = ""
190
+ self.peer = RelayPeer(secret=pairing["secret"], role="computer", relay=pairing.get("relay") or RELAY_URL,
191
+ on_state=self._on_state, on_peers=self._on_peers, on_message=self._on_message, connect=connect,
192
+ # A page socket fell out of the replay window: its recorded frames would open again — rotate the
193
+ # nonce so every request it ever sealed is refused, and tell the pages (they re-ask reads).
194
+ on_evict=lambda _sender: self._rotate(), max_senders=max_senders)
195
+ # Auto mode's loop for this pairing (docs/code-auto-mode.md) — its state is read now, so the first hello lists it.
196
+ safe_id = _js.utf16_slice(re.sub(r"[^A-Za-z0-9_-]", "_", _js.js_string(pairing.get("computerId") or "default")), 0, 64)
197
+ auto_dir = Path(auto_dir) if auto_dir else default_auto_dir()
198
+ self._output_log = auto_dir / "output-log.jsonl" # each preview fetch: a digest of the path, never the path
199
+ self._seen_path = auto_dir / f"seen-{safe_id}.json" # what the person has seen, shared by their devices (seen.py)
200
+ self._seen: dict | None = None
201
+ self.auto = AutoRunner(base=self.base, auth=self._auth, client=client, poll_ms=auto_poll_ms, log=log,
202
+ state_path=auto_dir / f"auto-{safe_id}.json", log_path=auto_dir / "auto-log.jsonl",
203
+ on_verdict=lambda v: self.peer.send({"t": "autoverdict", **v, "ts": int(time.time() * 1000)}),
204
+ list_asks=self._pending_asks)
205
+
206
+ # ── lifecycle ─────────────────────────────────────────────────────────────────────────────────────────────────────
207
+ async def start(self) -> None:
208
+ self._spawn(self._every(SWEEP_EVERY_S, self._sweep))
209
+ self._spawn(self._every(HELLO_EVERY_S, lambda: self.hello() if self.peer.peers >= 2 else None))
210
+ self._spawn(self._watch_asks())
211
+ self._spawn(self._every(self._asks_reconcile_s, lambda: self._spawn(self._reconcile_asks()) if self._asks_live else None))
212
+ self.auto.start()
213
+ await self.peer.start()
214
+
215
+ def stop(self) -> None:
216
+ self._stopping = True
217
+ self.auto.stop()
218
+ for p in list(self._subs):
219
+ self._unsubscribe(p, _ALL)
220
+ for c in list(self._inflight.values()):
221
+ c.abort()
222
+ for t in list(self._tasks):
223
+ t.cancel()
224
+ self.peer.stop()
225
+
226
+ async def aclose(self) -> None:
227
+ self.stop()
228
+ await self.peer.aclose()
229
+ if self._tasks:
230
+ await asyncio.wait(list(self._tasks), timeout=5)
231
+
232
+ def _spawn(self, coro: Any) -> asyncio.Task:
233
+ t = asyncio.get_running_loop().create_task(coro)
234
+ self._tasks.add(t)
235
+ t.add_done_callback(self._tasks.discard)
236
+ return t
237
+
238
+ @staticmethod
239
+ async def _every(seconds: float, fn: Callable[[], Any]) -> None:
240
+ while True:
241
+ await asyncio.sleep(seconds)
242
+ fn()
243
+
244
+ # ── relay events ──────────────────────────────────────────────────────────────────────────────────────────────────
245
+ def _on_state(self, s: str) -> None:
246
+ self._log(f"witbitz-code: {self.name} · relay {s}")
247
+ if s == "open":
248
+ self._rotate()
249
+
250
+ def _rotate(self) -> None:
251
+ self.nonce = _js.b64u(os.urandom(18))
252
+ self.hello()
253
+
254
+ def _on_peers(self, n: int) -> None:
255
+ if n >= 2:
256
+ self.hello()
257
+ else:
258
+ for p in list(self._subs):
259
+ self._unsubscribe(p, _ALL) # nobody is watching: close OpenCode's streams
260
+
261
+ def _on_message(self, m: Any) -> None:
262
+ self._spawn(self._handle_logged(m))
263
+
264
+ async def _handle_logged(self, m: Any) -> None:
265
+ try:
266
+ await self._handle(m)
267
+ except asyncio.CancelledError:
268
+ raise
269
+ except Exception as e:
270
+ self._log(f"witbitz-code: {self.name} · {e}")
271
+
272
+ def hello(self) -> asyncio.Future | None:
273
+ if not self.nonce:
274
+ return None # no hello before this socket has its nonce
275
+ # caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
276
+ return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
277
+ "k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders"], "auto": self.auto.sessions()})
278
+
279
+ def _auth(self) -> dict:
280
+ pw = self.pairing.get("password") or read_env_password(Path(self.pairing["envFile"]) if self.pairing.get("envFile") else None)
281
+ return {"authorization": "Basic " + base64.b64encode(f"opencode:{pw}".encode()).decode()} if pw else {}
282
+
283
+ async def _handle(self, m: Any) -> None:
284
+ if not isinstance(m, dict) or not isinstance(m.get("t"), str):
285
+ return
286
+ t = m["t"]
287
+ if t == "ping":
288
+ self.hello()
289
+ return
290
+ current = isinstance(m.get("k"), str) and m["k"] == self.nonce
291
+ if t == "cancel":
292
+ c = self._inflight.get(m.get("id")) if current and isinstance(m.get("id"), str) else None
293
+ if c:
294
+ c.abort()
295
+ elif t == "sub":
296
+ if current:
297
+ self._subscribe(m.get("p"), m.get("c"))
298
+ elif t == "unsub":
299
+ if current:
300
+ self._unsubscribe(m.get("p"), m.get("c")) # no id = the anonymous interest, as in sub
301
+ elif t == "auto":
302
+ # Auto mode on/off for one session — nonce-checked like a request (a recording cannot switch it). No new power: a
303
+ # page that can switch Auto could already answer the same asks itself.
304
+ if current and self.auto.set_auto(m.get("sid"), m.get("dir"), _js.truthy(m.get("on"))):
305
+ self.hello()
306
+ elif t == "tools":
307
+ # Which suggested tools this computer has (spaces/public/codeTools.js) — a PATH lookup, nonce-checked like a request.
308
+ if current:
309
+ await self.peer.send({"t": "tools", **probe_tools()})
310
+ elif t == "req":
311
+ await self._request(m, current)
312
+
313
+ # ── requests ──────────────────────────────────────────────────────────────────────────────────────────────────────
314
+ async def _request(self, m: dict, current: bool) -> None:
315
+ rid = m["id"] if isinstance(m.get("id"), str) else ""
316
+ if not rid:
317
+ return
318
+
319
+ def reply(st: int, b: Any) -> asyncio.Future:
320
+ return self.peer.send({"t": "res", "id": rid, "st": st, "b": b if isinstance(b, str) else _js.stringify(b)})
321
+
322
+ # Refuse rather than truncate: a shortened id could collide, and `cancel` would look up the long one.
323
+ if _js.utf16_len(rid) > 64:
324
+ await reply(400, {"error": "request id longer than 64 characters"})
325
+ return
326
+ if not current:
327
+ await reply(409, {"error": "stale: this computer's connector changed — reconnecting"})
328
+ return
329
+ p = m.get("p") if isinstance(m.get("p"), str) else ""
330
+ bare, _, query = p.partition("?")
331
+ # A saved attachment, for the page's chip (preview, download): answered HERE from the attachments folder, never forwarded.
332
+ if m.get("m") == "GET" and bare == ATTACHMENT_ROUTE:
333
+ q = dict(urllib.parse.parse_qsl(query))
334
+ st, b = serve_attachment(root=self._attach_root, session=q.get("session"), file=q.get("file"))
335
+ await reply(st, b)
336
+ return
337
+ # A file a reply produced, for the page's preview under it (outputs.py): answered HERE, from the folder OpenCode
338
+ # reports for the session — never one the page names — and logged on this computer as a digest of the path.
339
+ if m.get("m") == "GET" and bare == OUTPUT_ROUTE:
340
+ q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
341
+ sid = q.get("session", "")
342
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", sid):
343
+ await reply(400, {"error": "not a session id"})
344
+ return
345
+ dir_query = "directory=" + urllib.parse.quote(q["directory"], safe="!'()*") if q.get("directory") else "" # encodeURIComponent
346
+ session = await self._session_for(sid, dir_query)
347
+ if not session or not isinstance(session.get("directory"), str):
348
+ await reply(404, {"error": "no such session on this computer"})
349
+ return
350
+ stat_only = q.get("stat") == "1"
351
+ st, b = serve_output(directory=session["directory"], path=q.get("path"), stat=stat_only)
352
+ self._log_output({"at": int(time.time() * 1000), "session": sid, "digest": hashlib.sha256(q.get("path", "").encode()).hexdigest(),
353
+ "stat": stat_only, "st": st})
354
+ await reply(st, b)
355
+ return
356
+ # What the person has seen, shared by their devices (seen.py): answered HERE and kept on this computer.
357
+ if bare == SEEN_ROUTE and m.get("m") in ("GET", "POST"):
358
+ if m.get("m") == "GET":
359
+ await reply(200, self._seen_record())
360
+ return
361
+ body = m.get("b")
362
+ if not isinstance(body, str) or _js.utf16_len(body) > 1_000_000:
363
+ await reply(413, {"error": "a seen record is at most 1 MB"})
364
+ return
365
+ try:
366
+ incoming = json.loads(body)
367
+ except ValueError:
368
+ incoming = None
369
+ if not isinstance(incoming, dict):
370
+ await reply(400, {"error": "not a seen record"})
371
+ return
372
+ current = self._seen_record()
373
+ merged = merge_seen(current, norm_seen(incoming))
374
+ if merged is not current:
375
+ self._seen = merged
376
+ self._save_seen()
377
+ await reply(200, merged)
378
+ return
379
+ # "ALLOW THIS FOLDER" (folders.py): the folder a waiting outside-the-project ask belongs to, and allowing it for the
380
+ # conversation. Answered HERE; the folder comes from the ask and the disk, never from the page.
381
+ if bare == FOLDER_ROUTE and m.get("m") in ("GET", "POST"):
382
+ st, b = await folder_route(method=m["m"], query=query, body=m.get("b"), pending=self._pending_asks, call=self._opencode_json,
383
+ log=lambda line: self._log(f"opencode-connector: {self.name} · {line}"))
384
+ await reply(st, b)
385
+ return
386
+ if not allowed_request(m.get("m"), m.get("p")):
387
+ await reply(403, {"error": "not allowed by the connector"})
388
+ return
389
+ # What is still waiting (the page's card recovery): OpenCode's list when it can give one, else the asks its event
390
+ # stream carried (asks.py). None: forwarded as before.
391
+ if m.get("m") == "GET" and bare == "/permission":
392
+ directory = urllib.parse.parse_qs(query).get("directory", [""])[0]
393
+ pending = await self._pending_asks(directory) if directory else None
394
+ if pending is not None:
395
+ await reply(200, pending)
396
+ return
397
+ method, path, body = m["m"], m["p"], m.get("b")
398
+ # ATTACHMENTS: the files a turn carries are saved on this computer and the message names them.
399
+ turn_of = re.fullmatch(r"/session/([^/]+)/message", bare) if method == "POST" else None
400
+ # PROJECT NOTES, every turn, only where the plugin is installed: reads of ITS project's notes folder and writes into
401
+ # notes/ ask nothing, whatever the model — one note used to cost FOUR approvals (measured 2026-09-14). AGENTS.md —
402
+ # injected as INSTRUCTIONS — still asks for every edit. The twin of ruleNotes in tools/opencode-connector.mjs.
403
+ if turn_of and self._notes_plugin.exists():
404
+ await self._rule_notes(turn_of.group(1), query)
405
+ if turn_of and isinstance(body, str) and '"file"' in body:
406
+ try:
407
+ parsed = _js.parse(body)
408
+ except Exception: # OpenCode answers a malformed body itself
409
+ parsed = None
410
+ has_files = isinstance(parsed, dict) and isinstance(parsed.get("parts"), list) and any(
411
+ isinstance(x, dict) and x.get("type") == "file" and str(x.get("url") or "").startswith("data:") for x in parsed["parts"])
412
+ if has_files:
413
+ # Only for a session OpenCode has — an invented id must not get a folder (security review: disk filling).
414
+ known = await self._session_for(turn_of.group(1), query)
415
+ if known is None:
416
+ await reply(404, {"error": "no such session on this computer"})
417
+ return
418
+ self._prune_daily()
419
+ staged = stage_message_body(parsed, session_id=turn_of.group(1), root=self._attach_root, max_file_bytes=self._attach_max)
420
+ if staged and "error" in staged:
421
+ await reply(staged["error"]["status"], {"error": staged["error"]["message"]})
422
+ return
423
+ if staged:
424
+ await self._allow_attachment_reads(turn_of.group(1), query, known)
425
+ body = _js.stringify(staged["body"])
426
+ ctrl = _Inflight()
427
+ self._inflight[rid] = ctrl
428
+ # A turn POST returns only when the model has finished; it has no timeout (its progress rides the event stream).
429
+ turn = method == "POST" and path.split("?")[0].endswith("/message")
430
+ timer = None if turn else asyncio.get_running_loop().call_later(self._timeout_ms / 1000, ctrl.time_out)
431
+ ctrl.task = asyncio.get_running_loop().create_task(self._forward(method, path, body))
432
+ try:
433
+ try:
434
+ status, text = await asyncio.shield(ctrl.task)
435
+ except asyncio.CancelledError:
436
+ if not ctrl.aborted or self._stopping: # the connector itself is stopping: no answer, just go
437
+ ctrl.task.cancel()
438
+ raise
439
+ raise _Aborted() from None
440
+ except _TooLarge:
441
+ mb = _round(self._max_response / 1048576) or "<1"
442
+ await reply(413, {"error": f"OpenCode's answer is over {mb} MB — too large to send through the relay"})
443
+ return
444
+ except (_Aborted, Exception):
445
+ if ctrl.timed_out:
446
+ await reply(504, {"error": f"OpenCode did not answer within {_round(self._timeout_ms / 1000)} s"})
447
+ elif ctrl.aborted:
448
+ await reply(499, {"error": "cancelled"})
449
+ else:
450
+ await reply(502, {"error": f"OpenCode is not answering at {self.base} — start it on that computer: {START_HINT}"})
451
+ return
452
+ finally:
453
+ if timer is not None:
454
+ timer.cancel()
455
+ if self._inflight.get(rid) is ctrl:
456
+ del self._inflight[rid]
457
+ # /config and /config/providers carry API keys: rebuilt from an allowlist of fields before they leave (relay.py).
458
+ status, text = project_response(method, path, status, text)
459
+ gone = re.fullmatch(r"/session/([^/]+)", bare) if method == "DELETE" and 200 <= status < 300 else None
460
+ if gone:
461
+ remove_session_attachments(self._attach_root, gone.group(1)) # a deleted session takes its saved files along
462
+ await reply(status, text)
463
+
464
+ def _seen_record(self) -> dict:
465
+ if self._seen is None:
466
+ try:
467
+ self._seen = norm_seen(json.loads(self._seen_path.read_text(encoding="utf-8")))
468
+ except (OSError, ValueError):
469
+ self._seen = norm_seen(None)
470
+ return self._seen
471
+
472
+ def _save_seen(self) -> None:
473
+ try:
474
+ self._seen_path.parent.mkdir(parents=True, exist_ok=True)
475
+ tmp = self._seen_path.with_name(self._seen_path.name + ".tmp")
476
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
477
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
478
+ f.write(_js.stringify(self._seen))
479
+ os.replace(tmp, self._seen_path)
480
+ except OSError as e:
481
+ self._log(f"witbitz-code: {self.name} · could not keep what was seen ({e})")
482
+
483
+ def _log_output(self, rec: dict) -> None:
484
+ try:
485
+ self._output_log.parent.mkdir(parents=True, exist_ok=True)
486
+ fd = os.open(self._output_log, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
487
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
488
+ f.write(json.dumps(rec, separators=(",", ":")) + "\n")
489
+ except OSError:
490
+ pass # a log never breaks a preview
491
+
492
+ async def _session_for(self, sid: str, query: str) -> dict | None:
493
+ """The session as OpenCode has it, or None when it has no such session (or cannot say)."""
494
+ try:
495
+ r = await self._client.get(f"{self.base}/session/{sid}" + (f"?{query}" if query else ""), headers=self._auth())
496
+ if r.status_code >= 300:
497
+ return None
498
+ s = r.json()
499
+ return s if isinstance(s, dict) else None
500
+ except Exception: # noqa: BLE001
501
+ return None
502
+
503
+ async def _rule_notes(self, sid: str, query: str) -> None:
504
+ s = await self._session_for(sid, query)
505
+ want = notes_rules(s, self._notes_root) if s else []
506
+ if not want:
507
+ return
508
+ have = s.get("permission") if isinstance(s.get("permission"), list) else []
509
+
510
+ def effective(rule: dict) -> Any: # the last matching rule wins, as in OpenCode
511
+ action = None
512
+ for r in have:
513
+ if isinstance(r, dict) and r.get("permission") == rule["permission"] and r.get("pattern") == rule["pattern"]:
514
+ action = r.get("action")
515
+ return action
516
+
517
+ missing = [rule for rule in want if effective(rule) != rule["action"]]
518
+ if not missing:
519
+ return
520
+ url = f"{self.base}/session/{sid}" + (f"?{query}" if query else "")
521
+ try:
522
+ p = await self._client.patch(url, headers={**self._auth(), "content-type": "application/json"}, content=_js.utf8(_js.stringify({"permission": missing})))
523
+ if p.status_code >= 300:
524
+ raise RuntimeError(f"PATCH answered {p.status_code}")
525
+ except Exception as e: # noqa: BLE001 — reading and writing notes will ask; the turn still goes
526
+ self._log(f"opencode-connector: {self.name} · could not allow project notes reads for {sid} ({e}) — reading them will ask")
527
+
528
+ def _prune_daily(self) -> None:
529
+ if time.time() - self._pruned_at < 86400:
530
+ return
531
+ self._pruned_at = time.time()
532
+ try:
533
+ prune_attachments(self._attach_root)
534
+ except OSError:
535
+ pass
536
+
537
+ async def _allow_attachment_reads(self, sid: str, query: str, s: dict) -> None:
538
+ """One rule per session lets the agent read THAT session's folder (external_directory) — and nothing next to it.
539
+ Measured: PATCH /session/:id APPENDS the rule to a session that already exists."""
540
+ if sid in self._ruled:
541
+ return
542
+ rule = attachment_rule(self._attach_root, sid)
543
+ url = f"{self.base}/session/{sid}" + (f"?{query}" if query else "")
544
+ try:
545
+ perms = s.get("permission")
546
+ has = isinstance(perms, list) and any(isinstance(x, dict) and x.get("permission") == rule["permission"] and x.get("pattern") == rule["pattern"] and x.get("action") == "allow" for x in perms)
547
+ if not has:
548
+ p = await self._client.patch(url, headers={**self._auth(), "content-type": "application/json"}, content=_js.utf8(_js.stringify({"permission": [rule]})))
549
+ if p.status_code >= 300:
550
+ raise RuntimeError(f"PATCH answered {p.status_code}")
551
+ self._ruled.add(sid)
552
+ except Exception as e: # noqa: BLE001 — reading the files will ask; the turn still goes
553
+ self._log(f"opencode-connector: {self.name} · could not allow attachment reads for {sid} ({e}) — reading them will ask")
554
+
555
+ async def _forward(self, method: Any, path: str, body: Any) -> tuple[int, str]:
556
+ if not isinstance(method, str) or not _TOKEN.fullmatch(method):
557
+ raise ValueError("not an HTTP method")
558
+ headers = {**self._auth(), **({"content-type": "application/json"} if isinstance(body, str) else {})}
559
+ content = _js.utf8(body) if isinstance(body, str) else None
560
+ # Read with a cap: refuse while reading, never after holding an unbounded answer in memory.
561
+ async with self._client.stream(method, self.base + path, headers=headers, content=content) as r:
562
+ dec = codecs.getincrementaldecoder("utf-8-sig")("replace") # TextDecoder: BOM dropped, bad bytes replaced
563
+ parts, size = [], 0
564
+ async for chunk in r.aiter_bytes():
565
+ size += len(chunk)
566
+ if size > self._max_response:
567
+ raise _TooLarge() # leaving the stream context drops the connection: OpenCode stops sending
568
+ parts.append(dec.decode(chunk))
569
+ parts.append(dec.decode(b"", final=True))
570
+ return r.status_code, "".join(parts)
571
+
572
+ # ── what is waiting (asks.py) ─────────────────────────────────────────────────────────────────────────────────────
573
+ async def _opencode(self, method: str, path: str, directory: str, body: Any = None) -> tuple[int, str]:
574
+ url = f"{self.base}{path}{'&' if '?' in path else '?'}directory={urllib.parse.quote(directory, safe=_URI_SAFE)}"
575
+ headers = {**self._auth(), **({"content-type": "application/json"} if body is not None else {})}
576
+ r = await asyncio.wait_for(self._client.request(method, url, headers=headers, content=_js.utf8(_js.stringify(body)) if body is not None else None),
577
+ self._timeout_ms / 1000)
578
+ return r.status_code, r.text
579
+
580
+ async def _opencode_json(self, method: str, path: str, directory: str, body: Any = None) -> tuple[bool, int, Any]:
581
+ status, text = await self._opencode(method, path, directory, body)
582
+ try:
583
+ data = _js.parse(text)
584
+ except ValueError:
585
+ data = None
586
+ return 200 <= status < 300, status, data
587
+
588
+ async def _settle_dead(self, dead: list[tuple[str, dict]]) -> None:
589
+ """Reply to asks whose turn was stopped: OpenCode keeps them otherwise, and one broken ask keeps its folder's list broken."""
590
+ for directory, ask in dead:
591
+ try:
592
+ status, _ = await self._opencode("POST", f"/permission/{urllib.parse.quote(ask['id'], safe=_URI_SAFE)}/reply", directory, {"reply": "reject"})
593
+ except asyncio.CancelledError:
594
+ raise
595
+ except Exception:
596
+ continue # OpenCode restarting: the ask went with it
597
+ if 200 <= status < 300:
598
+ self._log(f"opencode-connector: {self.name} · cleared a {ask.get('permission') or 'permission'} approval a stopped turn left waiting")
599
+
600
+ async def _pending_asks(self, directory: str) -> list | None:
601
+ """OpenCode's list of waiting asks in a folder; the recorded ones when that list fails; None when neither can say.
602
+ The book answers only for a folder it has followed since a whole read, on the same connection: an ask from before
603
+ that is unknown to it, and a list missing it would read as "answered" (the page drops the card, Auto a refusal)."""
604
+ epoch = self._asks_epoch if self._asks_live else -1
605
+ try:
606
+ status, text = await self._opencode("GET", "/permission", directory)
607
+ except asyncio.CancelledError:
608
+ raise
609
+ except Exception:
610
+ return None # OpenCode is not answering
611
+ try:
612
+ data = _js.parse(text)
613
+ except ValueError:
614
+ data = None
615
+ if 200 <= status < 300 and isinstance(data, list):
616
+ self._asks.merge(directory, data)
617
+ if epoch >= 0 and self._asks_live and self._asks_epoch == epoch:
618
+ self._asks_complete[directory] = epoch
619
+ return data
620
+ return self._asks.list(directory) if self._asks_live and self._asks_complete.get(directory) == self._asks_epoch else None
621
+
622
+ def _learn_folder(self, directory: str) -> None:
623
+ """A folder with activity whose list the book does not know in full: read it now, while no broken ask is in it yet."""
624
+ if not directory or self._asks_complete.get(directory) == self._asks_epoch or time.monotonic() - self._asks_tried.get(directory, -math.inf) < 5:
625
+ return
626
+ self._asks_tried[directory] = time.monotonic()
627
+ self._spawn(self._pending_asks(directory))
628
+
629
+ async def _reconcile_asks(self) -> None:
630
+ """Asks whose session is not running at TWO checks in a row are dead (a stop the stream missed) — one reading never
631
+ rejects a live ask. A stop the stream saw is settled at once by its idle event."""
632
+ for directory in self._asks.directories():
633
+ known = self._asks.list(directory)
634
+ try:
635
+ status, text = await self._opencode("GET", "/session/status", directory)
636
+ data = _js.parse(text) if 200 <= status < 300 else None
637
+ except asyncio.CancelledError:
638
+ raise
639
+ except Exception:
640
+ continue # next time
641
+ if not isinstance(data, dict):
642
+ continue
643
+ busy = [sid for sid, v in data.items() if _js.truthy(v) and not (isinstance(v, dict) and v.get("type") == "idle")]
644
+ before = self._asks_suspects.get(directory, set())
645
+ not_running = [a["id"] for a in known if a["sessionID"] not in busy]
646
+ self._asks_suspects[directory] = set(not_running)
647
+ await self._settle_dead(self._asks.settle_idle(directory, busy, [i for i in not_running if i in before]))
648
+ for directory in [d for d in self._asks_suspects if d not in self._asks.directories()]:
649
+ del self._asks_suspects[directory]
650
+
651
+ def _on_ask_frame(self, data: str) -> None:
652
+ if not ASK_FRAME.search(data):
653
+ return
654
+ try:
655
+ frame = _js.parse(data)
656
+ except ValueError:
657
+ return
658
+ dead = self._asks.apply(frame)
659
+ if dead:
660
+ self._spawn(self._settle_dead(dead))
661
+ self._learn_folder(frame.get("directory") if isinstance(frame, dict) and isinstance(frame.get("directory"), str) else "")
662
+
663
+ async def _watch_asks(self) -> None:
664
+ wait = 1.0
665
+ while not self._stopping:
666
+ try:
667
+ headers = {**self._auth(), "accept": "text/event-stream"}
668
+ async with self._client.stream("GET", self.base + "/global/event", headers=headers) as r:
669
+ if not 200 <= r.status_code < 300 or "event-stream" not in r.headers.get("content-type", ""):
670
+ raise RuntimeError(f"global event stream {r.status_code}")
671
+ self._asks_epoch += 1
672
+ self._asks_live = True
673
+ wait = 1.0
674
+ self._spawn(self._reconcile_asks()) # a turn stopped while the stream was down
675
+ feed = sse_reader(self._on_ask_frame)
676
+ dec = codecs.getincrementaldecoder("utf-8-sig")("replace")
677
+ async for chunk in r.aiter_bytes():
678
+ feed(dec.decode(chunk))
679
+ except asyncio.CancelledError:
680
+ raise
681
+ except Exception:
682
+ pass # OpenCode restarted, or is not running yet, or has no /global/event
683
+ finally:
684
+ self._asks_live = False
685
+ await asyncio.sleep(wait)
686
+ wait = min(wait * 2, 30.0)
687
+
688
+ # ── events ────────────────────────────────────────────────────────────────────────────────────────────────────────
689
+ # One OpenCode stream per path, shared by every device watching it; each device (its client id `c`) renews its own
690
+ # interest, so one device leaving never closes the stream another is still reading.
691
+ def _subscribe(self, p: Any, c: Any = None) -> None:
692
+ if not allowed_event_path(p):
693
+ return
694
+ cid = _client_id(c)
695
+ existing = self._subs.get(p)
696
+ if existing:
697
+ existing.clients[cid] = time.monotonic()
698
+ return
699
+ sub = _Sub(clients={cid: time.monotonic()})
700
+ self._subs[p] = sub
701
+ sub.task = self._spawn(self._pump(p, sub))
702
+
703
+ def _unsubscribe(self, p: Any, c: Any) -> None:
704
+ """One client leaves (no id = the anonymous '_'), or — internally, with _ALL — everyone."""
705
+ sub = self._subs.get(p) if isinstance(p, str) else None
706
+ if not sub:
707
+ return
708
+ if c is not _ALL:
709
+ sub.clients.pop(_client_id(c), None)
710
+ if sub.clients:
711
+ return
712
+ del self._subs[p]
713
+ if sub.timer:
714
+ sub.timer.cancel()
715
+ if sub.task:
716
+ sub.task.cancel()
717
+
718
+ def _flush(self, p: str, sub: _Sub) -> None:
719
+ sub.timer = None
720
+ if not sub.buffer:
721
+ return
722
+ batch, sub.buffer = sub.buffer, []
723
+ self.peer.send({"t": "evts", "p": p, "b": _js.stringify(batch)})
724
+
725
+ async def _pump(self, p: str, sub: _Sub) -> None:
726
+ loop = asyncio.get_running_loop()
727
+
728
+ def on_data(data: str) -> None:
729
+ sub.buffer.append(data)
730
+ if not sub.timer:
731
+ sub.timer = loop.call_later(self._flush_s, self._flush, p, sub)
732
+
733
+ while self._subs.get(p) is sub:
734
+ try:
735
+ headers = {**self._auth(), "accept": "text/event-stream"}
736
+ async with self._client.stream("GET", self.base + p, headers=headers) as r:
737
+ if not 200 <= r.status_code < 300:
738
+ raise RuntimeError(f"event stream {r.status_code}")
739
+ feed = sse_reader(on_data)
740
+ dec = codecs.getincrementaldecoder("utf-8-sig")("replace")
741
+ async for chunk in r.aiter_bytes():
742
+ feed(dec.decode(chunk))
743
+ except asyncio.CancelledError:
744
+ raise
745
+ except Exception:
746
+ pass # OpenCode restarted, or is not running yet
747
+ if self._subs.get(p) is not sub:
748
+ break
749
+ await asyncio.sleep(1) # OpenCode went away mid-stream: try again while someone watches
750
+
751
+ def _sweep(self) -> None:
752
+ now = time.monotonic()
753
+ for p, s in list(self._subs.items()):
754
+ for cid, at in list(s.clients.items()):
755
+ if now - at > SUB_TTL_S:
756
+ del s.clients[cid]
757
+ if not s.clients:
758
+ self._unsubscribe(p, _ALL)
759
+
760
+
761
+ class _Aborted(Exception):
762
+ pass
763
+
764
+
765
+ @dataclass
766
+ class Connector:
767
+ servers: list[PairingServer]
768
+ client: httpx.AsyncClient
769
+
770
+ @property
771
+ def peers(self) -> list[RelayPeer]:
772
+ return [s.peer for s in self.servers]
773
+
774
+ def stop(self) -> None:
775
+ for s in self.servers:
776
+ s.stop()
777
+
778
+ async def aclose(self) -> None:
779
+ for s in self.servers:
780
+ await s.aclose()
781
+ await self.client.aclose()
782
+
783
+
784
+ def _log_stderr(msg: str) -> None:
785
+ print(msg, file=sys.stderr, flush=True)
786
+
787
+
788
+ def local_client() -> httpx.AsyncClient:
789
+ # trust_env=False: OpenCode is on 127.0.0.1 and a proxy from the environment must never see its password.
790
+ # No client timeout: the 30 s request timer covers the whole call, and an event stream is open-ended.
791
+ return httpx.AsyncClient(trust_env=False, follow_redirects=True, timeout=httpx.Timeout(None, connect=10.0))
792
+
793
+
794
+ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: Callable[[str], Any] = _log_stderr,
795
+ client: httpx.AsyncClient | None = None, connect: Connect | None = None,
796
+ request_timeout_ms: float = REQUEST_TIMEOUT_MS, max_senders: int = 64,
797
+ max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
798
+ attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
799
+ asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
800
+ notes_plugin_path: Path | None = None) -> Connector:
801
+ """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
802
+ request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
803
+ client = client or local_client()
804
+ try:
805
+ pruned = prune_attachments(Path(attach_root) if attach_root else default_root())
806
+ if pruned:
807
+ log(f"opencode-connector: removed {pruned} attachment folder(s) untouched for 30 days")
808
+ except OSError:
809
+ pass
810
+ servers = []
811
+ for p in pairings:
812
+ s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
813
+ max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
814
+ attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
815
+ notes_root=notes_root, notes_plugin_path=notes_plugin_path)
816
+ await s.start()
817
+ servers.append(s)
818
+ return Connector(servers, client)