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,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()