feat: claude code runner (#9)
This commit is contained in:
@@ -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 engine’s 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
@@ -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
|
||||
|
||||
|
||||
@@ -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`). Claude’s settings system supports allow/deny tool rules. ([Claude Code][2])
|
||||
|
||||
**Safety note:** `-p/--print` skips the workspace trust dialog; only use this flag in trusted directories.
|
||||
|
||||
Takopi should document this clearly: if permissions aren’t configured and Claude tries to use a gated tool, the run may block or fail.
|
||||
|
||||
---
|
||||
|
||||
## Config additions
|
||||
|
||||
Takopi config lives at either:
|
||||
|
||||
* `.takopi/takopi.toml` (project-local), or
|
||||
* `~/.takopi/takopi.toml` (home). (Existing Takopi behavior.)
|
||||
|
||||
Add a new optional `[claude]` section.
|
||||
|
||||
Recommended v1 schema:
|
||||
|
||||
```toml
|
||||
# .takopi/takopi.toml
|
||||
|
||||
engine = "claude"
|
||||
|
||||
[claude]
|
||||
model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too)
|
||||
allowed_tools = "Bash,Read,Edit" # optional but strongly recommended for automation
|
||||
dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only)
|
||||
use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* `--allowedTools` exists specifically to auto-approve tools in programmatic runs. ([Claude Code][1])
|
||||
* Claude Code tools (Bash/Edit/Write/WebSearch/etc.) and whether permission is required are documented. ([Claude Code][2])
|
||||
* Takopi only reads `model`, `allowed_tools`, `dangerously_skip_permissions`, and `use_api_billing` from `[claude]`.
|
||||
* By default Takopi strips `ANTHROPIC_API_KEY` from the subprocess environment so Claude uses subscription billing. Set `use_api_billing = true` to keep the key.
|
||||
|
||||
---
|
||||
|
||||
## Code changes (by file)
|
||||
|
||||
### 1) `src/takopi/engines.py`
|
||||
|
||||
Add a new backend:
|
||||
|
||||
* Engine ID: `EngineId("claude")`
|
||||
|
||||
* `check_setup()` should:
|
||||
|
||||
* `shutil.which("claude")` must exist.
|
||||
* Error message should include official install options and “run `claude` once to authenticate”.
|
||||
|
||||
* Install methods include install scripts, Homebrew, and npm. ([Claude Code][4])
|
||||
* Agent SDK / CLI can use Claude Code authentication from running `claude`, or API key auth. ([Claude][5])
|
||||
|
||||
* `build_runner()` should parse `[claude]` config and instantiate `ClaudeRunner`.
|
||||
|
||||
* `startup_message()` e.g.:
|
||||
|
||||
* `takopi (claude) is ready\npwd: ...`
|
||||
|
||||
### 2) New file: `src/takopi/runners/claude.py`
|
||||
|
||||
Implement a new `Runner`:
|
||||
|
||||
#### Public API
|
||||
|
||||
* `engine: EngineId = "claude"`
|
||||
* `format_resume(token) -> str`: returns `` `claude --resume {token}` ``
|
||||
* `extract_resume(text) -> ResumeToken | None`: parse last match of `--resume/-r`
|
||||
* `is_resume_line(line) -> bool`: matches the above patterns
|
||||
* `run(prompt, resume)` async generator of `TakopiEvent`
|
||||
|
||||
#### Subprocess invocation
|
||||
|
||||
Use Agent SDK CLI non-interactively:
|
||||
|
||||
Core invocation:
|
||||
|
||||
* `claude -p --output-format stream-json --verbose` ([Claude Code][1])
|
||||
* `--verbose` overrides config and is required for full stream-json output.
|
||||
|
||||
Resume:
|
||||
|
||||
* add `--resume <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 CodexRunner’s “started → completed” item tracking and renders well in existing `TakopiProgressRenderer`.
|
||||
|
||||
**CompletedEvent**
|
||||
|
||||
* Emit on `result` message:
|
||||
|
||||
* `ok = (is_error == false)` (treat `is_error` as authoritative; `subtype` is informational)
|
||||
* `answer = result` on success; on error, a concise message using `errors` and/or denials
|
||||
* `usage` attach:
|
||||
|
||||
* `total_cost_usd`, `usage`, `modelUsage`, `duration_ms`, `duration_api_ms`, `num_turns` ([Claude Code][3])
|
||||
* Always include `resume` (same session_id).
|
||||
* Emit exactly one completed event per run. After emitting it, ignore any
|
||||
trailing JSON lines (do not emit a second completion).
|
||||
* We do not use an idle-timeout completion; completion is driven by Claude’s
|
||||
`result` event or process exit handling.
|
||||
|
||||
**Permission denials**
|
||||
Because result includes `permission_denials`, optionally emit warning ActionEvent(s) *before* CompletedEvent (CompletedEvent must be final):
|
||||
|
||||
* kind: `warning`
|
||||
* title: “permission denied: <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 don’t know session_id until `system/init`, so:
|
||||
|
||||
* spawn process,
|
||||
* wait until the **first** `system/init`,
|
||||
* acquire lock for that session id **before** yielding StartedEvent,
|
||||
* then continue yielding.
|
||||
|
||||
This mirrors CodexRunner’s correct behavior and ensures “new run + resume run” serialize once the session is known.
|
||||
Assumption: Claude emits a single `system/init` per run. If multiple `init`
|
||||
events arrive, ignore the subsequent ones (do not attempt to re-lock).
|
||||
|
||||
#### Cancellation / termination
|
||||
|
||||
Reuse the existing subprocess lifecycle pattern (like `CodexRunner.manage_subprocess`):
|
||||
|
||||
* Kill the process group on cancellation
|
||||
* Drain stderr concurrently (log-only)
|
||||
* Ensure locks release in `finally`
|
||||
|
||||
## Documentation updates
|
||||
|
||||
### README
|
||||
|
||||
Add a “Claude Code engine” section that covers:
|
||||
|
||||
* Installation (install script / brew / npm). ([Claude Code][4])
|
||||
* Authentication:
|
||||
|
||||
* run `claude` once and follow prompts, or use API key auth (Agent SDK docs mention `ANTHROPIC_API_KEY`). ([Claude][5])
|
||||
* Non-interactive permission caveat + how to configure:
|
||||
|
||||
* settings allow/deny rules,
|
||||
* or `--allowedTools` / `[claude].allowed_tools`. ([Claude Code][2])
|
||||
* Resume format: `` `claude --resume <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 doesn’t 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 Code’s documented tool list (Bash/Edit/Write/WebSearch/etc.). ([Claude Code][2])
|
||||
|
||||
[1]: https://code.claude.com/docs/en/headless "Run Claude Code programmatically - Claude Code Docs"
|
||||
[2]: https://code.claude.com/docs/en/settings "Claude Code settings - Claude Code Docs"
|
||||
[3]: https://code.claude.com/docs/en/sdk/sdk-typescript "Agent SDK reference - TypeScript - Claude Docs"
|
||||
[4]: https://code.claude.com/docs/en/quickstart "Quickstart - Claude Code Docs"
|
||||
[5]: https://platform.claude.com/docs/en/agent-sdk/quickstart "Quickstart - Claude Docs"
|
||||
@@ -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"}]}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -108,6 +108,7 @@ The normalized event model MUST NOT live under `runners/` because it is core dom
|
||||
The canonical representation of “resume” embedded in chat is the runner’s **engine CLI resume command**, e.g.:
|
||||
|
||||
- Codex: ``codex resume <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`. Claude’s final `result` message carries the session id and final answer (`result.result`), which the runner uses as `completed.answer`.
|
||||
|
||||
### 6.3 Run completion event (MUST)
|
||||
|
||||
```python
|
||||
@@ -436,7 +440,8 @@ Final output MUST include:
|
||||
|
||||
### 9.1 v0.2.0 behavior (Decision #5)
|
||||
|
||||
- A single runner/engine is selected at startup via config/CLI (default: Codex).
|
||||
- A single runner/engine is selected at startup via CLI subcommand (no default).
|
||||
- If no engine subcommand is provided, Takopi prints the engine chooser panel and exits.
|
||||
- Resume extraction uses only the selected runner’s parser.
|
||||
- If the user attempts to resume a thread created by a different engine, resume extraction will fail and the bot treats it as a new thread.
|
||||
|
||||
@@ -445,7 +450,7 @@ Final output MUST include:
|
||||
Takopi MAY support:
|
||||
|
||||
- trying all registered runners’ `extract_resume` to auto-select a runner for resumes
|
||||
- falling back to default runner when no resume is present
|
||||
- selecting a preferred engine from config when no resume is present
|
||||
|
||||
The architecture SHOULD keep this future change localized to a `RunnerRegistry` / router.
|
||||
|
||||
|
||||
+3
-2
@@ -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"]
|
||||
|
||||
@@ -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 @@
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0.dev0"
|
||||
|
||||
+1
-53
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for Takopi."""
|
||||
@@ -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, "")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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}}}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user