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,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"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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("_", "/"))
@@ -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)