kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/ai.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""One-shot streaming helper for the ``ask`` command.
|
|
2
|
+
|
|
3
|
+
This is the simple, non-agentic path: send a single prompt and stream the
|
|
4
|
+
answer to the console. It runs on top of :class:`UnifiedClient` so it
|
|
5
|
+
benefits from the full provider registry (OpenRouter unless overridden). There
|
|
6
|
+
is no default model: the caller passes one, or the provider's chosen model is
|
|
7
|
+
used.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.markup import escape
|
|
16
|
+
|
|
17
|
+
from kiwimatecoder.client import ProviderError, TextDelta, UnifiedClient
|
|
18
|
+
from kiwimatecoder.config import get_prompt_cache, get_sampling, no_model_message, resolve_model
|
|
19
|
+
from kiwimatecoder.providers import ProviderConfig, default_provider
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
SYSTEM_PROMPT = (
|
|
24
|
+
"You are KiwiMateCoder, an expert coding assistant. Give clear, concise, and "
|
|
25
|
+
+ "accurate coding help. Prefer showing code over lengthy explanations."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def stream_response(
|
|
30
|
+
prompt: str,
|
|
31
|
+
api_key: str,
|
|
32
|
+
model: str | None = None,
|
|
33
|
+
provider: ProviderConfig | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Stream a single answer to the console."""
|
|
36
|
+
provider = provider or default_provider()
|
|
37
|
+
model = model or resolve_model(provider)
|
|
38
|
+
if not model:
|
|
39
|
+
console.print(f"[red]{escape(no_model_message(provider))}[/red]")
|
|
40
|
+
return
|
|
41
|
+
client = UnifiedClient(
|
|
42
|
+
provider,
|
|
43
|
+
api_key,
|
|
44
|
+
sampling=get_sampling(),
|
|
45
|
+
prompt_cache=get_prompt_cache(),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
messages: list[dict[str, Any]] = [
|
|
49
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
50
|
+
{"role": "user", "content": prompt},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
status = console.status("[dim]Thinking…[/dim]")
|
|
54
|
+
status.start()
|
|
55
|
+
thinking = True
|
|
56
|
+
try:
|
|
57
|
+
try:
|
|
58
|
+
async for event in client.stream_chat(messages, tools=None, model=model):
|
|
59
|
+
if isinstance(event, TextDelta):
|
|
60
|
+
if thinking:
|
|
61
|
+
status.stop()
|
|
62
|
+
thinking = False
|
|
63
|
+
console.print(event.text, end="", markup=False, highlight=False)
|
|
64
|
+
finally:
|
|
65
|
+
if thinking:
|
|
66
|
+
status.stop()
|
|
67
|
+
except ProviderError as exc:
|
|
68
|
+
console.print(f"\n[red]{escape(str(exc))}[/red]")
|
kiwimatecoder/audit.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Append-only audit trail of tool actions.
|
|
2
|
+
|
|
3
|
+
Every tool decision (allowed, denied, or dry-run) is written as one JSON line
|
|
4
|
+
to ``~/.kiwimatecoder/audit.log`` with secrets redacted. Logging is best-effort
|
|
5
|
+
so a read-only home directory never breaks a session.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from kiwimatecoder.config import ensure_config_dir
|
|
17
|
+
from kiwimatecoder.redaction import redact
|
|
18
|
+
|
|
19
|
+
AUDIT_LOG_NAME = "audit.log"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def audit_log_path() -> Path:
|
|
23
|
+
"""Return the audit log path (resolved lazily so tests can patch CONFIG_DIR)."""
|
|
24
|
+
return ensure_config_dir() / AUDIT_LOG_NAME
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record_tool_event(
|
|
28
|
+
*,
|
|
29
|
+
tool: str,
|
|
30
|
+
args: dict[str, Any],
|
|
31
|
+
decision: str,
|
|
32
|
+
reason: str = "",
|
|
33
|
+
duration_ms: int | None = None,
|
|
34
|
+
ok: bool | None = None,
|
|
35
|
+
hunks: tuple[int, ...] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Append one redacted JSON record; never raises."""
|
|
38
|
+
try:
|
|
39
|
+
encoded_args = redact(json.dumps(args, default=str, ensure_ascii=False))
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
encoded_args = redact(str(args))
|
|
42
|
+
entry: dict[str, Any] = {
|
|
43
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
44
|
+
"tool": tool,
|
|
45
|
+
"args": encoded_args,
|
|
46
|
+
"decision": decision,
|
|
47
|
+
}
|
|
48
|
+
if reason:
|
|
49
|
+
entry["reason"] = redact(reason)
|
|
50
|
+
if duration_ms is not None:
|
|
51
|
+
entry["duration_ms"] = duration_ms
|
|
52
|
+
if ok is not None:
|
|
53
|
+
entry["ok"] = ok
|
|
54
|
+
if hunks is not None:
|
|
55
|
+
entry["hunks"] = list(hunks)
|
|
56
|
+
try:
|
|
57
|
+
path = audit_log_path()
|
|
58
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
59
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
60
|
+
try:
|
|
61
|
+
os.chmod(path, 0o600)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
kiwimatecoder/browser.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Optional headless-browser automation backed by Playwright.
|
|
2
|
+
|
|
3
|
+
Playwright is an optional dependency::
|
|
4
|
+
|
|
5
|
+
pip install 'kiwimatecoder[browser]' && playwright install chromium
|
|
6
|
+
|
|
7
|
+
Importing this module never imports ``playwright``; the import happens lazily
|
|
8
|
+
on the first :func:`get_driver` call, so the base install stays dependency-free.
|
|
9
|
+
The sync Playwright API refuses to run inside a running asyncio event loop
|
|
10
|
+
(which is where the agent invokes synchronous tools), so the real driver owns a
|
|
11
|
+
single worker thread and proxies every action onto it. Chromium therefore talks
|
|
12
|
+
to its own thread while the tool call blocks, and successive tool calls share
|
|
13
|
+
one browser page until :func:`reset_driver` closes it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
from kiwimatecoder import config
|
|
26
|
+
|
|
27
|
+
INSTALL_HINT = (
|
|
28
|
+
"Playwright is not installed. Run: "
|
|
29
|
+
"pip install 'kiwimatecoder[browser]' && playwright install chromium"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
WORKSPACE_STATE_DIR = ".kiwimatecoder"
|
|
33
|
+
SCREENSHOT_DIR_NAME = "screenshots"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BrowserError(Exception):
|
|
37
|
+
"""A user-facing browser failure with a ready-to-show message."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Driver(Protocol):
|
|
41
|
+
"""Minimal page interface implemented by the Playwright driver and fakes."""
|
|
42
|
+
|
|
43
|
+
def open(self, url: str) -> tuple[str, str]:
|
|
44
|
+
"""Navigate to ``url`` and return ``(final_url, title)``."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def text(self) -> str:
|
|
48
|
+
"""Return the visible text of the current page."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def screenshot(self, path: Path) -> None:
|
|
52
|
+
"""Save a PNG screenshot of the current page to ``path``."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def click(self, selector: str) -> None:
|
|
56
|
+
"""Click the first element matching ``selector``."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def type(self, selector: str, text: str) -> None:
|
|
60
|
+
"""Replace the value of the element matching ``selector``."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def evaluate(self, script: str) -> Any:
|
|
64
|
+
"""Run ``script`` in the page and return its (JSON-ish) result."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
"""Close the page, browser, and Playwright driver."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _friendly(action: str, exc: Exception) -> str:
|
|
73
|
+
"""Collapse an exception into one bounded, user-facing line."""
|
|
74
|
+
detail = " ".join(str(exc).split())
|
|
75
|
+
if len(detail) > 500:
|
|
76
|
+
detail = detail[:497] + "..."
|
|
77
|
+
if not detail:
|
|
78
|
+
detail = exc.__class__.__name__
|
|
79
|
+
return f"{action} failed: {detail}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _PlaywrightDriver:
|
|
83
|
+
"""A Chromium page hosted on a dedicated worker thread."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, settings: dict[str, Any], console: Any = None) -> None:
|
|
86
|
+
self._settings = dict(settings)
|
|
87
|
+
self._console = console
|
|
88
|
+
self._executor = ThreadPoolExecutor(
|
|
89
|
+
max_workers=1, thread_name_prefix="kiwi-browser"
|
|
90
|
+
)
|
|
91
|
+
self._playwright: Any = None
|
|
92
|
+
self._browser: Any = None
|
|
93
|
+
self._page: Any = None
|
|
94
|
+
|
|
95
|
+
# -- thread plumbing ---------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def _run(self, action: str, fn: Callable[..., Any], *args: Any) -> Any:
|
|
98
|
+
future = self._executor.submit(fn, *args)
|
|
99
|
+
try:
|
|
100
|
+
return future.result()
|
|
101
|
+
except BrowserError:
|
|
102
|
+
raise
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise BrowserError(_friendly(action, exc)) from exc
|
|
105
|
+
|
|
106
|
+
def start(self) -> None:
|
|
107
|
+
"""Import Playwright and launch Chromium, raising ``BrowserError``."""
|
|
108
|
+
self._run("Could not start the browser", self._start)
|
|
109
|
+
|
|
110
|
+
def _start(self) -> None:
|
|
111
|
+
try:
|
|
112
|
+
from playwright.sync_api import sync_playwright # type: ignore[import-not-found]
|
|
113
|
+
except ImportError as exc:
|
|
114
|
+
raise BrowserError(INSTALL_HINT) from exc
|
|
115
|
+
try:
|
|
116
|
+
self._playwright = sync_playwright().start()
|
|
117
|
+
self._browser = self._playwright.chromium.launch(
|
|
118
|
+
headless=bool(self._settings.get("headless", True))
|
|
119
|
+
)
|
|
120
|
+
self._page = self._browser.new_page()
|
|
121
|
+
timeout = int(self._settings.get("timeout_ms", 15000))
|
|
122
|
+
self._page.set_default_timeout(timeout)
|
|
123
|
+
self._page.set_default_navigation_timeout(timeout)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
self._close_impl()
|
|
126
|
+
raise BrowserError(_friendly("Could not start Chromium", exc)) from exc
|
|
127
|
+
|
|
128
|
+
def _ensure_page(self) -> None:
|
|
129
|
+
if self._page is None:
|
|
130
|
+
raise BrowserError("No page is open. Use action='open' with a URL first.")
|
|
131
|
+
|
|
132
|
+
def _close_impl(self) -> None:
|
|
133
|
+
page, self._page = self._page, None
|
|
134
|
+
browser, self._browser = self._browser, None
|
|
135
|
+
playwright, self._playwright = self._playwright, None
|
|
136
|
+
for obj in (page, browser):
|
|
137
|
+
if obj is not None:
|
|
138
|
+
try:
|
|
139
|
+
obj.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
if playwright is not None:
|
|
143
|
+
try:
|
|
144
|
+
playwright.stop()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
try:
|
|
150
|
+
self._run("Could not close the browser", self._close_impl)
|
|
151
|
+
finally:
|
|
152
|
+
self._executor.shutdown(wait=False)
|
|
153
|
+
|
|
154
|
+
# -- Driver actions ----------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def open(self, url: str) -> tuple[str, str]:
|
|
157
|
+
def action() -> tuple[str, str]:
|
|
158
|
+
self._ensure_page()
|
|
159
|
+
response = self._page.goto(url, wait_until="domcontentloaded")
|
|
160
|
+
final_url = str(
|
|
161
|
+
getattr(response, "url", None) or getattr(self._page, "url", "") or url
|
|
162
|
+
)
|
|
163
|
+
try:
|
|
164
|
+
title = str(self._page.title() or "")
|
|
165
|
+
except Exception:
|
|
166
|
+
title = ""
|
|
167
|
+
return final_url, title
|
|
168
|
+
|
|
169
|
+
return self._run("Opening the page", action)
|
|
170
|
+
|
|
171
|
+
def text(self) -> str:
|
|
172
|
+
def action() -> str:
|
|
173
|
+
self._ensure_page()
|
|
174
|
+
return str(self._page.inner_text("body") or "")
|
|
175
|
+
|
|
176
|
+
return self._run("Reading the page", action)
|
|
177
|
+
|
|
178
|
+
def screenshot(self, path: Path) -> None:
|
|
179
|
+
def action() -> None:
|
|
180
|
+
self._ensure_page()
|
|
181
|
+
self._page.screenshot(path=str(path), full_page=True)
|
|
182
|
+
|
|
183
|
+
self._run("Taking the screenshot", action)
|
|
184
|
+
|
|
185
|
+
def click(self, selector: str) -> None:
|
|
186
|
+
def action() -> None:
|
|
187
|
+
self._ensure_page()
|
|
188
|
+
self._page.click(selector)
|
|
189
|
+
|
|
190
|
+
self._run("Clicking the element", action)
|
|
191
|
+
|
|
192
|
+
def type(self, selector: str, text: str) -> None:
|
|
193
|
+
def action() -> None:
|
|
194
|
+
self._ensure_page()
|
|
195
|
+
self._page.fill(selector, text)
|
|
196
|
+
|
|
197
|
+
self._run("Typing into the element", action)
|
|
198
|
+
|
|
199
|
+
def evaluate(self, script: str) -> Any:
|
|
200
|
+
def action() -> Any:
|
|
201
|
+
self._ensure_page()
|
|
202
|
+
return self._page.evaluate(script)
|
|
203
|
+
|
|
204
|
+
return self._run("Evaluating the script", action)
|
|
205
|
+
|
|
206
|
+
def set_timeout(self, timeout_ms: int) -> None:
|
|
207
|
+
def action() -> None:
|
|
208
|
+
self._ensure_page()
|
|
209
|
+
self._page.set_default_timeout(timeout_ms)
|
|
210
|
+
|
|
211
|
+
self._run("Setting the timeout", action)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_DRIVER: Driver | None = None
|
|
215
|
+
_LOCK = threading.Lock()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_driver(
|
|
219
|
+
settings: dict[str, Any] | None = None, console: Any = None
|
|
220
|
+
) -> Driver:
|
|
221
|
+
"""Return the singleton driver, launching Chromium on first use."""
|
|
222
|
+
global _DRIVER
|
|
223
|
+
with _LOCK:
|
|
224
|
+
if _DRIVER is not None:
|
|
225
|
+
return _DRIVER
|
|
226
|
+
effective = settings if settings is not None else config.get_browser()
|
|
227
|
+
driver = _PlaywrightDriver(effective, console)
|
|
228
|
+
try:
|
|
229
|
+
driver.start()
|
|
230
|
+
except Exception:
|
|
231
|
+
driver.close()
|
|
232
|
+
raise
|
|
233
|
+
_DRIVER = driver
|
|
234
|
+
return _DRIVER
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def current_driver() -> Driver | None:
|
|
238
|
+
"""Return the live driver without creating one (None before ``open``)."""
|
|
239
|
+
return _DRIVER
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def reset_driver() -> None:
|
|
243
|
+
"""Close the singleton driver, if any. Never raises."""
|
|
244
|
+
global _DRIVER
|
|
245
|
+
with _LOCK:
|
|
246
|
+
driver, _DRIVER = _DRIVER, None
|
|
247
|
+
if driver is not None:
|
|
248
|
+
try:
|
|
249
|
+
driver.close()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def screenshot_path(workspace_root: str | Path) -> Path:
|
|
255
|
+
"""Return a fresh screenshot path under the workspace, creating dirs."""
|
|
256
|
+
directory = (
|
|
257
|
+
Path(workspace_root) / WORKSPACE_STATE_DIR / SCREENSHOT_DIR_NAME
|
|
258
|
+
)
|
|
259
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
261
|
+
return directory / f"{stamp}.png"
|