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
witbitz_code/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""witbitz-code: reach OpenCode on this computer from the Spaces Code section, end-to-end encrypted.
|
|
2
|
+
|
|
3
|
+
The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phase 5b), held to the JS one by shared test
|
|
4
|
+
vectors and cross-implementation tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.2.0"
|
witbitz_code/__main__.py
ADDED
witbitz_code/_js.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""JavaScript semantics this port has to reproduce, in one place.
|
|
2
|
+
|
|
3
|
+
The JS tools are the reference implementation and the page runs them, so wherever the wire or a shared file depends on
|
|
4
|
+
a JS rule, Python follows the rule rather than its own idiom:
|
|
5
|
+
|
|
6
|
+
- JSON.stringify: compact, integral floats print as integers (1000, not 1000.0), lone UTF-16 surrogates are escaped,
|
|
7
|
+
integer-like object keys come first. Byte-identical output keeps the pairings file and the gzip-size threshold equal.
|
|
8
|
+
- String lengths and slices count UTF-16 code units (chunking at 192 KiB, `name.slice(0, 80)`, `id.slice(0, 64)`). A
|
|
9
|
+
slice may split a surrogate pair exactly as JS does; joining the halves back recombines it.
|
|
10
|
+
- Truthiness, unary `+`, `String(x)`, `Number.isInteger`: the registry and payload readers lean on all of them.
|
|
11
|
+
- atob's "forgiving base64": what the page accepts, Python accepts, and nothing more.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import decimal
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Undefined:
|
|
27
|
+
"""A property that is absent (JS `undefined`), as opposed to JSON null (None)."""
|
|
28
|
+
|
|
29
|
+
_inst = None
|
|
30
|
+
|
|
31
|
+
def __new__(cls):
|
|
32
|
+
if cls._inst is None:
|
|
33
|
+
cls._inst = super().__new__(cls)
|
|
34
|
+
return cls._inst
|
|
35
|
+
|
|
36
|
+
def __bool__(self) -> bool:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return "undefined"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
UNDEFINED = _Undefined()
|
|
44
|
+
|
|
45
|
+
# ECMAScript WhiteSpace + LineTerminator — what `\s` and String.prototype.trim() mean in JS (Python's differ: no U+FEFF).
|
|
46
|
+
WS = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
47
|
+
# JS `.` stops at these; Python's `.` stops only at \n.
|
|
48
|
+
NOT_LT = "[^\n\r\u2028\u2029]"
|
|
49
|
+
_TRIM = re.compile(f"\\A[{WS}]+|[{WS}]+\\Z")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_num(v: Any) -> bool:
|
|
53
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def truthy(v: Any) -> bool:
|
|
57
|
+
if v is None or v is False or v is UNDEFINED:
|
|
58
|
+
return False
|
|
59
|
+
if is_num(v):
|
|
60
|
+
return v == v and v != 0
|
|
61
|
+
if isinstance(v, str):
|
|
62
|
+
return v != ""
|
|
63
|
+
return True # objects and arrays, even empty ones
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_integer(v: Any) -> bool:
|
|
67
|
+
"""Number.isInteger — 42.0 from JSON counts, exactly as JSON.parse("42.0") === 42 in JS."""
|
|
68
|
+
if not is_num(v):
|
|
69
|
+
return False
|
|
70
|
+
return isinstance(v, int) or (math.isfinite(v) and float(v).is_integer())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_safe_integer(v: Any) -> bool:
|
|
74
|
+
return is_integer(v) and abs(v) <= MAX_SAFE_INTEGER
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def strict_eq(a: Any, b: Any) -> bool:
|
|
78
|
+
"""`===` for JSON values: same kind and equal; objects by identity."""
|
|
79
|
+
if is_num(a) and is_num(b):
|
|
80
|
+
return a == b
|
|
81
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
82
|
+
return a == b
|
|
83
|
+
if isinstance(a, bool) and isinstance(b, bool):
|
|
84
|
+
return a == b
|
|
85
|
+
return a is b
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def trim(s: str) -> str:
|
|
89
|
+
return _TRIM.sub("", s)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── numbers ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
_DEC = re.compile(r"[+-]?(?:Infinity|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _string_to_number(s: str) -> float:
|
|
97
|
+
t = trim(s)
|
|
98
|
+
if t == "":
|
|
99
|
+
return 0
|
|
100
|
+
if _DEC.fullmatch(t):
|
|
101
|
+
return float(t.replace("Infinity", "inf"))
|
|
102
|
+
for prefix, base, digits in (("0x", 16, "[0-9a-fA-F]"), ("0o", 8, "[0-7]"), ("0b", 2, "[01]")):
|
|
103
|
+
if re.fullmatch(f"0[{prefix[1]}{prefix[1].upper()}]{digits}+", t):
|
|
104
|
+
return float(int(t[2:], base))
|
|
105
|
+
return math.nan
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def to_number(v: Any) -> float:
|
|
109
|
+
"""Unary `+v`."""
|
|
110
|
+
if v is UNDEFINED:
|
|
111
|
+
return math.nan
|
|
112
|
+
if v is None or v is False:
|
|
113
|
+
return 0
|
|
114
|
+
if v is True:
|
|
115
|
+
return 1
|
|
116
|
+
if is_num(v):
|
|
117
|
+
return v
|
|
118
|
+
if isinstance(v, str):
|
|
119
|
+
return _string_to_number(v)
|
|
120
|
+
if isinstance(v, (list, tuple)):
|
|
121
|
+
return _string_to_number(js_string(v))
|
|
122
|
+
return math.nan
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def number_str(x: float) -> str:
|
|
126
|
+
"""Number.prototype.toString() — shortest round-trip digits, JS exponent rules."""
|
|
127
|
+
if isinstance(x, int) and abs(x) <= MAX_SAFE_INTEGER:
|
|
128
|
+
return str(x)
|
|
129
|
+
x = float(x)
|
|
130
|
+
if x != x:
|
|
131
|
+
return "NaN"
|
|
132
|
+
if math.isinf(x):
|
|
133
|
+
return "Infinity" if x > 0 else "-Infinity"
|
|
134
|
+
if x == 0:
|
|
135
|
+
return "0"
|
|
136
|
+
sign = "-" if x < 0 else ""
|
|
137
|
+
d = decimal.Decimal(repr(abs(x))).normalize()
|
|
138
|
+
_, digits, exp = d.as_tuple()
|
|
139
|
+
s = "".join(map(str, digits))
|
|
140
|
+
k, n = len(s), len(s) + exp
|
|
141
|
+
if k <= n <= 21:
|
|
142
|
+
return sign + s + "0" * (n - k)
|
|
143
|
+
if 0 < n <= 21:
|
|
144
|
+
return sign + s[:n] + "." + s[n:]
|
|
145
|
+
if -6 < n <= 0:
|
|
146
|
+
return sign + "0." + "0" * (-n) + s
|
|
147
|
+
e = n - 1
|
|
148
|
+
mant = s if k == 1 else s[0] + "." + s[1:]
|
|
149
|
+
return f"{sign}{mant}e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def js_string(v: Any) -> str:
|
|
153
|
+
"""String(v)."""
|
|
154
|
+
if v is UNDEFINED:
|
|
155
|
+
return "undefined"
|
|
156
|
+
if v is None:
|
|
157
|
+
return "null"
|
|
158
|
+
if v is True:
|
|
159
|
+
return "true"
|
|
160
|
+
if v is False:
|
|
161
|
+
return "false"
|
|
162
|
+
if is_num(v):
|
|
163
|
+
return number_str(v)
|
|
164
|
+
if isinstance(v, str):
|
|
165
|
+
return v
|
|
166
|
+
if isinstance(v, (list, tuple)):
|
|
167
|
+
return ",".join("" if e is None or e is UNDEFINED else js_string(e) for e in v)
|
|
168
|
+
return "[object Object]"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clean_number(v: float) -> float:
|
|
172
|
+
"""A computed number as JS would hold it for JSON: integral floats become ints (so they print as 5, not 5.0)."""
|
|
173
|
+
if isinstance(v, float) and math.isfinite(v) and v.is_integer() and abs(v) <= MAX_SAFE_INTEGER:
|
|
174
|
+
return int(v)
|
|
175
|
+
return v
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── UTF-16 ────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
def utf16_len(s: str) -> int:
|
|
180
|
+
return len(s.encode("utf-16-le", "surrogatepass")) // 2
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def utf16_slice(s: str, start: int, end: int | None = None) -> str:
|
|
184
|
+
b = s.encode("utf-16-le", "surrogatepass")
|
|
185
|
+
return b[2 * start : None if end is None else 2 * end].decode("utf-16-le", "surrogatepass")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def utf16_join(parts) -> str:
|
|
189
|
+
"""''.join, then fuse surrogate halves that a UTF-16 slice split — JS strings do this implicitly."""
|
|
190
|
+
s = "".join(parts)
|
|
191
|
+
if not _SURROGATE.search(s):
|
|
192
|
+
return s
|
|
193
|
+
return s.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def utf8(s: str) -> bytes:
|
|
200
|
+
"""A JS string as UTF-8 the way fetch/TextEncoder send it: pairs fused, a lone surrogate becomes U+FFFD."""
|
|
201
|
+
return _SURROGATE.sub("\ufffd", utf16_join([s])).encode("utf-8")
|
|
202
|
+
|
|
203
|
+
# ── JSON ──────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
205
|
+
_NEEDS_ESCAPE = re.compile('["\\\\\x00-\x1f\ud800-\udfff]')
|
|
206
|
+
_INDEX_KEY = re.compile(r"0|[1-9]\d*")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _quote(s: str) -> str:
|
|
210
|
+
if _SURROGATE.search(s):
|
|
211
|
+
s = utf16_join([s]) # a pair held as two code points is one character to JS
|
|
212
|
+
return '"' + _NEEDS_ESCAPE.sub(lambda m: _ESCAPES.get(m.group(0)) or "\\u%04x" % ord(m.group(0)), s) + '"'
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _ordered_keys(d: dict) -> list:
|
|
216
|
+
keys = [k if isinstance(k, str) else js_string(k) for k in d]
|
|
217
|
+
idx = sorted((k for k in keys if _INDEX_KEY.fullmatch(k) and int(k) < 2**32 - 1), key=int)
|
|
218
|
+
idx_set = set(idx)
|
|
219
|
+
return idx + [k for k in keys if k not in idx_set]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def stringify(v: Any, indent: int | None = None) -> str:
|
|
223
|
+
"""JSON.stringify(v, null, indent) for JSON-shaped values."""
|
|
224
|
+
gap = " " * indent if indent else ""
|
|
225
|
+
|
|
226
|
+
def ser(x: Any, cur: str) -> str | None:
|
|
227
|
+
if x is None:
|
|
228
|
+
return "null"
|
|
229
|
+
if x is True:
|
|
230
|
+
return "true"
|
|
231
|
+
if x is False:
|
|
232
|
+
return "false"
|
|
233
|
+
if x is UNDEFINED:
|
|
234
|
+
return None
|
|
235
|
+
if is_num(x):
|
|
236
|
+
return number_str(x) if math.isfinite(x) else "null"
|
|
237
|
+
if isinstance(x, str):
|
|
238
|
+
return _quote(x)
|
|
239
|
+
inner = cur + gap
|
|
240
|
+
if isinstance(x, (list, tuple)):
|
|
241
|
+
items = [ser(e, inner) or "null" for e in x]
|
|
242
|
+
if not items:
|
|
243
|
+
return "[]"
|
|
244
|
+
if not gap:
|
|
245
|
+
return "[" + ",".join(items) + "]"
|
|
246
|
+
return "[\n" + inner + (",\n" + inner).join(items) + "\n" + cur + "]"
|
|
247
|
+
if isinstance(x, dict):
|
|
248
|
+
src = {(k if isinstance(k, str) else js_string(k)): val for k, val in x.items()}
|
|
249
|
+
members = []
|
|
250
|
+
for k in _ordered_keys(src):
|
|
251
|
+
sv = ser(src[k], inner)
|
|
252
|
+
if sv is not None:
|
|
253
|
+
members.append(_quote(k) + (": " if gap else ":") + sv)
|
|
254
|
+
if not members:
|
|
255
|
+
return "{}"
|
|
256
|
+
if not gap:
|
|
257
|
+
return "{" + ",".join(members) + "}"
|
|
258
|
+
return "{\n" + inner + (",\n" + inner).join(members) + "\n" + cur + "}"
|
|
259
|
+
raise TypeError(f"not JSON-serializable: {type(x).__name__}")
|
|
260
|
+
|
|
261
|
+
out = ser(v, "")
|
|
262
|
+
if out is None:
|
|
263
|
+
raise TypeError("undefined is not JSON")
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _no_constants(name: str):
|
|
268
|
+
raise ValueError(f"{name} is not JSON")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse(text: str) -> Any:
|
|
272
|
+
"""JSON.parse — refuses NaN/Infinity, which Python's json would otherwise accept."""
|
|
273
|
+
return json.loads(text, parse_constant=_no_constants)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def json_bytes(v: Any) -> float:
|
|
277
|
+
"""UTF-8 size of JSON.stringify(v ?? null) — Infinity when it cannot be serialized (compress.js _jsonBytes)."""
|
|
278
|
+
try:
|
|
279
|
+
return len(stringify(None if v is UNDEFINED else v).encode("utf-8"))
|
|
280
|
+
except (TypeError, ValueError, RecursionError):
|
|
281
|
+
return math.inf
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── base64 ────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
_B64_BAD = re.compile(r"[^A-Za-z0-9+/]")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def atob(s: str) -> bytes:
|
|
289
|
+
"""WHATWG forgiving-base64 decode (what `atob` accepts). Raises ValueError where atob throws."""
|
|
290
|
+
t = re.sub(r"[\t\n\x0c\r ]", "", s)
|
|
291
|
+
if len(t) % 4 == 0:
|
|
292
|
+
if t.endswith("=="):
|
|
293
|
+
t = t[:-2]
|
|
294
|
+
elif t.endswith("="):
|
|
295
|
+
t = t[:-1]
|
|
296
|
+
if len(t) % 4 == 1 or _B64_BAD.search(t):
|
|
297
|
+
raise ValueError("invalid base64")
|
|
298
|
+
return base64.b64decode(t + "=" * (-len(t) % 4))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def btoa(b: bytes) -> str:
|
|
302
|
+
return base64.b64encode(b).decode("ascii")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def b64u(b: bytes) -> str:
|
|
306
|
+
"""base64url, unpadded (every B64/b64u/b64url helper on the JS side)."""
|
|
307
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def unb64u_loose(s: Any) -> bytes:
|
|
311
|
+
"""deviceLink UNB64 / recovery fromB64url: url alphabet mapped back, then atob (no padding added)."""
|
|
312
|
+
return atob(js_string(s).replace("-", "+").replace("_", "/"))
|
witbitz_code/account.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Writes to the account: the `computers` registry and the legacy direct-server settings — tools/opencode-pair.mjs.
|
|
2
|
+
|
|
3
|
+
⚠ The registry and index2 are LIVE ACCOUNT DATA. op:'state' REPLACES a doc, so every write here READS first, refuses
|
|
4
|
+
unless that read was a definitive 200, merges, writes the whole doc back, and verifies by reading it again.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import _js, compress
|
|
13
|
+
from ._js import truthy
|
|
14
|
+
from .computers import (
|
|
15
|
+
COMPUTERS_DOC,
|
|
16
|
+
live_computers,
|
|
17
|
+
norm_registry,
|
|
18
|
+
with_computer,
|
|
19
|
+
without_computer,
|
|
20
|
+
)
|
|
21
|
+
from .net import ApiCall
|
|
22
|
+
|
|
23
|
+
# Account docs above ~24 KB are gzip-wrapped as { z } (roomSync.js pushIndexDoc) — the same rule as the app.
|
|
24
|
+
GZIP_OVER = 24000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_ms() -> int:
|
|
28
|
+
return int(time.time() * 1000)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unwrap_index_state(state: Any) -> Any:
|
|
32
|
+
return compress.gunzip_b64(state["z"]) if isinstance(state, dict) and isinstance(state.get("z"), str) else state
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def wrap_index_state(state: Any) -> Any:
|
|
36
|
+
return {"z": compress.gzip_b64(state)} if compress.HAS_GZIP and compress.json_bytes(state) > GZIP_OVER else state
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def read_doc(call: ApiCall, idx: dict, name: str) -> dict:
|
|
40
|
+
rd = await call({"op": "state", "room": idx["room"], "mk": idx["mk"], "name": name})
|
|
41
|
+
j = rd.j
|
|
42
|
+
err = j.get("error") if isinstance(j, dict) else None
|
|
43
|
+
if rd.status != 200 or not truthy(j) or truthy(err):
|
|
44
|
+
detail = f": {_js.js_string(err)}" if truthy(err) else ""
|
|
45
|
+
return {"ok": False, "why": f"{name} read refused ({rd.status}{detail}) — NOT writing"}
|
|
46
|
+
try:
|
|
47
|
+
state = j.get("state") if isinstance(j, dict) else None
|
|
48
|
+
return {"ok": True, "state": unwrap_index_state(state if truthy(state) else None)}
|
|
49
|
+
except Exception as e: # a gzip wrap that does not open
|
|
50
|
+
return {"ok": False, "why": f"{name} unreadable ({e}) — NOT writing"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_body(idx: dict, name: str, patch: Any, summary: str) -> dict:
|
|
54
|
+
return {"op": "state", "room": idx["room"], "mk": idx["mk"], "name": name, "patch": patch, "summary": summary, "by": "node"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def publish_computer(*, call: ApiCall, idx: dict, computer_id: str, name: str, relay: str, secret: str,
|
|
58
|
+
now: float | None = None) -> dict:
|
|
59
|
+
"""Put (or refresh) this computer in the account's registry."""
|
|
60
|
+
now = _now_ms() if now is None else now
|
|
61
|
+
rd = await read_doc(call, idx, COMPUTERS_DOC)
|
|
62
|
+
if not rd["ok"]:
|
|
63
|
+
return rd
|
|
64
|
+
nxt = with_computer(rd["state"], computer_id, {"name": name, "relay": relay, "secret": secret}, now)
|
|
65
|
+
wr = await call(_write_body(idx, COMPUTERS_DOC, wrap_index_state({**nxt, "at": now}), "computer paired"))
|
|
66
|
+
if wr.status != 200:
|
|
67
|
+
return {"ok": False, "why": f"computers write refused ({wr.status})"}
|
|
68
|
+
back = await read_doc(call, idx, COMPUTERS_DOC)
|
|
69
|
+
got = norm_registry(back["state"])["computers"].get(computer_id) if back["ok"] else None
|
|
70
|
+
if not got or got.get("removed") or got.get("secret") != secret:
|
|
71
|
+
return {"ok": False, "why": "wrote the registry but the read-back does not match — check the account before relying on it"}
|
|
72
|
+
return {"ok": True, "computers": live_computers(back["state"])}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def unpublish_computer(*, call: ApiCall, idx: dict, computer_id: str, now: float | None = None) -> dict:
|
|
76
|
+
"""Tombstone this computer in the account's registry (its secret leaves the account). Nothing there → nothing written."""
|
|
77
|
+
now = _now_ms() if now is None else now
|
|
78
|
+
rd = await read_doc(call, idx, COMPUTERS_DOC)
|
|
79
|
+
if not rd["ok"]:
|
|
80
|
+
return rd
|
|
81
|
+
cur = norm_registry(rd["state"])["computers"].get(computer_id)
|
|
82
|
+
if not cur or cur.get("removed"):
|
|
83
|
+
return {"ok": True, "noop": True}
|
|
84
|
+
nxt = without_computer(rd["state"], computer_id, now)
|
|
85
|
+
wr = await call(_write_body(idx, COMPUTERS_DOC, wrap_index_state({**nxt, "at": now}), "computer unpaired"))
|
|
86
|
+
if wr.status != 200:
|
|
87
|
+
return {"ok": False, "why": f"computers write refused ({wr.status})"}
|
|
88
|
+
back = await read_doc(call, idx, COMPUTERS_DOC)
|
|
89
|
+
e = norm_registry(back["state"])["computers"].get(computer_id) if back["ok"] else None
|
|
90
|
+
if not e or not e.get("removed"):
|
|
91
|
+
return {"ok": False, "why": "wrote the tombstone but the read-back does not show it"}
|
|
92
|
+
return {"ok": True}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def clear_legacy_direct(*, call: ApiCall, idx: dict, now: float | None = None) -> dict:
|
|
96
|
+
"""The OLD direct path (pre-relay): empty index2.code.base/pass so no device keeps dialling a server address. Keeps
|
|
97
|
+
contacts, calendar and the chosen model/agent; nothing set → nothing written."""
|
|
98
|
+
now = _now_ms() if now is None else now
|
|
99
|
+
rd = await read_doc(call, idx, "index2")
|
|
100
|
+
if not rd["ok"]:
|
|
101
|
+
return rd
|
|
102
|
+
s = rd["state"] if isinstance(rd["state"], dict) else {}
|
|
103
|
+
prev = s.get("code") if isinstance(s.get("code"), dict) else None
|
|
104
|
+
if not prev or (not truthy(prev.get("base")) and not truthy(prev.get("pass"))):
|
|
105
|
+
return {"ok": True, "noop": True}
|
|
106
|
+
prev_mod = _js.to_number(prev.get("mod", _js.UNDEFINED))
|
|
107
|
+
prev_mod = prev_mod if prev_mod == prev_mod and prev_mod != 0 else 0 # Number(prev.mod) || 0
|
|
108
|
+
code = {
|
|
109
|
+
"base": "",
|
|
110
|
+
"pass": "",
|
|
111
|
+
"model": prev["model"] if isinstance(prev.get("model"), str) else "",
|
|
112
|
+
"agent": prev["agent"] if isinstance(prev.get("agent"), str) else "",
|
|
113
|
+
"mod": _js.clean_number(max(now, prev_mod + 1)),
|
|
114
|
+
}
|
|
115
|
+
nxt = dict(s)
|
|
116
|
+
nxt["v"] = s["v"] if truthy(s.get("v")) else 2
|
|
117
|
+
nxt["code"] = code
|
|
118
|
+
nxt["at"] = now
|
|
119
|
+
wr = await call(_write_body(idx, "index2", wrap_index_state(nxt), "code direct server cleared"))
|
|
120
|
+
if wr.status != 200:
|
|
121
|
+
return {"ok": False, "why": f"index2 write refused ({wr.status})"}
|
|
122
|
+
back = await read_doc(call, idx, "index2")
|
|
123
|
+
bs = back.get("state") if back["ok"] else None
|
|
124
|
+
bc = bs.get("code") if isinstance(bs, dict) else None
|
|
125
|
+
if not back["ok"] or not truthy(bs) or not truthy(bc) or not isinstance(bc, dict) or bc.get("base") != "" or bc.get("pass") != "":
|
|
126
|
+
return {"ok": False, "why": "cleared index2 but the read-back does not match"}
|
|
127
|
+
return {"ok": True}
|
witbitz_code/asks.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""What is waiting on this computer, kept from OpenCode's event stream — tools/code-asks.mjs, in Python.
|
|
2
|
+
|
|
3
|
+
GET /permission is how Auto mode (auto_runner.py) and the page's card recovery learn what is waiting. Two OpenCode
|
|
4
|
+
behaviours make it unreliable on its own (measured, OpenCode 1.18.30, 2026-09-15):
|
|
5
|
+
|
|
6
|
+
- ONE ask with an `undefined` in its metadata breaks the list for the WHOLE folder (`400 Expected JSON value, got undefined
|
|
7
|
+
at [0].metadata.timeout` — webfetch without a timeout). Nothing could see that ask: no card, no Auto; the agent waited.
|
|
8
|
+
- A STOPPED turn leaves its asks in the list — after abort, even after the session is deleted — until each is replied to
|
|
9
|
+
or OpenCode restarts.
|
|
10
|
+
|
|
11
|
+
The event stream carries the same asks intact. The book follows /global/event: it adds `permission.asked`, removes
|
|
12
|
+
`permission.replied` (whose id is `requestID`), and when a session goes idle it hands back the asks still recorded for it —
|
|
13
|
+
dead, for the connector to reply to so OpenCode's list heals. Held to the JS answers by tools/code-asks.vectors.json.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
# Frames worth parsing — the stream also carries every token of every session.
|
|
23
|
+
ASK_FRAME = re.compile(r'"(?:permission\.(?:asked|replied)|session\.(?:idle|status|deleted)|server\.instance\.disposed|global\.disposed)"')
|
|
24
|
+
|
|
25
|
+
MAX_PER_DIR = 200
|
|
26
|
+
MAX_GONE = 1000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _str(x: Any) -> str:
|
|
30
|
+
return x if isinstance(x, str) and x else ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_ask(a: Any) -> bool:
|
|
34
|
+
return isinstance(a, dict) and bool(_str(a.get("id"))) and bool(_str(a.get("sessionID")))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AskBook:
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._by_dir: dict[str, dict[str, dict]] = {} # directory → {ask id → ask}, in the order asked
|
|
40
|
+
self._gone: dict[str, bool] = {} # replied or dead — a list fetched a moment earlier must not bring it back
|
|
41
|
+
|
|
42
|
+
def _forget(self, ask_id: str) -> None:
|
|
43
|
+
self._gone[ask_id] = True
|
|
44
|
+
if len(self._gone) > MAX_GONE:
|
|
45
|
+
del self._gone[next(iter(self._gone))]
|
|
46
|
+
|
|
47
|
+
def _add(self, directory: str, ask: Any) -> None:
|
|
48
|
+
if not directory or not _is_ask(ask) or ask["id"] in self._gone:
|
|
49
|
+
return
|
|
50
|
+
asks = self._by_dir.setdefault(directory, {})
|
|
51
|
+
if ask["id"] in asks:
|
|
52
|
+
return
|
|
53
|
+
asks[ask["id"]] = ask
|
|
54
|
+
if len(asks) > MAX_PER_DIR:
|
|
55
|
+
del asks[next(iter(asks))]
|
|
56
|
+
|
|
57
|
+
def _remove(self, ask_id: str) -> None:
|
|
58
|
+
for directory, asks in list(self._by_dir.items()):
|
|
59
|
+
if asks.pop(ask_id, None) is not None and not asks:
|
|
60
|
+
del self._by_dir[directory]
|
|
61
|
+
self._forget(ask_id)
|
|
62
|
+
|
|
63
|
+
def _take_dead(self, directory: str, is_dead: Callable[[dict], bool]) -> list[tuple[str, dict]]:
|
|
64
|
+
asks = self._by_dir.get(directory)
|
|
65
|
+
if not asks:
|
|
66
|
+
return []
|
|
67
|
+
dead = [(directory, ask) for ask in asks.values() if is_dead(ask)]
|
|
68
|
+
for _, ask in dead:
|
|
69
|
+
self._remove(ask["id"])
|
|
70
|
+
return dead
|
|
71
|
+
|
|
72
|
+
def apply(self, frame: Any) -> list[tuple[str, dict]]:
|
|
73
|
+
"""One /global/event frame ({directory, payload: {type, properties}}). Returns the asks it showed to be dead."""
|
|
74
|
+
frame = frame if isinstance(frame, dict) else {}
|
|
75
|
+
directory = _str(frame.get("directory"))
|
|
76
|
+
payload = frame.get("payload") if isinstance(frame.get("payload"), dict) else {}
|
|
77
|
+
kind = _str(payload.get("type"))
|
|
78
|
+
p = payload.get("properties") if isinstance(payload.get("properties"), dict) else {}
|
|
79
|
+
if kind == "global.disposed":
|
|
80
|
+
self._by_dir.clear()
|
|
81
|
+
return []
|
|
82
|
+
if kind == "server.instance.disposed":
|
|
83
|
+
self._by_dir.pop(_str(p.get("directory")) or directory, None) # its asks went with it
|
|
84
|
+
return []
|
|
85
|
+
if not directory:
|
|
86
|
+
return []
|
|
87
|
+
if kind == "permission.asked":
|
|
88
|
+
self._add(directory, p)
|
|
89
|
+
return []
|
|
90
|
+
if kind == "permission.replied":
|
|
91
|
+
ask_id = _str(p.get("requestID")) or _str(p.get("id"))
|
|
92
|
+
if ask_id:
|
|
93
|
+
self._remove(ask_id)
|
|
94
|
+
return []
|
|
95
|
+
info = p.get("info") if isinstance(p.get("info"), dict) else {}
|
|
96
|
+
sid = _str(p.get("sessionID")) or _str(info.get("id"))
|
|
97
|
+
if not sid:
|
|
98
|
+
return []
|
|
99
|
+
status = p.get("status") if isinstance(p.get("status"), dict) else {}
|
|
100
|
+
idle = kind in ("session.idle", "session.deleted") or (kind == "session.status" and status.get("type") == "idle")
|
|
101
|
+
return self._take_dead(directory, lambda ask: ask["sessionID"] == sid) if idle else []
|
|
102
|
+
|
|
103
|
+
def merge(self, directory: str, lst: Any) -> None:
|
|
104
|
+
"""Asks OpenCode's own list returned (when it works): added, never removed — it may predate an event already seen."""
|
|
105
|
+
for ask in lst if isinstance(lst, list) else []:
|
|
106
|
+
self._add(directory, ask)
|
|
107
|
+
|
|
108
|
+
def settle_idle(self, directory: str, busy: Any, known: Any) -> list[tuple[str, dict]]:
|
|
109
|
+
"""From GET /session/status (it lists a subagent's session too): every ask whose session is not running is dead.
|
|
110
|
+
`known`: the ask ids recorded BEFORE the status was read — one that arrived meanwhile is never taken."""
|
|
111
|
+
running = set(x for x in busy if isinstance(x, str)) if isinstance(busy, list) else set()
|
|
112
|
+
before = set(x for x in known if isinstance(x, str)) if isinstance(known, list) else set()
|
|
113
|
+
return self._take_dead(directory, lambda ask: ask["id"] in before and ask["sessionID"] not in running)
|
|
114
|
+
|
|
115
|
+
def list(self, directory: str) -> list[dict]:
|
|
116
|
+
return list(self._by_dir.get(directory, {}).values())
|
|
117
|
+
|
|
118
|
+
def directories(self) -> list[str]:
|
|
119
|
+
return list(self._by_dir)
|