solvewright 0.1.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.
Files changed (31) hide show
  1. solvewright/__init__.py +3 -0
  2. solvewright/__main__.py +3 -0
  3. solvewright/agents.py +450 -0
  4. solvewright/cli.py +263 -0
  5. solvewright/connection.py +137 -0
  6. solvewright/interviewer/cases/portals/brief.txt +13 -0
  7. solvewright/interviewer/cases/portals/interviewer.txt +49 -0
  8. solvewright/interviewer/cases/portals/reported-round.txt +34 -0
  9. solvewright/interviewer/data/portal-samples/sample-01.json +967 -0
  10. solvewright/interviewer/data/portal-samples/sample-02.json +967 -0
  11. solvewright/interviewer/data/portal-samples/sample-03.json +967 -0
  12. solvewright/interviewer/data/portal-samples/sample-04.json +967 -0
  13. solvewright/interviewer/data/portal-samples/sample-05.json +967 -0
  14. solvewright/interviewer/data/portal-samples/sample-06.json +967 -0
  15. solvewright/interviewer/data/portal-samples/sample-07.json +967 -0
  16. solvewright/interviewer/data/portal-samples/sample-08.json +967 -0
  17. solvewright/interviewer/interviewer.txt +113 -0
  18. solvewright/interviewer/references/case-generation.txt +52 -0
  19. solvewright/interviewer/references/curriculum.txt +21 -0
  20. solvewright/interviewer/references/scoring.txt +66 -0
  21. solvewright/interviewer/references/session-format.txt +60 -0
  22. solvewright/interviewer/tools/portal_lab.py +217 -0
  23. solvewright/runtime.py +673 -0
  24. solvewright/service.py +52 -0
  25. solvewright/settings.py +52 -0
  26. solvewright/worker.py +159 -0
  27. solvewright-0.1.0.dist-info/METADATA +132 -0
  28. solvewright-0.1.0.dist-info/RECORD +31 -0
  29. solvewright-0.1.0.dist-info/WHEEL +4 -0
  30. solvewright-0.1.0.dist-info/entry_points.txt +2 -0
  31. solvewright-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Solvewright connector: runs your own Claude Code or Codex for Interview Lab."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
