feat: claude code runner (#9)

This commit is contained in:
banteg
2026-01-01 17:04:49 +04:00
committed by GitHub
parent 4885e3b878
commit 936ea5109b
30 changed files with 2259 additions and 382 deletions
+122
View File
@@ -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/<engine>.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 engines own CLI resume command, e.g.
`` `pi --resume <session_id>` ``.
- If your engine uses the standard `"<engine> resume <token>"` 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<token>[^`\\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=<session_id>, title=<model>)`
- `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.
+1 -6
View File
@@ -146,12 +146,7 @@ def render_setup_guide(result: SetupResult):
## Adding a Runner
1. Implement the `Runner` protocol in `src/takopi/runners/<engine>.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
+384
View File
@@ -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 <session_id>`** (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 <id>`
* `claude -r <id>` (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`). Claudes 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 arent 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 <session_id>` if resuming. ([Claude Code][1])
Model:
* add `--model <name>` if configured. ([Claude Code][1])
Permissions:
* add `--allowedTools "<rules>"` 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 CodexRunners “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 Claudes
`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: <tool_name>”
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:<session_id>` (string) in a `WeakValueDictionary` of `anyio.Lock`.
* When resuming:
* acquire lock before spawning subprocess.
* When starting a new session:
* you dont 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 CodexRunners 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 <id>` ``.
### `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 doesnt run until gate opens.
2. **Resume parsing**
* `format_resume` returns `claude --resume <id>`
* `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 Codes 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"
@@ -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"}]}
```
+232
View File
@@ -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 -- <query>
```
Notes:
- `--verbose` is required for `stream-json` output (clis may otherwise drop events).
- `-p/--print` is required for `--output-format` and `--include-partial-messages`.
- `-- <query>` is required to safely pass prompts that start with `-`.
- Resuming uses `--resume <session_id>` 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 <session_id>`
```
Runner must implement its own regex (cannot use `compile_resume_pattern` because
that only matches `<engine> resume <token>`). Suggested regex:
```
(?im)^\s*`?claude\s+(?:--resume|-r)\s+(?P<token>[^`\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:<session_id>` 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: <tool_name>"`
- `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 <path>` |
| `WebSearch` | `web_search` | `input.query` |
| (default) | `tool` | tool name |
For `file_change`, emit `detail.changes = [{"path": <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: <cwd>"`
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.
+7 -2
View File
@@ -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 runners **engine CLI resume command**, e.g.:
- Codex: ``codex resume <uuid>``
- Claude Code: ``claude --resume <uuid>``
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`. Claudes 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 runners 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.
+3 -2
View File
@@ -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"]
+27 -18
View File
@@ -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 <token>` 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
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.3.0.dev0"
+1 -53
View File
@@ -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<engine>[a-z0-9_-]+)\s+resume\s+(?P<token>(?=[^`\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(
+60 -32
View File
@@ -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__":
+56
View File
@@ -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()
+60 -4
View File
@@ -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,13 +30,15 @@ class EngineBackend:
def _codex_check_setup(_config: EngineConfig, _config_path: Path) -> list[SetupIssue]:
if shutil.which("codex") is None:
return [
SetupIssue(
return [_codex_install_issue()]
return []
def _codex_install_issue() -> SetupIssue:
return SetupIssue(
"Install the Codex CLI",
(" [dim]$[/] npm install -g @openai/codex",),
)
]
return []
def _codex_build_runner(config: EngineConfig, config_path: Path) -> Runner:
@@ -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())
+30 -4
View File
@@ -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)
+2 -18
View File
@@ -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:
+38 -2
View File
@@ -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<token>[^`\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
+613
View File
@@ -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<token>[^`\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()
+25 -128
View File
@@ -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,29 +426,10 @@ 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):
async for evt in self._run_with_resume_lock(prompt, resume, self._run):
yield evt
async def _run( # noqa: C901
@@ -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":
+2 -10
View File
@@ -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]:
+1
View File
@@ -0,0 +1 @@
"""Utility helpers for Takopi."""
+27
View File
@@ -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, "")
+73
View File
@@ -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)
+69
View File
@@ -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()
+5
View File
@@ -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"}}]}
+8
View File
@@ -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}}}
+276
View File
@@ -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"
-19
View File
@@ -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
+21
View File
@@ -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
+3 -3
View File
@@ -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)",
Generated
+1 -77
View File
@@ -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" },