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
|
## Adding a Runner
|
||||||
|
|
||||||
1. Implement the `Runner` protocol in `src/takopi/runners/<engine>.py`.
|
See `docs/adding-a-runner.md` for the full guide and a worked example.
|
||||||
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).
|
|
||||||
|
|
||||||
## Data Flow
|
## 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.:
|
The canonical representation of “resume” embedded in chat is the runner’s **engine CLI resume command**, e.g.:
|
||||||
|
|
||||||
- Codex: ``codex resume <uuid>``
|
- Codex: ``codex resume <uuid>``
|
||||||
|
- Claude Code: ``claude --resume <uuid>``
|
||||||
|
|
||||||
Takopi MUST treat the runner as the authority for:
|
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`.
|
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)
|
### 6.3 Run completion event (MUST)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -436,7 +440,8 @@ Final output MUST include:
|
|||||||
|
|
||||||
### 9.1 v0.2.0 behavior (Decision #5)
|
### 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.
|
- 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.
|
- 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:
|
Takopi MAY support:
|
||||||
|
|
||||||
- trying all registered runners’ `extract_resume` to auto-select a runner for resumes
|
- 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.
|
The architecture SHOULD keep this future change localized to a `RunnerRegistry` / router.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "takopi"
|
name = "takopi"
|
||||||
authors = [{name = "banteg"}]
|
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."
|
description = "Run OpenAI Codex CLI with Telegram as the human-in-the-loop interface."
|
||||||
readme = "readme.md"
|
readme = "readme.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -29,6 +29,7 @@ Issues = "https://github.com/banteg/takopi/issues"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
takopi = "takopi.cli:main"
|
takopi = "takopi.cli:main"
|
||||||
|
takopi-debug-onboarding = "takopi.debug_onboarding:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.9.18,<0.10.0"]
|
requires = ["uv_build>=0.9.18,<0.10.0"]
|
||||||
@@ -44,5 +45,5 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = ["--cov=takopi", "--cov-report=term-missing"]
|
addopts = ["--cov=takopi", "--cov-report=term-missing", "--cov-fail-under=70"]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -2,22 +2,26 @@
|
|||||||
|
|
||||||
🐙 *he just wants to help-pi*
|
🐙 *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
|
## 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
|
## requirements
|
||||||
|
|
||||||
- `uv` for installation (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
|
- `uv` for installation (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
|
||||||
|
- at least one engine installed:
|
||||||
- `codex` on PATH (`npm install -g @openai/codex` or `brew install codex`)
|
- `codex` on PATH (`npm install -g @openai/codex` or `brew install codex`)
|
||||||
|
- `claude` on PATH (`npm install -g @anthropic-ai/claude-code`)
|
||||||
|
|
||||||
## install
|
## 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)
|
1. get `bot_token` from [@BotFather](https://t.me/BotFather)
|
||||||
2. get `chat_id` from [@myidbot](https://t.me/myidbot)
|
2. get `chat_id` from [@myidbot](https://t.me/myidbot)
|
||||||
3. send `/start` to the bot (telegram won't let it message you first)
|
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
|
## config
|
||||||
|
|
||||||
takopi reads `.takopi/takopi.toml` in the current repo, otherwise `~/.takopi/takopi.toml`.
|
global config `~/.takopi/takopi.toml`, repo-level config `.takopi/takopi.toml`
|
||||||
legacy `.codex/takopi.toml` is migrated automatically.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
bot_token = "123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||||
@@ -43,6 +46,13 @@ chat_id = 123456789
|
|||||||
[codex]
|
[codex]
|
||||||
# optional: profile from ~/.codex/config.toml
|
# optional: profile from ~/.codex/config.toml
|
||||||
profile = "takopi"
|
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
|
## usage
|
||||||
@@ -51,27 +61,26 @@ start takopi in the repo you want to work on:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd ~/dev/your-repo
|
cd ~/dev/your-repo
|
||||||
takopi
|
takopi codex
|
||||||
|
# or
|
||||||
|
takopi claude
|
||||||
```
|
```
|
||||||
|
|
||||||
send a message to the bot.
|
send a message to the bot.
|
||||||
|
|
||||||
to continue a thread, reply to a bot message containing a resume line.
|
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`.
|
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.
|
if you prefer no notifications, `--no-final-notify` edits the progress message into the final answer.
|
||||||
|
|
||||||
`--no-final-notify` edits the progress message into the final answer (no new notification).
|
|
||||||
|
|
||||||
`--debug` enables verbose logs.
|
|
||||||
|
|
||||||
## notes
|
## notes
|
||||||
|
|
||||||
* private chat only
|
* private chat only: the bot only responds to the configured `chat_id`
|
||||||
* run exactly one instance per bot token
|
* run only one takopi instance per bot token: multiple instances will race telegram's `getUpdates` offsets and cause missed updates
|
||||||
|
|
||||||
## development
|
## development
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.2.0"
|
__version__ = "0.3.0.dev0"
|
||||||
|
|||||||
+1
-53
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
import inspect
|
import inspect
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -53,54 +52,16 @@ def _is_cancel_command(text: str) -> bool:
|
|||||||
return command == "/cancel" or command.startswith("/cancel@")
|
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:
|
def _strip_resume_lines(text: str, *, is_resume_line: Callable[[str], bool]) -> str:
|
||||||
stripped_lines: list[str] = []
|
stripped_lines: list[str] = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
if is_resume_line(line) or _RESUME_COMMAND_RE.match(line):
|
if is_resume_line(line):
|
||||||
continue
|
continue
|
||||||
stripped_lines.append(line)
|
stripped_lines.append(line)
|
||||||
prompt = "\n".join(stripped_lines).strip()
|
prompt = "\n".join(stripped_lines).strip()
|
||||||
return prompt or "continue"
|
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
|
PROGRESS_EDIT_EVERY_S = 2.0
|
||||||
|
|
||||||
|
|
||||||
@@ -793,19 +754,6 @@ async def _run_main_loop(
|
|||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
continue
|
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:
|
if resume_token is None:
|
||||||
tg.start_soon(
|
tg.start_soon(
|
||||||
|
|||||||
+60
-32
@@ -8,14 +8,9 @@ import typer
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .bridge import BridgeConfig, _run_main_loop
|
from .bridge import BridgeConfig, _run_main_loop
|
||||||
from .config import ConfigError, load_telegram_config
|
from .config import ConfigError, load_telegram_config
|
||||||
from .engines import (
|
from .engines import EngineBackend, get_backend, get_engine_config, list_backends
|
||||||
EngineBackend,
|
|
||||||
get_backend,
|
|
||||||
get_engine_config,
|
|
||||||
list_backend_ids,
|
|
||||||
)
|
|
||||||
from .logging import setup_logging
|
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
|
from .telegram import TelegramClient
|
||||||
|
|
||||||
|
|
||||||
@@ -70,30 +65,7 @@ def _parse_bridge_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run(
|
def _run_engine(*, engine: str, final_notify: bool, debug: bool) -> None:
|
||||||
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:
|
|
||||||
setup_logging(debug=debug)
|
setup_logging(debug=debug)
|
||||||
try:
|
try:
|
||||||
backend = get_backend(engine)
|
backend = get_backend(engine)
|
||||||
@@ -115,8 +87,64 @@ def run(
|
|||||||
anyio.run(_run_main_loop, cfg)
|
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:
|
def main() -> None:
|
||||||
typer.run(run)
|
app()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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 .config import ConfigError
|
||||||
from .runner import Runner
|
from .runner import Runner
|
||||||
from .runners.codex import CodexRunner
|
from .runners.codex import CodexRunner
|
||||||
|
from .runners.claude import ClaudeRunner
|
||||||
|
|
||||||
EngineConfig = dict[str, Any]
|
EngineConfig = dict[str, Any]
|
||||||
|
|
||||||
@@ -29,13 +30,15 @@ class EngineBackend:
|
|||||||
|
|
||||||
def _codex_check_setup(_config: EngineConfig, _config_path: Path) -> list[SetupIssue]:
|
def _codex_check_setup(_config: EngineConfig, _config_path: Path) -> list[SetupIssue]:
|
||||||
if shutil.which("codex") is None:
|
if shutil.which("codex") is None:
|
||||||
return [
|
return [_codex_install_issue()]
|
||||||
SetupIssue(
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_install_issue() -> SetupIssue:
|
||||||
|
return SetupIssue(
|
||||||
"Install the Codex CLI",
|
"Install the Codex CLI",
|
||||||
(" [dim]$[/] npm install -g @openai/codex",),
|
(" [dim]$[/] npm install -g @openai/codex",),
|
||||||
)
|
)
|
||||||
]
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _codex_build_runner(config: EngineConfig, config_path: Path) -> Runner:
|
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}"
|
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] = {
|
_ENGINE_BACKENDS: dict[str, EngineBackend] = {
|
||||||
"codex": EngineBackend(
|
"codex": EngineBackend(
|
||||||
id="codex",
|
id="codex",
|
||||||
@@ -85,6 +125,13 @@ _ENGINE_BACKENDS: dict[str, EngineBackend] = {
|
|||||||
build_runner=_codex_build_runner,
|
build_runner=_codex_build_runner,
|
||||||
startup_message=_codex_startup_message,
|
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
|
) 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]:
|
def list_backends() -> list[EngineBackend]:
|
||||||
return list(_ENGINE_BACKENDS.values())
|
return list(_ENGINE_BACKENDS.values())
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
@@ -22,7 +23,7 @@ class SetupResult:
|
|||||||
return not self.issues
|
return not self.issues
|
||||||
|
|
||||||
|
|
||||||
def _config_issue(path: Path) -> SetupIssue:
|
def config_issue(path: Path) -> SetupIssue:
|
||||||
config_display = _config_path_display(path)
|
config_display = _config_path_display(path)
|
||||||
return SetupIssue(
|
return SetupIssue(
|
||||||
"Create a config",
|
"Create a config",
|
||||||
@@ -51,7 +52,7 @@ def check_setup(backend: EngineBackend) -> SetupResult:
|
|||||||
config, config_path = load_telegram_config()
|
config, config_path = load_telegram_config()
|
||||||
except ConfigError:
|
except ConfigError:
|
||||||
issues.extend(backend.check_setup({}, config_path))
|
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)
|
return SetupResult(issues=issues, config_path=config_path)
|
||||||
|
|
||||||
token = config.get("bot_token")
|
token = config.get("bot_token")
|
||||||
@@ -62,7 +63,7 @@ def check_setup(backend: EngineBackend) -> SetupResult:
|
|||||||
|
|
||||||
issues.extend(backend.check_setup(config, config_path))
|
issues.extend(backend.check_setup(config, config_path))
|
||||||
if missing_or_invalid_config:
|
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)
|
return SetupResult(issues=issues, config_path=config_path)
|
||||||
|
|
||||||
@@ -96,10 +97,35 @@ def render_setup_guide(result: SetupResult) -> None:
|
|||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
"\n".join(parts).rstrip(),
|
"\n".join(parts).rstrip(),
|
||||||
title="[bold]Welcome to takopi![/]",
|
title="[bold]welcome to takopi![/]",
|
||||||
subtitle=f"{_OCTOPUS} setup required",
|
subtitle=f"{_OCTOPUS} setup required",
|
||||||
border_style="yellow",
|
border_style="yellow",
|
||||||
padding=(1, 2),
|
padding=(1, 2),
|
||||||
expand=False,
|
expand=False,
|
||||||
)
|
)
|
||||||
console.print(panel)
|
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 typing import Callable
|
||||||
|
|
||||||
from .model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent
|
from .model import Action, ActionEvent, ResumeToken, StartedEvent, TakopiEvent
|
||||||
|
from .utils.paths import relativize_path
|
||||||
|
|
||||||
STATUS_RUNNING = "▸"
|
STATUS_RUNNING = "▸"
|
||||||
STATUS_UPDATE = "↻"
|
STATUS_UPDATE = "↻"
|
||||||
@@ -21,24 +22,7 @@ MAX_FILE_CHANGES_INLINE = 3
|
|||||||
|
|
||||||
|
|
||||||
def format_changed_file_path(path: str, *, base_dir: Path | None = None) -> str:
|
def format_changed_file_path(path: str, *, base_dir: Path | None = None) -> str:
|
||||||
raw = path.strip()
|
return f"`{relativize_path(path, base_dir=base_dir)}`"
|
||||||
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}`"
|
|
||||||
|
|
||||||
|
|
||||||
def format_elapsed(elapsed_s: float) -> str:
|
def format_elapsed(elapsed_s: float) -> str:
|
||||||
|
|||||||
+38
-2
@@ -3,8 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Callable
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
from .model import EngineId, ResumeToken, TakopiEvent
|
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*$")
|
return re.compile(rf"(?im)^\s*`?{name}\s+resume\s+(?P<token>[^`\s]+)`?\s*$")
|
||||||
|
|
||||||
|
|
||||||
class ResumeRunnerMixin:
|
class ResumeTokenMixin:
|
||||||
engine: EngineId
|
engine: EngineId
|
||||||
resume_re: re.Pattern[str]
|
resume_re: re.Pattern[str]
|
||||||
|
|
||||||
@@ -39,6 +42,39 @@ class ResumeRunnerMixin:
|
|||||||
return ResumeToken(engine=self.engine, value=found)
|
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):
|
class Runner(Protocol):
|
||||||
engine: str
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.abc import ByteReceiveStream, Process
|
|
||||||
from anyio.streams.text import TextReceiveStream
|
|
||||||
from ..model import (
|
from ..model import (
|
||||||
Action,
|
Action,
|
||||||
ActionEvent,
|
ActionEvent,
|
||||||
@@ -27,7 +21,15 @@ from ..model import (
|
|||||||
StartedEvent,
|
StartedEvent,
|
||||||
TakopiEvent,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -230,7 +232,7 @@ def _translate_item_event(etype: str, item: dict[str, Any]) -> list[TakopiEvent]
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
if kind == "command":
|
if kind == "command":
|
||||||
title = str(item.get("command") or "")
|
title = relativize_command(str(item.get("command") or ""))
|
||||||
if phase in {"started", "updated"}:
|
if phase in {"started", "updated"}:
|
||||||
return [
|
return [
|
||||||
_action_event(
|
_action_event(
|
||||||
@@ -406,94 +408,7 @@ def translate_codex_event(event: dict[str, Any], *, title: str) -> list[TakopiEv
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _iter_text_lines(stream: ByteReceiveStream):
|
class CodexRunner(SessionLockMixin, ResumeTokenMixin, Runner):
|
||||||
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):
|
|
||||||
engine: EngineId = ENGINE
|
engine: EngineId = ENGINE
|
||||||
resume_re = _RESUME_RE
|
resume_re = _RESUME_RE
|
||||||
|
|
||||||
@@ -511,29 +426,10 @@ class CodexRunner(ResumeRunnerMixin, Runner):
|
|||||||
WeakValueDictionary()
|
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(
|
async def run(
|
||||||
self, prompt: str, resume: ResumeToken | None
|
self, prompt: str, resume: ResumeToken | None
|
||||||
) -> AsyncIterator[TakopiEvent]:
|
) -> AsyncIterator[TakopiEvent]:
|
||||||
resume_token = resume
|
async for evt in self._run_with_resume_lock(prompt, resume, self._run):
|
||||||
if resume_token is not None and resume_token.engine != ENGINE:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"resume token is for engine {resume_token.engine!r}, not {ENGINE!r}"
|
|
||||||
)
|
|
||||||
if resume_token is None:
|
|
||||||
async for evt in self._run(prompt, resume_token):
|
|
||||||
yield evt
|
|
||||||
return
|
|
||||||
lock = self._lock_for(resume_token)
|
|
||||||
async with lock:
|
|
||||||
async for evt in self._run(prompt, resume_token):
|
|
||||||
yield evt
|
yield evt
|
||||||
|
|
||||||
async def _run( # noqa: C901
|
async def _run( # noqa: C901
|
||||||
@@ -586,30 +482,31 @@ class CodexRunner(ResumeRunnerMixin, Runner):
|
|||||||
return f"codex.note.{note_seq}"
|
return f"codex.note.{note_seq}"
|
||||||
|
|
||||||
async with anyio.create_task_group() as tg:
|
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.send(prompt.encode())
|
||||||
await proc_stdin.aclose()
|
await proc_stdin.aclose()
|
||||||
|
|
||||||
async for raw_line in _iter_text_lines(proc_stdout):
|
async for json_line in iter_jsonl(
|
||||||
raw = raw_line.rstrip("\n")
|
proc_stdout, logger=logger, tag="codex"
|
||||||
logger.debug("[codex][jsonl] %s", raw)
|
):
|
||||||
line = raw.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
if did_emit_completed:
|
if did_emit_completed:
|
||||||
continue
|
continue
|
||||||
try:
|
if json_line.data is None:
|
||||||
evt = json.loads(line)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.debug("[codex] invalid json line: %s", line)
|
|
||||||
note = _note_completed(
|
note = _note_completed(
|
||||||
next_note_id(),
|
next_note_id(),
|
||||||
"invalid JSON from codex; ignoring line",
|
"invalid JSON from codex; ignoring line",
|
||||||
ok=False,
|
ok=False,
|
||||||
detail={"line": line},
|
detail={"line": json_line.line},
|
||||||
)
|
)
|
||||||
yield note
|
yield note
|
||||||
continue
|
continue
|
||||||
|
evt = json_line.data
|
||||||
|
|
||||||
etype = evt.get("type")
|
etype = evt.get("type")
|
||||||
if etype == "error":
|
if etype == "error":
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from ..model import (
|
|||||||
StartedEvent,
|
StartedEvent,
|
||||||
TakopiEvent,
|
TakopiEvent,
|
||||||
)
|
)
|
||||||
from ..runner import ResumeRunnerMixin, Runner, compile_resume_pattern
|
from ..runner import ResumeTokenMixin, Runner, SessionLockMixin, compile_resume_pattern
|
||||||
|
|
||||||
ENGINE: EngineId = EngineId("mock")
|
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)
|
return ResumeToken(engine=engine, value=value or uuid.uuid4().hex)
|
||||||
|
|
||||||
|
|
||||||
class MockRunner(ResumeRunnerMixin, Runner):
|
class MockRunner(SessionLockMixin, ResumeTokenMixin, Runner):
|
||||||
engine: EngineId
|
engine: EngineId
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -81,14 +81,6 @@ class MockRunner(ResumeRunnerMixin, Runner):
|
|||||||
)
|
)
|
||||||
self.resume_re = compile_resume_pattern(engine)
|
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(
|
async def run(
|
||||||
self, prompt: str, resume: ResumeToken | None
|
self, prompt: str, resume: ResumeToken | None
|
||||||
) -> AsyncIterator[TakopiEvent]:
|
) -> 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
|
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
|
@pytest.mark.anyio
|
||||||
async def test_handle_message_cancelled_renders_cancelled_state() -> None:
|
async def test_handle_message_cancelled_renders_cancelled_state() -> None:
|
||||||
from takopi.bridge import BridgeConfig, handle_message
|
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
|
import pytest
|
||||||
|
|
||||||
from takopi.runners import codex
|
from takopi.utils import subprocess as subprocess_utils
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
@@ -13,9 +13,9 @@ async def test_manage_subprocess_kills_when_terminate_times_out(
|
|||||||
_ = timeout
|
_ = timeout
|
||||||
return True
|
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,
|
sys.executable,
|
||||||
"-c",
|
"-c",
|
||||||
"import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(10)",
|
"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" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ 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" }
|
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 = [
|
wheels = [
|
||||||
@@ -51,45 +50,6 @@ version = "7.13.1"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/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" },
|
{ 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" }
|
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" }
|
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 = [
|
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/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/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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "takopi"
|
name = "takopi"
|
||||||
version = "0.2.0"
|
version = "0.3.0.dev0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
|
|||||||
Reference in New Issue
Block a user