solvewright/agents.py ADDED
@@ -0,0 +1,450 @@
1
+ """Running the person's own Claude Code or Codex, locked to one interview folder.
2
+
3
+ Both agents run unmodified and signed in with the person's own account. The
4
+ connector never reads or stores their credentials. What it adds is a boundary:
5
+
6
+ * Claude Code runs in `--restricted --safe-mode` with its OS sandbox for
7
+ commands: file tools stay inside the round folder, commands cannot reach the
8
+ network or read the rest of the home folder, and the person's own CLAUDE.md,
9
+ hooks, plugins and MCP servers are not loaded.
10
+ * Codex runs through its documented app-server with a permission profile that
11
+ denies the home folder except the round folders, its plugins, apps, hooks,
12
+ web search and every configured MCP server switched off.
13
+
14
+ When a platform cannot provide that boundary, the agent loses the capability
15
+ rather than the boundary: Claude Code without its sandbox gets no command
16
+ tool at all, and the connector reports it.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import queue
23
+ import re
24
+ import shutil
25
+ import signal
26
+ import subprocess
27
+ import sys
28
+ import threading
29
+ import time
30
+ from pathlib import Path
31
+
32
+ WINDOWS = sys.platform == "win32"
33
+
34
+ REPLY_SCHEMA = {"type": "object", "properties": {
35
+ "message": {"type": "string"}, "title": {"type": "string"}, "caseFamily": {"type": "string"},
36
+ "concepts": {"type": "array", "items": {"type": "string"}}, "stage": {"type": "string"},
37
+ "part": {"type": "integer"}, "parts": {"type": "integer"}, "finished": {"type": "boolean"}},
38
+ "required": ["message", "title", "caseFamily", "concepts", "stage", "part", "parts", "finished"], "additionalProperties": False}
39
+
40
+ # Claude Code resolves these aliases to the newest model of each family.
41
+ CLAUDE_MODELS = [{"id": "", "name": "Your Claude Code default"}, {"id": "fable", "name": "Fable"},
42
+ {"id": "opus", "name": "Opus"}, {"id": "sonnet", "name": "Sonnet"}, {"id": "haiku", "name": "Haiku"}]
43
+ CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]
44
+
45
+ # Codex features that would give the interviewer reach beyond its folder.
46
+ CODEX_FEATURES_OFF = ("apps", "plugins", "plugin_hooks", "hooks", "computer_use", "browser_use", "in_app_browser",
47
+ "memories", "multi_agent", "multi_agent_v2", "remote_control", "image_generation", "js_repl",
48
+ "enable_mcp_apps", "standalone_web_search", "web_search_request", "tool_suggest",
49
+ "request_permissions_tool")
50
+ CLAUDE_FLAGS = ("--restricted", "--safe-mode", "--permission-prompts", "--strict-mcp-config")
51
+
52
+
53
+ def executable(name: str) -> str | None:
54
+ if WINDOWS:
55
+ native = shutil.which(name + ".exe")
56
+ if native:
57
+ return native
58
+ candidates = [Path.home() / ".local" / "bin" / (name + ".exe")]
59
+ if name == "codex" and os.environ.get("LOCALAPPDATA"):
60
+ candidates.append(Path(os.environ["LOCALAPPDATA"]) / "Programs" / "OpenAI" / "Codex" / "bin" / "codex.exe")
61
+ for candidate in candidates:
62
+ if candidate.is_file():
63
+ return str(candidate)
64
+ found = shutil.which(name)
65
+ fallback = Path.home() / ".local" / "bin" / name
66
+ return found or (str(fallback) if fallback.is_file() else None)
67
+
68
+
69
+ def provider_executable(name: str) -> str:
70
+ binary = executable(name)
71
+ label = "Claude Code" if name == "claude" else "Codex"
72
+ if not binary:
73
+ raise ValueError(f"{label} is not installed on this computer. Install it, sign in once in a terminal, then restart the connector.")
74
+ if WINDOWS and Path(binary).suffix.lower() in (".cmd", ".bat", ".ps1"):
75
+ raise ValueError(f"The connector needs the native Windows {name}.exe. Install the native {label} and open a new terminal.")
76
+ return binary
77
+
78
+
79
+ def run_quietly(command: list[str], timeout: float = 10) -> str:
80
+ try:
81
+ done = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
82
+ except (OSError, subprocess.SubprocessError):
83
+ return ""
84
+ return (done.stdout or "") + (done.stderr or "")
85
+
86
+
87
+ def version_of(name: str) -> str | None:
88
+ binary = executable(name)
89
+ if not binary:
90
+ return None
91
+ match = re.search(r"\d+\.\d+\.\d+", run_quietly([binary, "--version"]))
92
+ return match.group(0) if match else "unknown"
93
+
94
+
95
+ def claude_supports_isolation(binary: str) -> bool:
96
+ help_text = run_quietly([binary, "--help"])
97
+ return all(flag in help_text for flag in CLAUDE_FLAGS)
98
+
99
+
100
+ def claude_can_sandbox() -> bool:
101
+ if sys.platform == "darwin":
102
+ return Path("/usr/bin/sandbox-exec").exists()
103
+ if sys.platform.startswith("linux"):
104
+ return bool(shutil.which("bwrap") and shutil.which("socat"))
105
+ return False
106
+
107
+
108
+ _described: tuple[float, dict] | None = None
109
+
110
+
111
+ def describe(max_age: float = 300) -> dict:
112
+ """What the browser shows about this computer's agents. No network, no model call."""
113
+ global _described
114
+ if _described and time.monotonic() - _described[0] < max_age:
115
+ return json.loads(json.dumps(_described[1]))
116
+ agents = {}
117
+ for name in ("claude", "codex"):
118
+ version = version_of(name)
119
+ if name == "claude":
120
+ isolation = "sandbox" if claude_can_sandbox() else "no-commands"
121
+ else:
122
+ isolation = "sandbox"
123
+ agents[name] = {"available": version is not None, "version": version, "isolation": isolation if version else None}
124
+ _described = (time.monotonic(), agents)
125
+ return json.loads(json.dumps(agents))
126
+
127
+
128
+ def agent_environment() -> dict:
129
+ environment = os.environ.copy()
130
+ environment.update(PYTHONIOENCODING="utf-8", PYTHONUTF8="1")
131
+ if WINDOWS:
132
+ environment["PATH"] = str(Path(sys.executable).parent) + os.pathsep + environment.get("PATH", "")
133
+ return environment
134
+
135
+
136
+ def python_command() -> str:
137
+ return Path(sys.executable).name if WINDOWS else "python3"
138
+
139
+
140
+ def python_read_roots() -> list[str]:
141
+ """Where the interviewer's `python3` lives, when that is inside the home folder."""
142
+ home = Path.home().resolve()
143
+ python = shutil.which("python3")
144
+ if not python:
145
+ return []
146
+ roots: set[Path] = set()
147
+ found = Path(python)
148
+ try:
149
+ reply = json.loads(run_quietly([python, "-c", "import json,site,sys;print(json.dumps([sys.base_prefix,sys.prefix,site.getusersitepackages()]))"]).strip().splitlines()[-1])
150
+ roots.update(Path(item) for item in reply if isinstance(item, str))
151
+ except (ValueError, IndexError):
152
+ pass
153
+ if found.is_relative_to(home) and found.parent.parent != home:
154
+ roots.add(found.parent.parent) # A version manager's shims and versions, for example ~/.pyenv.
155
+ return sorted(str(root.resolve()) for root in roots if root.resolve().is_relative_to(home) and root.resolve() != home)
156
+
157
+
158
+ def interrupt_process(process) -> None:
159
+ if process.poll() is not None:
160
+ return
161
+ try:
162
+ process.send_signal(signal.CTRL_BREAK_EVENT if WINDOWS else signal.SIGINT)
163
+ if WINDOWS:
164
+ try:
165
+ process.wait(timeout=3)
166
+ except subprocess.TimeoutExpired:
167
+ process.terminate()
168
+ except ProcessLookupError:
169
+ pass
170
+ except OSError:
171
+ if not WINDOWS:
172
+ raise
173
+ process.terminate()
174
+
175
+
176
+ def claude_command(binary: str, directory: Path, state: dict, sandbox: bool, read_roots: list[str]) -> list[str]:
177
+ command = [binary, "--print", "--output-format", "stream-json", "--verbose", "--json-schema", json.dumps(REPLY_SCHEMA),
178
+ "--restricted", "--safe-mode", "--strict-mcp-config",
179
+ "--permission-mode", "acceptEdits", "--permission-prompts", "none"]
180
+ if sandbox:
181
+ settings = {"sandbox": {
182
+ "enabled": True, "failIfUnavailable": True, "allowUnsandboxedCommands": False, "autoAllowBashIfSandboxed": True,
183
+ "filesystem": {"denyRead": ["~"], "allowRead": [str(directory), *read_roots]},
184
+ "network": {"allowedDomains": []}}}
185
+ command += ["--tools", "Read,Write,Edit,Glob,Grep,Bash", "--settings", json.dumps(settings)]
186
+ else:
187
+ command += ["--tools", "Read,Write,Edit,Glob,Grep"]
188
+ if state.get("agentSessionId"):
189
+ command += ["--resume", state["agentSessionId"]]
190
+ model = state["settings"].get("model", "").strip()
191
+ if model:
192
+ command += ["--model", model]
193
+ effort = state["settings"].get("effort", "").strip()
194
+ if effort:
195
+ command += ["--effort", effort]
196
+ return command
197
+
198
+
199
+ def claude_preamble(sandbox: bool) -> str:
200
+ python = python_command()
201
+ if sandbox:
202
+ return (f"Runtime tool constraint: commands run in a sandbox with no network, and can read and write only inside "
203
+ f"this round folder. Use {python} for checks, for example `{python} .examiner/verify_setup.py`. Use "
204
+ f"Read, Write and Edit for files. If a step is blocked, say so in your structured reply.\n\n")
205
+ return ("Runtime tool constraint: on this computer you have no command tool, because Claude Code's sandbox is not "
206
+ "available here. Verify the setup and candidate work by careful inspection and hand-traces, record in "
207
+ ".examiner/manifest.json that the reference was not executed, and say so when it matters.\n\n")
208
+
209
+
210
+ class CodexClient:
211
+ """Newline JSON-RPC client for Codex's documented app-server, isolated."""
212
+
213
+ def __init__(self, logs: Path, write_root: Path):
214
+ self.binary = provider_executable("codex")
215
+ self.write_root = write_root
216
+ logs.mkdir(parents=True, exist_ok=True)
217
+ self.log = (logs / "codex-runtime.log").open("a", encoding="utf-8")
218
+ self.lock = threading.Lock()
219
+ self.condition = threading.Condition()
220
+ self.pending, self.finished, self.messages = {}, {}, {}
221
+ self.sequence = 0
222
+ self.loaded: set[str] = set()
223
+ self.closed = threading.Event()
224
+ self.isolation = "sandbox"
225
+ self.process = None
226
+ try:
227
+ # Learn the configured MCP servers without starting any, then start
228
+ # the app-server once with every one of them already switched off.
229
+ self.disable = self._configured_mcp_servers()
230
+ self._launch(self._overrides(profile=True, disable=self.disable))
231
+ still = [name for name, server in self._mcp_servers().items() if server.get("enabled", True)]
232
+ if still:
233
+ raise ValueError("Codex kept MCP servers enabled (" + ", ".join(still) + "); the examiner will not run with them.")
234
+ except Exception:
235
+ self.close()
236
+ raise
237
+
238
+ def _configured_mcp_servers(self) -> list[str]:
239
+ features = [part for name in CODEX_FEATURES_OFF for part in ("-c", f"features.{name}=false")]
240
+ try:
241
+ done = subprocess.run([self.binary, "mcp", "list", "--json", *features], capture_output=True, text=True, encoding="utf-8",
242
+ errors="replace", timeout=30, env=agent_environment())
243
+ servers = json.loads(done.stdout) if done.returncode == 0 else None
244
+ except (OSError, subprocess.SubprocessError, ValueError):
245
+ servers = None
246
+ if not isinstance(servers, list):
247
+ raise ValueError("This Codex cannot list its MCP servers, so the connector cannot switch them off. Update Codex, then try again.")
248
+ return sorted({server["name"] for server in servers if isinstance(server, dict) and isinstance(server.get("name"), str)})
249
+
250
+ def _overrides(self, profile: bool, disable=()) -> list[str]:
251
+ values = ["notify=[]", 'web_search="disabled"']
252
+ values += [f"features.{name}=false" for name in CODEX_FEATURES_OFF]
253
+ for name in disable:
254
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", name):
255
+ raise ValueError(f"Codex has an MCP server named {name!r} that the connector cannot switch off safely.")
256
+ values.append(f"mcp_servers.{name}.enabled=false")
257
+ if profile:
258
+ readable = {"~/.local/bin", *self._install_roots()}
259
+ entries = ['"~"="deny"'] + [f'{json.dumps(path)}="read"' for path in sorted(readable)]
260
+ entries.append(f'{json.dumps(str(self.write_root))}="write"')
261
+ values += ['default_permissions="solvewright"', 'permissions.solvewright.extends=":workspace"',
262
+ "permissions.solvewright.filesystem={" + ", ".join(entries) + "}"]
263
+ self.profile = profile
264
+ return [part for value in values for part in ("-c", value)]
265
+
266
+ def _install_roots(self) -> list[str]:
267
+ home = Path.home().resolve()
268
+ roots = []
269
+ for path in {Path(self.binary), Path(self.binary).resolve()}:
270
+ parent = path.parent
271
+ if parent.resolve().is_relative_to(home):
272
+ # The release folder, e.g. ~/.codex/packages/standalone/releases/<version>/.
273
+ roots.append(str(parent.parent.resolve() if parent.name == "bin" else parent.resolve()))
274
+ return roots
275
+
276
+ def _launch(self, overrides: list[str]) -> None:
277
+ self.closed.clear()
278
+ self.process = subprocess.Popen([self.binary, "app-server", *overrides], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
279
+ stderr=self.log, text=True, encoding="utf-8", env=agent_environment(), bufsize=1,
280
+ cwd=str(self.write_root))
281
+ self.reader = threading.Thread(target=self._read, args=(self.process,), daemon=True)
282
+ self.reader.start()
283
+ self.rpc("initialize", {"clientInfo": {"name": "solvewright_connector", "title": "Solvewright Interview Lab", "version": "1"}})
284
+ self.send({"method": "initialized", "params": {}})
285
+
286
+ def _mcp_servers(self) -> dict:
287
+ reply = self.rpc("config/read", {"includeLayers": False})
288
+ config = reply.get("config") or reply
289
+ servers = config.get("mcp_servers") or {}
290
+ return servers if isinstance(servers, dict) else {}
291
+
292
+ def _stop(self) -> None:
293
+ if self.process and self.process.poll() is None:
294
+ self.process.terminate()
295
+ try:
296
+ self.process.wait(timeout=5)
297
+ except subprocess.TimeoutExpired:
298
+ self.process.kill()
299
+ self.process.wait()
300
+ reader = getattr(self, "reader", None)
301
+ if reader and reader is not threading.current_thread():
302
+ reader.join(timeout=5)
303
+
304
+ def send(self, value) -> None:
305
+ with self.lock:
306
+ self.process.stdin.write(json.dumps(value) + "\n")
307
+ self.process.stdin.flush()
308
+
309
+ def rpc(self, method: str, params: dict, timeout: float = 45):
310
+ with self.condition:
311
+ self.sequence += 1
312
+ key = self.sequence
313
+ response: queue.Queue = queue.Queue()
314
+ self.pending[key] = response
315
+ try:
316
+ if self.closed.is_set():
317
+ raise ValueError("Codex disconnected. Send another message to resume this round.")
318
+ self.send({"id": key, "method": method, "params": params})
319
+ message = response.get(timeout=timeout)
320
+ except queue.Empty:
321
+ raise ValueError(f"Codex did not answer {method}. Check the connector's terminal.")
322
+ except (BrokenPipeError, OSError) as error:
323
+ raise ValueError("Codex disconnected. Send another message to resume this round.") from error
324
+ finally:
325
+ with self.condition:
326
+ self.pending.pop(key, None)
327
+ if "error" in message:
328
+ raise ValueError(message["error"].get("message", "Codex request failed."))
329
+ return message.get("result", {})
330
+
331
+ def _read(self, process) -> None:
332
+ try:
333
+ for line in process.stdout:
334
+ try:
335
+ data = json.loads(line)
336
+ if "id" in data and ("result" in data or "error" in data):
337
+ target = self.pending.get(data["id"])
338
+ if target:
339
+ target.put(data)
340
+ elif "id" in data and "method" in data:
341
+ # No approval is ever granted on the person's behalf.
342
+ self.send({"id": data["id"], "error": {"code": -32601, "message": "Approval is unavailable in Interview Lab."}})
343
+ elif data.get("method") == "item/completed":
344
+ params = data["params"]
345
+ item = params.get("item", {})
346
+ if item.get("type") == "agentMessage":
347
+ self.messages[(params["threadId"], params["turnId"])] = item.get("text", "")
348
+ elif data.get("method") == "turn/completed":
349
+ params = data["params"]
350
+ with self.condition:
351
+ self.finished[(params["threadId"], params["turn"]["id"])] = params["turn"]
352
+ self.condition.notify_all()
353
+ except (ValueError, KeyError, TypeError, AttributeError):
354
+ continue
355
+ finally:
356
+ if process is self.process:
357
+ with self.condition:
358
+ self.closed.set()
359
+ for target in self.pending.values():
360
+ target.put({"error": {"message": "Codex disconnected. Send another message to resume this round."}})
361
+ self.condition.notify_all()
362
+
363
+ def _start_thread(self, directory: Path, model: str) -> str:
364
+ params = {"cwd": str(directory), "approvalPolicy": "never", "serviceName": "solvewright-interviews"}
365
+ if not self.profile:
366
+ params["sandbox"] = "workspace-write"
367
+ if model:
368
+ params["model"] = model
369
+ try:
370
+ return self.rpc("thread/start", params)["thread"]["id"]
371
+ except ValueError as error:
372
+ if not self.profile or "not permitted" not in str(error).lower():
373
+ raise
374
+ # This Codex cannot start under the read-restricting profile: keep the
375
+ # write and network limits, and say plainly that reads are not limited.
376
+ self._stop()
377
+ self._launch(self._overrides(profile=False, disable=self.disable))
378
+ self.isolation = "writes"
379
+ params["sandbox"] = "workspace-write"
380
+ return self.rpc("thread/start", params)["thread"]["id"]
381
+
382
+ def turn(self, state: dict, directory: Path, prompt: str, callback, cancelled=None) -> dict:
383
+ if cancelled and cancelled.is_set():
384
+ raise ValueError("The interviewer turn was stopped. Send a message to continue.")
385
+ model = state["settings"].get("model", "").strip()
386
+ if not state.get("agentSessionId"):
387
+ state["agentSessionId"] = self._start_thread(directory, model)
388
+ self.loaded.add(state["agentSessionId"])
389
+ callback()
390
+ thread = state["agentSessionId"]
391
+ if thread not in self.loaded:
392
+ resume = {"threadId": thread, "excludeTurns": True}
393
+ if not self.profile:
394
+ resume["sandbox"] = "workspace-write"
395
+ self.rpc("thread/resume", resume)
396
+ self.loaded.add(thread)
397
+ if cancelled and cancelled.is_set():
398
+ raise ValueError("The interviewer turn was stopped. Send a message to continue.")
399
+ params = {"threadId": thread, "cwd": str(directory), "input": [{"type": "text", "text": prompt}],
400
+ "outputSchema": REPLY_SCHEMA, "approvalPolicy": "never"}
401
+ effort = state["settings"].get("effort", "").strip()
402
+ if effort:
403
+ params["effort"] = effort
404
+ if not self.profile:
405
+ params["sandboxPolicy"] = {"type": "workspaceWrite", "writableRoots": [str(directory)], "networkAccess": False}
406
+ started = self.rpc("turn/start", params)
407
+ turn = started["turn"]["id"]
408
+ state["activeTurn"] = turn
409
+ callback()
410
+ if cancelled and cancelled.is_set():
411
+ self.interrupt(state)
412
+ key = (thread, turn)
413
+ with self.condition:
414
+ while key not in self.finished:
415
+ if self.closed.is_set() or self.process.poll() is not None:
416
+ raise ValueError("Codex stopped. Send another message to resume this round.")
417
+ self.condition.wait(timeout=5)
418
+ completed = self.finished.pop(key)
419
+ if completed["status"] != "completed":
420
+ error = completed.get("error") or {}
421
+ raise ValueError(error.get("message", f"Codex turn {completed['status']}. Your files and conversation were kept."))
422
+ content = self.messages.pop(key, "")
423
+ if not content:
424
+ content = next((item.get("text", "") for item in reversed(completed.get("items", [])) if item.get("type") == "agentMessage"), "")
425
+ return json.loads(content)
426
+
427
+ def models(self) -> list[dict]:
428
+ """The models this Codex account offers, with the efforts each one supports. No model call."""
429
+ found, cursor = [], None
430
+ for _page in range(5):
431
+ reply = self.rpc("model/list", {"cursor": cursor, "limit": 50} if cursor else {"limit": 50})
432
+ for model in reply.get("data", []):
433
+ if model.get("hidden"):
434
+ continue
435
+ found.append({"id": model.get("model") or model.get("id"), "name": model.get("displayName") or model.get("id"),
436
+ "default": bool(model.get("isDefault")), "defaultEffort": model.get("defaultReasoningEffort"),
437
+ "efforts": [option.get("reasoningEffort") for option in model.get("supportedReasoningEfforts", []) if option.get("reasoningEffort")]})
438
+ cursor = reply.get("nextCursor")
439
+ if not cursor:
440
+ break
441
+ return found
442
+
443
+ def interrupt(self, state: dict) -> None:
444
+ if state.get("activeTurn"):
445
+ self.rpc("turn/interrupt", {"threadId": state["agentSessionId"], "turnId": state["activeTurn"]})
446
+
447
+ def close(self) -> None:
448
+ self._stop()
449
+ self.closed.set()
450
+ self.log.close()