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.
- witbitz_code/__init__.py +7 -0
- witbitz_code/__main__.py +3 -0
- witbitz_code/_js.py +312 -0
- witbitz_code/account.py +127 -0
- witbitz_code/asks.py +119 -0
- witbitz_code/attachments.py +266 -0
- witbitz_code/auto.py +561 -0
- witbitz_code/auto_runner.py +481 -0
- witbitz_code/cli.py +223 -0
- witbitz_code/codetools.py +128 -0
- witbitz_code/compress.py +25 -0
- witbitz_code/computers.py +189 -0
- witbitz_code/connector.py +818 -0
- witbitz_code/devicelink.py +159 -0
- witbitz_code/folders.py +250 -0
- witbitz_code/link.py +205 -0
- witbitz_code/net.py +67 -0
- witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code/outputs.py +115 -0
- witbitz_code/pair.py +222 -0
- witbitz_code/pairings.py +214 -0
- witbitz_code/plugins.py +140 -0
- witbitz_code/policy.py +48 -0
- witbitz_code/qr.py +59 -0
- witbitz_code/relay.py +783 -0
- witbitz_code/seen.py +49 -0
- witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0.dist-info/METADATA +145 -0
- witbitz_code-1.2.0.dist-info/RECORD +36 -0
- witbitz_code-1.2.0.dist-info/WHEEL +5 -0
- witbitz_code-1.2.0.dist-info/entry_points.txt +2 -0
- witbitz_code-1.2.0.dist-info/licenses/LICENSE +21 -0
- witbitz_code-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|