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,481 @@
|
|
|
1
|
+
"""Code Auto mode's loop inside the connector — tools/code-auto-runner.mjs, in Python (docs/code-auto-mode.md §2).
|
|
2
|
+
|
|
3
|
+
For each session switched to Auto it polls OpenCode's pending permission asks (GET /permission?directory=…, or the
|
|
4
|
+
connector's `list_asks` — which falls back to the asks the event stream carried when that list breaks), and for each
|
|
5
|
+
new one: the deterministic layer (auto.py) answers what needs no model; otherwise the SESSION'S OWN model reviews it in a
|
|
6
|
+
throw-away session whose rules deny every tool. Only a clear allow is answered `once`, a deny is answered `reject` with the
|
|
7
|
+
reason for the agent, and everything else — ask, nonsense, a severe allow, a timeout, any error — is LEFT for the person's
|
|
8
|
+
approval card. Each decision: one line in the local log (a digest, never the command) and an on_verdict callback (the
|
|
9
|
+
connector sends it to open pages as `autoverdict`).
|
|
10
|
+
|
|
11
|
+
The state file and the log are the JS connector's, byte for byte in shape: a computer can switch between the two.
|
|
12
|
+
|
|
13
|
+
The JS runner's three rules from OpenCode's own code hold here too: a SUBAGENT's asks are decided under the nearest
|
|
14
|
+
ancestor in Auto (reviewed against what the person asked there); a refusal is HELD until nothing else is pending in its
|
|
15
|
+
session (OpenCode's reject cancels every other pending ask there); and starting a subagent is allowed without a review
|
|
16
|
+
only when that agent's own rules ask for bash/edit/webfetch/websearch (policy.py).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import math
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import stat
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Awaitable, Callable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
from urllib.parse import quote
|
|
31
|
+
|
|
32
|
+
import httpx
|
|
33
|
+
|
|
34
|
+
from . import _js
|
|
35
|
+
from .folders import allowed_folders
|
|
36
|
+
from .auto import SCRATCH_DIR, action_for, classify_deterministic, log_record, parse_verdict, reviewer_prompt, scratch_paths
|
|
37
|
+
from .policy import SUBAGENT_GATED
|
|
38
|
+
|
|
39
|
+
REVIEW_TITLE = "witbitz-auto-review" # the page never lists a session with this title
|
|
40
|
+
HANDLED_TTL_S = 30 * 60
|
|
41
|
+
MAX_USER_MESSAGES = 6
|
|
42
|
+
MAX_DETAIL = 300
|
|
43
|
+
PARENT_TTL_MS = 10 * 60_000
|
|
44
|
+
AGENTS_TTL_MS = 60_000
|
|
45
|
+
MAX_DEPTH = 4 # parent links followed looking for the session in Auto
|
|
46
|
+
REFUSAL_SUFFIX = "(Witbitz Auto mode refused this — try a narrower or safer step.)"
|
|
47
|
+
_UNKNOWN = object() # OpenCode could not say who a session's parent is (not cached)
|
|
48
|
+
_SESSION_ID = re.compile(r"ses[A-Za-z0-9_-]{1,80}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _now_ms() -> int:
|
|
52
|
+
return int(time.time() * 1000)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _uri_component(s: str) -> str:
|
|
56
|
+
return quote(s, safe="-_.!~*'()") # encodeURIComponent
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def action_of(rules: Any, perm: str) -> Any:
|
|
60
|
+
"""OpenCode's own evaluation for the catch-all pattern: the LAST rule that matches wins."""
|
|
61
|
+
action = None
|
|
62
|
+
for r in rules if isinstance(rules, list) else []:
|
|
63
|
+
if isinstance(r, dict) and r.get("permission") in (perm, "*") and r.get("pattern") == "*":
|
|
64
|
+
action = r.get("action")
|
|
65
|
+
return action
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def detail_of(req: dict) -> str:
|
|
69
|
+
"""What the ask is about, for the person's own page (the verdict is sealed to it, like the ask) — never for the log."""
|
|
70
|
+
md = req.get("metadata")
|
|
71
|
+
cmd = md.get("command") if isinstance(md, dict) else None
|
|
72
|
+
patterns = req.get("patterns")
|
|
73
|
+
first = patterns[0] if isinstance(patterns, list) and patterns and isinstance(patterns[0], str) else ""
|
|
74
|
+
return _js.utf16_slice(cmd if isinstance(cmd, str) and cmd else first, 0, MAX_DETAIL)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scratch_on_disk(paths: list[str], directory: str = SCRATCH_DIR) -> bool:
|
|
78
|
+
"""The scratch rule (auto.py) trusts a path's words; the disk must agree before Auto allows on it: the directory is a
|
|
79
|
+
real one (not a link) this user owns, and every target — as far as it exists — resolves inside it. A link to nowhere
|
|
80
|
+
is refused too (a write would create its target, wherever that is). Anything unreadable ⇒ False: the reviewer decides."""
|
|
81
|
+
try:
|
|
82
|
+
st = os.lstat(directory)
|
|
83
|
+
if not stat.S_ISDIR(st.st_mode) or (hasattr(os, "getuid") and st.st_uid != os.getuid()):
|
|
84
|
+
return False
|
|
85
|
+
root = os.path.realpath(directory, strict=True)
|
|
86
|
+
for p in paths:
|
|
87
|
+
cur = p
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
real = os.path.realpath(cur, strict=True)
|
|
91
|
+
except OSError:
|
|
92
|
+
real = None
|
|
93
|
+
if os.path.lexists(cur):
|
|
94
|
+
return False
|
|
95
|
+
if real is not None:
|
|
96
|
+
if real != root and not real.startswith(root + "/"):
|
|
97
|
+
return False
|
|
98
|
+
break
|
|
99
|
+
up = os.path.dirname(cur)
|
|
100
|
+
if up == cur:
|
|
101
|
+
return False
|
|
102
|
+
cur = up
|
|
103
|
+
return True
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AutoRunner:
|
|
109
|
+
def __init__(self, *, base: str, auth: Callable[[], dict], client: httpx.AsyncClient, state_path: Path | None,
|
|
110
|
+
log_path: Path | None, poll_ms: float = 1000, review_timeout_ms: float = 30_000, home: str | None = None,
|
|
111
|
+
on_verdict: Callable[[dict], Any] = lambda v: None, log: Callable[[str], Any] = lambda m: None,
|
|
112
|
+
now: Callable[[], int] = _now_ms, scratch_check: Callable[[list[str]], bool] = scratch_on_disk,
|
|
113
|
+
list_asks: Callable[[str], Awaitable[list | None]] | None = None) -> None:
|
|
114
|
+
self._root = re.sub(r"/+\Z", "", base or "")
|
|
115
|
+
self._auth = auth
|
|
116
|
+
self._client = client
|
|
117
|
+
self._state_path = Path(state_path) if state_path else None
|
|
118
|
+
self._log_path = Path(log_path) if log_path else None
|
|
119
|
+
self._poll_s = poll_ms / 1000
|
|
120
|
+
self._review_timeout_ms = review_timeout_ms
|
|
121
|
+
self._home = home if home is not None else str(Path.home())
|
|
122
|
+
self._on_verdict = on_verdict
|
|
123
|
+
self._log = log
|
|
124
|
+
self._now = now
|
|
125
|
+
self._scratch_check = scratch_check
|
|
126
|
+
self._list_asks = list_asks
|
|
127
|
+
self._auto: dict[str, dict] = {} # sessionID → {dir, at}
|
|
128
|
+
self._handled: dict[str, int] = {} # permission id → when it was taken up (so a poll never reviews it twice)
|
|
129
|
+
self._parents: dict[str, dict] = {} # sessionID → {parent, at} — so a subagent's ask finds its Auto session
|
|
130
|
+
self._agents: dict[str, dict] = {} # directory → {list, at} — GET /agent, for what a subagent's own rules allow
|
|
131
|
+
self._held: dict[str, dict[str, dict]] = {} # sessionID → {permission id → a refusal waiting for its session}
|
|
132
|
+
self._tasks: set[asyncio.Task] = set()
|
|
133
|
+
self._loop_task: asyncio.Task | None = None
|
|
134
|
+
self._stopped = False
|
|
135
|
+
self._load()
|
|
136
|
+
|
|
137
|
+
# ── state on the computer ─────────────────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
def _load(self) -> None:
|
|
139
|
+
try:
|
|
140
|
+
if not self._state_path or not self._state_path.exists():
|
|
141
|
+
return
|
|
142
|
+
doc = _js.parse(self._state_path.read_text(encoding="utf-8"))
|
|
143
|
+
sessions = doc.get("sessions") if isinstance(doc, dict) else None
|
|
144
|
+
for sid, v in (sessions.items() if isinstance(sessions, dict) else []):
|
|
145
|
+
if sid.startswith("ses") and isinstance(v, dict) and isinstance(v.get("dir"), str) and v["dir"].startswith("/"):
|
|
146
|
+
at = _js.to_number(v.get("at", _js.UNDEFINED))
|
|
147
|
+
self._auto[sid] = {"dir": v["dir"], "at": _js.clean_number(at) if _js.truthy(at) else 0}
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self._log(f"code-auto: ignoring an unreadable {self._state_path} ({e})")
|
|
150
|
+
|
|
151
|
+
def _save(self) -> None:
|
|
152
|
+
if not self._state_path:
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
self._state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
tmp = self._state_path.with_name(self._state_path.name + ".tmp")
|
|
157
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
158
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
159
|
+
f.write(_js.stringify({"sessions": self._auto}, 2))
|
|
160
|
+
os.replace(tmp, self._state_path) # atomic: a crash mid-write never leaves half a file
|
|
161
|
+
except Exception as e:
|
|
162
|
+
self._log(f"code-auto: could not save {self._state_path} ({e})")
|
|
163
|
+
|
|
164
|
+
def _append_log(self, rec: dict) -> None:
|
|
165
|
+
if not self._log_path:
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
fd = os.open(self._log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
170
|
+
with os.fdopen(fd, "a", encoding="utf-8") as f:
|
|
171
|
+
f.write(_js.stringify(rec) + "\n")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
self._log(f"code-auto: could not write {self._log_path} ({e})")
|
|
174
|
+
|
|
175
|
+
# ── OpenCode ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
def _url(self, path: str, directory: str) -> str:
|
|
177
|
+
return f"{self._root}{path}{'&' if '?' in path else '?'}directory={_uri_component(directory)}"
|
|
178
|
+
|
|
179
|
+
async def _call(self, method: str, path: str, directory: str, body: Any = _js.UNDEFINED) -> tuple[bool, int, Any]:
|
|
180
|
+
has_body = body is not _js.UNDEFINED
|
|
181
|
+
headers = {**self._auth(), **({"content-type": "application/json"} if has_body else {})}
|
|
182
|
+
r = await self._client.request(method, self._url(path, directory), headers=headers,
|
|
183
|
+
content=_js.utf8(_js.stringify(body)) if has_body else None)
|
|
184
|
+
try:
|
|
185
|
+
data = _js.parse(r.text)
|
|
186
|
+
except ValueError:
|
|
187
|
+
data = None # an empty or non-JSON answer
|
|
188
|
+
return 200 <= r.status_code < 300, r.status_code, data
|
|
189
|
+
|
|
190
|
+
async def _reply(self, req: dict, directory: str, answer: str, message: str = "") -> bool:
|
|
191
|
+
try:
|
|
192
|
+
ok, _, _ = await self._call("POST", f"/permission/{_uri_component(req['id'])}/reply", directory,
|
|
193
|
+
{"reply": answer, "message": message} if message else {"reply": answer})
|
|
194
|
+
return ok
|
|
195
|
+
except Exception:
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
async def _session_context(self, sid: str, directory: str) -> tuple[dict | None, list[str], dict[str, str]]:
|
|
199
|
+
"""The session's model, what the person asked for, and which tool made each call — from its own transcript."""
|
|
200
|
+
_, _, msgs = await self._call("GET", f"/session/{_uri_component(sid)}/message", directory)
|
|
201
|
+
model, user_messages, tools = None, [], {}
|
|
202
|
+
for m in msgs if isinstance(msgs, list) else []:
|
|
203
|
+
for p in (m.get("parts") if isinstance(m, dict) and isinstance(m.get("parts"), list) else []):
|
|
204
|
+
if isinstance(p, dict) and p.get("type") == "tool" and isinstance(p.get("callID"), str) and isinstance(p.get("tool"), str):
|
|
205
|
+
tools[p["callID"]] = p["tool"]
|
|
206
|
+
info = m.get("info") if isinstance(m, dict) and isinstance(m.get("info"), dict) else {}
|
|
207
|
+
if info.get("role") == "assistant" and _js.truthy(info.get("providerID")) and _js.truthy(info.get("modelID")):
|
|
208
|
+
model = {"providerID": info["providerID"], "modelID": info["modelID"]}
|
|
209
|
+
if info.get("role") == "user":
|
|
210
|
+
parts = m.get("parts") if isinstance(m.get("parts"), list) else []
|
|
211
|
+
text = _js.trim("\n".join(p["text"] for p in parts if isinstance(p, dict) and p.get("type") == "text"
|
|
212
|
+
and isinstance(p.get("text"), str) and not _js.truthy(p.get("synthetic"))))
|
|
213
|
+
if text:
|
|
214
|
+
user_messages.append(text)
|
|
215
|
+
return model, user_messages[-MAX_USER_MESSAGES:], tools
|
|
216
|
+
|
|
217
|
+
async def _parent_of(self, sid: str, directory: str) -> Any:
|
|
218
|
+
c = self._parents.get(sid)
|
|
219
|
+
if c and self._now() - c["at"] < PARENT_TTL_MS:
|
|
220
|
+
return c["parent"]
|
|
221
|
+
try:
|
|
222
|
+
ok, _, data = await self._call("GET", f"/session/{_uri_component(sid)}", directory)
|
|
223
|
+
except asyncio.CancelledError:
|
|
224
|
+
raise
|
|
225
|
+
except Exception:
|
|
226
|
+
return _UNKNOWN
|
|
227
|
+
if not ok or not isinstance(data, dict):
|
|
228
|
+
return _UNKNOWN
|
|
229
|
+
parent = data["parentID"] if isinstance(data.get("parentID"), str) and data["parentID"] else None
|
|
230
|
+
self._parents[sid] = {"parent": parent, "at": self._now()}
|
|
231
|
+
return parent
|
|
232
|
+
|
|
233
|
+
async def _owner_of(self, sid: str, directory: str) -> str | None:
|
|
234
|
+
"""The session in Auto this ask belongs to: its own, or its nearest ancestor's in the same project."""
|
|
235
|
+
cur: Any = sid
|
|
236
|
+
for _ in range(MAX_DEPTH):
|
|
237
|
+
if not isinstance(cur, str) or not cur:
|
|
238
|
+
return None
|
|
239
|
+
on = self._auto.get(cur)
|
|
240
|
+
if on and on["dir"] == directory:
|
|
241
|
+
return cur
|
|
242
|
+
cur = await self._parent_of(cur, directory)
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
async def _subagent_asks(self, name: str, directory: str) -> bool | None:
|
|
246
|
+
c = self._agents.get(directory)
|
|
247
|
+
if not c or self._now() - c["at"] > AGENTS_TTL_MS:
|
|
248
|
+
try:
|
|
249
|
+
ok, _, data = await self._call("GET", "/agent", directory)
|
|
250
|
+
except asyncio.CancelledError:
|
|
251
|
+
raise
|
|
252
|
+
except Exception:
|
|
253
|
+
return None
|
|
254
|
+
if not ok or not isinstance(data, list):
|
|
255
|
+
return None
|
|
256
|
+
c = {"list": data, "at": self._now()}
|
|
257
|
+
self._agents[directory] = c
|
|
258
|
+
agent = next((a for a in c["list"] if isinstance(a, dict) and a.get("name") == name), None)
|
|
259
|
+
if not agent or not isinstance(agent.get("permission"), list):
|
|
260
|
+
return False
|
|
261
|
+
return all(action_of(agent["permission"], p) in ("ask", "deny") for p in SUBAGENT_GATED)
|
|
262
|
+
|
|
263
|
+
async def _classify_subagent(self, req: dict, directory: str) -> dict | None:
|
|
264
|
+
if req.get("permission") != "task":
|
|
265
|
+
return None
|
|
266
|
+
md = req.get("metadata") if isinstance(req.get("metadata"), dict) else {}
|
|
267
|
+
patterns = req.get("patterns") if isinstance(req.get("patterns"), list) else []
|
|
268
|
+
name = md.get("subagent_type") or (patterns[0] if patterns else "")
|
|
269
|
+
if not isinstance(name, str) or not name:
|
|
270
|
+
return None
|
|
271
|
+
asks = await self._subagent_asks(name, directory)
|
|
272
|
+
if asks is True:
|
|
273
|
+
return {"stage": "fast-allow", "decision": "allow", "rule": "fast:subagent-asks",
|
|
274
|
+
"reason": f"starts the {name} agent, whose own commands each ask for approval"}
|
|
275
|
+
if asks is False:
|
|
276
|
+
return {"stage": "fast-ask", "decision": "ask", "rule": "ask:subagent-unguarded",
|
|
277
|
+
"reason": f"the {name} agent's own commands would run without asking — start it yourself if you trust it"}
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
async def _folders_of(self, sid: str, directory: str) -> list[str]:
|
|
281
|
+
"""The folders the person allowed for the session that asked ("allow this folder") — they count as the project."""
|
|
282
|
+
try:
|
|
283
|
+
ok, _, data = await self._call("GET", f"/session/{_uri_component(sid)}", directory)
|
|
284
|
+
except asyncio.CancelledError:
|
|
285
|
+
raise
|
|
286
|
+
except Exception:
|
|
287
|
+
return []
|
|
288
|
+
return allowed_folders(data.get("permission")) if ok and isinstance(data, dict) else []
|
|
289
|
+
|
|
290
|
+
async def _review(self, req: dict, directory: str, owner: str | None = None, folders: list[str] | None = None) -> tuple[dict | None, str, str]:
|
|
291
|
+
model, user_messages, tools = await self._session_context(owner or req["sessionID"], directory)
|
|
292
|
+
if not model:
|
|
293
|
+
return None, "", "the session has no model to review with yet"
|
|
294
|
+
label = f"{_js.js_string(model['providerID'])}/{_js.js_string(model['modelID'])}"
|
|
295
|
+
# A subagent's ask names a call in ITS transcript, not the owner's: then there is no tool line, as before.
|
|
296
|
+
call = req.get("tool") if isinstance(req.get("tool"), dict) else {}
|
|
297
|
+
tool = tools.get(call["callID"], "") if isinstance(call.get("callID"), str) else ""
|
|
298
|
+
prompt = reviewer_prompt(req, directory, user_messages, tool, folders or [])
|
|
299
|
+
_, _, created = await self._call("POST", "/session", directory,
|
|
300
|
+
{"title": REVIEW_TITLE, "permission": [{"permission": "*", "pattern": "*", "action": "deny"}]})
|
|
301
|
+
rid = created.get("id") if isinstance(created, dict) else None
|
|
302
|
+
if not _js.truthy(rid):
|
|
303
|
+
return None, label, "could not open a review session"
|
|
304
|
+
rid = _js.js_string(rid)
|
|
305
|
+
timed_out = False
|
|
306
|
+
try:
|
|
307
|
+
body = {"model": model, "system": prompt["system"], "tools": {"*": False}, "parts": [{"type": "text", "text": prompt["text"]}]}
|
|
308
|
+
try:
|
|
309
|
+
_, _, answer = await asyncio.wait_for(self._call("POST", f"/session/{_uri_component(rid)}/message", directory, body),
|
|
310
|
+
self._review_timeout_ms / 1000)
|
|
311
|
+
except asyncio.TimeoutError:
|
|
312
|
+
timed_out = True
|
|
313
|
+
raise
|
|
314
|
+
parts = answer.get("parts") if isinstance(answer, dict) and isinstance(answer.get("parts"), list) else []
|
|
315
|
+
texts = [p["text"] for p in parts if isinstance(p, dict) and p.get("type") == "text" and _js.truthy(p.get("text"))]
|
|
316
|
+
text = _js.js_string(texts[-1]) if texts else ""
|
|
317
|
+
return parse_verdict(text), label, "" if text else "the reviewer gave no answer"
|
|
318
|
+
except asyncio.CancelledError:
|
|
319
|
+
raise
|
|
320
|
+
except Exception:
|
|
321
|
+
secs = math.floor(self._review_timeout_ms / 1000 + 0.5) or 1 # Math.round(ms / 1000) || 1
|
|
322
|
+
return None, label, f"the reviewer did not answer within {secs} s" if timed_out else "the review failed"
|
|
323
|
+
finally:
|
|
324
|
+
if timed_out:
|
|
325
|
+
try:
|
|
326
|
+
await self._call("POST", f"/session/{_uri_component(rid)}/abort", directory)
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
try:
|
|
330
|
+
await self._call("DELETE", f"/session/{_uri_component(rid)}", directory)
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
def _emit(self, req: dict, stage: str, action: str, rec: dict, reason: str, answered: Any) -> None:
|
|
335
|
+
try:
|
|
336
|
+
self._on_verdict({"id": req["id"], "sessionID": req.get("sessionID"), "permission": req.get("permission"), "detail": detail_of(req),
|
|
337
|
+
"stage": stage, "action": action, "severity": rec["severity"], "rule": rec["rule"],
|
|
338
|
+
"reason": reason, "answered": answered})
|
|
339
|
+
except Exception:
|
|
340
|
+
pass # a listener never breaks the loop
|
|
341
|
+
|
|
342
|
+
async def _decide(self, req: dict, directory: str, owner: str | None = None) -> None:
|
|
343
|
+
t0 = self._now()
|
|
344
|
+
folders = await self._folders_of(req["sessionID"], directory)
|
|
345
|
+
det = classify_deterministic(req, directory=directory, home=self._home, folders=folders)
|
|
346
|
+
if det and det["rule"] == "fast:agent-scratch" and not self._scratch_check(scratch_paths(req)):
|
|
347
|
+
det = None # the disk disagrees: the reviewer's
|
|
348
|
+
det = det or await self._classify_subagent(req, directory)
|
|
349
|
+
model, note = "", ""
|
|
350
|
+
if det:
|
|
351
|
+
stage = det["stage"]
|
|
352
|
+
severity = 100 if stage == "hard-deny" else 50 if stage == "fast-ask" else 0
|
|
353
|
+
verdict = {"decision": det["decision"], "severity": severity, "rule": det["rule"], "reason": det["reason"]}
|
|
354
|
+
else:
|
|
355
|
+
stage = "reviewer"
|
|
356
|
+
verdict, model, note = await self._review(req, directory, owner, folders)
|
|
357
|
+
action = det["decision"] if det else action_for(verdict)
|
|
358
|
+
reason_given = (verdict or {}).get("reason") or ""
|
|
359
|
+
rec = log_record(req=req, stage=stage, verdict=verdict, action=action, model=model, ms=self._now() - t0, at=t0)
|
|
360
|
+
reason = reason_given or note or ""
|
|
361
|
+
if action == "deny":
|
|
362
|
+
# HELD, not sent (see the module docstring). The page hears it now (answered None) and again when it lands.
|
|
363
|
+
self._held.setdefault(req["sessionID"], {})[req["id"]] = {
|
|
364
|
+
"req": req, "dir": directory, "stage": stage, "rec": rec, "reason": reason,
|
|
365
|
+
"message": f"{reason_given or 'Refused by Auto mode.'} {REFUSAL_SUFFIX}"}
|
|
366
|
+
self._emit(req, stage, action, rec, reason, None)
|
|
367
|
+
return
|
|
368
|
+
answered = await self._reply(req, directory, "once") if action == "allow" else None
|
|
369
|
+
self._append_log({**rec, "answered": False} if answered is False else rec)
|
|
370
|
+
self._emit(req, stage, action, rec, reason, answered)
|
|
371
|
+
|
|
372
|
+
async def _release_held(self, directory: str, pending: list) -> None:
|
|
373
|
+
"""Send the held refusals of every session whose OTHER asks have all settled."""
|
|
374
|
+
for sid in list(self._held):
|
|
375
|
+
refusals = self._held.get(sid) or {}
|
|
376
|
+
mine = [h for h in refusals.values() if h["dir"] == directory]
|
|
377
|
+
if not mine:
|
|
378
|
+
continue
|
|
379
|
+
pending_ids = {p.get("id") for p in pending if isinstance(p, dict) and p.get("sessionID") == sid}
|
|
380
|
+
for h in mine:
|
|
381
|
+
if h["req"]["id"] in pending_ids:
|
|
382
|
+
continue
|
|
383
|
+
refusals.pop(h["req"]["id"], None) # answered elsewhere before it could be sent
|
|
384
|
+
self._append_log({**h["rec"], "answered": False})
|
|
385
|
+
self._emit(h["req"], h["stage"], "deny", h["rec"], h["reason"], False)
|
|
386
|
+
if any(pid not in refusals for pid in pending_ids):
|
|
387
|
+
continue # something else in the session is still open
|
|
388
|
+
for h in [x for x in list(refusals.values()) if x["dir"] == directory]:
|
|
389
|
+
refusals.pop(h["req"]["id"], None)
|
|
390
|
+
answered = await self._reply(h["req"], directory, "reject", h["message"])
|
|
391
|
+
self._append_log({**h["rec"], "answered": False} if answered is False else h["rec"])
|
|
392
|
+
self._emit(h["req"], h["stage"], "deny", h["rec"], h["reason"], answered)
|
|
393
|
+
if not refusals:
|
|
394
|
+
self._held.pop(sid, None)
|
|
395
|
+
|
|
396
|
+
# ── the loop ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
397
|
+
async def _pending(self, directory: str) -> list | None:
|
|
398
|
+
try:
|
|
399
|
+
if self._list_asks is not None:
|
|
400
|
+
return await self._list_asks(directory)
|
|
401
|
+
ok, _, data = await self._call("GET", "/permission", directory)
|
|
402
|
+
return data if ok and isinstance(data, list) else None
|
|
403
|
+
except asyncio.CancelledError:
|
|
404
|
+
raise
|
|
405
|
+
except Exception:
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
async def _poll(self) -> None:
|
|
409
|
+
if self._stopped or not self._auto:
|
|
410
|
+
return
|
|
411
|
+
cutoff = self._now() - HANDLED_TTL_S * 1000
|
|
412
|
+
for pid, at in list(self._handled.items()):
|
|
413
|
+
if at < cutoff:
|
|
414
|
+
del self._handled[pid]
|
|
415
|
+
for directory in dict.fromkeys(v["dir"] for v in self._auto.values()):
|
|
416
|
+
# None = nobody can say what is waiting (OpenCode restarting, or its list broken with no events to go on): skip the
|
|
417
|
+
# folder. Taken as "nothing is waiting", it would drop every held refusal as answered elsewhere.
|
|
418
|
+
pending = await self._pending(directory)
|
|
419
|
+
if pending is None:
|
|
420
|
+
continue
|
|
421
|
+
for req in pending:
|
|
422
|
+
if not isinstance(req, dict) or not isinstance(req.get("id"), str) or req["id"] in self._handled:
|
|
423
|
+
continue
|
|
424
|
+
sid = req.get("sessionID")
|
|
425
|
+
if not isinstance(sid, str):
|
|
426
|
+
continue
|
|
427
|
+
owner = await self._owner_of(sid, directory)
|
|
428
|
+
if not owner:
|
|
429
|
+
continue
|
|
430
|
+
self._handled[req["id"]] = self._now()
|
|
431
|
+
self._spawn(self._decide_logged(req, directory, owner))
|
|
432
|
+
await self._release_held(directory, pending)
|
|
433
|
+
|
|
434
|
+
async def _decide_logged(self, req: dict, directory: str, owner: str | None = None) -> None:
|
|
435
|
+
try:
|
|
436
|
+
await self._decide(req, directory, owner)
|
|
437
|
+
except asyncio.CancelledError:
|
|
438
|
+
raise
|
|
439
|
+
except Exception as e:
|
|
440
|
+
self._log(f"code-auto: {e}")
|
|
441
|
+
|
|
442
|
+
def _spawn(self, coro: Any) -> None:
|
|
443
|
+
t = asyncio.get_running_loop().create_task(coro)
|
|
444
|
+
self._tasks.add(t)
|
|
445
|
+
t.add_done_callback(self._tasks.discard)
|
|
446
|
+
|
|
447
|
+
async def _run(self) -> None:
|
|
448
|
+
while not self._stopped:
|
|
449
|
+
try:
|
|
450
|
+
await self._poll()
|
|
451
|
+
except asyncio.CancelledError:
|
|
452
|
+
raise
|
|
453
|
+
except Exception as e:
|
|
454
|
+
self._log(f"code-auto: {e}")
|
|
455
|
+
await asyncio.sleep(self._poll_s)
|
|
456
|
+
|
|
457
|
+
def start(self) -> None:
|
|
458
|
+
if self._loop_task is None and not self._stopped:
|
|
459
|
+
self._loop_task = asyncio.get_running_loop().create_task(self._run())
|
|
460
|
+
|
|
461
|
+
def set_auto(self, session_id: Any, directory: Any, on: bool) -> bool:
|
|
462
|
+
if not isinstance(session_id, str) or not _SESSION_ID.fullmatch(session_id):
|
|
463
|
+
return False
|
|
464
|
+
if on:
|
|
465
|
+
if not isinstance(directory, str) or not directory.startswith("/") or _js.utf16_len(directory) > 1024:
|
|
466
|
+
return False
|
|
467
|
+
self._auto[session_id] = {"dir": directory, "at": self._now()} # an existing key keeps its place, as Map.set does
|
|
468
|
+
else:
|
|
469
|
+
self._auto.pop(session_id, None)
|
|
470
|
+
self._save()
|
|
471
|
+
return True
|
|
472
|
+
|
|
473
|
+
def sessions(self) -> list[str]:
|
|
474
|
+
return list(self._auto)
|
|
475
|
+
|
|
476
|
+
def stop(self) -> None:
|
|
477
|
+
self._stopped = True
|
|
478
|
+
if self._loop_task:
|
|
479
|
+
self._loop_task.cancel()
|
|
480
|
+
for t in list(self._tasks):
|
|
481
|
+
t.cancel()
|