diff --git a/docs/adding-a-runner.md b/docs/adding-a-runner.md new file mode 100644 index 0000000..7fc5912 --- /dev/null +++ b/docs/adding-a-runner.md @@ -0,0 +1,122 @@ +# Adding a Runner + +This guide walks through adding a new engine to Takopi without changing the +domain model. Use the existing runners (Codex/Claude) as references. + +## Quick checklist + +1. Implement `Runner` in `src/takopi/runners/.py`. +2. Emit Takopi events from `takopi.model` and implement resume helpers + (`format_resume`, `extract_resume`, `is_resume_line`). +3. Register an `EngineBackend` in `src/takopi/engines.py` with setup checks + and runner construction. +4. Add CLI subcommand in `src/takopi/cli.py`. +5. Extend tests (runner contract + engine-specific translation tests). + +--- + +## Example: adding a `pi` engine + +This is a concrete walkthrough for an imaginary CLI called `pi`. The goal is to +make it easy to drop in another engine without changing the Takopi domain model. + +### 1) Decide engine identity + resume format + +- Engine id: `"pi"` (used in config, resume tokens, and CLI subcommand). +- Canonical resume line: the engine’s own CLI resume command, e.g. + `` `pi --resume ` ``. +- If your engine uses the standard `" resume "` format, you can + reuse `compile_resume_pattern()`. Otherwise, define a custom regex in the + runner (like Claude does). + +### 2) Implement `src/takopi/runners/pi.py` + +Skeleton outline: + +```py +ENGINE: EngineId = "pi" +_RESUME_RE = re.compile(r"(?im)^\s*`?pi\s+--resume\s+(?P[^`\\s]+)`?\\s*$") + +@dataclass +class PiRunner(SessionLockMixin, ResumeTokenMixin, Runner): + engine: EngineId = ENGINE + resume_re: re.Pattern[str] = _RESUME_RE + + pi_cmd: str = "pi" + model: str | None = None + allowed_tools: list[str] | None = None + + def _build_args(self, prompt: str, resume: ResumeToken | None) -> list[str]: + args = ["--jsonl"] + if resume is not None: + args.extend(["--resume", resume.value]) + if self.model is not None: + args.extend(["--model", self.model]) + if self.allowed_tools: + args.extend(["--allowed-tools", ",".join(self.allowed_tools)]) + args.append("--") + args.append(prompt) + return args + + async def run( + self, prompt: str, resume: ResumeToken | None + ) -> AsyncIterator[TakopiEvent]: + async for evt in self._run_with_resume_lock(prompt, resume, self._run): + yield evt +``` + +Key implementation notes: + +- Use `SessionLockMixin` to enforce per-session serialization. +- Use `ResumeTokenMixin` for `format_resume` / `extract_resume` / `is_resume_line`. +- Use `iter_jsonl(...)` + `drain_stderr(...)` from `takopi.utils.streams`. +- **Do not truncate** tool outputs in the runner; pass full strings into events. + Truncation belongs in renderers. + +### 3) Map Pi JSONL → Takopi events + +Example Pi lines (imaginary): + +```json +{"type":"session.start","session_id":"pi_01","model":"pi-large"} +{"type":"tool.use","id":"toolu_1","name":"Bash","input":{"command":"ls"}} +{"type":"tool.result","tool_use_id":"toolu_1","content":"ok","is_error":false} +{"type":"final","session_id":"pi_01","ok":true,"answer":"Done."} +``` + +Mapping guidance: + +- `session.start` → `StartedEvent(engine="pi", resume=, title=)` +- `tool.use` → `ActionEvent(phase="started")` +- `tool.result` → `ActionEvent(phase="completed")` and **pop** pending actions +- `final` → `CompletedEvent(ok, answer, resume)` (emit **exactly one**) + +If Pi emits warnings/errors before the final event, surface them as completed +`ActionEvent`s (e.g., `kind="warning"`). + +### 4) Register engine in `src/takopi/engines.py` + +Add: + +- `_pi_check_setup()` that verifies `pi` exists on PATH +- `_pi_build_runner()` that reads `[pi]` config and returns `PiRunner` +- A new `EngineBackend(id="pi", display_name="Pi", ...)` entry + +Example config (minimal): + +```toml +[pi] +model = "pi-large" +allowed_tools = ["Bash", "Read"] +``` + +### 5) Add CLI subcommand + +Expose `takopi pi` alongside `takopi codex` / `takopi claude` by adding a new +`@app.command()` in `src/takopi/cli.py`. + +### 6) Tests + fixtures + +- Add `tests/test_pi_runner.py` for translation behavior. +- Reuse `tests/test_runner_contract.py` to ensure lock/resume invariants. +- Add JSONL fixtures under `tests/fixtures/` for the Pi stream. diff --git a/docs/developing.md b/docs/developing.md index 642e5ab..a9a5f0f 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -146,12 +146,7 @@ def render_setup_guide(result: SetupResult): ## Adding a Runner -1. Implement the `Runner` protocol in `src/takopi/runners/.py`. -2. Emit Takopi events from `takopi.model` and implement resume helpers - (`format_resume`, `extract_resume`, `is_resume_line`). -3. Register an `EngineBackend` in `src/takopi/engines.py` with setup checks - and runner construction. -4. Extend tests (runner contract + any engine-specific translation tests). +See `docs/adding-a-runner.md` for the full guide and a worked example. ## Data Flow diff --git a/docs/runner/claude/claude-runner.md b/docs/runner/claude/claude-runner.md new file mode 100644 index 0000000..25e7214 --- /dev/null +++ b/docs/runner/claude/claude-runner.md @@ -0,0 +1,384 @@ +Below is a concrete implementation spec for adding **Anthropic Claude Code (“claude” CLI / Agent SDK runtime)** as a first-class engine in Takopi (v0.2.0). + +--- + +## Scope + +### Goal + +Add a new engine backend **`claude`** so Takopi can: + +* Run Claude Code non-interactively via the **Agent SDK CLI** (`claude -p`). ([Claude Code][1]) +* Stream progress in Telegram by parsing **`--output-format stream-json --verbose`** (newline-delimited JSON). Note: `--output-format` only works with `-p/--print`. ([Claude Code][1]) +* Support resumable sessions via **`--resume `** (Takopi emits a canonical resume line the user can reply with). ([Claude Code][1]) + +### Non-goals (v1) + +* Interactive Q&A inside a single run (e.g., answering `AskUserQuestion` prompts mid-flight). +* Full “slash commands” integration (Claude Code docs note many slash commands are interactive-only). ([Claude Code][1]) +* MCP prompt-handling for permissions (use allow rules instead). + +--- + +## UX and behavior + +### Engine selection + +* Existing: `takopi codex` +* New: `takopi claude` + +Takopi requires an explicit engine subcommand; `takopi` alone prints the engine +selection panel and exits. + +### Resume UX (canonical line) + +Takopi appends a **single backticked** resume line at the end of the message, like: + +```text +`claude --resume 8b2d2b30-...` +``` + +Rationale: + +* Claude Code supports resuming a specific conversation by session ID with `--resume`. ([Claude Code][1]) +* The CLI reference also documents `--resume/-r` as the resume mechanism. + +Takopi should parse either: + +* `claude --resume ` +* `claude -r ` (short form from docs) + +**Note:** Claude session IDs should be treated as **opaque strings**. Do not assume UUID format. + +### Permissions / non-interactive runs + +In `-p` mode, Claude Code can require tool approvals. Takopi cannot click/answer interactive prompts, so **users must preconfigure permissions** (via Claude Code settings or `--allowedTools`). Claude’s settings system supports allow/deny tool rules. ([Claude Code][2]) + +**Safety note:** `-p/--print` skips the workspace trust dialog; only use this flag in trusted directories. + +Takopi should document this clearly: if permissions aren’t configured and Claude tries to use a gated tool, the run may block or fail. + +--- + +## Config additions + +Takopi config lives at either: + +* `.takopi/takopi.toml` (project-local), or +* `~/.takopi/takopi.toml` (home). (Existing Takopi behavior.) + +Add a new optional `[claude]` section. + +Recommended v1 schema: + +```toml +# .takopi/takopi.toml + +engine = "claude" + +[claude] +model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too) +allowed_tools = "Bash,Read,Edit" # optional but strongly recommended for automation +dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only) +use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing) +``` + +Notes: + +* `--allowedTools` exists specifically to auto-approve tools in programmatic runs. ([Claude Code][1]) +* Claude Code tools (Bash/Edit/Write/WebSearch/etc.) and whether permission is required are documented. ([Claude Code][2]) +* Takopi only reads `model`, `allowed_tools`, `dangerously_skip_permissions`, and `use_api_billing` from `[claude]`. +* By default Takopi strips `ANTHROPIC_API_KEY` from the subprocess environment so Claude uses subscription billing. Set `use_api_billing = true` to keep the key. + +--- + +## Code changes (by file) + +### 1) `src/takopi/engines.py` + +Add a new backend: + +* Engine ID: `EngineId("claude")` + +* `check_setup()` should: + + * `shutil.which("claude")` must exist. + * Error message should include official install options and “run `claude` once to authenticate”. + + * Install methods include install scripts, Homebrew, and npm. ([Claude Code][4]) + * Agent SDK / CLI can use Claude Code authentication from running `claude`, or API key auth. ([Claude][5]) + +* `build_runner()` should parse `[claude]` config and instantiate `ClaudeRunner`. + +* `startup_message()` e.g.: + + * `takopi (claude) is ready\npwd: ...` + +### 2) New file: `src/takopi/runners/claude.py` + +Implement a new `Runner`: + +#### Public API + +* `engine: EngineId = "claude"` +* `format_resume(token) -> str`: returns `` `claude --resume {token}` `` +* `extract_resume(text) -> ResumeToken | None`: parse last match of `--resume/-r` +* `is_resume_line(line) -> bool`: matches the above patterns +* `run(prompt, resume)` async generator of `TakopiEvent` + +#### Subprocess invocation + +Use Agent SDK CLI non-interactively: + +Core invocation: + +* `claude -p --output-format stream-json --verbose` ([Claude Code][1]) + * `--verbose` overrides config and is required for full stream-json output. + +Resume: + +* add `--resume ` if resuming. ([Claude Code][1]) + +Model: + +* add `--model ` if configured. ([Claude Code][1]) + +Permissions: + +* add `--allowedTools ""` if configured. ([Claude Code][1]) +* add `--dangerously-skip-permissions` only if explicitly enabled (high risk; document clearly). + +Prompt passing: + +* Pass the prompt as the final positional argument after `--` (CLI expects `prompt` as an argument). This also protects prompts that begin with `-`. ([Claude Code][1]) + +Other flags: + +* Claude exposes more CLI flags, but Takopi does not surface them in config. + +#### Stream parsing + +In stream-json mode, Claude emits newline-delimited JSON objects. ([Claude Code][1]) + +Per the official Agent SDK TypeScript reference, message types include: + +* `system` with `subtype: 'init'` and fields like `session_id`, `cwd`, `tools`, `model`, `permissionMode`, `output_style`. ([Claude Code][3]) +* `assistant` / `user` messages with Anthropic SDK message objects. ([Claude Code][3]) +* final `result` message with: + + * `subtype: 'success'` or error subtype(s), + * `is_error`, + * `result` (string on success), + * `usage`, `total_cost_usd`, `modelUsage`, + * `errors` list on failures, + * `permission_denials`. ([Claude Code][3]) + +Takopi should: + +* Parse each line as JSON; on decode error emit a warning ActionEvent (like CodexRunner does) and continue. +* Prefer stdout for JSON; log stderr separately (do not merge). +* Treat unknown top-level fields (e.g., `parent_tool_use_id`) as optional metadata and ignore them unless needed. + +#### Mapping to Takopi events + +**StartedEvent** + +* Emit upon first `system/init` message: + + * `resume = ResumeToken(engine="claude", value=session_id)` + (treat `session_id` as opaque; do not validate as UUID) + * `title = model` (or user-specified config title; default `"claude"`) + * `meta` should include `cwd`, `tools`, `permissionMode`, `output_style` for debugging. + +**Action events (progress)** +The core useful progress comes from tool usage. + +Claude Code tools list is documented (Bash/Edit/Write/WebSearch/WebFetch/TodoWrite/Task/etc.). ([Claude Code][2]) + +Strategy: + +* When you see an **assistant message** with a content block `type: "tool_use"`: + + * Emit `ActionEvent(phase="started")` with: + + * `action.id = tool_use.id` + * `action.kind` based on tool name (complete mapping): + + * `Bash` → `command` + * `Edit`/`Write`/`NotebookEdit` → `file_change` (best-effort path extraction) + * `Read` → `tool` + * `Glob`/`Grep` → `tool` + * `WebSearch`/`WebFetch` → `web_search` + * `TodoWrite`/`TodoRead` → `note` + * `AskUserQuestion` → `note` + * `Task`/`Agent` → `tool` + * `KillShell` → `command` + * otherwise → `tool` + * `action.title`: + + * Bash: use `input.command` if present + * Read/Write/Edit/NotebookEdit: use file path (best-effort; field may be `file_path` or `path`) + * Glob/Grep: use pattern + * WebSearch: use query + * WebFetch: use URL + * TodoWrite/TodoRead: short summary (e.g., “update todos”) + * AskUserQuestion: short summary (e.g., “ask user”) + * otherwise: tool name + * `detail` includes a compacted copy of input (or a safe summary). + +* When you see a **user message** with a content block `type: "tool_result"`: + + * Emit `ActionEvent(phase="completed")` for `tool_use_id` + * `ok = not is_error` + * `content` may be a string or an array of content blocks; normalize to a string for summaries + * `detail` includes a small summary (char count / first line / “(truncated)”) + +This mirrors CodexRunner’s “started → completed” item tracking and renders well in existing `TakopiProgressRenderer`. + +**CompletedEvent** + +* Emit on `result` message: + + * `ok = (is_error == false)` (treat `is_error` as authoritative; `subtype` is informational) + * `answer = result` on success; on error, a concise message using `errors` and/or denials + * `usage` attach: + + * `total_cost_usd`, `usage`, `modelUsage`, `duration_ms`, `duration_api_ms`, `num_turns` ([Claude Code][3]) + * Always include `resume` (same session_id). +* Emit exactly one completed event per run. After emitting it, ignore any + trailing JSON lines (do not emit a second completion). +* We do not use an idle-timeout completion; completion is driven by Claude’s + `result` event or process exit handling. + +**Permission denials** +Because result includes `permission_denials`, optionally emit warning ActionEvent(s) *before* CompletedEvent (CompletedEvent must be final): + +* kind: `warning` +* title: “permission denied: ” + This preserves the “warnings before started/completed” ordering principle Takopi already tests for CodexRunner. + +#### Session serialization / locks + +Must match Takopi runner contract: + +* Lock key: `claude:` (string) in a `WeakValueDictionary` of `anyio.Lock`. +* When resuming: + + * acquire lock before spawning subprocess. +* When starting a new session: + + * you don’t know session_id until `system/init`, so: + + * spawn process, + * wait until the **first** `system/init`, + * acquire lock for that session id **before** yielding StartedEvent, + * then continue yielding. + +This mirrors CodexRunner’s correct behavior and ensures “new run + resume run” serialize once the session is known. +Assumption: Claude emits a single `system/init` per run. If multiple `init` +events arrive, ignore the subsequent ones (do not attempt to re-lock). + +#### Cancellation / termination + +Reuse the existing subprocess lifecycle pattern (like `CodexRunner.manage_subprocess`): + +* Kill the process group on cancellation +* Drain stderr concurrently (log-only) +* Ensure locks release in `finally` + +## Documentation updates + +### README + +Add a “Claude Code engine” section that covers: + +* Installation (install script / brew / npm). ([Claude Code][4]) +* Authentication: + + * run `claude` once and follow prompts, or use API key auth (Agent SDK docs mention `ANTHROPIC_API_KEY`). ([Claude][5]) +* Non-interactive permission caveat + how to configure: + + * settings allow/deny rules, + * or `--allowedTools` / `[claude].allowed_tools`. ([Claude Code][2]) +* Resume format: `` `claude --resume ` ``. + +### `docs/developing.md` + +Extend “Adding a Runner” with: + +* “ClaudeRunner parses Agent SDK stream-json output” +* Mention key message types and the init/result messages. + +--- + +## Test plan + +Mirror the existing `CodexRunner` tests patterns. + +### New tests: `tests/test_claude_runner.py` + +1. **Contract & locking** + +* `test_run_serializes_same_session` (stub `_run` like Codex tests) +* `test_run_allows_parallel_new_sessions` +* `test_run_serializes_new_session_after_session_is_known`: + + * Provide a fake `claude` executable in tmp_path that: + + * prints system/init with session_id, + * then waits on a file gate, + * a second invocation with `--resume` writes a marker file and exits, + * assert the resume invocation doesn’t run until gate opens. + +2. **Resume parsing** + +* `format_resume` returns `claude --resume ` +* `extract_resume` handles both `--resume` and `-r` + +3. **Translation / event ordering** + +* Fake `claude` outputs: + + * system/init + * assistant tool_use (Bash) + * user tool_result + * result success with `result: "ok"` +* Assert Takopi yields: + + * StartedEvent + * ActionEvent started + * ActionEvent completed + * CompletedEvent(ok=True, answer="ok") + +4. **Failure modes** + +* `result` subtype error with `errors: [...]`: + + * CompletedEvent(ok=False) +* permission_denials exist: + + * warning ActionEvent(s) emitted before CompletedEvent + +5. **Cancellation** + +* Stub `claude` that sleeps; ensure cancellation kills it (pattern already used for codex subprocess cancellation tests). + +--- + +## Implementation checklist + +* [ ] Add `ClaudeBackend` in `src/takopi/engines.py` and register in `ENGINES`. +* [ ] Add `src/takopi/runners/claude.py` implementing the `Runner` protocol. +* [ ] Add tests + stub executable fixtures. +* [ ] Update README and developing docs. +* [ ] Run full test suite. + +--- + +If you want, I can also propose the exact **event-to-action mapping table** (tool → kind/title/detail rules) you should start with, based on Claude Code’s documented tool list (Bash/Edit/Write/WebSearch/etc.). ([Claude Code][2]) + +[1]: https://code.claude.com/docs/en/headless "Run Claude Code programmatically - Claude Code Docs" +[2]: https://code.claude.com/docs/en/settings "Claude Code settings - Claude Code Docs" +[3]: https://code.claude.com/docs/en/sdk/sdk-typescript "Agent SDK reference - TypeScript - Claude Docs" +[4]: https://code.claude.com/docs/en/quickstart "Quickstart - Claude Code Docs" +[5]: https://platform.claude.com/docs/en/agent-sdk/quickstart "Quickstart - Claude Docs" diff --git a/docs/runner/claude/claude-stream-json-cheatsheet.md b/docs/runner/claude/claude-stream-json-cheatsheet.md new file mode 100644 index 0000000..ddf3477 --- /dev/null +++ b/docs/runner/claude/claude-stream-json-cheatsheet.md @@ -0,0 +1,108 @@ +# Claude `stream-json` event cheatsheet + +`claude -p --output-format stream-json --verbose` writes **one JSON object per line** +(JSONL) with a required `type` field. (`--output-format` only works with `-p`.) + +This cheatsheet is derived from `humanlayer/claudecode-go/types.go` and +`client_test.go`. + +## Top-level event lines + +### `system` (init) + +Fields: +- `type`: `"system"` +- `subtype`: `"init"` +- `session_id` +- `tools`: array of tool names +- `mcp_servers`: array of `{name, status}` +- `cwd`, `model`, `permissionMode`, `apiKeySource` (optional) + +Example: +```json +{"type":"system","subtype":"init","session_id":"session_01","cwd":"/repo","model":"sonnet","permissionMode":"auto","apiKeySource":"env","tools":["Bash","Read","Write","WebSearch"],"mcp_servers":[{"name":"approvals","status":"connected"}]} +``` + +### `assistant` / `user` + +Fields: +- `type`: `"assistant"` or `"user"` +- `session_id` +- `message` (see below) + +Example (assistant text): +```json +{"type":"assistant","session_id":"session_01","message":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"Planning next steps."}],"usage":{"input_tokens":120,"output_tokens":45}}} +``` + +Example (assistant tool use): +```json +{"type":"assistant","session_id":"session_01","message":{"id":"msg_2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}]}} +``` + +Example (user tool result, string content): +```json +{"type":"user","session_id":"session_01","message":{"id":"msg_3","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"total 2\nREADME.md\nsrc\n"}]}} +``` + +Example (user tool result, array content): +```json +{"type":"user","session_id":"session_01","message":{"id":"msg_4","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":[{"type":"text","text":"Task completed"}]}]}} +``` + +Optional parent field (for nested tool usage): +```json +{"type":"assistant","parent_tool_use_id":"toolu_parent","session_id":"session_01", ...} +``` + +### `result` + +Fields (success path): +- `type`: `"result"` +- `subtype`: `"success"` (or `"completion"`) +- `session_id` +- `total_cost_usd`, `is_error`, `duration_ms`, `duration_api_ms`, `num_turns` +- `result`: final answer string +- `usage`: usage object +- `modelUsage`: optional per-model usage + +Example (success): +```json +{"type":"result","subtype":"success","session_id":"session_01","total_cost_usd":0.0123,"is_error":false,"duration_ms":12345,"duration_api_ms":12000,"num_turns":2,"result":"Done.","usage":{"input_tokens":150,"output_tokens":70,"service_tier":"standard","server_tool_use":{"web_search_requests":0}}} +``` + +Example (error + permission denials): +```json +{"type":"result","subtype":"error","session_id":"session_02","total_cost_usd":0.001,"is_error":true,"duration_ms":2000,"duration_api_ms":1800,"num_turns":1,"result":"","error":"Permission denied","permission_denials":[{"tool_name":"Bash","tool_use_id":"toolu_9","tool_input":{"command":"git fetch origin main"}}]} +``` + +## Message object (`message` field) + +Fields: +- `id`, `type`, `role` +- `model` (optional) +- `content`: array of content blocks +- `usage` (assistant messages) + +## Content block shapes (in `message.content[]`) + +### Text +```json +{"type":"text","text":"Hello"} +``` + +### Tool use +```json +{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}} +``` + +### Tool result +String content: +```json +{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"} +``` + +Array content (Task tool format): +```json +{"type":"tool_result","tool_use_id":"toolu_2","content":[{"type":"text","text":"Task done"}]} +``` diff --git a/docs/runner/claude/claude-takopi-events.md b/docs/runner/claude/claude-takopi-events.md new file mode 100644 index 0000000..1d9b93d --- /dev/null +++ b/docs/runner/claude/claude-takopi-events.md @@ -0,0 +1,232 @@ +# Claude Code -> Takopi event mapping (spec) + +This document specifies how to add a Claude Code runner to Takopi by translating +Claude CLI `--output-format stream-json` JSONL events into Takopi events. It is +based on the reverse-engineered schema in `humanlayer/claudecode-go`: + +- `claudecode-go/types.go` (StreamEvent, Message, Content, Result) +- `claudecode-go/client.go` (CLI flags, stream parsing) +- `claudecode-go/client_test.go` (schema validation + permission_denials) + +The goal is to make a Claude runner feel identical to the Codex runner from the +bridge/renderer point of view while preserving Takopi invariants (stable action +ids, per-session serialization, single completed event). + +--- + +## 1. Input stream contract (Claude CLI) + +Claude Code CLI emits **one JSON object per line** (JSONL) when invoked with +`--output-format stream-json` (only valid with `-p/--print`). + +Recommended invocation (matches claudecode-go): + +``` +claude -p --output-format stream-json --verbose -- +``` + +Notes: +- `--verbose` is required for `stream-json` output (clis may otherwise drop events). +- `-p/--print` is required for `--output-format` and `--include-partial-messages`. +- `-- ` is required to safely pass prompts that start with `-`. +- Resuming uses `--resume ` and optional `--fork-session`. +- The CLI does **not** read the prompt from stdin in claudecode-go; it passes the + prompt as the final positional argument after `--`. + +--- + +## 2. Resume tokens and resume lines + +- Engine id: `claude` +- Canonical resume line (embedded in chat): + +``` +`claude --resume ` +``` + +Runner must implement its own regex (cannot use `compile_resume_pattern` because +that only matches ` resume `). Suggested regex: + +``` +(?im)^\s*`?claude\s+(?:--resume|-r)\s+(?P[^`\s]+)`?\s*$ +``` + +**Note:** Claude session IDs should be treated as opaque strings. + +Resume rules: +- If a resume token is provided to `run()`, the runner MUST verify that any + `session_id` observed in the stream matches it. +- If the stream yields a different `session_id`, emit a fatal error and end the run. + +--- + +## 3. Session lifecycle + serialization + +Takopi requires **serialization per session id**: + +- For new runs (`resume=None`), do **not** acquire a lock until a `session_id` + is observed (usually the first `system.init` event). +- Once the session id is known, acquire a lock for `claude:` and hold + it until the run completes. +- For resumed runs, acquire the lock immediately on entry. + +This matches the Codex runner behavior in `takopi/runners/codex.py`. + +--- + +## 4. Event translation (Claude JSONL -> Takopi) + +### 4.1 Top-level `system` events + +Claude emits a system init event early in the stream: + +``` +{"type":"system","subtype":"init","session_id":"...", ...} +``` + +**Mapping:** +- Emit a Takopi `started` event as soon as `session_id` is known. +- Assume only one `system.init` per run; if more appear, ignore the subsequent + ones to avoid re-locking. +- Optional: emit a `note` action summarizing tools/MCP servers (debug-only). + +### 4.2 `assistant` / `user` message events + +Claude messages include a `message` object with a `content[]` array. Each content +block can represent text, tool usage, or tool results. + +For each content block: + +#### A) `type = "tool_use"` +**Mapping:** emit `action` with `phase="started"`. + +- `action.id` = `content.id` +- `action.kind` = map from tool name (see section 5) +- `title`: + - if kind=`command`: use `input.command` if present + - else: tool name or derived label +- `detail` should include: + - `tool_name`, `tool_input`, `message_id`, `parent_tool_use_id` (if provided) + +#### B) `type = "tool_result"` +**Mapping:** emit `action` with `phase="completed"`. + +- `action.id` = `content.tool_use_id` +- `ok`: + - if `content.is_error` exists and is true -> `ok=False` + - else `ok=True` +- `detail` should include: + - `tool_use_id`, `content` (raw), `message_id` + +The runner SHOULD keep a small in-memory map from `tool_use_id -> tool_name` +(learned from `tool_use`) so the completed action title can match the started +action title. + +#### C) `type = "text"` +**Mapping:** +- Default: do **not** emit an action (avoid duplicate rendering). +- Store the latest assistant text as a fallback final answer if `result.result` + is empty or missing. + +#### D) `type = "thinking"` or other unknown types +**Mapping:** optional `note` action (phase completed) with title derived from +content; otherwise ignore. + +### 4.3 `result` events + +The terminal event looks like: + +``` +{"type":"result","subtype":"success", ...} +``` + +**Mapping:** emit a single Takopi `completed` event: + +- `ok = !event.is_error` +- `answer = event.result` (fallback to last assistant text if empty) +- `error = event.error` (if present) +- `resume = ResumeToken(engine="claude", value=event.session_id)` +- `usage = event.usage` (pass through) +- Emit exactly one `completed` event; ignore any trailing JSON lines afterward. + No idle-timeout completion is used. + +#### Permission denials +`result.permission_denials` may contain tool calls that were blocked. Emit a +warning action for each denial *before* the final `completed` event: + +- `action.kind = "warning"` +- `title = "permission denied: "` +- `detail = {tool_name, tool_use_id, tool_input}` +- `ok = False`, `level = "warning"` + +### 4.4 Error handling / malformed lines + +- If a JSONL line is invalid JSON: emit a warning action and continue. +- If the subprocess exits non-zero or the stream ends without a `result` event: + emit `completed` with `ok=False` and `error` explaining the failure. +- Emit **exactly one** `completed` event per run. + +--- + +## 5. Tool name -> ActionKind mapping heuristics + +Claude tool names can evolve. The runner SHOULD map based on tool name and input +shape. Suggested rules: + +| Tool name pattern | ActionKind | Title logic | +| --- | --- | --- | +| `Bash`, `Shell` | `command` | `input.command` | +| `Write`, `Edit`, `MultiEdit`, `NotebookEdit` | `file_change` | `input.path` | +| `Read` | `tool` | `Read ` | +| `WebSearch` | `web_search` | `input.query` | +| (default) | `tool` | tool name | + +For `file_change`, emit `detail.changes = [{"path": , "kind": "update"}]`. +If input indicates creation (ex: `create: true`), use `kind: "add"`. + +If a tool name is unknown, map to `tool` and include the full input in `detail`. + +--- + +## 6. Usage mapping + +Takopi `completed.usage` should mirror the Claude `result.usage` object +without transformation. Optionally include `modelUsage` inside `usage` or +`detail` if downstream consumers want it (currently unused by renderers). + +--- + +## 7. Implementation checklist (handoff) + +Add a Claude runner without changing the Takopi domain model: + +1. Create `takopi/runners/claude.py` implementing `Runner` and (custom) + resume parsing. +2. Update `takopi/engines.py`: + - add `claude` backend id + - `check_setup`: locate `claude` binary (PATH + common locations) + - `build_runner`: read `[claude]` config + construct runner + - `startup_message`: `"claude is ready\npwd: "` +3. Add new docs (this file + `claude-stream-json-cheatsheet.md`). +4. Add fixtures in `tests/fixtures/` (see below). +5. Add unit tests mirroring `tests/test_codex_*` but for Claude translation + and resume parsing (recommended, not required for initial handoff). + +--- + +## 8. Suggested Takopi config keys + +A minimal TOML config for Claude: + +```toml +[claude] +# model: opus | sonnet | haiku +model = "sonnet" + +allowed_tools = ["Bash", "Read", "Write", "WebSearch"] +dangerously_skip_permissions = false +use_api_billing = false +``` + +Takopi only maps these keys to Claude CLI flags; other options should be configured in Claude Code settings. +When `use_api_billing` is false (default), Takopi strips `ANTHROPIC_API_KEY` from the Claude subprocess environment to prefer subscription billing. diff --git a/docs/specification.md b/docs/specification.md index ceed474..0b44c20 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -108,6 +108,7 @@ The normalized event model MUST NOT live under `runners/` because it is core dom The canonical representation of “resume” embedded in chat is the runner’s **engine CLI resume command**, e.g.: - Codex: ``codex resume `` +- Claude Code: ``claude --resume `` Takopi MUST treat the runner as the authority for: @@ -290,6 +291,9 @@ Codex emits `thread.started` (with `thread_id`) before any `turn.*` / `item.*` e Codex also emits exactly one `agent_message`/`assistant_message` per turn; the runner uses that message text as `completed.answer`. +**Claude Code note (non-normative):** +Claude Code emits `system.init` (with `session_id`) before any `assistant`/`user` message objects; the runner should emit `started` on `system.init`. Claude’s final `result` message carries the session id and final answer (`result.result`), which the runner uses as `completed.answer`. + ### 6.3 Run completion event (MUST) ```python @@ -436,7 +440,8 @@ Final output MUST include: ### 9.1 v0.2.0 behavior (Decision #5) -- A single runner/engine is selected at startup via config/CLI (default: Codex). +- A single runner/engine is selected at startup via CLI subcommand (no default). +- If no engine subcommand is provided, Takopi prints the engine chooser panel and exits. - Resume extraction uses only the selected runner’s parser. - If the user attempts to resume a thread created by a different engine, resume extraction will fail and the bot treats it as a new thread. @@ -445,7 +450,7 @@ Final output MUST include: Takopi MAY support: - trying all registered runners’ `extract_resume` to auto-select a runner for resumes -- falling back to default runner when no resume is present +- selecting a preferred engine from config when no resume is present The architecture SHOULD keep this future change localized to a `RunnerRegistry` / router. diff --git a/pyproject.toml b/pyproject.toml index 1bdbd4b..e1a1ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "takopi" authors = [{name = "banteg"}] -version = "0.2.0" +version = "0.3.0.dev0" description = "Run OpenAI Codex CLI with Telegram as the human-in-the-loop interface." readme = "readme.md" license = { file = "LICENSE" } @@ -29,6 +29,7 @@ Issues = "https://github.com/banteg/takopi/issues" [project.scripts] takopi = "takopi.cli:main" +takopi-debug-onboarding = "takopi.debug_onboarding:main" [build-system] requires = ["uv_build>=0.9.18,<0.10.0"] @@ -44,5 +45,5 @@ dev = [ ] [tool.pytest.ini_options] -addopts = ["--cov=takopi", "--cov-report=term-missing"] +addopts = ["--cov=takopi", "--cov-report=term-missing", "--cov-fail-under=70"] testpaths = ["tests"] diff --git a/readme.md b/readme.md index 0e23147..a29cb14 100644 --- a/readme.md +++ b/readme.md @@ -2,22 +2,26 @@ 🐙 *he just wants to help-pi* -telegram bot for [codex](https://github.com/openai/codex). runs `codex exec --json`, streams progress, and supports resumable sessions. +telegram bridge for codex and claude code. runs the agent cli, streams progress, and supports resumable sessions. ## features -stateless resume via `codex resume ` lines in chat. +stateless resume, continue a thread in the chat or pick up in the terminal. -edits a single progress message while codex runs (commands, tools, notes, file changes, elapsed time). +progress updates while agent runs (commands, tools, notes, file changes, elapsed time). -renders markdown to telegram entities. +robust markdown rendering of output with a lot of quality of life tweaks. -runs in parallel across threads and queues per thread to keep codex history sane. +parallel runs across threads, per thread queue support. + +`/cancel` a running task. ## requirements - `uv` for installation (`curl -LsSf https://astral.sh/uv/install.sh | sh`) -- `codex` on PATH (`npm install -g @openai/codex` or `brew install codex`) +- at least one engine installed: + - `codex` on PATH (`npm install -g @openai/codex` or `brew install codex`) + - `claude` on PATH (`npm install -g @anthropic-ai/claude-code`) ## install @@ -29,12 +33,11 @@ runs in parallel across threads and queues per thread to keep codex history sane 1. get `bot_token` from [@BotFather](https://t.me/BotFather) 2. get `chat_id` from [@myidbot](https://t.me/myidbot) 3. send `/start` to the bot (telegram won't let it message you first) -4. run `codex` once interactively in the repo to trust the directory +4. run your agent cli once interactively in the repo to trust the directory ## config -takopi reads `.takopi/takopi.toml` in the current repo, otherwise `~/.takopi/takopi.toml`. -legacy `.codex/takopi.toml` is migrated automatically. +global config `~/.takopi/takopi.toml`, repo-level config `.takopi/takopi.toml` ```toml bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" @@ -43,6 +46,13 @@ chat_id = 123456789 [codex] # optional: profile from ~/.codex/config.toml profile = "takopi" + +[claude] +model = "sonnet" +allowed_tools = ["Bash", "Read", "Write", "WebSearch"] +dangerously_skip_permissions = false +# uses subscription by default, override to use api billing +use_api_billing = false ``` ## usage @@ -51,27 +61,26 @@ start takopi in the repo you want to work on: ```sh cd ~/dev/your-repo -takopi +takopi codex +# or +takopi claude ``` send a message to the bot. to continue a thread, reply to a bot message containing a resume line. +you can also copy it to resume an interactive session in your terminal. to stop a run, reply to the progress message with `/cancel`. -## cli +default: progress is silent, final answer is sent as a new message so you receive a notification, progress message is deleted. -default: progress is silent, final answer is sent as a new message (notification), progress message is deleted. - -`--no-final-notify` edits the progress message into the final answer (no new notification). - -`--debug` enables verbose logs. +if you prefer no notifications, `--no-final-notify` edits the progress message into the final answer. ## notes -* private chat only -* run exactly one instance per bot token +* private chat only: the bot only responds to the configured `chat_id` +* run only one takopi instance per bot token: multiple instances will race telegram's `getUpdates` offsets and cause missed updates ## development diff --git a/src/takopi/__init__.py b/src/takopi/__init__.py index d3ec452..dabe9f1 100644 --- a/src/takopi/__init__.py +++ b/src/takopi/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0.dev0" diff --git a/src/takopi/bridge.py b/src/takopi/bridge.py index 1eacca4..fd02f59 100644 --- a/src/takopi/bridge.py +++ b/src/takopi/bridge.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import re import time import inspect from collections import deque @@ -53,54 +52,16 @@ def _is_cancel_command(text: str) -> bool: return command == "/cancel" or command.startswith("/cancel@") -_RESUME_COMMAND_RE = re.compile( - r"(?im)^\s*`?(?P[a-z0-9_-]+)\s+resume\s+(?P(?=[^`\s]*\d)[^`\s]+)`?\s*$" -) - - -def _resume_attempt(text: str | None) -> tuple[bool, str | None]: - if not text: - return False, None - match = _RESUME_COMMAND_RE.search(text) - if match: - return True, match.group("engine").lower() - return False, None - - -def _resume_warning_text(engine_hint: str | None, current_engine: str) -> str: - if engine_hint and engine_hint.lower() != current_engine.lower(): - return ( - f"That looks like a {engine_hint} resume command, but this bot is running " - f"{current_engine}. Starting a new thread." - ) - return "Couldn't parse a resume command; starting a new thread." - - def _strip_resume_lines(text: str, *, is_resume_line: Callable[[str], bool]) -> str: stripped_lines: list[str] = [] for line in text.splitlines(): - if is_resume_line(line) or _RESUME_COMMAND_RE.match(line): + if is_resume_line(line): continue stripped_lines.append(line) prompt = "\n".join(stripped_lines).strip() return prompt or "continue" -async def _send_resume_warning( - bot: BotClient, - chat_id: int, - user_msg_id: int, - engine_hint: str | None, - current_engine: str, -) -> None: - await bot.send_message( - chat_id=chat_id, - text=_resume_warning_text(engine_hint, current_engine), - reply_to_message_id=user_msg_id, - disable_notification=True, - ) - - PROGRESS_EDIT_EVERY_S = 2.0 @@ -793,19 +754,6 @@ async def _run_main_loop( text, ) continue - if resume_token is None: - attempt_text, engine_text = _resume_attempt(text) - attempt_reply, engine_reply = _resume_attempt(r.get("text")) - attempt = attempt_text or attempt_reply - if attempt: - tg.start_soon( - _send_resume_warning, - cfg.bot, - msg["chat"]["id"], - user_msg_id, - engine_text or engine_reply, - str(cfg.runner.engine), - ) if resume_token is None: tg.start_soon( diff --git a/src/takopi/cli.py b/src/takopi/cli.py index 7462206..5738fa3 100644 --- a/src/takopi/cli.py +++ b/src/takopi/cli.py @@ -8,14 +8,9 @@ import typer from . import __version__ from .bridge import BridgeConfig, _run_main_loop from .config import ConfigError, load_telegram_config -from .engines import ( - EngineBackend, - get_backend, - get_engine_config, - list_backend_ids, -) +from .engines import EngineBackend, get_backend, get_engine_config, list_backends from .logging import setup_logging -from .onboarding import check_setup, render_setup_guide +from .onboarding import check_setup, render_engine_choice, render_setup_guide from .telegram import TelegramClient @@ -70,30 +65,7 @@ def _parse_bridge_config( ) -def run( - version: bool = typer.Option( - False, - "--version", - help="Show the version and exit.", - callback=_version_callback, - is_eager=True, - ), - final_notify: bool = typer.Option( - True, - "--final-notify/--no-final-notify", - help="Send the final response as a new message (not an edit).", - ), - engine: str = typer.Option( - "codex", - "--engine", - help=f"Engine backend id ({', '.join(list_backend_ids())}).", - ), - debug: bool = typer.Option( - False, - "--debug/--no-debug", - help="Log engine JSONL, Telegram requests, and rendered messages.", - ), -) -> None: +def _run_engine(*, engine: str, final_notify: bool, debug: bool) -> None: setup_logging(debug=debug) try: backend = get_backend(engine) @@ -115,8 +87,64 @@ def run( anyio.run(_run_main_loop, cfg) +app = typer.Typer( + add_completion=False, + invoke_without_command=True, + help="Run takopi with an explicit engine subcommand.", +) + + +@app.callback() +def app_main( + ctx: typer.Context, + version: bool = typer.Option( + False, + "--version", + help="Show the version and exit.", + callback=_version_callback, + is_eager=True, + ), +) -> None: + """Takopi CLI.""" + if ctx.invoked_subcommand is None: + render_engine_choice(list_backends()) + raise typer.Exit(code=1) + + +@app.command(help="Run with the Codex engine.") +def codex( + final_notify: bool = typer.Option( + True, + "--final-notify/--no-final-notify", + help="Send the final response as a new message (not an edit).", + ), + debug: bool = typer.Option( + False, + "--debug/--no-debug", + help="Log engine JSONL, Telegram requests, and rendered messages.", + ), +) -> None: + _run_engine(engine="codex", final_notify=final_notify, debug=debug) + + +@app.command(help="Run with the Claude engine.") +def claude( + final_notify: bool = typer.Option( + True, + "--final-notify/--no-final-notify", + help="Send the final response as a new message (not an edit).", + ), + debug: bool = typer.Option( + False, + "--debug/--no-debug", + help="Log engine JSONL, Telegram requests, and rendered messages.", + ), +) -> None: + _run_engine(engine="claude", final_notify=final_notify, debug=debug) + + def main() -> None: - typer.run(run) + app() if __name__ == "__main__": diff --git a/src/takopi/debug_onboarding.py b/src/takopi/debug_onboarding.py new file mode 100644 index 0000000..d2d15ad --- /dev/null +++ b/src/takopi/debug_onboarding.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import typer + +from .config import ConfigError +from .engines import SetupIssue, get_backend, get_install_issue, list_backend_ids +from .onboarding import SetupResult, check_setup, config_issue, render_setup_guide + + +def _dedupe_issues(issues: list[SetupIssue]) -> list[SetupIssue]: + seen: set[SetupIssue] = set() + deduped: list[SetupIssue] = [] + for issue in issues: + if issue in seen: + continue + seen.add(issue) + deduped.append(issue) + return deduped + + +def run( + engine: str = typer.Option( + "codex", + "--engine", + help=f"Engine backend id ({', '.join(list_backend_ids())}).", + ), + force: bool = typer.Option( + True, + "--force/--no-force", + help="Render onboarding panel even if setup looks OK.", + ), +) -> None: + try: + backend = get_backend(engine) + except ConfigError as e: + typer.echo(str(e), err=True) + raise typer.Exit(code=1) + setup = check_setup(backend) + if force: + forced_issues = [ + get_install_issue(backend.id), + config_issue(setup.config_path), + ] + setup = SetupResult( + issues=_dedupe_issues([*setup.issues, *forced_issues]), + config_path=setup.config_path, + ) + render_setup_guide(setup) + + +def main() -> None: + typer.run(run) + + +if __name__ == "__main__": + main() diff --git a/src/takopi/engines.py b/src/takopi/engines.py index 445610c..3bffaee 100644 --- a/src/takopi/engines.py +++ b/src/takopi/engines.py @@ -8,6 +8,7 @@ from typing import Any, Callable from .config import ConfigError from .runner import Runner from .runners.codex import CodexRunner +from .runners.claude import ClaudeRunner EngineConfig = dict[str, Any] @@ -29,15 +30,17 @@ class EngineBackend: def _codex_check_setup(_config: EngineConfig, _config_path: Path) -> list[SetupIssue]: if shutil.which("codex") is None: - return [ - SetupIssue( - "Install the Codex CLI", - (" [dim]$[/] npm install -g @openai/codex",), - ) - ] + return [_codex_install_issue()] return [] +def _codex_install_issue() -> SetupIssue: + return SetupIssue( + "Install the Codex CLI", + (" [dim]$[/] npm install -g @openai/codex",), + ) + + def _codex_build_runner(config: EngineConfig, config_path: Path) -> Runner: codex_cmd = shutil.which("codex") if not codex_cmd: @@ -77,6 +80,43 @@ def _codex_startup_message(cwd: str) -> str: return f"codex is ready\npwd: {cwd}" +def _claude_check_setup(_config: EngineConfig, _config_path: Path) -> list[SetupIssue]: + claude_cmd = "claude" + if shutil.which(claude_cmd) is None: + return [_claude_install_issue()] + return [] + + +def _claude_install_issue() -> SetupIssue: + return SetupIssue( + "Install the Claude Code CLI", + (" [dim]$[/] npm install -g @anthropic-ai/claude-code",), + ) + + +def _claude_build_runner(config: EngineConfig, _config_path: Path) -> Runner: + claude_cmd = "claude" + + model = config.get("model") + allowed_tools = config.get("allowed_tools") + dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True + use_api_billing = config.get("use_api_billing") is True + title = str(model) if model is not None else "claude" + + return ClaudeRunner( + claude_cmd=claude_cmd, + model=model, + allowed_tools=allowed_tools, + dangerously_skip_permissions=dangerously_skip_permissions, + use_api_billing=use_api_billing, + session_title=title, + ) + + +def _claude_startup_message(cwd: str) -> str: + return f"claude is ready\npwd: {cwd}" + + _ENGINE_BACKENDS: dict[str, EngineBackend] = { "codex": EngineBackend( id="codex", @@ -85,6 +125,13 @@ _ENGINE_BACKENDS: dict[str, EngineBackend] = { build_runner=_codex_build_runner, startup_message=_codex_startup_message, ), + "claude": EngineBackend( + id="claude", + display_name="Claude", + check_setup=_claude_check_setup, + build_runner=_claude_build_runner, + startup_message=_claude_startup_message, + ), } @@ -98,6 +145,15 @@ def get_backend(engine_id: str) -> EngineBackend: ) from exc +def get_install_issue(engine_id: str) -> SetupIssue: + if engine_id == "codex": + return _codex_install_issue() + if engine_id == "claude": + return _claude_install_issue() + available = ", ".join(sorted(_ENGINE_BACKENDS)) + raise ConfigError(f"Unknown engine {engine_id!r}. Available: {available}.") + + def list_backends() -> list[EngineBackend]: return list(_ENGINE_BACKENDS.values()) diff --git a/src/takopi/onboarding.py b/src/takopi/onboarding.py index e1a5560..c357220 100644 --- a/src/takopi/onboarding.py +++ b/src/takopi/onboarding.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +from typing import Sequence from rich.console import Console from rich.panel import Panel @@ -22,7 +23,7 @@ class SetupResult: return not self.issues -def _config_issue(path: Path) -> SetupIssue: +def config_issue(path: Path) -> SetupIssue: config_display = _config_path_display(path) return SetupIssue( "Create a config", @@ -51,7 +52,7 @@ def check_setup(backend: EngineBackend) -> SetupResult: config, config_path = load_telegram_config() except ConfigError: issues.extend(backend.check_setup({}, config_path)) - issues.append(_config_issue(config_path)) + issues.append(config_issue(config_path)) return SetupResult(issues=issues, config_path=config_path) token = config.get("bot_token") @@ -62,7 +63,7 @@ def check_setup(backend: EngineBackend) -> SetupResult: issues.extend(backend.check_setup(config, config_path)) if missing_or_invalid_config: - issues.append(_config_issue(config_path)) + issues.append(config_issue(config_path)) return SetupResult(issues=issues, config_path=config_path) @@ -96,10 +97,35 @@ def render_setup_guide(result: SetupResult) -> None: panel = Panel( "\n".join(parts).rstrip(), - title="[bold]Welcome to takopi![/]", + title="[bold]welcome to takopi![/]", subtitle=f"{_OCTOPUS} setup required", border_style="yellow", padding=(1, 2), expand=False, ) console.print(panel) + + +def render_engine_choice(backends: Sequence[EngineBackend]) -> None: + console = Console(stderr=True) + parts: list[str] = [] + parts.append("[bold]available engines:[/]") + parts.append("") + for idx, backend in enumerate(backends, start=1): + parts.append(f"[bold yellow]{idx}.[/] [dim]$[/] takopi {backend.id}") + if backend.id == "claude": + description = "use claude code" + else: + description = f"use {backend.display_name.lower()}" + parts.append(f" [dim]{description}[/]") + parts.append("") + + panel = Panel( + "\n".join(parts).rstrip(), + title="[bold]welcome to takopi![/]", + subtitle=f"{_OCTOPUS} choose engine", + border_style="yellow", + padding=(1, 2), + expand=False, + ) + console.print(panel) diff --git a/src/takopi/render.py b/src/takopi/render.py index 28d8a36..4b3a00a 100644 --- a/src/takopi/render.py +++ b/src/takopi/render.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Callable from .model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent +from .utils.paths import relativize_path STATUS_RUNNING = "▸" STATUS_UPDATE = "↻" @@ -21,24 +22,7 @@ MAX_FILE_CHANGES_INLINE = 3 def format_changed_file_path(path: str, *, base_dir: Path | None = None) -> str: - raw = path.strip() - if raw.startswith("./"): - raw = raw[2:] - - base = Path.cwd() if base_dir is None else base_dir - try: - raw_path = Path(raw) - except Exception: - return f"`{raw}`" - - if raw_path.is_absolute(): - try: - raw_path = raw_path.relative_to(base) - raw = raw_path.as_posix() - except Exception: - pass - - return f"`{raw}`" + return f"`{relativize_path(path, base_dir=base_dir)}`" def format_elapsed(elapsed_s: float) -> str: diff --git a/src/takopi/runner.py b/src/takopi/runner.py index 0474b6e..8334ae3 100644 --- a/src/takopi/runner.py +++ b/src/takopi/runner.py @@ -3,8 +3,11 @@ from __future__ import annotations import re -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from typing import Protocol +from weakref import WeakValueDictionary + +import anyio from .model import EngineId, ResumeToken, TakopiEvent @@ -14,7 +17,7 @@ def compile_resume_pattern(engine: EngineId) -> re.Pattern[str]: return re.compile(rf"(?im)^\s*`?{name}\s+resume\s+(?P[^`\s]+)`?\s*$") -class ResumeRunnerMixin: +class ResumeTokenMixin: engine: EngineId resume_re: re.Pattern[str] @@ -39,6 +42,39 @@ class ResumeRunnerMixin: return ResumeToken(engine=self.engine, value=found) +class SessionLockMixin: + engine: EngineId + _session_locks: WeakValueDictionary[str, anyio.Lock] + + def _lock_for(self, token: ResumeToken) -> anyio.Lock: + key = f"{token.engine}:{token.value}" + lock = self._session_locks.get(key) + if lock is None: + lock = anyio.Lock() + self._session_locks[key] = lock + return lock + + async def _run_with_resume_lock( + self, + prompt: str, + resume: ResumeToken | None, + run_fn: Callable[[str, ResumeToken | None], AsyncIterator[TakopiEvent]], + ) -> AsyncIterator[TakopiEvent]: + resume_token = resume + if resume_token is not None and resume_token.engine != self.engine: + raise RuntimeError( + f"resume token is for engine {resume_token.engine!r}, not {self.engine!r}" + ) + if resume_token is None: + async for evt in run_fn(prompt, resume_token): + yield evt + return + lock = self._lock_for(resume_token) + async with lock: + async for evt in run_fn(prompt, resume_token): + yield evt + + class Runner(Protocol): engine: str diff --git a/src/takopi/runners/claude.py b/src/takopi/runners/claude.py new file mode 100644 index 0000000..db924f1 --- /dev/null +++ b/src/takopi/runners/claude.py @@ -0,0 +1,613 @@ +from __future__ import annotations + +import logging +import os +import re +import subprocess +from collections import deque +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any, Literal +from weakref import WeakValueDictionary + +import anyio + +from ..model import ( + Action, + ActionEvent, + ActionKind, + CompletedEvent, + EngineId, + ResumeToken, + StartedEvent, + TakopiEvent, +) +from ..runner import ResumeTokenMixin, Runner, SessionLockMixin +from ..utils.paths import relativize_command, relativize_path +from ..utils.streams import drain_stderr, iter_jsonl +from ..utils.subprocess import manage_subprocess + +logger = logging.getLogger(__name__) + +ENGINE: EngineId = EngineId("claude") +STDERR_TAIL_LINES = 200 + +_RESUME_RE = re.compile( + r"(?im)^\s*`?claude\s+(?:--resume|-r)\s+(?P[^`\s]+)`?\s*$" +) + + +@dataclass +class ClaudeStreamState: + pending_actions: dict[str, Action] = field(default_factory=dict) + last_assistant_text: str | None = None + + +def _action_event( + *, + phase: Literal["started", "updated", "completed"], + action: Action, + ok: bool | None = None, + message: str | None = None, + level: Literal["debug", "info", "warning", "error"] | None = None, +) -> ActionEvent: + return ActionEvent( + engine=ENGINE, + action=action, + phase=phase, + ok=ok, + message=message, + level=level, + ) + + +def _note_completed( + action_id: str, + message: str, + *, + ok: bool = False, + detail: dict[str, Any] | None = None, +) -> ActionEvent: + return _action_event( + phase="completed", + action=Action( + id=action_id, + kind="warning", + title=message, + detail=detail or {}, + ), + ok=ok, + message=message, + level="warning" if not ok else "info", + ) + + +def _normalize_tool_result(content: Any) -> str: + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text" and isinstance(item.get("text"), str): + parts.append(item["text"]) + elif isinstance(item.get("text"), str): + parts.append(item["text"]) + elif isinstance(item, str): + parts.append(item) + return "\n".join(part for part in parts if part) + if content is None: + return "" + if isinstance(content, str): + return content + return str(content) + + +def _coerce_comma_list(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, (list, tuple, set)): + parts = [str(item) for item in value if item is not None] + joined = ",".join(part for part in parts if part) + return joined or None + text = str(value) + return text or None + + +def _tool_input_path(tool_input: dict[str, Any]) -> str | None: + for key in ("file_path", "path"): + value = tool_input.get(key) + if isinstance(value, str) and value: + return value + return None + + +def _tool_kind_and_title( + name: str, tool_input: dict[str, Any] +) -> tuple[ActionKind, str]: + if name in {"Bash", "Shell", "KillShell"}: + command = tool_input.get("command") + display = relativize_command(str(command or name)) + return "command", display + if name in {"Edit", "Write", "NotebookEdit", "MultiEdit"}: + path = _tool_input_path(tool_input) + if path: + return "file_change", relativize_path(str(path)) + return "file_change", str(name) + if name == "Read": + path = _tool_input_path(tool_input) + if path: + return "tool", f"read: `{relativize_path(str(path))}`" + return "tool", "read" + if name == "Glob": + pattern = tool_input.get("pattern") + if pattern: + return "tool", f"glob: `{pattern}`" + return "tool", "glob" + if name == "Grep": + pattern = tool_input.get("pattern") + if pattern: + return "tool", f"grep: {pattern}" + return "tool", "grep" + if name == "WebSearch": + query = tool_input.get("query") + return "web_search", str(query or "search") + if name == "WebFetch": + url = tool_input.get("url") + return "web_search", str(url or "fetch") + if name in {"TodoWrite", "TodoRead"}: + return "note", "update todos" if name == "TodoWrite" else "read todos" + if name == "AskUserQuestion": + return "note", "ask user" + if name in {"Task", "Agent"}: + desc = tool_input.get("description") or tool_input.get("prompt") + return "tool", str(desc or name) + return "tool", name + + +def _tool_action( + content: dict[str, Any], + *, + message_id: str | None, + parent_tool_use_id: str | None, +) -> Action | None: + tool_id = content.get("id") + if not isinstance(tool_id, str) or not tool_id: + return None + tool_name = str(content.get("name") or "tool") + tool_input = content.get("input") + if not isinstance(tool_input, dict): + tool_input = {} + + kind, title = _tool_kind_and_title(tool_name, tool_input) + + detail: dict[str, Any] = { + "name": tool_name, + "input": tool_input, + } + if message_id: + detail["message_id"] = message_id + if parent_tool_use_id: + detail["parent_tool_use_id"] = parent_tool_use_id + + if kind == "file_change": + path = _tool_input_path(tool_input) + if path: + detail["changes"] = [{"path": path, "kind": "update"}] + + return Action(id=tool_id, kind=kind, title=title, detail=detail) + + +def _tool_result_event( + content: dict[str, Any], + *, + action: Action, + message_id: str | None, +) -> ActionEvent: + is_error = content.get("is_error") is True + raw_result = content.get("content") + normalized = _normalize_tool_result(raw_result) + preview = normalized + + detail = dict(action.detail) + detail.update( + { + "tool_use_id": content.get("tool_use_id"), + "result_preview": preview, + "result_len": len(normalized), + "is_error": is_error, + } + ) + if message_id: + detail["message_id"] = message_id + + return _action_event( + phase="completed", + action=Action( + id=action.id, + kind=action.kind, + title=action.title, + detail=detail, + ), + ok=not is_error, + ) + + +def _extract_error(event: dict[str, Any]) -> str | None: + error = event.get("error") + if isinstance(error, str) and error: + return error + errors = event.get("errors") + if isinstance(errors, list): + for item in errors: + if isinstance(item, dict): + message = item.get("message") or item.get("error") + if isinstance(message, str) and message: + return message + elif isinstance(item, str) and item: + return item + if event.get("is_error"): + return "claude run failed" + return None + + +def _usage_payload(event: dict[str, Any]) -> dict[str, Any]: + usage: dict[str, Any] = {} + for key in ( + "total_cost_usd", + "duration_ms", + "duration_api_ms", + "num_turns", + ): + value = event.get(key) + if value is not None: + usage[key] = value + for key in ("usage", "modelUsage"): + value = event.get(key) + if value is not None: + usage[key] = value + return usage + + +def translate_claude_event( + event: dict[str, Any], + *, + title: str, + state: ClaudeStreamState, +) -> list[TakopiEvent]: + etype = event.get("type") + if etype == "system" and event.get("subtype") == "init": + session_id = event.get("session_id") + if not session_id: + return [] + model = event.get("model") + event_title = str(model) if model else title + meta: dict[str, Any] = {} + for key in ("cwd", "tools", "permissionMode", "output_style", "apiKeySource"): + if key in event: + meta[key] = event.get(key) + if "mcp_servers" in event: + meta["mcp_servers"] = event.get("mcp_servers") + + return [ + StartedEvent( + engine=ENGINE, + resume=ResumeToken(engine=ENGINE, value=str(session_id)), + title=event_title, + meta=meta or None, + ) + ] + + if etype == "assistant": + message = event.get("message") + if not isinstance(message, dict): + return [] + message_id = message.get("id") + if not isinstance(message_id, str): + message_id = None + parent_tool_use_id = event.get("parent_tool_use_id") + if not isinstance(parent_tool_use_id, str): + parent_tool_use_id = None + content_blocks = message.get("content") + if not isinstance(content_blocks, list): + return [] + out: list[TakopiEvent] = [] + for content in content_blocks: + if not isinstance(content, dict): + continue + ctype = content.get("type") + if ctype == "tool_use": + action = _tool_action( + content, + message_id=message_id, + parent_tool_use_id=parent_tool_use_id, + ) + if action is None: + continue + state.pending_actions[action.id] = action + out.append(_action_event(phase="started", action=action)) + elif ctype == "text": + text = content.get("text") + if isinstance(text, str) and text: + state.last_assistant_text = text + return out + + if etype == "user": + message = event.get("message") + if not isinstance(message, dict): + return [] + message_id = message.get("id") + if not isinstance(message_id, str): + message_id = None + content_blocks = message.get("content") + if not isinstance(content_blocks, list): + return [] + out: list[TakopiEvent] = [] + for content in content_blocks: + if not isinstance(content, dict): + continue + if content.get("type") != "tool_result": + continue + tool_use_id = content.get("tool_use_id") + if not isinstance(tool_use_id, str) or not tool_use_id: + continue + action = state.pending_actions.pop(tool_use_id, None) + if action is None: + action = Action( + id=tool_use_id, + kind="tool", + title="tool result", + detail={}, + ) + out.append( + _tool_result_event(content, action=action, message_id=message_id) + ) + return out + + if etype == "result": + out: list[TakopiEvent] = [] + for idx, denial in enumerate(event.get("permission_denials") or []): + if not isinstance(denial, dict): + continue + tool_name = denial.get("tool_name") + denial_title = "permission denied" + if isinstance(tool_name, str) and tool_name: + denial_title = f"permission denied: {tool_name}" + tool_use_id = denial.get("tool_use_id") + action_id = ( + f"claude.permission.{tool_use_id}" + if isinstance(tool_use_id, str) and tool_use_id + else f"claude.permission.{idx}" + ) + out.append( + _action_event( + phase="completed", + action=Action( + id=action_id, + kind="warning", + title=denial_title, + detail=denial, + ), + ok=False, + level="warning", + ) + ) + + ok = not event.get("is_error", False) + result_text = event.get("result") + if not isinstance(result_text, str): + result_text = "" + if ok and not result_text and state.last_assistant_text: + result_text = state.last_assistant_text + + resume_value = event.get("session_id") + resume = ( + ResumeToken(engine=ENGINE, value=str(resume_value)) + if resume_value + else None + ) + error = None if ok else _extract_error(event) + usage = _usage_payload(event) + + out.append( + CompletedEvent( + engine=ENGINE, + ok=ok, + answer=result_text, + resume=resume, + error=error, + usage=usage or None, + ) + ) + return out + + return [] + + +@dataclass +class ClaudeRunner(SessionLockMixin, ResumeTokenMixin, Runner): + engine: EngineId = ENGINE + resume_re: re.Pattern[str] = _RESUME_RE + + claude_cmd: str = "claude" + model: str | None = None + allowed_tools: list[str] | None = None + dangerously_skip_permissions: bool = False + use_api_billing: bool = False + session_title: str = "claude" + _session_locks: WeakValueDictionary[str, anyio.Lock] = field( + default_factory=WeakValueDictionary, init=False, repr=False + ) + + def format_resume(self, token: ResumeToken) -> str: + if token.engine != ENGINE: + raise RuntimeError(f"resume token is for engine {token.engine!r}") + return f"`claude --resume {token.value}`" + + def _build_args(self, prompt: str, resume: ResumeToken | None) -> list[str]: + args: list[str] = ["-p", "--output-format", "stream-json", "--verbose"] + if resume is not None: + args.extend(["--resume", resume.value]) + if self.model is not None: + args.extend(["--model", str(self.model)]) + allowed_tools = _coerce_comma_list(self.allowed_tools) + if allowed_tools is not None: + args.extend(["--allowedTools", allowed_tools]) + if self.dangerously_skip_permissions is True: + args.append("--dangerously-skip-permissions") + args.append("--") + args.append(prompt) + return args + + async def run( + self, prompt: str, resume: ResumeToken | None + ) -> AsyncIterator[TakopiEvent]: + async for evt in self._run_with_resume_lock(prompt, resume, self._run): + yield evt + + async def _run( # noqa: C901 + self, + prompt: str, + resume_token: ResumeToken | None, + ) -> AsyncIterator[TakopiEvent]: + logger.info( + "[claude] start run resume=%r", + resume_token.value if resume_token else None, + ) + logger.debug("[claude] prompt: %s", prompt) + args = [self.claude_cmd] + args.extend(self._build_args(prompt, resume_token)) + + session_lock: anyio.Lock | None = None + session_lock_acquired = False + did_emit_completed = False + note_seq = 0 + state = ClaudeStreamState() + expected_session = resume_token + found_session: ResumeToken | None = None + + def next_note_id() -> str: + nonlocal note_seq + note_seq += 1 + return f"claude.note.{note_seq}" + + try: + env: dict[str, str] | None = None + if self.use_api_billing is not True: + env = dict(os.environ) + env.pop("ANTHROPIC_API_KEY", None) + async with manage_subprocess( + *args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) as proc: + if proc.stdout is None or proc.stderr is None: + raise RuntimeError("claude failed to open subprocess pipes") + proc_stdout = proc.stdout + proc_stderr = proc.stderr + if proc.stdin is not None: + await proc.stdin.aclose() + + stderr_chunks: deque[str] = deque(maxlen=STDERR_TAIL_LINES) + rc: int | None = None + + async with anyio.create_task_group() as tg: + tg.start_soon( + drain_stderr, + proc_stderr, + stderr_chunks, + logger, + "claude", + ) + async for json_line in iter_jsonl( + proc_stdout, logger=logger, tag="claude" + ): + if did_emit_completed: + continue + if json_line.data is None: + yield _note_completed( + next_note_id(), + "invalid JSON from claude; ignoring line", + ok=False, + detail={"line": json_line.raw}, + ) + continue + evt = json_line.data + + for out_evt in translate_claude_event( + evt, + title=self.session_title, + state=state, + ): + if isinstance(out_evt, StartedEvent): + session = out_evt.resume + if session.engine != ENGINE: + raise RuntimeError( + "claude emitted session token for wrong engine" + ) + if ( + expected_session is not None + and session != expected_session + ): + raise RuntimeError( + "claude emitted a different session id than expected" + ) + if expected_session is None: + session_lock = self._lock_for(session) + await session_lock.acquire() + session_lock_acquired = True + found_session = session + yield out_evt + continue + yield out_evt + if isinstance(out_evt, CompletedEvent): + did_emit_completed = True + break + rc = await proc.wait() + + logger.debug("[claude] process exit pid=%s rc=%s", proc.pid, rc) + if did_emit_completed: + return + + if rc != 0: + stderr_text = "".join(stderr_chunks) + message = f"claude failed (rc={rc})." + yield _note_completed( + next_note_id(), + message, + ok=False, + detail={"stderr_tail": stderr_text}, + ) + resume_for_completed = found_session or resume_token + yield CompletedEvent( + engine=ENGINE, + ok=False, + answer="", + resume=resume_for_completed, + error=message, + ) + return + + if not found_session: + message = "claude finished but no session_id was captured" + resume_for_completed = resume_token + yield CompletedEvent( + engine=ENGINE, + ok=False, + answer="", + resume=resume_for_completed, + error=message, + ) + return + + message = "claude finished without a result event" + yield CompletedEvent( + engine=ENGINE, + ok=False, + answer=state.last_assistant_text or "", + resume=found_session, + error=message, + ) + finally: + if session_lock is not None and session_lock_acquired: + session_lock.release() diff --git a/src/takopi/runners/codex.py b/src/takopi/runners/codex.py index ff7d595..00d637c 100644 --- a/src/takopi/runners/codex.py +++ b/src/takopi/runners/codex.py @@ -1,20 +1,14 @@ from __future__ import annotations -import json import logging -import os -import signal import subprocess from collections import deque from collections.abc import AsyncIterator -from contextlib import asynccontextmanager from dataclasses import dataclass from typing import Any, cast from weakref import WeakValueDictionary import anyio -from anyio.abc import ByteReceiveStream, Process -from anyio.streams.text import TextReceiveStream from ..model import ( Action, ActionEvent, @@ -27,7 +21,15 @@ from ..model import ( StartedEvent, TakopiEvent, ) -from ..runner import ResumeRunnerMixin, Runner, compile_resume_pattern +from ..runner import ( + ResumeTokenMixin, + Runner, + SessionLockMixin, + compile_resume_pattern, +) +from ..utils.paths import relativize_command +from ..utils.streams import drain_stderr, iter_jsonl +from ..utils.subprocess import manage_subprocess logger = logging.getLogger(__name__) @@ -230,7 +232,7 @@ def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent] return [] if kind == "command": - title = str(item.get("command") or "") + title = relativize_command(str(item.get("command") or "")) if phase in {"started", "updated"}: return [ _action_event( @@ -406,94 +408,7 @@ def translate_codex_event(event: dict[str, Any], *, title: str) -> list[TakopiEv return [] -async def _iter_text_lines(stream: ByteReceiveStream): - text_stream = TextReceiveStream(stream, errors="replace") - buffer = "" - while True: - try: - chunk = await text_stream.receive() - except anyio.EndOfStream: - if buffer: - yield buffer - return - buffer += chunk - while True: - split_at = buffer.find("\n") - if split_at < 0: - break - line = buffer[: split_at + 1] - buffer = buffer[split_at + 1 :] - yield line - - -async def _drain_stderr(stderr: ByteReceiveStream, chunks: deque[str]) -> None: - try: - async for line in _iter_text_lines(stderr): - logger.debug("[codex][stderr] %s", line.rstrip()) - chunks.append(line) - except Exception as e: - logger.debug("[codex][stderr] drain error: %s", e) - - -async def _wait_for_process(proc: Process, timeout: float) -> bool: - with anyio.move_on_after(timeout) as scope: - await proc.wait() - return scope.cancel_called - - -def _terminate_process(proc: Process) -> None: - if proc.returncode is not None: - return - if os.name == "posix" and proc.pid is not None: - try: - os.killpg(proc.pid, signal.SIGTERM) - return - except ProcessLookupError: - return - except Exception as e: - logger.debug("[codex] failed to terminate process group: %s", e) - try: - proc.terminate() - except ProcessLookupError: - return - - -def _kill_process(proc: Process) -> None: - if proc.returncode is not None: - return - if os.name == "posix" and proc.pid is not None: - try: - os.killpg(proc.pid, signal.SIGKILL) - return - except ProcessLookupError: - return - except Exception as e: - logger.debug("[codex] failed to kill process group: %s", e) - try: - proc.kill() - except ProcessLookupError: - return - - -@asynccontextmanager -async def manage_subprocess(*args, **kwargs): - """Ensure subprocesses receive SIGTERM, then SIGKILL after a 2s timeout.""" - if os.name == "posix": - kwargs.setdefault("start_new_session", True) - proc = await anyio.open_process(args, **kwargs) - try: - yield proc - finally: - if proc.returncode is None: - with anyio.CancelScope(shield=True): - _terminate_process(proc) - timed_out = await _wait_for_process(proc, timeout=2.0) - if timed_out: - _kill_process(proc) - await proc.wait() - - -class CodexRunner(ResumeRunnerMixin, Runner): +class CodexRunner(SessionLockMixin, ResumeTokenMixin, Runner): engine: EngineId = ENGINE resume_re = _RESUME_RE @@ -511,30 +426,11 @@ class CodexRunner(ResumeRunnerMixin, Runner): WeakValueDictionary() ) - def _lock_for(self, token: ResumeToken) -> anyio.Lock: - key = f"{token.engine}:{token.value}" - lock = self._session_locks.get(key) - if lock is None: - lock = anyio.Lock() - self._session_locks[key] = lock - return lock - async def run( self, prompt: str, resume: ResumeToken | None ) -> AsyncIterator[TakopiEvent]: - resume_token = resume - if resume_token is not None and resume_token.engine != ENGINE: - raise RuntimeError( - f"resume token is for engine {resume_token.engine!r}, not {ENGINE!r}" - ) - if resume_token is None: - async for evt in self._run(prompt, resume_token): - yield evt - return - lock = self._lock_for(resume_token) - async with lock: - async for evt in self._run(prompt, resume_token): - yield evt + async for evt in self._run_with_resume_lock(prompt, resume, self._run): + yield evt async def _run( # noqa: C901 self, @@ -586,30 +482,31 @@ class CodexRunner(ResumeRunnerMixin, Runner): return f"codex.note.{note_seq}" async with anyio.create_task_group() as tg: - tg.start_soon(_drain_stderr, proc_stderr, stderr_chunks) + tg.start_soon( + drain_stderr, + proc_stderr, + stderr_chunks, + logger, + "codex", + ) await proc_stdin.send(prompt.encode()) await proc_stdin.aclose() - async for raw_line in _iter_text_lines(proc_stdout): - raw = raw_line.rstrip("\n") - logger.debug("[codex][jsonl] %s", raw) - line = raw.strip() - if not line: - continue + async for json_line in iter_jsonl( + proc_stdout, logger=logger, tag="codex" + ): if did_emit_completed: continue - try: - evt = json.loads(line) - except json.JSONDecodeError: - logger.debug("[codex] invalid json line: %s", line) + if json_line.data is None: note = _note_completed( next_note_id(), "invalid JSON from codex; ignoring line", ok=False, - detail={"line": line}, + detail={"line": json_line.line}, ) yield note continue + evt = json_line.data etype = evt.get("type") if etype == "error": diff --git a/src/takopi/runners/mock.py b/src/takopi/runners/mock.py index 1479c3d..7c85946 100644 --- a/src/takopi/runners/mock.py +++ b/src/takopi/runners/mock.py @@ -16,7 +16,7 @@ from ..model import ( StartedEvent, TakopiEvent, ) -from ..runner import ResumeRunnerMixin, Runner, compile_resume_pattern +from ..runner import ResumeTokenMixin, Runner, SessionLockMixin, compile_resume_pattern ENGINE: EngineId = EngineId("mock") @@ -59,7 +59,7 @@ def _resume_token(engine: EngineId, value: str | None) -> ResumeToken: return ResumeToken(engine=engine, value=value or uuid.uuid4().hex) -class MockRunner(ResumeRunnerMixin, Runner): +class MockRunner(SessionLockMixin, ResumeTokenMixin, Runner): engine: EngineId def __init__( @@ -81,14 +81,6 @@ class MockRunner(ResumeRunnerMixin, Runner): ) self.resume_re = compile_resume_pattern(engine) - def _lock_for(self, token: ResumeToken) -> anyio.Lock: - key = f"{token.engine}:{token.value}" - lock = self._session_locks.get(key) - if lock is None: - lock = anyio.Lock() - self._session_locks[key] = lock - return lock - async def run( self, prompt: str, resume: ResumeToken | None ) -> AsyncIterator[TakopiEvent]: diff --git a/src/takopi/utils/__init__.py b/src/takopi/utils/__init__.py new file mode 100644 index 0000000..6027b91 --- /dev/null +++ b/src/takopi/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers for Takopi.""" diff --git a/src/takopi/utils/paths.py b/src/takopi/utils/paths.py new file mode 100644 index 0000000..91f053a --- /dev/null +++ b/src/takopi/utils/paths.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def relativize_path(value: str, *, base_dir: Path | None = None) -> str: + if not value: + return value + base = Path.cwd() if base_dir is None else base_dir + base_str = str(base) + if not base_str: + return value + if value == base_str: + return "." + if value.startswith(base_str): + suffix = value[len(base_str) :] + if suffix.startswith((os.sep, "/")): + suffix = suffix[1:] + return suffix or "." + return value + + +def relativize_command(value: str, *, base_dir: Path | None = None) -> str: + base = Path.cwd() if base_dir is None else base_dir + base_with_sep = f"{base}{os.sep}" + return value.replace(base_with_sep, "") diff --git a/src/takopi/utils/streams.py b/src/takopi/utils/streams.py new file mode 100644 index 0000000..dfbf611 --- /dev/null +++ b/src/takopi/utils/streams.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections import deque +from collections.abc import AsyncIterator +from dataclasses import dataclass +import json +import logging +from typing import Any + +import anyio +from anyio.abc import ByteReceiveStream +from anyio.streams.text import TextReceiveStream + + +async def iter_text_lines(stream: ByteReceiveStream) -> AsyncIterator[str]: + text_stream = TextReceiveStream(stream, errors="replace") + buffer = "" + while True: + try: + chunk = await text_stream.receive() + except anyio.EndOfStream: + if buffer: + yield buffer + return + buffer += chunk + while True: + split_at = buffer.find("\n") + if split_at < 0: + break + line = buffer[: split_at + 1] + buffer = buffer[split_at + 1 :] + yield line + + +@dataclass(frozen=True, slots=True) +class JsonLine: + raw: str + line: str + data: dict[str, Any] | None + + +async def iter_jsonl( + stream: ByteReceiveStream, + *, + logger: logging.Logger, + tag: str, +) -> AsyncIterator[JsonLine]: + async for raw_line in iter_text_lines(stream): + raw = raw_line.rstrip("\n") + logger.debug("[%s][jsonl] %s", tag, raw) + line = raw.strip() + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + logger.debug("[%s] invalid json line: %s", tag, line) + data = None + yield JsonLine(raw=raw, line=line, data=data) + + +async def drain_stderr( + stream: ByteReceiveStream, + chunks: deque[str], + logger: logging.Logger, + tag: str, +) -> None: + try: + async for line in iter_text_lines(stream): + logger.debug("[%s][stderr] %s", tag, line.rstrip()) + chunks.append(line) + except Exception as e: + logger.debug("[%s][stderr] drain error: %s", tag, e) diff --git a/src/takopi/utils/subprocess.py b/src/takopi/utils/subprocess.py new file mode 100644 index 0000000..2a9f12f --- /dev/null +++ b/src/takopi/utils/subprocess.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +import os +import signal +from contextlib import asynccontextmanager + +import anyio +from anyio.abc import Process + +logger = logging.getLogger(__name__) + + +async def wait_for_process(proc: Process, timeout: float) -> bool: + with anyio.move_on_after(timeout) as scope: + await proc.wait() + return scope.cancel_called + + +def terminate_process(proc: Process) -> None: + if proc.returncode is not None: + return + if os.name == "posix" and proc.pid is not None: + try: + os.killpg(proc.pid, signal.SIGTERM) + return + except ProcessLookupError: + return + except Exception as e: + logger.debug("[subprocess] failed to terminate process group: %s", e) + try: + proc.terminate() + except ProcessLookupError: + return + + +def kill_process(proc: Process) -> None: + if proc.returncode is not None: + return + if os.name == "posix" and proc.pid is not None: + try: + os.killpg(proc.pid, signal.SIGKILL) + return + except ProcessLookupError: + return + except Exception as e: + logger.debug("[subprocess] failed to kill process group: %s", e) + try: + proc.kill() + except ProcessLookupError: + return + + +@asynccontextmanager +async def manage_subprocess(*args, **kwargs): + """Ensure subprocesses receive SIGTERM, then SIGKILL after a 2s timeout.""" + if os.name == "posix": + kwargs.setdefault("start_new_session", True) + proc = await anyio.open_process(args, **kwargs) + try: + yield proc + finally: + if proc.returncode is None: + with anyio.CancelScope(shield=True): + terminate_process(proc) + timed_out = await wait_for_process(proc, timeout=2.0) + if timed_out: + kill_process(proc) + await proc.wait() diff --git a/tests/fixtures/claude_stream_error.jsonl b/tests/fixtures/claude_stream_error.jsonl new file mode 100644 index 0000000..5d2d398 --- /dev/null +++ b/tests/fixtures/claude_stream_error.jsonl @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","session_id":"session_02","cwd":"/Users/banteg/dev/project","model":"sonnet","permissionMode":"manual","apiKeySource":"env","tools":["Bash","Read","Write"],"mcp_servers":[{"name":"approvals","status":"connected"}]} +{"type":"assistant","session_id":"session_02","message":{"id":"msg_10","type":"message","role":"assistant","content":[{"type":"text","text":"I need permission to run this command."}],"usage":{"input_tokens":80,"output_tokens":20}}} +{"type":"assistant","session_id":"session_02","parent_tool_use_id":"toolu_parent","message":{"id":"msg_11","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_9","name":"Bash","input":{"command":"git fetch origin main"}}]}} +{"type":"user","session_id":"session_02","message":{"id":"msg_12","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_9","content":"permission denied"}]}} +{"type":"result","subtype":"error","session_id":"session_02","total_cost_usd":0.001,"is_error":true,"duration_ms":2000,"duration_api_ms":1800,"num_turns":1,"result":"","error":"Permission denied","permission_denials":[{"tool_name":"Bash","tool_use_id":"toolu_9","tool_input":{"command":"git fetch origin main"}}]} diff --git a/tests/fixtures/claude_stream_success.jsonl b/tests/fixtures/claude_stream_success.jsonl new file mode 100644 index 0000000..77b2bd3 --- /dev/null +++ b/tests/fixtures/claude_stream_success.jsonl @@ -0,0 +1,8 @@ +{"type":"system","subtype":"init","session_id":"session_01","cwd":"/Users/banteg/dev/project","model":"sonnet","permissionMode":"auto","apiKeySource":"env","tools":["Bash","Read","Write","WebSearch","Task"],"mcp_servers":[{"name":"approvals","status":"connected"}]} +{"type":"assistant","session_id":"session_01","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet","content":[{"type":"text","text":"I'll inspect the repo, then add notes."}],"usage":{"input_tokens":120,"output_tokens":45}}} +{"type":"assistant","session_id":"session_01","message":{"id":"msg_2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}],"usage":{"input_tokens":10,"output_tokens":5}}} +{"type":"user","session_id":"session_01","message":{"id":"msg_3","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"total 2\nREADME.md\nsrc\n"}]}]}} +{"type":"assistant","session_id":"session_01","message":{"id":"msg_4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Write","input":{"path":"notes.md","content":"hello"}}]}} +{"type":"user","session_id":"session_01","message":{"id":"msg_5","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":"ok"}]}} +{"type":"assistant","session_id":"session_01","message":{"id":"msg_6","type":"message","role":"assistant","content":[{"type":"text","text":"Done. Added notes.md."}],"usage":{"input_tokens":20,"output_tokens":12}}} +{"type":"result","subtype":"success","session_id":"session_01","total_cost_usd":0.0123,"is_error":false,"duration_ms":12345,"duration_api_ms":12000,"num_turns":2,"result":"Done. Added notes.md.","usage":{"input_tokens":150,"output_tokens":70,"service_tier":"standard","server_tool_use":{"web_search_requests":0}},"modelUsage":{"sonnet":{"inputTokens":150,"outputTokens":70,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0123,"contextWindow":200000}}} diff --git a/tests/test_claude_runner.py b/tests/test_claude_runner.py new file mode 100644 index 0000000..1adf56c --- /dev/null +++ b/tests/test_claude_runner.py @@ -0,0 +1,276 @@ +import json +from pathlib import Path + +import anyio +import pytest + +from takopi.model import ActionEvent, CompletedEvent, ResumeToken, StartedEvent +from takopi.runners.claude import ( + ClaudeRunner, + ClaudeStreamState, + ENGINE, + translate_claude_event, +) + + +def _load_fixture(name: str) -> list[dict]: + path = Path(__file__).parent / "fixtures" / name + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def test_claude_resume_format_and_extract() -> None: + runner = ClaudeRunner(claude_cmd="claude") + token = ResumeToken(engine=ENGINE, value="sid") + + assert runner.format_resume(token) == "`claude --resume sid`" + assert runner.extract_resume("`claude --resume sid`") == token + assert runner.extract_resume("claude -r other") == ResumeToken( + engine=ENGINE, value="other" + ) + assert runner.extract_resume("`codex resume sid`") is None + + +def test_translate_success_fixture() -> None: + state = ClaudeStreamState() + events: list = [] + for event in _load_fixture("claude_stream_success.jsonl"): + events.extend(translate_claude_event(event, title="claude", state=state)) + + assert isinstance(events[0], StartedEvent) + started = next(evt for evt in events if isinstance(evt, StartedEvent)) + + action_events = [evt for evt in events if isinstance(evt, ActionEvent)] + assert len(action_events) == 4 + + started_actions = { + (evt.action.id, evt.phase): evt + for evt in action_events + if evt.phase == "started" + } + assert started_actions[("toolu_1", "started")].action.kind == "command" + write_action = started_actions[("toolu_2", "started")].action + assert write_action.kind == "file_change" + assert write_action.detail["changes"][0]["path"] == "notes.md" + + completed_actions = { + (evt.action.id, evt.phase): evt + for evt in action_events + if evt.phase == "completed" + } + assert completed_actions[("toolu_1", "completed")].ok is True + assert completed_actions[("toolu_2", "completed")].ok is True + + completed = next(evt for evt in events if isinstance(evt, CompletedEvent)) + assert events[-1] == completed + assert completed.ok is True + assert completed.resume == started.resume + assert completed.answer == "Done. Added notes.md." + + +def test_translate_error_fixture_permission_denials() -> None: + state = ClaudeStreamState() + events: list = [] + for event in _load_fixture("claude_stream_error.jsonl"): + events.extend(translate_claude_event(event, title="claude", state=state)) + + started = next(evt for evt in events if isinstance(evt, StartedEvent)) + completed = next(evt for evt in events if isinstance(evt, CompletedEvent)) + warnings = [ + evt + for evt in events + if isinstance(evt, ActionEvent) and evt.action.kind == "warning" + ] + + assert warnings + assert events.index(warnings[0]) < events.index(completed) + assert completed.ok is False + assert completed.error == "Permission denied" + assert completed.resume == started.resume + + +def test_tool_results_pop_pending_actions() -> None: + state = ClaudeStreamState() + + tool_use_event = { + "type": "assistant", + "message": { + "id": "msg_1", + "content": [ + { + "type": "tool_use", + "id": "toolu_1", + "name": "Bash", + "input": {"command": "echo hi"}, + } + ], + }, + } + tool_result_event = { + "type": "user", + "message": { + "id": "msg_2", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "ok", + "is_error": False, + } + ], + }, + } + + translate_claude_event(tool_use_event, title="claude", state=state) + assert "toolu_1" in state.pending_actions + + translate_claude_event(tool_result_event, title="claude", state=state) + assert not state.pending_actions + + +@pytest.mark.anyio +async def test_run_serializes_same_session() -> None: + runner = ClaudeRunner(claude_cmd="claude") + gate = anyio.Event() + in_flight = 0 + max_in_flight = 0 + + async def run_stub(*_args, **_kwargs): + nonlocal in_flight, max_in_flight + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + try: + await gate.wait() + yield CompletedEvent( + engine=ENGINE, + resume=ResumeToken(engine=ENGINE, value="sid"), + ok=True, + answer="ok", + ) + finally: + in_flight -= 1 + + runner._run = run_stub # type: ignore[assignment] + + async def drain(prompt: str, resume: ResumeToken | None) -> None: + async for _event in runner.run(prompt, resume): + pass + + token = ResumeToken(engine=ENGINE, value="sid") + async with anyio.create_task_group() as tg: + tg.start_soon(drain, "a", token) + tg.start_soon(drain, "b", token) + await anyio.sleep(0) + gate.set() + assert max_in_flight == 1 + + +@pytest.mark.anyio +async def test_run_serializes_new_session_after_session_is_known( + tmp_path, monkeypatch +) -> None: + gate_path = tmp_path / "gate" + resume_marker = tmp_path / "resume_started" + session_id = "session_01" + + claude_path = tmp_path / "claude" + claude_path.write_text( + "#!/usr/bin/env python3\n" + "import json\n" + "import os\n" + "import sys\n" + "import time\n" + "\n" + "gate = os.environ['CLAUDE_TEST_GATE']\n" + "resume_marker = os.environ['CLAUDE_TEST_RESUME_MARKER']\n" + "session_id = os.environ['CLAUDE_TEST_SESSION_ID']\n" + "\n" + "args = sys.argv[1:]\n" + "if '--resume' in args or '-r' in args:\n" + " print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n" + " with open(resume_marker, 'w', encoding='utf-8') as f:\n" + " f.write('started')\n" + " f.flush()\n" + " sys.exit(0)\n" + "\n" + "print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n" + "while not os.path.exists(gate):\n" + " time.sleep(0.001)\n" + "sys.exit(0)\n", + encoding="utf-8", + ) + claude_path.chmod(0o755) + + monkeypatch.setenv("CLAUDE_TEST_GATE", str(gate_path)) + monkeypatch.setenv("CLAUDE_TEST_RESUME_MARKER", str(resume_marker)) + monkeypatch.setenv("CLAUDE_TEST_SESSION_ID", session_id) + + runner = ClaudeRunner(claude_cmd=str(claude_path)) + + session_started = anyio.Event() + resume_value: str | None = None + new_done = anyio.Event() + + async def run_new() -> None: + nonlocal resume_value + async for event in runner.run("hello", None): + if isinstance(event, StartedEvent): + resume_value = event.resume.value + session_started.set() + new_done.set() + + async def run_resume() -> None: + assert resume_value is not None + async for _event in runner.run( + "resume", ResumeToken(engine=ENGINE, value=resume_value) + ): + pass + + async with anyio.create_task_group() as tg: + tg.start_soon(run_new) + await session_started.wait() + + tg.start_soon(run_resume) + await anyio.sleep(0.01) + + assert not resume_marker.exists() + + gate_path.write_text("go", encoding="utf-8") + await new_done.wait() + + with anyio.fail_after(2): + while not resume_marker.exists(): + await anyio.sleep(0.001) + + +@pytest.mark.anyio +async def test_run_strips_anthropic_api_key_by_default(tmp_path, monkeypatch) -> None: + claude_path = tmp_path / "claude" + claude_path.write_text( + "#!/usr/bin/env python3\n" + "import json\n" + "import os\n" + "\n" + "session_id = 'session_01'\n" + "status = 'set' if os.environ.get('ANTHROPIC_API_KEY') else 'unset'\n" + "print(json.dumps({'type': 'system', 'subtype': 'init', 'session_id': session_id}), flush=True)\n" + "print(json.dumps({'type': 'result', 'subtype': 'success', 'is_error': False, 'result': f'api={status}', 'session_id': session_id}), flush=True)\n" + "raise SystemExit(0)\n", + encoding="utf-8", + ) + claude_path.chmod(0o755) + + monkeypatch.setenv("ANTHROPIC_API_KEY", "secret") + + runner = ClaudeRunner(claude_cmd=str(claude_path)) + answer: str | None = None + async for event in runner.run("hello", None): + if isinstance(event, CompletedEvent): + answer = event.answer + assert answer == "api=unset" + + runner_api = ClaudeRunner(claude_cmd=str(claude_path), use_api_billing=True) + answer = None + async for event in runner_api.run("hello", None): + if isinstance(event, CompletedEvent): + answer = event.answer + assert answer == "api=set" diff --git a/tests/test_exec_bridge.py b/tests/test_exec_bridge.py index 63da9af..10a85a1 100644 --- a/tests/test_exec_bridge.py +++ b/tests/test_exec_bridge.py @@ -624,25 +624,6 @@ def test_cancel_command_accepts_extra_text() -> None: assert _is_cancel_command("/cancelled") is False -def test_resume_attempt_does_not_trigger_on_plain_resume_word() -> None: - from takopi.bridge import _resume_attempt - - attempt, engine = _resume_attempt("resume abc123") - assert attempt is False - assert engine is None - - -def test_resume_warning_for_other_engine() -> None: - from takopi.bridge import _resume_attempt, _resume_warning_text - - attempt, engine = _resume_attempt("claude resume abc123") - assert attempt is True - assert engine == "claude" - warning = _resume_warning_text(engine, "codex") - assert "claude" in warning.lower() - assert "codex" in warning.lower() - - @pytest.mark.anyio async def test_handle_message_cancelled_renders_cancelled_state() -> None: from takopi.bridge import BridgeConfig, handle_message diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..21bf74b --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from takopi.utils.paths import relativize_command + + +def test_relativize_command_rewrites_cwd_paths(tmp_path: Path) -> None: + base = tmp_path / "repo" + base.mkdir() + command = f'find {base}/tests -type f -name "*.py" | head -20' + expected = 'find tests -type f -name "*.py" | head -20' + assert relativize_command(command, base_dir=base) == expected + + +def test_relativize_command_rewrites_equals_paths(tmp_path: Path) -> None: + base = tmp_path / "repo" + base.mkdir() + command = f'rg -n --files -g "*.py" --path={base}/src' + expected = 'rg -n --files -g "*.py" --path=src' + assert relativize_command(command, base_dir=base) == expected diff --git a/tests/test_subprocess.py b/tests/test_subprocess.py index ddbf951..39faca9 100644 --- a/tests/test_subprocess.py +++ b/tests/test_subprocess.py @@ -2,7 +2,7 @@ import sys import pytest -from takopi.runners import codex +from takopi.utils import subprocess as subprocess_utils @pytest.mark.anyio @@ -13,9 +13,9 @@ async def test_manage_subprocess_kills_when_terminate_times_out( _ = timeout return True - monkeypatch.setattr(codex, "_wait_for_process", fake_wait_for_process) + monkeypatch.setattr(subprocess_utils, "wait_for_process", fake_wait_for_process) - async with codex.manage_subprocess( + async with subprocess_utils.manage_subprocess( sys.executable, "-c", "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(10)", diff --git a/uv.lock b/uv.lock index f434f5e..5010aec 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,6 @@ version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ @@ -51,45 +50,6 @@ version = "7.13.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, - { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, - { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, - { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, - { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, - { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, - { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, - { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, - { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, - { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, - { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, - { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, - { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, - { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, - { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, - { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, - { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, - { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, @@ -193,42 +153,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, @@ -430,7 +354,7 @@ wheels = [ [[package]] name = "takopi" -version = "0.2.0" +version = "0.3.0.dev0" source = { editable = "." } dependencies = [ { name = "anyio" },