From ad4c64ba839a27f8c794970507005524d2806255 Mon Sep 17 00:00:00 2001 From: Clayde Date: Wed, 6 May 2026 16:25:27 +0000 Subject: [PATCH 01/18] Add design doc for Pebble webhook + skill framework Specifies a FastAPI webhook endpoint inside the Clayde container that receives speech-to-text messages from a Pebble watch app and dispatches them to the Claude CLI with a catalog of markdown-defined skills. Includes Traefik reverse proxy with Let's Encrypt for TLS. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-06-pebble-webhook-design.md | 373 ++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-pebble-webhook-design.md diff --git a/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md b/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md new file mode 100644 index 0000000..ed84119 --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md @@ -0,0 +1,373 @@ +# Pebble Webhook + Skill Framework — Design + +**Date:** 2026-05-06 +**Status:** Approved (design phase) + +## Goal + +Extend Clayde with an HTTP webhook endpoint that receives speech-to-text +messages from a Pebble watch app and dispatches them to Claude with a +catalog of available *skills*. Provide the framework for skills to be added +later (markdown files mounted from the host); no skills are populated as +part of this work. + +## Non-goals + +- Skill content (note-taking, calendar, etc.) — deferred. +- Knowledge repo location/format — deferred. +- Google Calendar CLI auth inside container — deferred. +- Reply channel back to the user (Pebble notification, email, etc.). + Fire-and-forget only. +- Per-request session resumption across messages. +- Retrying on Claude usage limits. +- Persisting queued jobs across container restart. +- Multi-user / multi-tenant. + +## Inputs and constraints + +- The Pebble app sends `POST` with body `{"text": str, "timestamp": int}` + and supports a configurable bearer token. +- The app is fire-and-forget; it does not display the webhook response to + the user. A 200 response is sufficient. +- Cost-sensitive: must use the Claude Code CLI backend, not the Anthropic + API. +- The text is speech-to-text output and may contain transcription errors. + Phonetically similar phrases must be considered when matching intent to + skills. +- Existing GitHub poll loop must continue to function unchanged. + +## Architecture + +Single container, single Python process. asyncio runtime hosts both: + +- the existing poll loop (`run_loop`) as a background asyncio task, and +- a FastAPI app served by uvicorn on an internal port (default 8080). + +Traefik runs as a separate compose service. It reads docker labels on the +`clayde` service and routes `https:///webhook/pebble` to +`clayde:8080`. Traefik also handles Let's Encrypt certificate issuance and +renewal via the HTTP-01 challenge. The existing Watchtower sidecar is +unchanged. + +### Request flow + +``` +[Pebble app] ──HTTPS──▶ [Traefik] ──HTTP──▶ [clayde:8080 FastAPI] + │ + ▼ + [in-memory asyncio.Queue] + │ + ▼ + [worker coroutine: claude CLI] + │ + ▼ + [skill execution] +``` + +The webhook handler verifies the bearer token, validates the payload, +enqueues the job, and returns 200. A single worker coroutine pops jobs and +runs the Claude CLI serially. A single worker (not a pool) avoids +concurrent `claude` processes and git races on shared repos. + +## Webhook endpoint + +### Routes + +| Method | Path | Auth | Purpose | +|--------|-------------------|-------------|--------------------------| +| POST | `/webhook/pebble` | Bearer | Receive Pebble message | +| GET | `/health` | None | Liveness check (Traefik) | + +All other paths return 404. No admin endpoints are exposed. + +### Authentication + +`Authorization: Bearer ` header. The token is compared in +constant time against `CLAYDE_PEBBLE_TOKEN`. Missing or wrong token +returns 401. + +### Payload + +```python +class PebblePayload(BaseModel): + text: str + timestamp: int # unix seconds +``` + +Bad shape returns 422 (FastAPI default behavior). + +### Responses + +| Status | Body | Condition | +|--------|---------------------------------------|-------------------| +| 200 | `{"queued": true, "id": ""}` | Accepted | +| 401 | `{"detail": "unauthorized"}` | Bad/missing token | +| 422 | (FastAPI default) | Bad payload | +| 503 | `{"queued": false, "reason": "full"}` | Queue at capacity | + +### Queueing + +`asyncio.Queue` with `maxsize = CLAYDE_PEBBLE_QUEUE_MAX` (default 100). +In-memory only; queued jobs are lost on container restart by design. +Enqueue is non-blocking: if `put_nowait` raises `QueueFull`, the handler +returns 503. + +## Skill mechanism + +### Skill format + +A skill is a single markdown file with frontmatter: + +```markdown +--- +name: add-note +description: Append a markdown note to the knowledge repo. Use when the user + wants to remember, jot down, or save something. +--- + +(Body: full instructions for Claude. Paths to use, conventions, examples.) +``` + +### Discovery + +`CLAYDE_SKILL_DIRS` is a colon-separated list of directories inside the +container. At startup AND on each request, every directory is scanned +recursively for `*.md` files. Re-scanning per request is cheap and lets +new skills be hot-added without restarting the container. + +The compose file mounts host skill directories read-only: + +```yaml +volumes: + - ./data:/data + - ~/skills/personal:/skills/personal:ro + - ~/skills/shared:/skills/shared:ro +environment: + - CLAYDE_SKILL_DIRS=/skills/personal:/skills/shared +``` + +### Conflict resolution + +If two skill files share a `name` field: + +1. Log a warning naming both paths. +2. The first-discovered skill wins. +3. Discovery order is deterministic: directories in `CLAYDE_SKILL_DIRS` + order, then alphabetical filename order within each directory. + +### System prompt construction + +A fresh system prompt is built per request and looks like: + +``` +You are Clayde, acting on a voice command from the user via a Pebble watch. + +The text you receive is speech-to-text output. It MAY contain transcription +errors. Consider phonetically similar words and the most likely intent — +e.g. "calendar" might arrive as "colander". Use judgement. + +Available skills: + +- add-note: Append a markdown note to the knowledge repo. Use when the user + wants to remember, jot, or save something. +- add-calendar-event: ... +- (one line per discovered skill) + +To use a skill, read the full file at the path noted, then follow it. +Skill files: + +- add-note: /skills/personal/add-note.md +- add-calendar-event: /skills/shared/add-calendar-event.md + +If no skill matches, respond with exactly "No matching skill" and stop. Do +not invent or improvise. + +User said (timestamp ): + +``` + +The Pebble flow uses this prompt instead of the standard CLAUDE.md +identity prompt; the Pebble run is not "Clayde the GitHub agent", it is +"Clayde executing a voice command". + +### Working directory + +Per-request scratch directory at `/tmp/clayde-pebble-` (mkdtemp, +cleaned after the run). Skills that need to operate in a specific repo or +filesystem location `cd` themselves per their own instructions. The +framework makes no assumption about repo context. + +## Claude invocation + +A new helper extends the existing CLI backend: + +```python +def invoke_claude_pebble(system_prompt: str, user_text: str, cwd: str) -> str +``` + +Differences from the existing CLI invocation used for issue tasks: + +- System prompt is the freshly built skill catalog, passed via + `--append-system-prompt` along with a base prompt that overrides the + issue-handling identity. CLAUDE.md is *not* injected. +- Always a fresh CLI session. No `--resume` flag. (One-shot per request.) +- Working directory is the per-request scratch directory. +- Same `UsageLimitError` detection as today. +- Same OTel cost tracking (returns `cost_eur=0.0` for the CLI backend). +- Timeout: `CLAYDE_PEBBLE_TIMEOUT` seconds (default 600). + +stdout/stderr are captured and attached to the process span. The CLI's +final `result` text is parsed from the JSON output. If the result is +exactly `"No matching skill"`, this is recorded as +`pebble.skill = none`, `pebble.success = true` — an intentional no-op, +not a failure. + +### Failure modes + +| Cause | Handling | +|----------------------|------------------------------------------------| +| Timeout | Log + OTel error; no retry | +| `UsageLimitError` | Log + OTel error; no retry | +| Skill execution fail | Claude reports it in stdout; logged + OTel err | +| Worker exception | Caught at worker boundary; loop survives | + +The user can simply press the Pebble button again to retry. + +## Observability + +OpenTelemetry spans (exported to existing `traces.jsonl` via +`FileSpanExporter`): + +- `clayde.pebble.enqueue` — attributes: `pebble.job_id`, + `pebble.timestamp`, `pebble.text`, `pebble.text_len`, + `http.status_code`. +- `clayde.pebble.process` — attributes: `pebble.job_id` (cross-ref), + `pebble.skill`, `pebble.duration_ms`, `pebble.success`, + `error.type`/`error.message` if applicable. The full `pebble.text` is + also included on the process span for self-contained traces. + +Standard logging includes `job_id` in every line for log↔trace +cross-reference. Logger name: `clayde.webhook` and `clayde.webhook.worker`. +Per the user's preference, full text is logged (this deployment is +single-user; no privacy redaction needed). + +## Configuration + +New environment variables in `data/config.env`: + +| Key | Purpose | Default | +|----------------------------|--------------------------------------|-----------------------------| +| `CLAYDE_PEBBLE_ENABLED` | Mount webhook routes / start uvicorn | `false` | +| `CLAYDE_PEBBLE_TOKEN` | Bearer token for `/webhook/pebble` | (required when enabled) | +| `CLAYDE_PEBBLE_PORT` | Internal HTTP port | `8080` | +| `CLAYDE_PEBBLE_TIMEOUT` | CLI timeout (seconds) | `600` | +| `CLAYDE_PEBBLE_QUEUE_MAX` | Queue capacity | `100` | +| `CLAYDE_SKILL_DIRS` | Colon-separated skill dirs | (required when enabled) | +| `CLAYDE_PEBBLE_HOST` | Public hostname (Traefik routing) | (required when enabled) | + +`config.env.template` is updated with the new keys. + +## Deployment + +`docker-compose.yml` gains a Traefik service and routing labels on the +`clayde` service: + +```yaml +services: + traefik: + image: traefik:v3 + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.websecure.address=:443 + - --entrypoints.web.address=:80 + - --certificatesresolvers.le.acme.email=${CLAYDE_GIT_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./data/letsencrypt:/letsencrypt + labels: + - "com.centurylinklabs.watchtower.enable=true" + + clayde: + # ...existing config... + expose: + - "8080" + volumes: + - ./data:/data + - ~/.claude/.credentials.json:/home/clayde/.claude/.credentials.json + - ~/skills:/skills:ro + labels: + - "com.centurylinklabs.watchtower.enable=true" + - "traefik.enable=true" + - "traefik.http.routers.clayde.rule=Host(`${CLAYDE_PEBBLE_HOST}`) && PathPrefix(`/webhook`)" + - "traefik.http.routers.clayde.entrypoints=websecure" + - "traefik.http.routers.clayde.tls.certresolver=le" + - "traefik.http.services.clayde.loadbalancer.server.port=8080" +``` + +The `clayde` console entry point is updated. When +`CLAYDE_PEBBLE_ENABLED=true`, the entry point composes the existing loop +coroutine and a uvicorn server using `asyncio.gather`. When the flag is +false (or unset), behavior is identical to today. + +## Code layout + +New files: + +``` +src/clayde/webhook/ + __init__.py + app.py # FastAPI app factory; routes; pydantic models + auth.py # bearer token verification (constant-time) + queue.py # asyncio.Queue wrapper + worker coroutine + skills.py # discover skills, build catalog and system prompt + runner.py # invoke_claude_pebble() — CLI invocation +``` + +Modified files: + +- `src/clayde/claude.py` — extract a reusable CLI invocation primitive + used by both existing tasks and the new pebble runner. No behavior + change for existing callers. +- `src/clayde/orchestrator.py` — `run_loop` becomes async; new entry + point composes loop coroutine + uvicorn server when + `CLAYDE_PEBBLE_ENABLED=true`. +- `src/clayde/config.py` — add new pebble settings to `Settings`. +- `pyproject.toml` — add `fastapi`, `uvicorn[standard]` dependencies. +- `docker-compose.yml` — add Traefik service and routing labels. +- `config.env.template` — document new env vars. +- `CLAUDE.md`, `README.md` — document the endpoint, skill format, env + vars. + +## Tests + +Unit and integration tests under `tests/`: + +- `test_webhook_auth.py` — bearer token accept/reject, missing header. +- `test_webhook_queue.py` — enqueue under cap, reject (503) over cap. +- `test_webhook_skills.py` — skill discovery, duplicate-name handling, + catalog and system prompt construction. +- `test_webhook_app.py` — end-to-end with a mocked + `invoke_claude_pebble`. + +Existing GitHub-loop tests must continue to pass unchanged. + +## Success criteria + +1. `POST /webhook/pebble` with valid token + payload returns 200, job + queued. +2. Bad token → 401. Bad payload → 422. Queue full → 503. +3. A test skill (e.g. `echo-skill.md` that writes the input text to a + file) executes end-to-end in a dev environment. +4. OTel spans show `clayde.pebble.enqueue` and `clayde.pebble.process` + cross-referenced by the same `pebble.job_id`. +5. The existing GitHub poll loop and its tests are unaffected. +6. Traefik issues a Let's Encrypt certificate and the HTTPS endpoint is + reachable from the public internet. From a00c2e5d69f5e6900e7f37b3d5949284d1307fba Mon Sep 17 00:00:00 2001 From: Clayde Date: Wed, 6 May 2026 16:26:04 +0000 Subject: [PATCH 02/18] Align compose volume mount example with skill dirs env var example Co-Authored-By: Claude Opus 4.7 --- docs/superpowers/specs/2026-05-06-pebble-webhook-design.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md b/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md index ed84119..393492a 100644 --- a/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md +++ b/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md @@ -302,7 +302,10 @@ services: volumes: - ./data:/data - ~/.claude/.credentials.json:/home/clayde/.claude/.credentials.json - - ~/skills:/skills:ro + # Mount one or more skill dirs read-only. Paths must match + # CLAYDE_SKILL_DIRS in data/config.env. Example: + - ~/skills/personal:/skills/personal:ro + - ~/skills/shared:/skills/shared:ro labels: - "com.centurylinklabs.watchtower.enable=true" - "traefik.enable=true" From 08124e0af658c02f62408d198cd5abf7b4968242 Mon Sep 17 00:00:00 2001 From: Clayde Date: Thu, 7 May 2026 12:53:33 +0000 Subject: [PATCH 03/18] Address spec review notes: fixed /skills/ path, single-skill prompt rule, private docker network - Drop CLAYDE_SKILL_DIRS env var. Hardcode /skills/ as the in-container scan root; recursive discovery means subdirectory layout is free. - Drop startup scan; only scan per request (cheap, allows hot-add). - Add explicit "choose at most one skill" rule to system prompt. - Add two-network compose layout (web + internal). Traefik publishes ports on web, clayde joins only internal. No host ingress to clayde except via Traefik. Co-Authored-By: Claude Opus 4.7 --- .../specs/2026-05-06-pebble-webhook-design.md | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md b/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md index 393492a..7dce4b6 100644 --- a/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md +++ b/docs/superpowers/specs/2026-05-06-pebble-webhook-design.md @@ -130,20 +130,23 @@ description: Append a markdown note to the knowledge repo. Use when the user ### Discovery -`CLAYDE_SKILL_DIRS` is a colon-separated list of directories inside the -container. At startup AND on each request, every directory is scanned -recursively for `*.md` files. Re-scanning per request is cheap and lets -new skills be hot-added without restarting the container. +Skills live under the fixed in-container path `/skills/`. The host mounts +one or more subdirectories there read-only; subdirectory layout is free +because discovery is fully recursive. No environment variable configures +this — the path is hardcoded. -The compose file mounts host skill directories read-only: +Discovery happens on each request only (no startup scan). Walking the +tree is cheap and lets new skills be hot-added by simply dropping a file +into a mounted host directory — no container restart needed. + +The compose file mounts host skill directories read-only under +`/skills/`: ```yaml volumes: - ./data:/data - ~/skills/personal:/skills/personal:ro - ~/skills/shared:/skills/shared:ro -environment: - - CLAYDE_SKILL_DIRS=/skills/personal:/skills/shared ``` ### Conflict resolution @@ -152,8 +155,8 @@ If two skill files share a `name` field: 1. Log a warning naming both paths. 2. The first-discovered skill wins. -3. Discovery order is deterministic: directories in `CLAYDE_SKILL_DIRS` - order, then alphabetical filename order within each directory. +3. Discovery order is deterministic: alphabetical by full path under + `/skills/`. ### System prompt construction @@ -179,8 +182,9 @@ Skill files: - add-note: /skills/personal/add-note.md - add-calendar-event: /skills/shared/add-calendar-event.md -If no skill matches, respond with exactly "No matching skill" and stop. Do -not invent or improvise. +Choose AT MOST ONE skill per command. If no skill matches, respond with +exactly "No matching skill" and stop. Do not invent or improvise. Do not +chain multiple skills. User said (timestamp ): @@ -262,24 +266,42 @@ New environment variables in `data/config.env`: | `CLAYDE_PEBBLE_PORT` | Internal HTTP port | `8080` | | `CLAYDE_PEBBLE_TIMEOUT` | CLI timeout (seconds) | `600` | | `CLAYDE_PEBBLE_QUEUE_MAX` | Queue capacity | `100` | -| `CLAYDE_SKILL_DIRS` | Colon-separated skill dirs | (required when enabled) | | `CLAYDE_PEBBLE_HOST` | Public hostname (Traefik routing) | (required when enabled) | +Skill location is *not* configurable — it is hardcoded to `/skills/`. +Mount host skill directories under that path in `docker-compose.yml`, +e.g. `~/skills/personal:/skills/personal:ro`. See *Deployment* below. + `config.env.template` is updated with the new keys. ## Deployment -`docker-compose.yml` gains a Traefik service and routing labels on the -`clayde` service: +`docker-compose.yml` gains a Traefik service, an internal-only network, +and routing labels on the `clayde` service. + +Network design: two networks. `web` is the default bridge — only Traefik +is attached here, and only Traefik publishes ports to the host (80, 443). +`internal` is a private bridge with no host port mappings — both Traefik +and `clayde` join it, and Traefik reaches `clayde:8080` over this +network. `clayde` is **not** attached to `web`, so its FastAPI port is +unreachable from the host or the internet — the only ingress path is +through Traefik. ```yaml +networks: + web: + internal: + internal: false # outbound internet still required for git/gh/claude + services: traefik: image: traefik:v3 restart: unless-stopped + networks: [web, internal] command: - --providers.docker=true - --providers.docker.exposedbydefault=false + - --providers.docker.network=internal - --entrypoints.websecure.address=:443 - --entrypoints.web.address=:80 - --certificatesresolvers.le.acme.email=${CLAYDE_GIT_EMAIL} @@ -297,13 +319,14 @@ services: clayde: # ...existing config... + networks: [internal] expose: - "8080" volumes: - ./data:/data - ~/.claude/.credentials.json:/home/clayde/.claude/.credentials.json - # Mount one or more skill dirs read-only. Paths must match - # CLAYDE_SKILL_DIRS in data/config.env. Example: + # Mount one or more skill dirs read-only under /skills/. + # Subdirectory layout is free — discovery is recursive. - ~/skills/personal:/skills/personal:ro - ~/skills/shared:/skills/shared:ro labels: @@ -315,6 +338,12 @@ services: - "traefik.http.services.clayde.loadbalancer.server.port=8080" ``` +Note on the `internal` network: it is **not** marked `internal: true`, +because `clayde` still needs outbound internet for git, the GitHub API, +and the Claude CLI. Marking it `internal: true` would block egress. +Privacy comes from the absence of *ingress* port mappings on the +`clayde` service, not from network-level isolation. + The `clayde` console entry point is updated. When `CLAYDE_PEBBLE_ENABLED=true`, the entry point composes the existing loop coroutine and a uvicorn server using `asyncio.gather`. When the flag is From ab5be0de60848144fcfd79e812470865e44ad527 Mon Sep 17 00:00:00 2001 From: Clayde Date: Thu, 7 May 2026 13:08:11 +0000 Subject: [PATCH 04/18] Add implementation plan for Pebble webhook + skill framework 14-task TDD plan with bite-sized steps. Adds FastAPI webhook (/webhook/pebble + /health), bearer auth, in-memory asyncio queue, single serial worker, fresh-session Claude CLI invocation, recursive markdown-skill discovery under /skills/, OTel enqueue+process spans cross-referenced by pebble.job_id, and a Traefik+private-network docker-compose setup. Existing GitHub poll loop runs in a thread to keep the asyncio event loop responsive. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-07-pebble-webhook-framework.md | 1802 +++++++++++++++++ 1 file changed, 1802 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-07-pebble-webhook-framework.md diff --git a/docs/superpowers/plans/2026-05-07-pebble-webhook-framework.md b/docs/superpowers/plans/2026-05-07-pebble-webhook-framework.md new file mode 100644 index 0000000..57665f9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-pebble-webhook-framework.md @@ -0,0 +1,1802 @@ +# Pebble Webhook + Skill Framework Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a FastAPI webhook to the Clayde container that receives speech-to-text messages from a Pebble watch app, and a markdown-skill framework that lets Claude pick at most one skill per request and execute it via the Claude Code CLI. + +**Architecture:** Single Python process, asyncio. The existing GitHub poll loop is run in a worker thread (`asyncio.to_thread`) so it doesn't block the event loop. Uvicorn serves a FastAPI app on port 8080; an in-memory `asyncio.Queue` decouples HTTP handling from a single serial worker coroutine that invokes the Claude CLI per job. Skills are markdown files mounted under the fixed in-container path `/skills/`. Traefik runs as a separate compose service, terminates TLS via Let's Encrypt, and routes `/webhook` → `clayde:8080` over a private docker network; clayde is not attached to any externally-reachable network. + +**Tech Stack:** Python 3.12+, FastAPI, uvicorn, pydantic-settings, PyYAML (frontmatter parsing), asyncio, OpenTelemetry, Claude Code CLI, Docker Compose, Traefik v3. + +--- + +## File Structure + +### New files + +| Path | Purpose | +|------|---------| +| `src/clayde/webhook/__init__.py` | Package marker; re-exports `create_app`, `JobQueue`, `worker_loop`. | +| `src/clayde/webhook/auth.py` | Constant-time bearer token verification (FastAPI dependency). | +| `src/clayde/webhook/skills.py` | Skill dataclass, frontmatter parsing, recursive `/skills/` discovery, conflict logging, system-prompt builder. | +| `src/clayde/webhook/queue.py` | `PebbleJob` dataclass, `JobQueue` wrapper (in-memory `asyncio.Queue`), `QueueFullError`. | +| `src/clayde/webhook/runner.py` | `invoke_claude_pebble` — async subprocess wrapper around the Claude CLI with a fresh session per call; reuses limit/auth-error helpers from `clayde.claude`. | +| `src/clayde/webhook/worker.py` | `worker_loop` and `process_job` — pop jobs, build prompt, call runner, emit OTel `clayde.pebble.process` spans. | +| `src/clayde/webhook/app.py` | FastAPI app factory (`create_app`); `PebblePayload` model; routes `GET /health` and `POST /webhook/pebble`; OTel `clayde.pebble.enqueue` span. | +| `tests/test_webhook_skills.py` | Tests for discovery, dedup, prompt construction. | +| `tests/test_webhook_auth.py` | Tests for bearer verification. | +| `tests/test_webhook_queue.py` | Tests for enqueue + capacity. | +| `tests/test_webhook_runner.py` | Tests for runner with mocked subprocess. | +| `tests/test_webhook_app.py` | End-to-end FastAPI tests with mocked invoker. | + +### Modified files + +| Path | Why | +|------|-----| +| `src/clayde/config.py` | Add `pebble_*` settings fields. | +| `src/clayde/orchestrator.py` | New async entry path: when `pebble_enabled` is true, run uvicorn + worker + existing tick loop (in `to_thread`) under `asyncio.run`. | +| `pyproject.toml` | Add `fastapi`, `uvicorn[standard]`, `pyyaml` runtime deps; `pytest-asyncio`, `httpx` dev deps. | +| `docker-compose.yml` | Add `traefik` service, two networks (`web`, `internal`), routing labels and `internal` network on `clayde`, optional `~/skills/*` mounts. | +| `config.env.template` | Document new env vars. | +| `CLAUDE.md` | Document the webhook endpoint, skill format, and `/skills/` mount convention. | +| `README.md` | Brief operator section: enabling the webhook, mounting skills, Traefik setup. | + +--- + +## Task 1: Add dependencies and Settings fields + +**Files:** +- Modify: `pyproject.toml` +- Modify: `src/clayde/config.py` +- Test: `tests/test_config.py` + +- [ ] **Step 1: Add the failing test for new settings defaults** + +Append to `tests/test_config.py`: + +```python +def test_pebble_settings_defaults(monkeypatch, tmp_path): + from clayde.config import Settings, _reset_settings + monkeypatch.setattr("clayde.config.DATA_DIR", tmp_path) + _reset_settings() + s = Settings(_env_file=None) + assert s.pebble_enabled is False + assert s.pebble_token == "" + assert s.pebble_port == 8080 + assert s.pebble_timeout == 600 + assert s.pebble_queue_max == 100 + assert s.pebble_host == "" +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_config.py::test_pebble_settings_defaults -v` +Expected: FAIL with `AttributeError: 'Settings' object has no attribute 'pebble_enabled'`. + +- [ ] **Step 3: Add the settings fields** + +Modify `src/clayde/config.py`. Inside the `Settings` class, after `implement_max_retries: int = 3`, add: + +```python + # Pebble webhook + pebble_enabled: bool = False + pebble_token: str = "" + pebble_port: int = 8080 + pebble_timeout: int = 600 + pebble_queue_max: int = 100 + pebble_host: str = "" +``` + +- [ ] **Step 4: Add runtime + dev dependencies** + +Modify `pyproject.toml`. In `dependencies`, append (alphabetical-ish): + +```toml + "fastapi>=0.115", + "pyyaml>=6.0", + "uvicorn[standard]>=0.30", +``` + +Replace the `dev` extras line with: + +```toml +dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"] +``` + +- [ ] **Step 5: Sync the lockfile** + +Run: `uv sync` +Expected: lockfile updates; no errors. + +- [ ] **Step 6: Re-run the test to confirm it passes** + +Run: `uv run pytest tests/test_config.py::test_pebble_settings_defaults -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add pyproject.toml uv.lock src/clayde/config.py tests/test_config.py +git commit -m "feat(pebble): add settings fields and webhook dependencies" +``` + +--- + +## Task 2: Skill data model and frontmatter parser + +**Files:** +- Create: `src/clayde/webhook/__init__.py` (empty package marker for now) +- Create: `src/clayde/webhook/skills.py` +- Test: `tests/test_webhook_skills.py` + +- [ ] **Step 1: Create the empty package marker** + +Create `src/clayde/webhook/__init__.py` with the contents: + +```python +"""Pebble webhook + skill framework.""" +``` + +- [ ] **Step 2: Write the failing test for `_parse_skill`** + +Create `tests/test_webhook_skills.py` with: + +```python +from pathlib import Path + +import pytest + +from clayde.webhook.skills import Skill, _parse_skill + + +def _write(path: Path, content: str) -> Path: + path.write_text(content) + return path + + +def test_parse_skill_minimal(tmp_path): + p = _write(tmp_path / "note.md", """\ +--- +name: add-note +description: Append a note to the knowledge repo. +--- + +Body here. +""") + skill = _parse_skill(p) + assert skill == Skill(name="add-note", description="Append a note to the knowledge repo.", path=p) + + +def test_parse_skill_missing_frontmatter(tmp_path): + p = _write(tmp_path / "broken.md", "no frontmatter here\n") + with pytest.raises(ValueError, match="missing frontmatter"): + _parse_skill(p) + + +def test_parse_skill_unterminated_frontmatter(tmp_path): + p = _write(tmp_path / "broken.md", "---\nname: foo\ndescription: bar\n") + with pytest.raises(ValueError, match="unterminated frontmatter"): + _parse_skill(p) + + +def test_parse_skill_missing_name(tmp_path): + p = _write(tmp_path / "broken.md", "---\ndescription: only a description\n---\n\nBody.\n") + with pytest.raises(ValueError, match="name and description required"): + _parse_skill(p) + + +def test_parse_skill_missing_description(tmp_path): + p = _write(tmp_path / "broken.md", "---\nname: only-a-name\n---\n\nBody.\n") + with pytest.raises(ValueError, match="name and description required"): + _parse_skill(p) +``` + +- [ ] **Step 3: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_webhook_skills.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'clayde.webhook.skills'`. + +- [ ] **Step 4: Implement `Skill` and `_parse_skill`** + +Create `src/clayde/webhook/skills.py` with: + +```python +"""Skill discovery and system-prompt construction for the Pebble webhook.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +import yaml + +log = logging.getLogger("clayde.webhook") + +SKILLS_ROOT = Path("/skills") + + +@dataclass(frozen=True) +class Skill: + name: str + description: str + path: Path + + +def _parse_skill(path: Path) -> Skill: + """Parse a skill markdown file. Raises ValueError on malformed input.""" + text = path.read_text() + if not text.startswith("---\n"): + raise ValueError(f"missing frontmatter in {path}") + end = text.find("\n---", 4) + if end == -1: + raise ValueError(f"unterminated frontmatter in {path}") + fm_text = text[4:end] + data = yaml.safe_load(fm_text) or {} + name = data.get("name") + desc = data.get("description") + if not isinstance(name, str) or not isinstance(desc, str) or not name or not desc: + raise ValueError(f"name and description required in frontmatter of {path}") + return Skill(name=name.strip(), description=desc.strip(), path=path) +``` + +- [ ] **Step 5: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_webhook_skills.py -v` +Expected: PASS (all 5 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/clayde/webhook/__init__.py src/clayde/webhook/skills.py tests/test_webhook_skills.py +git commit -m "feat(pebble): add Skill model and frontmatter parser" +``` + +--- + +## Task 3: Skill discovery with deterministic conflict resolution + +**Files:** +- Modify: `src/clayde/webhook/skills.py` +- Test: `tests/test_webhook_skills.py` + +- [ ] **Step 1: Append failing tests for `discover_skills`** + +Append to `tests/test_webhook_skills.py`: + +```python +from clayde.webhook.skills import discover_skills + + +def _write_skill(path: Path, name: str, description: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"---\nname: {name}\ndescription: {description}\n---\n\nbody\n") + return path + + +def test_discover_recursive_alpha_order(tmp_path): + _write_skill(tmp_path / "personal" / "b.md", "b-skill", "B") + _write_skill(tmp_path / "personal" / "a.md", "a-skill", "A") + _write_skill(tmp_path / "shared" / "z.md", "z-skill", "Z") + skills = discover_skills(tmp_path) + assert [s.name for s in skills] == ["a-skill", "b-skill", "z-skill"] + + +def test_discover_dedup_first_wins(tmp_path, caplog): + a = _write_skill(tmp_path / "a" / "first.md", "dup", "first one") + _write_skill(tmp_path / "b" / "second.md", "dup", "second one") + with caplog.at_level("WARNING", logger="clayde.webhook"): + skills = discover_skills(tmp_path) + assert len(skills) == 1 + assert skills[0].path == a + assert any("Duplicate skill name" in r.getMessage() for r in caplog.records) + + +def test_discover_skips_malformed(tmp_path, caplog): + _write_skill(tmp_path / "ok.md", "ok-skill", "fine") + (tmp_path / "broken.md").write_text("not a skill file\n") + with caplog.at_level("WARNING", logger="clayde.webhook"): + skills = discover_skills(tmp_path) + assert [s.name for s in skills] == ["ok-skill"] + assert any("Failed to parse skill" in r.getMessage() for r in caplog.records) + + +def test_discover_missing_root(tmp_path): + missing = tmp_path / "does-not-exist" + assert discover_skills(missing) == [] +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_webhook_skills.py -v` +Expected: 4 new tests FAIL with `ImportError: cannot import name 'discover_skills'`. + +- [ ] **Step 3: Implement `discover_skills`** + +Append to `src/clayde/webhook/skills.py`: + +```python +def discover_skills(root: Path = SKILLS_ROOT) -> list[Skill]: + """Recursively discover all skills under ``root``. + + Returns a list ordered alphabetically by full path. On duplicate + ``name`` fields, the first-discovered skill wins; subsequent + duplicates are logged at WARNING and ignored. Malformed files are + logged at WARNING and skipped. + """ + if not root.exists(): + return [] + files = sorted(root.rglob("*.md")) + seen: dict[str, Skill] = {} + for path in files: + try: + skill = _parse_skill(path) + except (ValueError, OSError) as e: + log.warning("Failed to parse skill %s: %s", path, e) + continue + if skill.name in seen: + log.warning( + "Duplicate skill name %r — keeping %s, ignoring %s", + skill.name, seen[skill.name].path, path, + ) + continue + seen[skill.name] = skill + return list(seen.values()) +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_webhook_skills.py -v` +Expected: PASS (all 9 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clayde/webhook/skills.py tests/test_webhook_skills.py +git commit -m "feat(pebble): recursive skill discovery with dedup and skip-on-error" +``` + +--- + +## Task 4: System-prompt builder + +**Files:** +- Modify: `src/clayde/webhook/skills.py` +- Test: `tests/test_webhook_skills.py` + +- [ ] **Step 1: Append failing tests for `build_system_prompt` and `build_user_prompt`** + +Append to `tests/test_webhook_skills.py`: + +```python +from clayde.webhook.skills import build_system_prompt, build_user_prompt + + +def test_build_system_prompt_with_skills(): + skills = [ + Skill(name="add-note", description="Save a note.", path=Path("/skills/personal/add-note.md")), + Skill(name="add-event", description="Create a calendar event.", path=Path("/skills/shared/cal.md")), + ] + prompt = build_system_prompt(skills) + assert "Pebble watch" in prompt + assert "speech-to-text" in prompt + assert "phonetically similar" in prompt + assert "- add-note: Save a note." in prompt + assert "- add-event: Create a calendar event." in prompt + assert "/skills/personal/add-note.md" in prompt + assert "/skills/shared/cal.md" in prompt + assert "AT MOST ONE skill" in prompt + assert 'respond with\nexactly "No matching skill"' in prompt or '"No matching skill"' in prompt + + +def test_build_system_prompt_empty_catalog(): + prompt = build_system_prompt([]) + assert "(no skills available)" in prompt + assert 'respond with' in prompt + assert "No matching skill" in prompt + + +def test_build_user_prompt(): + out = build_user_prompt("hello world", 1778068506) + assert "1778068506" in out + assert "hello world" in out +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_webhook_skills.py -v` +Expected: 3 new tests FAIL with `ImportError`. + +- [ ] **Step 3: Implement the prompt builders** + +Append to `src/clayde/webhook/skills.py`: + +```python +_SYSTEM_PROMPT_TEMPLATE = """\ +You are Clayde, acting on a voice command from the user via a Pebble watch. + +The text you receive is speech-to-text output. It MAY contain transcription +errors. Consider phonetically similar words and the most likely intent — +e.g. "calendar" might arrive as "colander". Use judgement. + +{skill_section} + +Choose AT MOST ONE skill per command. If no skill matches, respond with +exactly "No matching skill" and stop. Do not invent or improvise. Do not +chain multiple skills. +""" + + +def build_system_prompt(skills: list[Skill]) -> str: + """Build the system prompt sent to the Claude CLI for a Pebble request.""" + if not skills: + skill_section = "Available skills: (no skills available)" + else: + catalog = "\n".join(f"- {s.name}: {s.description}" for s in skills) + files = "\n".join(f"- {s.name}: {s.path}" for s in skills) + skill_section = ( + "Available skills:\n\n" + f"{catalog}\n\n" + "To use a skill, read the full file at the path noted, then follow it.\n" + "Skill files:\n\n" + f"{files}" + ) + return _SYSTEM_PROMPT_TEMPLATE.format(skill_section=skill_section) + + +def build_user_prompt(text: str, timestamp: int) -> str: + """Build the user prompt (passed to ``claude -p``) for a Pebble request.""" + return f"(timestamp {timestamp})\n{text}" +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_webhook_skills.py -v` +Expected: PASS (all 12 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clayde/webhook/skills.py tests/test_webhook_skills.py +git commit -m "feat(pebble): build system + user prompts for CLI invocation" +``` + +--- + +## Task 5: Bearer-token auth helper + +**Files:** +- Create: `src/clayde/webhook/auth.py` +- Test: `tests/test_webhook_auth.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_webhook_auth.py` with: + +```python +import pytest +from fastapi import HTTPException + +from clayde.webhook.auth import verify_bearer + + +def test_verify_bearer_accepts_correct_token(): + verify_bearer("Bearer secret-xyz", expected="secret-xyz") # no exception + + +def test_verify_bearer_rejects_missing_header(): + with pytest.raises(HTTPException) as exc: + verify_bearer(None, expected="secret-xyz") + assert exc.value.status_code == 401 + + +def test_verify_bearer_rejects_wrong_scheme(): + with pytest.raises(HTTPException) as exc: + verify_bearer("Basic abc", expected="secret-xyz") + assert exc.value.status_code == 401 + + +def test_verify_bearer_rejects_wrong_token(): + with pytest.raises(HTTPException) as exc: + verify_bearer("Bearer wrong", expected="secret-xyz") + assert exc.value.status_code == 401 + + +def test_verify_bearer_rejects_empty_expected(): + # If the server is misconfigured (no token set), all requests must fail. + with pytest.raises(HTTPException) as exc: + verify_bearer("Bearer anything", expected="") + assert exc.value.status_code == 401 +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_webhook_auth.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'clayde.webhook.auth'`. + +- [ ] **Step 3: Implement `verify_bearer`** + +Create `src/clayde/webhook/auth.py` with: + +```python +"""Bearer-token verification for the Pebble webhook.""" + +from __future__ import annotations + +import secrets + +from fastapi import HTTPException + + +def verify_bearer(authorization: str | None, *, expected: str) -> None: + """Verify an ``Authorization: Bearer `` header. + + Raises ``HTTPException(401)`` for missing header, wrong scheme, + wrong token, or missing server-side token (misconfiguration). + Comparison is constant-time. + """ + if not expected: + raise HTTPException(status_code=401, detail="unauthorized") + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="unauthorized") + provided = authorization[len("Bearer "):] + if not secrets.compare_digest(provided, expected): + raise HTTPException(status_code=401, detail="unauthorized") +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_webhook_auth.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clayde/webhook/auth.py tests/test_webhook_auth.py +git commit -m "feat(pebble): bearer-token verification with constant-time compare" +``` + +--- + +## Task 6: In-memory job queue + +**Files:** +- Create: `src/clayde/webhook/queue.py` +- Test: `tests/test_webhook_queue.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_webhook_queue.py` with: + +```python +import asyncio + +import pytest + +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError + + +@pytest.mark.asyncio +async def test_enqueue_and_dequeue(): + q = JobQueue(maxsize=2) + job = PebbleJob(id="abc", text="hi", timestamp=1) + q.enqueue(job) + got = await q.get() + assert got == job + + +@pytest.mark.asyncio +async def test_enqueue_raises_when_full(): + q = JobQueue(maxsize=1) + q.enqueue(PebbleJob(id="a", text="", timestamp=0)) + with pytest.raises(QueueFullError): + q.enqueue(PebbleJob(id="b", text="", timestamp=0)) + + +@pytest.mark.asyncio +async def test_get_blocks_until_enqueued(): + q = JobQueue(maxsize=2) + job = PebbleJob(id="abc", text="hi", timestamp=1) + + async def producer(): + await asyncio.sleep(0.01) + q.enqueue(job) + + asyncio.create_task(producer()) + got = await asyncio.wait_for(q.get(), timeout=1.0) + assert got == job +``` + +Add `pytest-asyncio` config. Append to `pyproject.toml` (after the existing `[tool.pytest.ini_options]` section's `testpaths` line): + +```toml +asyncio_mode = "auto" +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_webhook_queue.py -v` +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement queue module** + +Create `src/clayde/webhook/queue.py` with: + +```python +"""In-memory job queue for the Pebble webhook.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + + +class QueueFullError(Exception): + """Raised when the queue is at capacity.""" + + +@dataclass(frozen=True) +class PebbleJob: + id: str + text: str + timestamp: int + + +class JobQueue: + """Thin wrapper over ``asyncio.Queue[PebbleJob]`` with non-blocking enqueue.""" + + def __init__(self, maxsize: int): + self._q: asyncio.Queue[PebbleJob] = asyncio.Queue(maxsize=maxsize) + + def enqueue(self, job: PebbleJob) -> None: + """Non-blocking enqueue. Raises ``QueueFullError`` when full.""" + try: + self._q.put_nowait(job) + except asyncio.QueueFull as e: + raise QueueFullError() from e + + async def get(self) -> PebbleJob: + return await self._q.get() +``` + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_webhook_queue.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml src/clayde/webhook/queue.py tests/test_webhook_queue.py +git commit -m "feat(pebble): in-memory asyncio job queue with non-blocking enqueue" +``` + +--- + +## Task 7: Async Claude CLI runner for Pebble + +**Files:** +- Create: `src/clayde/webhook/runner.py` +- Test: `tests/test_webhook_runner.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_webhook_runner.py` with: + +```python +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from clayde.claude import InvocationTimeoutError, UsageLimitError +from clayde.webhook import runner + + +class _FakeProc: + def __init__(self, stdout: bytes, stderr: bytes = b"", returncode: int = 0): + self._stdout = stdout + self._stderr = stderr + self.returncode = returncode + self.kill = MagicMock() + self.wait = AsyncMock() + + async def communicate(self): + return self._stdout, self._stderr + + +@pytest.fixture +def fake_subproc(monkeypatch): + captured = {} + + async def fake_create(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return captured["proc"] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + return captured + + +async def test_runner_returns_result_text(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc(json.dumps({"result": "all good"}).encode()) + out = await runner.invoke_claude_pebble( + system_prompt="sys", user_text="hi", cwd=str(tmp_path), timeout_s=10, + ) + assert out == "all good" + cmd = fake_subproc["args"] + assert "--append-system-prompt" in cmd + idx = cmd.index("--append-system-prompt") + assert cmd[idx + 1] == "sys" + assert "-p" in cmd + pidx = cmd.index("-p") + assert cmd[pidx + 1] == "hi" + assert "--resume" not in cmd + assert "--session-id" not in cmd # one-shot, no persistence + + +async def test_runner_raises_timeout(fake_subproc, tmp_path, monkeypatch): + proc = _FakeProc(b"") + + async def slow_communicate(): + await asyncio.sleep(10) + + proc.communicate = slow_communicate + fake_subproc["proc"] = proc + + with pytest.raises(InvocationTimeoutError): + await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=0, + ) + proc.kill.assert_called_once() + + +async def test_runner_raises_usage_limit_on_stderr(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc( + stdout=b"{}", stderr=b"hit your usage limit", returncode=1, + ) + with pytest.raises(UsageLimitError): + await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + + +async def test_runner_returns_no_match_unchanged(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc(json.dumps({"result": "No matching skill"}).encode()) + out = await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + assert out == "No matching skill" +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `uv run pytest tests/test_webhook_runner.py -v` +Expected: FAIL with `ModuleNotFoundError: clayde.webhook.runner`. + +- [ ] **Step 3: Implement the runner** + +Create `src/clayde/webhook/runner.py` with: + +```python +"""Async Pebble invocation of the Claude CLI — fresh session per call.""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from clayde.claude import ( + InvocationTimeoutError, + UsageLimitError, + _is_auth_error, + _is_limit_error, + _make_cli_env, + _resolve_cli_bin, +) + +log = logging.getLogger("clayde.webhook.worker") + + +async def invoke_claude_pebble( + *, system_prompt: str, user_text: str, cwd: str, timeout_s: int, +) -> str: + """Run the Claude CLI for a single Pebble request and return its result text. + + Always a fresh session — no resume, no session-id persistence. + Raises ``InvocationTimeoutError`` on timeout, ``UsageLimitError`` on + rate/usage limits, ``RuntimeError`` on auth errors. + """ + cli_bin = _resolve_cli_bin() + cmd = [ + cli_bin, + "-p", user_text, + "--append-system-prompt", system_prompt, + "--output-format", "json", + "--dangerously-skip-permissions", + ] + log.info("Invoking Claude CLI (cwd=%s, timeout=%ds)", cwd, timeout_s) + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=cwd, + env=_make_cli_env(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_b, stderr_b = await asyncio.wait_for( + proc.communicate(), timeout=timeout_s, + ) + except asyncio.TimeoutError as e: + proc.kill() + await proc.wait() + raise InvocationTimeoutError( + f"Claude CLI timed out after {timeout_s}s" + ) from e + + stdout = stdout_b.decode("utf-8", errors="replace") + stderr = stderr_b.decode("utf-8", errors="replace") + + output_text = "" + is_error = False + try: + parsed = json.loads(stdout) + output_text = parsed.get("result", "") or "" + is_error = bool(parsed.get("is_error", False)) + except (json.JSONDecodeError, TypeError): + output_text = stdout + + if proc.returncode != 0 or is_error: + error_text = stderr + if is_error: + error_text += " " + output_text + if _is_limit_error(error_text): + raise UsageLimitError("Claude CLI usage limit hit") + if _is_auth_error(error_text): + raise RuntimeError("Claude CLI authentication failed") + log.error( + "Claude CLI exited rc=%d is_error=%s stderr=%s", + proc.returncode, is_error, stderr[:500], + ) + + return output_text +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_webhook_runner.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clayde/webhook/runner.py tests/test_webhook_runner.py +git commit -m "feat(pebble): async Claude CLI runner with fresh session per call" +``` + +--- + +## Task 8: Worker coroutine with OTel processing span + +**Files:** +- Create: `src/clayde/webhook/worker.py` +- Modify: `src/clayde/webhook/__init__.py` +- Test: `tests/test_webhook_worker.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_webhook_worker.py` with: + +```python +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from clayde.webhook.queue import JobQueue, PebbleJob +from clayde.webhook.worker import process_job, worker_loop + + +async def test_process_job_calls_runner(monkeypatch, tmp_path): + monkeypatch.setattr("clayde.webhook.worker.SKILLS_ROOT", tmp_path) + captured = {} + + async def fake_invoke(*, system_prompt, user_text, cwd, timeout_s): + captured["system_prompt"] = system_prompt + captured["user_text"] = user_text + captured["cwd"] = cwd + return "did the thing" + + monkeypatch.setattr("clayde.webhook.worker.invoke_claude_pebble", fake_invoke) + + job = PebbleJob(id="job-1", text="hello", timestamp=1778) + await process_job(job, timeout_s=30) + + assert captured["user_text"].endswith("hello") + assert "1778" in captured["user_text"] + assert "Pebble watch" in captured["system_prompt"] + # cwd should exist during the call but be cleaned up after + assert captured["cwd"].startswith("/tmp/") + + +async def test_worker_loop_processes_until_cancelled(monkeypatch, tmp_path): + monkeypatch.setattr("clayde.webhook.worker.SKILLS_ROOT", tmp_path) + invocations = [] + + async def fake_invoke(**kwargs): + invocations.append(kwargs["user_text"]) + return "" + + monkeypatch.setattr("clayde.webhook.worker.invoke_claude_pebble", fake_invoke) + q = JobQueue(maxsize=4) + q.enqueue(PebbleJob(id="a", text="one", timestamp=1)) + q.enqueue(PebbleJob(id="b", text="two", timestamp=2)) + + task = asyncio.create_task(worker_loop(q, timeout_s=30)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert len(invocations) == 2 + + +async def test_worker_swallows_exceptions(monkeypatch, tmp_path): + monkeypatch.setattr("clayde.webhook.worker.SKILLS_ROOT", tmp_path) + + async def fake_invoke(**kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr("clayde.webhook.worker.invoke_claude_pebble", fake_invoke) + q = JobQueue(maxsize=2) + q.enqueue(PebbleJob(id="a", text="x", timestamp=0)) + + task = asyncio.create_task(worker_loop(q, timeout_s=30)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + # Loop must have remained alive long enough to be cancelled, not crash +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_webhook_worker.py -v` +Expected: FAIL with `ModuleNotFoundError: clayde.webhook.worker`. + +- [ ] **Step 3: Implement the worker** + +Create `src/clayde/webhook/worker.py` with: + +```python +"""Background worker: pop jobs and invoke the Claude CLI.""" + +from __future__ import annotations + +import asyncio +import logging +import tempfile +import time + +from clayde.telemetry import get_tracer +from clayde.webhook.queue import JobQueue, PebbleJob +from clayde.webhook.runner import invoke_claude_pebble +from clayde.webhook.skills import ( + SKILLS_ROOT, + build_system_prompt, + build_user_prompt, + discover_skills, +) + +log = logging.getLogger("clayde.webhook.worker") + + +async def process_job(job: PebbleJob, *, timeout_s: int) -> None: + """Process a single Pebble job. Records an OTel ``clayde.pebble.process`` span.""" + tracer = get_tracer() + with tracer.start_as_current_span("clayde.pebble.process") as span: + span.set_attribute("pebble.job_id", job.id) + span.set_attribute("pebble.timestamp", job.timestamp) + span.set_attribute("pebble.text", job.text) + span.set_attribute("pebble.text_len", len(job.text)) + + skills = discover_skills(SKILLS_ROOT) + span.set_attribute("pebble.skills_available", len(skills)) + system_prompt = build_system_prompt(skills) + user_text = build_user_prompt(job.text, job.timestamp) + + t0 = time.monotonic() + with tempfile.TemporaryDirectory(prefix=f"clayde-pebble-{job.id}-") as cwd: + try: + output = await invoke_claude_pebble( + system_prompt=system_prompt, + user_text=user_text, + cwd=cwd, + timeout_s=timeout_s, + ) + if output.strip() == "No matching skill": + span.set_attribute("pebble.skill", "none") + span.set_attribute("pebble.success", True) + log.info("[%s] processed (output: %d chars)", job.id, len(output)) + except Exception as e: + span.set_attribute("pebble.success", False) + span.set_attribute("error.type", type(e).__name__) + span.set_attribute("error.message", str(e)) + span.record_exception(e) + log.exception("[%s] failed: %s", job.id, e) + raise + finally: + duration_ms = int((time.monotonic() - t0) * 1000) + span.set_attribute("pebble.duration_ms", duration_ms) + + +async def worker_loop(queue: JobQueue, *, timeout_s: int) -> None: + """Pop jobs from the queue and process them serially. Runs until cancelled.""" + log.info("Pebble worker loop started (timeout_s=%d)", timeout_s) + while True: + job = await queue.get() + try: + await process_job(job, timeout_s=timeout_s) + except Exception: + # Already logged in process_job; keep the loop alive. + pass +``` + +Update `src/clayde/webhook/__init__.py`: + +```python +"""Pebble webhook + skill framework.""" + +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError +from clayde.webhook.worker import process_job, worker_loop + +__all__ = [ + "JobQueue", + "PebbleJob", + "QueueFullError", + "process_job", + "worker_loop", +] +``` + +Note the `worker_loop` keyword argument in tests should match: change tests to call `worker_loop(q, timeout_s=30)`. The test file already uses that — good. + +- [ ] **Step 4: Run the test to confirm it passes** + +Run: `uv run pytest tests/test_webhook_worker.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clayde/webhook/worker.py src/clayde/webhook/__init__.py tests/test_webhook_worker.py +git commit -m "feat(pebble): worker loop with OTel process span" +``` + +--- + +## Task 9: FastAPI app, routes, and enqueue span + +**Files:** +- Create: `src/clayde/webhook/app.py` +- Modify: `src/clayde/webhook/__init__.py` +- Test: `tests/test_webhook_app.py` + +- [ ] **Step 1: Write failing tests** + +Create `tests/test_webhook_app.py` with: + +```python +import pytest +from fastapi.testclient import TestClient + +from clayde.webhook.app import PebblePayload, create_app +from clayde.webhook.queue import JobQueue + + +@pytest.fixture +def queue(): + return JobQueue(maxsize=2) + + +@pytest.fixture +def client(queue): + app = create_app(queue=queue, expected_token="test-token") + return TestClient(app) + + +def test_health_no_auth(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"ok": True} + + +def test_post_unknown_path_returns_404(client): + r = client.post("/webhook/unknown", json={"text": "x", "timestamp": 1}) + assert r.status_code == 404 + + +def test_pebble_accepts_valid_request(client, queue): + r = client.post( + "/webhook/pebble", + json={"text": "hello", "timestamp": 1778068506}, + headers={"Authorization": "Bearer test-token"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["queued"] is True + assert "id" in body and isinstance(body["id"], str) and len(body["id"]) > 0 + + +def test_pebble_rejects_missing_token(client): + r = client.post( + "/webhook/pebble", + json={"text": "hi", "timestamp": 1}, + ) + assert r.status_code == 401 + + +def test_pebble_rejects_wrong_token(client): + r = client.post( + "/webhook/pebble", + json={"text": "hi", "timestamp": 1}, + headers={"Authorization": "Bearer wrong"}, + ) + assert r.status_code == 401 + + +def test_pebble_rejects_bad_payload(client): + r = client.post( + "/webhook/pebble", + json={"text": "hi"}, # missing timestamp + headers={"Authorization": "Bearer test-token"}, + ) + assert r.status_code == 422 + + +def test_pebble_returns_503_when_full(queue): + # Fill the queue using a smaller capacity so 503 is reachable. + small = JobQueue(maxsize=1) + app = create_app(queue=small, expected_token="t") + client = TestClient(app) + headers = {"Authorization": "Bearer t"} + r1 = client.post("/webhook/pebble", json={"text": "a", "timestamp": 1}, headers=headers) + r2 = client.post("/webhook/pebble", json={"text": "b", "timestamp": 2}, headers=headers) + assert r1.status_code == 200 + assert r2.status_code == 503 + assert r2.json() == {"queued": False, "reason": "full"} +``` + +- [ ] **Step 2: Run the tests to confirm they fail** + +Run: `uv run pytest tests/test_webhook_app.py -v` +Expected: FAIL with `ModuleNotFoundError: clayde.webhook.app`. + +- [ ] **Step 3: Implement the FastAPI app** + +Create `src/clayde/webhook/app.py` with: + +```python +"""FastAPI app, payload model, and routes for the Pebble webhook.""" + +from __future__ import annotations + +import logging +import uuid + +from fastapi import FastAPI, Header, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from clayde.telemetry import get_tracer +from clayde.webhook.auth import verify_bearer +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError + +log = logging.getLogger("clayde.webhook") + + +class PebblePayload(BaseModel): + text: str + timestamp: int + + +def create_app(*, queue: JobQueue, expected_token: str) -> FastAPI: + """Build a FastAPI app bound to the given queue and bearer token.""" + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + @app.get("/health") + async def health() -> dict: + return {"ok": True} + + @app.post("/webhook/pebble") + async def receive( + payload: PebblePayload, + authorization: str | None = Header(default=None), + ): + verify_bearer(authorization, expected=expected_token) + + job_id = str(uuid.uuid4()) + job = PebbleJob(id=job_id, text=payload.text, timestamp=payload.timestamp) + + tracer = get_tracer() + with tracer.start_as_current_span("clayde.pebble.enqueue") as span: + span.set_attribute("pebble.job_id", job_id) + span.set_attribute("pebble.text", payload.text) + span.set_attribute("pebble.text_len", len(payload.text)) + span.set_attribute("pebble.timestamp", payload.timestamp) + try: + queue.enqueue(job) + except QueueFullError: + span.set_attribute("http.status_code", 503) + log.warning("[%s] queue full — rejecting", job_id) + return JSONResponse( + status_code=503, + content={"queued": False, "reason": "full"}, + ) + span.set_attribute("http.status_code", 200) + log.info( + "[%s] enqueued (text_len=%d, ts=%d)", + job_id, len(payload.text), payload.timestamp, + ) + return {"queued": True, "id": job_id} + + return app +``` + +Update `src/clayde/webhook/__init__.py` to also export `create_app`: + +```python +"""Pebble webhook + skill framework.""" + +from clayde.webhook.app import PebblePayload, create_app +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError +from clayde.webhook.worker import process_job, worker_loop + +__all__ = [ + "JobQueue", + "PebbleJob", + "PebblePayload", + "QueueFullError", + "create_app", + "process_job", + "worker_loop", +] +``` + +- [ ] **Step 4: Run the tests to confirm they pass** + +Run: `uv run pytest tests/test_webhook_app.py -v` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/clayde/webhook/app.py src/clayde/webhook/__init__.py tests/test_webhook_app.py +git commit -m "feat(pebble): FastAPI app with bearer auth, queue, and OTel enqueue span" +``` + +--- + +## Task 10: Orchestrator integration — async entry point + +**Files:** +- Modify: `src/clayde/orchestrator.py` +- Test: `tests/test_orchestrator.py` + +- [ ] **Step 1: Write a failing test asserting webhook is started when pebble_enabled is true** + +Append to `tests/test_orchestrator.py`: + +```python +def test_run_loop_without_pebble_uses_legacy_path(monkeypatch): + """When pebble_enabled is False, run_loop must use the existing sync path.""" + from clayde import orchestrator + + calls = [] + monkeypatch.setattr(orchestrator, "main", lambda: calls.append("tick")) + monkeypatch.setattr(orchestrator, "_shutdown", True) # exit after first iteration + + class _S: + loop_interval_s = 0 + pebble_enabled = False + + monkeypatch.setattr(orchestrator, "get_settings", lambda: _S()) + orchestrator.run_loop() + # _shutdown=True from start means main() never runs; that's fine — the + # important assertion is no exception and no asyncio.run call. + + +def test_run_loop_with_pebble_invokes_async_entry(monkeypatch): + """When pebble_enabled is True, run_loop must hand off to the async entry.""" + from clayde import orchestrator + + invoked = {} + + async def fake_async_main(): + invoked["called"] = True + + monkeypatch.setattr(orchestrator, "_run_with_pebble", fake_async_main) + + class _S: + loop_interval_s = 0 + pebble_enabled = True + pebble_token = "x" + pebble_port = 8080 + pebble_timeout = 10 + pebble_queue_max = 2 + + monkeypatch.setattr(orchestrator, "get_settings", lambda: _S()) + orchestrator.run_loop() + assert invoked.get("called") is True +``` + +- [ ] **Step 2: Run the test to confirm it fails** + +Run: `uv run pytest tests/test_orchestrator.py -v -k pebble` +Expected: FAIL — `_run_with_pebble` doesn't exist. + +- [ ] **Step 3: Implement `_run_with_pebble` and dispatch from `run_loop`** + +In `src/clayde/orchestrator.py`, add these imports near the top (after the existing imports): + +```python +import asyncio + +import uvicorn + +from clayde.webhook import JobQueue, create_app, worker_loop +``` + +Add this function near the bottom of the file, just before `run_loop`: + +```python +async def _run_with_pebble() -> None: + """Async entry point that runs the GitHub tick loop, the Pebble webhook, + and the Pebble worker concurrently. + """ + setup_logging() + settings = get_settings() + interval = settings.loop_interval_s + log.info( + "Starting Clayde with Pebble webhook (port=%d, queue_max=%d)", + settings.pebble_port, settings.pebble_queue_max, + ) + + queue = JobQueue(maxsize=settings.pebble_queue_max) + app = create_app(queue=queue, expected_token=settings.pebble_token) + config = uvicorn.Config( + app, host="0.0.0.0", port=settings.pebble_port, + log_level="info", access_log=False, lifespan="off", + ) + server = uvicorn.Server(config) + + async def tick_loop() -> None: + while not _shutdown: + try: + await asyncio.to_thread(main) + except SystemExit: + pass + except Exception: + log.exception("Unhandled error in main loop") + for _ in range(interval): + if _shutdown: + break + await asyncio.sleep(1) + + async def worker_task() -> None: + await worker_loop(queue, timeout_s=settings.pebble_timeout) + + await asyncio.gather(server.serve(), tick_loop(), worker_task()) +``` + +Replace the existing `run_loop()` body with: + +```python +def run_loop(): + """Run main() in a loop with a configurable sleep interval. + + This is the container entry point. When ``pebble_enabled`` is true, + also serves the Pebble webhook + worker on the same event loop. + """ + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + settings = get_settings() + if settings.pebble_enabled: + asyncio.run(_run_with_pebble()) + return + + setup_logging() + interval = settings.loop_interval_s + log.info("Starting Clayde loop (interval=%ds)", interval) + + while not _shutdown: + try: + main() + except SystemExit: + pass + except Exception: + log.exception("Unhandled error in main loop") + if not _shutdown: + time.sleep(interval) +``` + +- [ ] **Step 4: Run the orchestrator tests to confirm they pass** + +Run: `uv run pytest tests/test_orchestrator.py -v` +Expected: PASS (existing tests still green; new tests pass). + +- [ ] **Step 5: Run the full suite** + +Run: `uv run pytest -v` +Expected: All tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/clayde/orchestrator.py tests/test_orchestrator.py +git commit -m "feat(pebble): async orchestrator entry that gates webhook on pebble_enabled" +``` + +--- + +## Task 11: Docker compose — Traefik, networks, mounts + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `config.env.template` +- Modify: `Dockerfile` (verify only — no change expected) + +- [ ] **Step 1: Replace `docker-compose.yml` content** + +Read the current `docker-compose.yml`. Replace its entire content with: + +```yaml +networks: + web: + internal: + +services: + traefik: + image: traefik:v3 + restart: unless-stopped + networks: [web, internal] + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=internal + - --entrypoints.websecure.address=:443 + - --entrypoints.web.address=:80 + - --certificatesresolvers.le.acme.email=${CLAYDE_GIT_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./data/letsencrypt:/letsencrypt + labels: + - "com.centurylinklabs.watchtower.enable=true" + + clayde: + image: ghcr.io/claydecode/me:main + restart: unless-stopped + user: "1000:1000" + networks: [internal] + expose: + - "8080" + environment: + - CLAYDE_ENABLED=true + volumes: + - ./data:/data + # Mount Claude CLI OAuth credentials (required when CLAYDE_CLAUDE_BACKEND=cli) + - ~/.claude/.credentials.json:/home/clayde/.claude/.credentials.json + # Pebble skill directories — mount one or more host dirs read-only + # under /skills/. Subdirectory layout is free; discovery is recursive. + - ~/skills/personal:/skills/personal:ro + - ~/skills/shared:/skills/shared:ro + labels: + - "com.centurylinklabs.watchtower.enable=true" + - "traefik.enable=true" + - "traefik.http.routers.clayde.rule=Host(`${CLAYDE_PEBBLE_HOST}`) && PathPrefix(`/webhook`)" + - "traefik.http.routers.clayde.entrypoints=websecure" + - "traefik.http.routers.clayde.tls.certresolver=le" + - "traefik.http.services.clayde.loadbalancer.server.port=8080" + + watchtower: + image: containrrr/watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: --interval 300 --cleanup --label-enable +``` + +- [ ] **Step 2: Update `config.env.template`** + +Read current `config.env.template`. Append: + +``` +# --- Pebble webhook --- +# Set to true to enable the FastAPI webhook on port 8080 (routed via Traefik). +CLAYDE_PEBBLE_ENABLED=false +# Bearer token the Pebble app sends in Authorization: Bearer . +# Generate a long random string and configure it in the Pebble app's settings. +CLAYDE_PEBBLE_TOKEN= +# Public hostname for Traefik routing (e.g. clayde.example.com). +# Required when CLAYDE_PEBBLE_ENABLED=true. +CLAYDE_PEBBLE_HOST= +# Internal HTTP port (default 8080; Traefik backend target). +CLAYDE_PEBBLE_PORT=8080 +# Per-request CLI timeout in seconds. +CLAYDE_PEBBLE_TIMEOUT=600 +# Maximum queued Pebble jobs before 503. +CLAYDE_PEBBLE_QUEUE_MAX=100 +``` + +- [ ] **Step 3: Verify `Dockerfile` does not need changes** + +Run: `grep -n "EXPOSE\|CMD\|ENTRYPOINT" Dockerfile` +Expected: existing CMD/ENTRYPOINT runs the `clayde` script (which now dispatches based on `pebble_enabled`). No `EXPOSE` change needed — `expose: 8080` in compose covers it. + +If the Dockerfile lacks an `EXPOSE 8080` line and you'd like documentation, optionally add it; otherwise leave it alone. + +- [ ] **Step 4: Validate compose syntax** + +Run: `docker compose config -q` +Expected: exit code 0, no output. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml config.env.template +git commit -m "feat(pebble): docker-compose with Traefik, private network, /skills mounts" +``` + +--- + +## Task 12: Manual smoke test (no commit) + +**Files:** none (verification only) + +- [ ] **Step 1: Build a local image** + +Run: `docker compose build clayde` +Expected: image builds. + +- [ ] **Step 2: Set up a test config and a minimal echo skill** + +In the project root: + +```bash +mkdir -p data +cp config.env.template data/config.env +# Edit data/config.env: set CLAYDE_ENABLED=false, CLAYDE_PEBBLE_ENABLED=true, +# CLAYDE_PEBBLE_TOKEN=test-token, CLAYDE_PEBBLE_HOST=localhost. + +mkdir -p ~/skills/personal +cat > ~/skills/personal/echo.md <<'EOF' +--- +name: echo +description: Echo the user's text into a file under /tmp/clayde-pebble-out.txt. +--- + +Append the user's text to `/tmp/clayde-pebble-out.txt`. Create the file +if it doesn't exist. Then respond with "echoed". +EOF +``` + +- [ ] **Step 3: Run only the clayde service (skip Traefik for local smoke)** + +```bash +docker run --rm -it \ + -p 8080:8080 \ + -e CLAYDE_PEBBLE_ENABLED=true \ + -e CLAYDE_PEBBLE_TOKEN=test-token \ + -e CLAYDE_PEBBLE_HOST=localhost \ + -e CLAYDE_ENABLED=false \ + -v "$PWD/data:/data" \ + -v "$HOME/skills/personal:/skills/personal:ro" \ + -v "$HOME/.claude/.credentials.json:/home/clayde/.claude/.credentials.json" \ + ghcr.io/claydecode/me:local +``` + +Expected: log line `Starting Clayde with Pebble webhook (port=8080, queue_max=100)`. + +- [ ] **Step 4: Hit `/health`** + +```bash +curl -sv http://localhost:8080/health +``` + +Expected: `HTTP/1.1 200 OK`, body `{"ok":true}`. + +- [ ] **Step 5: Hit `/webhook/pebble` with valid auth** + +```bash +curl -sv -X POST http://localhost:8080/webhook/pebble \ + -H "Authorization: Bearer test-token" \ + -H "Content-Type: application/json" \ + -d '{"text":"echo hello world","timestamp":1778068506}' +``` + +Expected: `HTTP/1.1 200 OK`, JSON `{"queued":true,"id":""}`. Inside the container, `/tmp/clayde-pebble-out.txt` should eventually contain `echo hello world`. + +- [ ] **Step 6: Hit `/webhook/pebble` with bad auth** + +```bash +curl -sv -X POST http://localhost:8080/webhook/pebble \ + -H "Authorization: Bearer wrong" \ + -H "Content-Type: application/json" \ + -d '{"text":"x","timestamp":1}' +``` + +Expected: `HTTP/1.1 401`. + +- [ ] **Step 7: Verify OTel spans cross-reference** + +```bash +docker exec grep clayde.pebble /data/logs/traces.jsonl | tail -2 +``` + +Expected: at least one `clayde.pebble.enqueue` and one `clayde.pebble.process` line, both containing the same `pebble.job_id` value. + +If anything fails: stop, fix, re-run the corresponding earlier task's tests. + +--- + +## Task 13: Documentation updates + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] **Step 1: Update CLAUDE.md** + +Open `CLAUDE.md`. Locate the `## Project Structure` section. Inside the `src/clayde/` tree listing, add the `webhook/` package after the `tasks/` package: + +``` + webhook/ + __init__.py + app.py # FastAPI app, /webhook/pebble, /health, OTel enqueue span + auth.py # constant-time bearer-token verification + queue.py # PebbleJob, JobQueue (in-memory asyncio.Queue), QueueFullError + runner.py # invoke_claude_pebble — async CLI subprocess, fresh session + skills.py # Skill model, /skills/ discovery, system + user prompt builders + worker.py # worker_loop, process_job — pop jobs, OTel process span +``` + +Locate the `## Configuration (data/config.env)` section. Append new rows to the env-var table: + +``` +| `CLAYDE_PEBBLE_ENABLED` | Set to `true` to enable the Pebble webhook | +| `CLAYDE_PEBBLE_TOKEN` | Bearer token the Pebble app sends | +| `CLAYDE_PEBBLE_HOST` | Public hostname for Traefik routing | +| `CLAYDE_PEBBLE_PORT` | Internal HTTP port (default 8080) | +| `CLAYDE_PEBBLE_TIMEOUT` | Per-request CLI timeout seconds (default 600) | +| `CLAYDE_PEBBLE_QUEUE_MAX` | Max queued jobs before 503 (default 100) | +``` + +After the Configuration section, add a new section: + +```markdown +--- + +## Pebble Webhook + +When `CLAYDE_PEBBLE_ENABLED=true`, the container also serves a FastAPI +webhook for a Pebble watch app, alongside the existing GitHub poll loop +(both run on the same asyncio event loop). + +- `POST /webhook/pebble` — accepts `{"text": str, "timestamp": int}` with + `Authorization: Bearer `. Returns 200 with a job id. +- `GET /health` — liveness probe (no auth). + +The text is dispatched to the Claude CLI with a system prompt listing +*skills* found under the in-container path `/skills/`. Each skill is a +single markdown file with frontmatter: + +\`\`\`markdown +--- +name: my-skill +description: One-line description used in skill catalog. +--- + +(Body: instructions for Claude.) +\`\`\` + +Mount one or more host directories read-only under `/skills/` in +`docker-compose.yml`. Discovery is recursive; subdirectory layout is +free. Duplicate `name` fields are logged and only the first-discovered +skill is used. + +Claude must pick AT MOST ONE skill per request, or respond exactly +"No matching skill". Each request gets a fresh `claude` session — no +context carries between requests. +``` + +- [ ] **Step 2: Update README.md** + +Open `README.md`. Add a new top-level section before any deployment +section (or at a sensible location near the existing setup docs): + +```markdown +## Pebble Watch Integration + +To enable receiving voice commands from a Pebble watch app: + +1. Set `CLAYDE_PEBBLE_ENABLED=true` and a strong random + `CLAYDE_PEBBLE_TOKEN` in `data/config.env`. +2. Set `CLAYDE_PEBBLE_HOST` to the public hostname Traefik should serve + (e.g. `clayde.example.com`). The hostname must resolve to the host's + public IP and ports 80 + 443 must be open for Let's Encrypt HTTP-01. +3. Mount one or more skill directories under `/skills/` in + `docker-compose.yml`. Each skill is a markdown file with frontmatter + `name` and `description`. See `CLAUDE.md` for details. +4. Configure the Pebble app to POST to + `https:///webhook/pebble` with the bearer token. + +The webhook is fire-and-forget: requests return 200 with a job id and +work happens asynchronously in a single serial worker. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md README.md +git commit -m "docs(pebble): document webhook endpoint, skill format, and operator setup" +``` + +--- + +## Task 14: Final verification + +- [ ] **Step 1: Run the full test suite** + +Run: `uv run pytest -v` +Expected: ALL tests pass — both the new Pebble tests and all pre-existing +tests (especially `test_orchestrator.py`, `test_claude.py`, `test_safety.py`). + +- [ ] **Step 2: Confirm no behavioural drift in the existing GitHub loop** + +Run: `uv run pytest tests/test_orchestrator.py tests/test_tasks_implement.py tests/test_tasks_plan.py tests/test_tasks_review.py -v` +Expected: All pass. + +- [ ] **Step 3: Confirm the legacy `clayde-once` entry point still works** + +Run: `uv run python -c "from clayde.orchestrator import main; print('import ok')"` +Expected: `import ok`. + +- [ ] **Step 4: Final commit (only if anything is staged)** + +If there are no further changes, this is a no-op. Otherwise commit any +last fixes with a descriptive message. + +--- + +## Self-Review Notes + +- Spec coverage: every requirement from the spec is implemented by a task above: + - Routes (200/401/422/503) → Task 9. + - Bearer auth + constant-time → Task 5. + - In-memory queue + 503 on full → Task 6 + Task 9. + - Skills under `/skills/`, recursive, dedup, alpha-by-path → Task 3. + - System prompt with phonetic-similarity hint + "at most one skill" rule → Task 4. + - One-shot CLI (no resume), `--append-system-prompt`, scratch cwd, timeout → Task 7. + - OTel `clayde.pebble.enqueue` and `clayde.pebble.process` cross-referenced by `pebble.job_id` → Task 8 + Task 9. + - Config env vars → Task 1 + Task 11. + - Docker compose with Traefik, two networks, no host port on clayde → Task 11. + - Loop + webhook coexist (asyncio + `to_thread`) → Task 10. + - Tests for auth/queue/skills/app → Tasks 5/6/3/4/7/8/9. +- No placeholders. +- Type consistency: `Skill`, `PebbleJob`, `JobQueue`, `QueueFullError` are + defined once and imported by name; the runner's signature + `invoke_claude_pebble(*, system_prompt, user_text, cwd, timeout_s)` is + used identically by tests and the worker. From cbccdd5a289aeeec85297e39c6e105a417f35a8b Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/140284559959984" Date: Thu, 7 May 2026 13:29:16 +0000 Subject: [PATCH 05/18] feat(pebble): add settings fields and webhook dependencies Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 5 +- src/clayde/config.py | 8 ++ tests/test_config.py | 13 ++ uv.lock | 319 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 344 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3819c67..6d7d5a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,17 +5,20 @@ description = "Clayde — autonomous GitHub issue agent" requires-python = ">=3.12" dependencies = [ "anthropic>=0.40", + "fastapi>=0.115", "jinja2>=3.1.6", "opentelemetry-api>=1.20", "opentelemetry-sdk>=1.20", "opentelemetry-exporter-otlp-proto-grpc>=1.20", "pydantic-settings>=2.0", "PyGitHub>=2.5.0", + "pyyaml>=6.0", "requests>=2.31", + "uvicorn[standard]>=0.30", ] [project.optional-dependencies] -dev = ["pytest>=8.0"] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"] [project.scripts] clayde = "clayde.orchestrator:run_loop" diff --git a/src/clayde/config.py b/src/clayde/config.py index 7c1c7d9..bdd6cc1 100644 --- a/src/clayde/config.py +++ b/src/clayde/config.py @@ -42,6 +42,14 @@ def effective_git_name(self) -> str: loop_interval_s: int = 300 implement_max_retries: int = 3 + # Pebble webhook + pebble_enabled: bool = False + pebble_token: str = "" + pebble_port: int = 8080 + pebble_timeout: int = 600 + pebble_queue_max: int = 100 + pebble_host: str = "" + @property def whitelisted_users_list(self) -> list[str]: return [u.strip() for u in self.whitelisted_users.split(",") if u.strip()] diff --git a/tests/test_config.py b/tests/test_config.py index bb670fb..5ea5dcd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -125,3 +125,16 @@ def test_creates_handler_and_configures_logger(self, tmp_path, monkeypatch): logger.removeHandler(h) h.close() _reset_settings() + + +def test_pebble_settings_defaults(monkeypatch, tmp_path): + from clayde.config import Settings, _reset_settings + monkeypatch.setattr("clayde.config.DATA_DIR", tmp_path) + _reset_settings() + s = Settings(_env_file=None) + assert s.pebble_enabled is False + assert s.pebble_token == "" + assert s.pebble_port == 8080 + assert s.pebble_timeout == 600 + assert s.pebble_queue_max == 100 + assert s.pebble_host == "" diff --git a/uv.lock b/uv.lock index 8984db9..92f5853 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -177,23 +186,30 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, + { name = "fastapi" }, { name = "jinja2" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-sdk" }, { name = "pydantic-settings" }, { name = "pygithub" }, + { name = "pyyaml" }, { name = "requests" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.optional-dependencies] dev = [ + { name = "httpx" }, { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.40" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "opentelemetry-api", specifier = ">=1.20" }, { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.20" }, @@ -201,10 +217,25 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.0" }, { name = "pygithub", specifier = ">=2.5.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.31" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] provides-extras = ["dev"] +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -285,6 +316,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.72.0" @@ -360,6 +407,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -862,6 +938,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -871,6 +960,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -895,6 +1030,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -925,6 +1073,177 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From 963f2c27be74ca10c334da168fe5f7298b14a479 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/139260747587872" Date: Thu, 7 May 2026 13:47:50 +0000 Subject: [PATCH 06/18] feat(pebble): add Skill model and frontmatter parser Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/webhook/__init__.py | 1 + src/clayde/webhook/skills.py | 37 ++++++++++++++++++++++++++ tests/test_webhook_skills.py | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 src/clayde/webhook/__init__.py create mode 100644 src/clayde/webhook/skills.py create mode 100644 tests/test_webhook_skills.py diff --git a/src/clayde/webhook/__init__.py b/src/clayde/webhook/__init__.py new file mode 100644 index 0000000..3d4d163 --- /dev/null +++ b/src/clayde/webhook/__init__.py @@ -0,0 +1 @@ +"""Pebble webhook + skill framework.""" diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py new file mode 100644 index 0000000..3d13baa --- /dev/null +++ b/src/clayde/webhook/skills.py @@ -0,0 +1,37 @@ +"""Skill discovery and system-prompt construction for the Pebble webhook.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +import yaml + +log = logging.getLogger("clayde.webhook") + +SKILLS_ROOT = Path("/skills") + + +@dataclass(frozen=True) +class Skill: + name: str + description: str + path: Path + + +def _parse_skill(path: Path) -> Skill: + """Parse a skill markdown file. Raises ValueError on malformed input.""" + text = path.read_text() + if not text.startswith("---\n"): + raise ValueError(f"missing frontmatter in {path}") + end = text.find("\n---", 4) + if end == -1: + raise ValueError(f"unterminated frontmatter in {path}") + fm_text = text[4:end] + data = yaml.safe_load(fm_text) or {} + name = data.get("name") + desc = data.get("description") + if not isinstance(name, str) or not isinstance(desc, str) or not name or not desc: + raise ValueError(f"name and description required in frontmatter of {path}") + return Skill(name=name.strip(), description=desc.strip(), path=path) diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py new file mode 100644 index 0000000..815c9c1 --- /dev/null +++ b/tests/test_webhook_skills.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from clayde.webhook.skills import Skill, _parse_skill + + +def _write(path: Path, content: str) -> Path: + path.write_text(content) + return path + + +def test_parse_skill_minimal(tmp_path): + p = _write(tmp_path / "note.md", """\ +--- +name: add-note +description: Append a note to the knowledge repo. +--- + +Body here. +""") + skill = _parse_skill(p) + assert skill == Skill(name="add-note", description="Append a note to the knowledge repo.", path=p) + + +def test_parse_skill_missing_frontmatter(tmp_path): + p = _write(tmp_path / "broken.md", "no frontmatter here\n") + with pytest.raises(ValueError, match="missing frontmatter"): + _parse_skill(p) + + +def test_parse_skill_unterminated_frontmatter(tmp_path): + p = _write(tmp_path / "broken.md", "---\nname: foo\ndescription: bar\n") + with pytest.raises(ValueError, match="unterminated frontmatter"): + _parse_skill(p) + + +def test_parse_skill_missing_name(tmp_path): + p = _write(tmp_path / "broken.md", "---\ndescription: only a description\n---\n\nBody.\n") + with pytest.raises(ValueError, match="name and description required"): + _parse_skill(p) + + +def test_parse_skill_missing_description(tmp_path): + p = _write(tmp_path / "broken.md", "---\nname: only-a-name\n---\n\nBody.\n") + with pytest.raises(ValueError, match="name and description required"): + _parse_skill(p) From d397ce521d72d3d3b6f46022ed5a9b62ee22a7fd Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/126103728184960" Date: Thu, 7 May 2026 13:50:29 +0000 Subject: [PATCH 07/18] fix(pebble): reject whitespace-only name/description in skill frontmatter Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/webhook/skills.py | 6 +++++- tests/test_webhook_skills.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py index 3d13baa..7d4f353 100644 --- a/src/clayde/webhook/skills.py +++ b/src/clayde/webhook/skills.py @@ -32,6 +32,10 @@ def _parse_skill(path: Path) -> Skill: data = yaml.safe_load(fm_text) or {} name = data.get("name") desc = data.get("description") + if isinstance(name, str): + name = name.strip() + if isinstance(desc, str): + desc = desc.strip() if not isinstance(name, str) or not isinstance(desc, str) or not name or not desc: raise ValueError(f"name and description required in frontmatter of {path}") - return Skill(name=name.strip(), description=desc.strip(), path=path) + return Skill(name=name, description=desc, path=path) diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py index 815c9c1..521c536 100644 --- a/tests/test_webhook_skills.py +++ b/tests/test_webhook_skills.py @@ -45,3 +45,15 @@ def test_parse_skill_missing_description(tmp_path): p = _write(tmp_path / "broken.md", "---\nname: only-a-name\n---\n\nBody.\n") with pytest.raises(ValueError, match="name and description required"): _parse_skill(p) + + +def test_parse_skill_whitespace_only_name(tmp_path): + p = _write(tmp_path / "broken.md", "---\nname: \" \"\ndescription: real desc\n---\n\nBody.\n") + with pytest.raises(ValueError, match="name and description required"): + _parse_skill(p) + + +def test_parse_skill_whitespace_only_description(tmp_path): + p = _write(tmp_path / "broken.md", "---\nname: real-name\ndescription: \" \"\n---\n\nBody.\n") + with pytest.raises(ValueError, match="name and description required"): + _parse_skill(p) From 0842dd9b077e6d6f430b1ef79fd4331af985430f Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/126103728184960" Date: Thu, 7 May 2026 13:51:40 +0000 Subject: [PATCH 08/18] feat(pebble): recursive skill discovery with dedup and skip-on-error --- src/clayde/webhook/skills.py | 28 +++++++++++++++++++++++++ tests/test_webhook_skills.py | 40 +++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py index 7d4f353..1aff057 100644 --- a/src/clayde/webhook/skills.py +++ b/src/clayde/webhook/skills.py @@ -39,3 +39,31 @@ def _parse_skill(path: Path) -> Skill: if not isinstance(name, str) or not isinstance(desc, str) or not name or not desc: raise ValueError(f"name and description required in frontmatter of {path}") return Skill(name=name, description=desc, path=path) + + +def discover_skills(root: Path = SKILLS_ROOT) -> list[Skill]: + """Recursively discover all skills under ``root``. + + Returns a list ordered alphabetically by full path. On duplicate + ``name`` fields, the first-discovered skill wins; subsequent + duplicates are logged at WARNING and ignored. Malformed files are + logged at WARNING and skipped. + """ + if not root.exists(): + return [] + files = sorted(root.rglob("*.md")) + seen: dict[str, Skill] = {} + for path in files: + try: + skill = _parse_skill(path) + except (ValueError, OSError) as e: + log.warning("Failed to parse skill %s: %s", path, e) + continue + if skill.name in seen: + log.warning( + "Duplicate skill name %r — keeping %s, ignoring %s", + skill.name, seen[skill.name].path, path, + ) + continue + seen[skill.name] = skill + return list(seen.values()) diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py index 521c536..fc1b28c 100644 --- a/tests/test_webhook_skills.py +++ b/tests/test_webhook_skills.py @@ -2,7 +2,7 @@ import pytest -from clayde.webhook.skills import Skill, _parse_skill +from clayde.webhook.skills import Skill, _parse_skill, discover_skills def _write(path: Path, content: str) -> Path: @@ -57,3 +57,41 @@ def test_parse_skill_whitespace_only_description(tmp_path): p = _write(tmp_path / "broken.md", "---\nname: real-name\ndescription: \" \"\n---\n\nBody.\n") with pytest.raises(ValueError, match="name and description required"): _parse_skill(p) + + +def _write_skill(path: Path, name: str, description: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"---\nname: {name}\ndescription: {description}\n---\n\nbody\n") + return path + + +def test_discover_recursive_alpha_order(tmp_path): + _write_skill(tmp_path / "personal" / "b.md", "b-skill", "B") + _write_skill(tmp_path / "personal" / "a.md", "a-skill", "A") + _write_skill(tmp_path / "shared" / "z.md", "z-skill", "Z") + skills = discover_skills(tmp_path) + assert [s.name for s in skills] == ["a-skill", "b-skill", "z-skill"] + + +def test_discover_dedup_first_wins(tmp_path, caplog): + a = _write_skill(tmp_path / "a" / "first.md", "dup", "first one") + _write_skill(tmp_path / "b" / "second.md", "dup", "second one") + with caplog.at_level("WARNING", logger="clayde.webhook"): + skills = discover_skills(tmp_path) + assert len(skills) == 1 + assert skills[0].path == a + assert any("Duplicate skill name" in r.getMessage() for r in caplog.records) + + +def test_discover_skips_malformed(tmp_path, caplog): + _write_skill(tmp_path / "ok.md", "ok-skill", "fine") + (tmp_path / "broken.md").write_text("not a skill file\n") + with caplog.at_level("WARNING", logger="clayde.webhook"): + skills = discover_skills(tmp_path) + assert [s.name for s in skills] == ["ok-skill"] + assert any("Failed to parse skill" in r.getMessage() for r in caplog.records) + + +def test_discover_missing_root(tmp_path): + missing = tmp_path / "does-not-exist" + assert discover_skills(missing) == [] From 207422e7101df88b5536c00ba552b80dd7f3564f Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/126103728184960" Date: Thu, 7 May 2026 13:53:54 +0000 Subject: [PATCH 09/18] feat(pebble): build system + user prompts for CLI invocation --- src/clayde/webhook/skills.py | 37 ++++++++++++++++++++++++++++++++++++ tests/test_webhook_skills.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/clayde/webhook/skills.py b/src/clayde/webhook/skills.py index 1aff057..c63eb20 100644 --- a/src/clayde/webhook/skills.py +++ b/src/clayde/webhook/skills.py @@ -41,6 +41,43 @@ def _parse_skill(path: Path) -> Skill: return Skill(name=name, description=desc, path=path) +_SYSTEM_PROMPT_TEMPLATE = """\ +You are Clayde, acting on a voice command from the user via a Pebble watch. + +The text you receive is speech-to-text output. It MAY contain transcription +errors. Consider phonetically similar words and the most likely intent — +e.g. "calendar" might arrive as "colander". Use judgement. + +{skill_section} + +Choose AT MOST ONE skill per command. If no skill matches, respond with +exactly "No matching skill" and stop. Do not invent or improvise. Do not +chain multiple skills. +""" + + +def build_system_prompt(skills: list[Skill]) -> str: + """Build the system prompt sent to the Claude CLI for a Pebble request.""" + if not skills: + skill_section = "Available skills: (no skills available)" + else: + catalog = "\n".join(f"- {s.name}: {s.description}" for s in skills) + files = "\n".join(f"- {s.name}: {s.path}" for s in skills) + skill_section = ( + "Available skills:\n\n" + f"{catalog}\n\n" + "To use a skill, read the full file at the path noted, then follow it.\n" + "Skill files:\n\n" + f"{files}" + ) + return _SYSTEM_PROMPT_TEMPLATE.format(skill_section=skill_section) + + +def build_user_prompt(text: str, timestamp: int) -> str: + """Build the user prompt (passed to ``claude -p``) for a Pebble request.""" + return f"(timestamp {timestamp})\n{text}" + + def discover_skills(root: Path = SKILLS_ROOT) -> list[Skill]: """Recursively discover all skills under ``root``. diff --git a/tests/test_webhook_skills.py b/tests/test_webhook_skills.py index fc1b28c..321112a 100644 --- a/tests/test_webhook_skills.py +++ b/tests/test_webhook_skills.py @@ -95,3 +95,36 @@ def test_discover_skips_malformed(tmp_path, caplog): def test_discover_missing_root(tmp_path): missing = tmp_path / "does-not-exist" assert discover_skills(missing) == [] + + +from clayde.webhook.skills import build_system_prompt, build_user_prompt + + +def test_build_system_prompt_with_skills(): + skills = [ + Skill(name="add-note", description="Save a note.", path=Path("/skills/personal/add-note.md")), + Skill(name="add-event", description="Create a calendar event.", path=Path("/skills/shared/cal.md")), + ] + prompt = build_system_prompt(skills) + assert "Pebble watch" in prompt + assert "speech-to-text" in prompt + assert "phonetically similar" in prompt + assert "- add-note: Save a note." in prompt + assert "- add-event: Create a calendar event." in prompt + assert "/skills/personal/add-note.md" in prompt + assert "/skills/shared/cal.md" in prompt + assert "AT MOST ONE skill" in prompt + assert 'respond with\nexactly "No matching skill"' in prompt or '"No matching skill"' in prompt + + +def test_build_system_prompt_empty_catalog(): + prompt = build_system_prompt([]) + assert "(no skills available)" in prompt + assert 'respond with' in prompt + assert "No matching skill" in prompt + + +def test_build_user_prompt(): + out = build_user_prompt("hello world", 1778068506) + assert "1778068506" in out + assert "hello world" in out From 6c812a9d36312694aac2433e0cbc3146be7110a4 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/126103728184960" Date: Thu, 7 May 2026 13:55:30 +0000 Subject: [PATCH 10/18] feat(pebble): bearer-token verification with constant-time compare Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/webhook/auth.py | 23 +++++++++++++++++++++++ tests/test_webhook_auth.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/clayde/webhook/auth.py create mode 100644 tests/test_webhook_auth.py diff --git a/src/clayde/webhook/auth.py b/src/clayde/webhook/auth.py new file mode 100644 index 0000000..7ad03e5 --- /dev/null +++ b/src/clayde/webhook/auth.py @@ -0,0 +1,23 @@ +"""Bearer-token verification for the Pebble webhook.""" + +from __future__ import annotations + +import secrets + +from fastapi import HTTPException + + +def verify_bearer(authorization: str | None, *, expected: str) -> None: + """Verify an ``Authorization: Bearer `` header. + + Raises ``HTTPException(401)`` for missing header, wrong scheme, + wrong token, or missing server-side token (misconfiguration). + Comparison is constant-time. + """ + if not expected: + raise HTTPException(status_code=401, detail="unauthorized") + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="unauthorized") + provided = authorization[len("Bearer "):] + if not secrets.compare_digest(provided, expected): + raise HTTPException(status_code=401, detail="unauthorized") diff --git a/tests/test_webhook_auth.py b/tests/test_webhook_auth.py new file mode 100644 index 0000000..3a1138a --- /dev/null +++ b/tests/test_webhook_auth.py @@ -0,0 +1,33 @@ +import pytest +from fastapi import HTTPException + +from clayde.webhook.auth import verify_bearer + + +def test_verify_bearer_accepts_correct_token(): + verify_bearer("Bearer secret-xyz", expected="secret-xyz") # no exception + + +def test_verify_bearer_rejects_missing_header(): + with pytest.raises(HTTPException) as exc: + verify_bearer(None, expected="secret-xyz") + assert exc.value.status_code == 401 + + +def test_verify_bearer_rejects_wrong_scheme(): + with pytest.raises(HTTPException) as exc: + verify_bearer("Basic abc", expected="secret-xyz") + assert exc.value.status_code == 401 + + +def test_verify_bearer_rejects_wrong_token(): + with pytest.raises(HTTPException) as exc: + verify_bearer("Bearer wrong", expected="secret-xyz") + assert exc.value.status_code == 401 + + +def test_verify_bearer_rejects_empty_expected(): + # If the server is misconfigured (no token set), all requests must fail. + with pytest.raises(HTTPException) as exc: + verify_bearer("Bearer anything", expected="") + assert exc.value.status_code == 401 From c1aafc3af269730ce72c168daa1d84b54213090d Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/126103728184960" Date: Thu, 7 May 2026 13:57:08 +0000 Subject: [PATCH 11/18] feat(pebble): in-memory asyncio job queue with non-blocking enqueue Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 + src/clayde/webhook/queue.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_webhook_queue.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 src/clayde/webhook/queue.py create mode 100644 tests/test_webhook_queue.py diff --git a/pyproject.toml b/pyproject.toml index 6d7d5a8..549720e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ clayde-ctl = "clayde.cli:main" [tool.pytest.ini_options] testpaths = ["tests"] +asyncio_mode = "auto" [build-system] requires = ["hatchling"] diff --git a/src/clayde/webhook/queue.py b/src/clayde/webhook/queue.py new file mode 100644 index 0000000..a2e4834 --- /dev/null +++ b/src/clayde/webhook/queue.py @@ -0,0 +1,34 @@ +"""In-memory job queue for the Pebble webhook.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + + +class QueueFullError(Exception): + """Raised when the queue is at capacity.""" + + +@dataclass(frozen=True) +class PebbleJob: + id: str + text: str + timestamp: int + + +class JobQueue: + """Thin wrapper over ``asyncio.Queue[PebbleJob]`` with non-blocking enqueue.""" + + def __init__(self, maxsize: int): + self._q: asyncio.Queue[PebbleJob] = asyncio.Queue(maxsize=maxsize) + + def enqueue(self, job: PebbleJob) -> None: + """Non-blocking enqueue. Raises ``QueueFullError`` when full.""" + try: + self._q.put_nowait(job) + except asyncio.QueueFull as e: + raise QueueFullError() from e + + async def get(self) -> PebbleJob: + return await self._q.get() diff --git a/tests/test_webhook_queue.py b/tests/test_webhook_queue.py new file mode 100644 index 0000000..ab90506 --- /dev/null +++ b/tests/test_webhook_queue.py @@ -0,0 +1,36 @@ +import asyncio + +import pytest + +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError + + +@pytest.mark.asyncio +async def test_enqueue_and_dequeue(): + q = JobQueue(maxsize=2) + job = PebbleJob(id="abc", text="hi", timestamp=1) + q.enqueue(job) + got = await q.get() + assert got == job + + +@pytest.mark.asyncio +async def test_enqueue_raises_when_full(): + q = JobQueue(maxsize=1) + q.enqueue(PebbleJob(id="a", text="", timestamp=0)) + with pytest.raises(QueueFullError): + q.enqueue(PebbleJob(id="b", text="", timestamp=0)) + + +@pytest.mark.asyncio +async def test_get_blocks_until_enqueued(): + q = JobQueue(maxsize=2) + job = PebbleJob(id="abc", text="hi", timestamp=1) + + async def producer(): + await asyncio.sleep(0.01) + q.enqueue(job) + + asyncio.create_task(producer()) + got = await asyncio.wait_for(q.get(), timeout=1.0) + assert got == job From e2211fb2c31d59da52031eb2eced1d1b391672f3 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/134009765169872" Date: Thu, 7 May 2026 13:59:51 +0000 Subject: [PATCH 12/18] feat(pebble): async Claude CLI runner with fresh session per call --- src/clayde/webhook/runner.py | 82 +++++++++++++++++++++++++++++++++++ tests/test_webhook_runner.py | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/clayde/webhook/runner.py create mode 100644 tests/test_webhook_runner.py diff --git a/src/clayde/webhook/runner.py b/src/clayde/webhook/runner.py new file mode 100644 index 0000000..62112cd --- /dev/null +++ b/src/clayde/webhook/runner.py @@ -0,0 +1,82 @@ +"""Async Pebble invocation of the Claude CLI — fresh session per call.""" + +from __future__ import annotations + +import asyncio +import json +import logging + +from clayde.claude import ( + InvocationTimeoutError, + UsageLimitError, + _is_auth_error, + _is_limit_error, + _make_cli_env, + _resolve_cli_bin, +) + +log = logging.getLogger("clayde.webhook.worker") + + +async def invoke_claude_pebble( + *, system_prompt: str, user_text: str, cwd: str, timeout_s: int, +) -> str: + """Run the Claude CLI for a single Pebble request and return its result text. + + Always a fresh session — no resume, no session-id persistence. + Raises ``InvocationTimeoutError`` on timeout, ``UsageLimitError`` on + rate/usage limits, ``RuntimeError`` on auth errors. + """ + cli_bin = _resolve_cli_bin() + cmd = [ + cli_bin, + "-p", user_text, + "--append-system-prompt", system_prompt, + "--output-format", "json", + "--dangerously-skip-permissions", + ] + log.info("Invoking Claude CLI (cwd=%s, timeout=%ds)", cwd, timeout_s) + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=cwd, + env=_make_cli_env(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout_b, stderr_b = await asyncio.wait_for( + proc.communicate(), timeout=timeout_s, + ) + except asyncio.TimeoutError as e: + proc.kill() + await proc.wait() + raise InvocationTimeoutError( + f"Claude CLI timed out after {timeout_s}s" + ) from e + + stdout = stdout_b.decode("utf-8", errors="replace") + stderr = stderr_b.decode("utf-8", errors="replace") + + output_text = "" + is_error = False + try: + parsed = json.loads(stdout) + output_text = parsed.get("result", "") or "" + is_error = bool(parsed.get("is_error", False)) + except (json.JSONDecodeError, TypeError): + output_text = stdout + + if proc.returncode != 0 or is_error: + error_text = stderr + if is_error: + error_text += " " + output_text + if _is_limit_error(error_text): + raise UsageLimitError("Claude CLI usage limit hit") + if _is_auth_error(error_text): + raise RuntimeError("Claude CLI authentication failed") + log.error( + "Claude CLI exited rc=%d is_error=%s stderr=%s", + proc.returncode, is_error, stderr[:500], + ) + + return output_text diff --git a/tests/test_webhook_runner.py b/tests/test_webhook_runner.py new file mode 100644 index 0000000..c8d9c03 --- /dev/null +++ b/tests/test_webhook_runner.py @@ -0,0 +1,84 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from clayde.claude import InvocationTimeoutError, UsageLimitError +from clayde.webhook import runner + + +class _FakeProc: + def __init__(self, stdout: bytes, stderr: bytes = b"", returncode: int = 0): + self._stdout = stdout + self._stderr = stderr + self.returncode = returncode + self.kill = MagicMock() + self.wait = AsyncMock() + + async def communicate(self): + return self._stdout, self._stderr + + +@pytest.fixture +def fake_subproc(monkeypatch): + captured = {} + + async def fake_create(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return captured["proc"] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + return captured + + +async def test_runner_returns_result_text(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc(json.dumps({"result": "all good"}).encode()) + out = await runner.invoke_claude_pebble( + system_prompt="sys", user_text="hi", cwd=str(tmp_path), timeout_s=10, + ) + assert out == "all good" + cmd = fake_subproc["args"] + assert "--append-system-prompt" in cmd + idx = cmd.index("--append-system-prompt") + assert cmd[idx + 1] == "sys" + assert "-p" in cmd + pidx = cmd.index("-p") + assert cmd[pidx + 1] == "hi" + assert "--resume" not in cmd + assert "--session-id" not in cmd # one-shot, no persistence + + +async def test_runner_raises_timeout(fake_subproc, tmp_path, monkeypatch): + proc = _FakeProc(b"") + + async def slow_communicate(): + await asyncio.sleep(10) + + proc.communicate = slow_communicate + fake_subproc["proc"] = proc + + with pytest.raises(InvocationTimeoutError): + await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=0, + ) + proc.kill.assert_called_once() + + +async def test_runner_raises_usage_limit_on_stderr(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc( + stdout=b"{}", stderr=b"hit your usage limit", returncode=1, + ) + with pytest.raises(UsageLimitError): + await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + + +async def test_runner_returns_no_match_unchanged(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc(json.dumps({"result": "No matching skill"}).encode()) + out = await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + assert out == "No matching skill" From 74c0efd962489cb2d392b95c4aa8c0aed6948f73 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/140209072396272" Date: Thu, 7 May 2026 14:06:09 +0000 Subject: [PATCH 13/18] fix(pebble): kill subprocess on caller cancellation; add branch tests Wrap the communicate() await in a try/finally (via BaseException) so that proc.kill() + proc.wait() run on both TimeoutError->InvocationTimeoutError and external CancelledError, preventing leaked subprocesses. Add three tests: is_error output triggers UsageLimitError, auth failure triggers RuntimeError, and external cancellation kills the proc. Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/webhook/runner.py | 25 +++++++++++------ tests/test_webhook_runner.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/clayde/webhook/runner.py b/src/clayde/webhook/runner.py index 62112cd..3c4cf51 100644 --- a/src/clayde/webhook/runner.py +++ b/src/clayde/webhook/runner.py @@ -44,15 +44,24 @@ async def invoke_claude_pebble( stderr=asyncio.subprocess.PIPE, ) try: - stdout_b, stderr_b = await asyncio.wait_for( - proc.communicate(), timeout=timeout_s, - ) - except asyncio.TimeoutError as e: + try: + stdout_b, stderr_b = await asyncio.wait_for( + proc.communicate(), timeout=timeout_s, + ) + except asyncio.TimeoutError as e: + raise InvocationTimeoutError( + f"Claude CLI timed out after {timeout_s}s" + ) from e + except BaseException: + # Ensure the subprocess is reaped on any exit path (timeout or + # caller cancellation). Killing an already-exited process is a + # no-op on POSIX. proc.kill() - await proc.wait() - raise InvocationTimeoutError( - f"Claude CLI timed out after {timeout_s}s" - ) from e + try: + await proc.wait() + except BaseException: + pass + raise stdout = stdout_b.decode("utf-8", errors="replace") stderr = stderr_b.decode("utf-8", errors="replace") diff --git a/tests/test_webhook_runner.py b/tests/test_webhook_runner.py index c8d9c03..e4459d9 100644 --- a/tests/test_webhook_runner.py +++ b/tests/test_webhook_runner.py @@ -82,3 +82,55 @@ async def test_runner_returns_no_match_unchanged(fake_subproc, tmp_path): system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, ) assert out == "No matching skill" + + +async def test_runner_raises_usage_limit_on_is_error_output(fake_subproc, tmp_path): + """When is_error=True and the limit pattern appears in result (not stderr), + UsageLimitError must still be raised.""" + fake_subproc["proc"] = _FakeProc( + stdout=json.dumps({"result": "you hit your usage limit", "is_error": True}).encode(), + stderr=b"", + returncode=1, + ) + with pytest.raises(UsageLimitError): + await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + + +async def test_runner_raises_runtime_error_on_auth_failure(fake_subproc, tmp_path): + fake_subproc["proc"] = _FakeProc( + stdout=b"{}", + stderr=b"failed to authenticate", + returncode=1, + ) + with pytest.raises(RuntimeError, match="authentication failed"): + await runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=10, + ) + + +async def test_runner_kills_proc_on_external_cancel(fake_subproc, tmp_path): + """If the runner coroutine is externally cancelled mid-communicate, + the subprocess must be killed and reaped.""" + proc = _FakeProc(b"") + + started = asyncio.Event() + + async def hanging_communicate(): + started.set() + await asyncio.sleep(60) + + proc.communicate = hanging_communicate + fake_subproc["proc"] = proc + + task = asyncio.create_task( + runner.invoke_claude_pebble( + system_prompt="s", user_text="t", cwd=str(tmp_path), timeout_s=60, + ) + ) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + proc.kill.assert_called_once() From cd33736d8ba9709802092d95a4c65dc09854da12 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/140669943393520" Date: Thu, 7 May 2026 14:08:02 +0000 Subject: [PATCH 14/18] feat(pebble): worker loop with OTel process span Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/webhook/__init__.py | 11 +++++ src/clayde/webhook/worker.py | 71 +++++++++++++++++++++++++++++++++ tests/test_webhook_worker.py | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/clayde/webhook/worker.py create mode 100644 tests/test_webhook_worker.py diff --git a/src/clayde/webhook/__init__.py b/src/clayde/webhook/__init__.py index 3d4d163..2d7e380 100644 --- a/src/clayde/webhook/__init__.py +++ b/src/clayde/webhook/__init__.py @@ -1 +1,12 @@ """Pebble webhook + skill framework.""" + +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError +from clayde.webhook.worker import process_job, worker_loop + +__all__ = [ + "JobQueue", + "PebbleJob", + "QueueFullError", + "process_job", + "worker_loop", +] diff --git a/src/clayde/webhook/worker.py b/src/clayde/webhook/worker.py new file mode 100644 index 0000000..36e7cc4 --- /dev/null +++ b/src/clayde/webhook/worker.py @@ -0,0 +1,71 @@ +"""Background worker: pop jobs and invoke the Claude CLI.""" + +from __future__ import annotations + +import asyncio +import logging +import tempfile +import time + +from clayde.telemetry import get_tracer +from clayde.webhook.queue import JobQueue, PebbleJob +from clayde.webhook.runner import invoke_claude_pebble +from clayde.webhook.skills import ( + SKILLS_ROOT, + build_system_prompt, + build_user_prompt, + discover_skills, +) + +log = logging.getLogger("clayde.webhook.worker") + + +async def process_job(job: PebbleJob, *, timeout_s: int) -> None: + """Process a single Pebble job. Records an OTel ``clayde.pebble.process`` span.""" + tracer = get_tracer() + with tracer.start_as_current_span("clayde.pebble.process") as span: + span.set_attribute("pebble.job_id", job.id) + span.set_attribute("pebble.timestamp", job.timestamp) + span.set_attribute("pebble.text", job.text) + span.set_attribute("pebble.text_len", len(job.text)) + + skills = discover_skills(SKILLS_ROOT) + span.set_attribute("pebble.skills_available", len(skills)) + system_prompt = build_system_prompt(skills) + user_text = build_user_prompt(job.text, job.timestamp) + + t0 = time.monotonic() + with tempfile.TemporaryDirectory(prefix=f"clayde-pebble-{job.id}-") as cwd: + try: + output = await invoke_claude_pebble( + system_prompt=system_prompt, + user_text=user_text, + cwd=cwd, + timeout_s=timeout_s, + ) + if output.strip() == "No matching skill": + span.set_attribute("pebble.skill", "none") + span.set_attribute("pebble.success", True) + log.info("[%s] processed (output: %d chars)", job.id, len(output)) + except Exception as e: + span.set_attribute("pebble.success", False) + span.set_attribute("error.type", type(e).__name__) + span.set_attribute("error.message", str(e)) + span.record_exception(e) + log.exception("[%s] failed: %s", job.id, e) + raise + finally: + duration_ms = int((time.monotonic() - t0) * 1000) + span.set_attribute("pebble.duration_ms", duration_ms) + + +async def worker_loop(queue: JobQueue, *, timeout_s: int) -> None: + """Pop jobs from the queue and process them serially. Runs until cancelled.""" + log.info("Pebble worker loop started (timeout_s=%d)", timeout_s) + while True: + job = await queue.get() + try: + await process_job(job, timeout_s=timeout_s) + except Exception: + # Already logged in process_job; keep the loop alive. + pass diff --git a/tests/test_webhook_worker.py b/tests/test_webhook_worker.py new file mode 100644 index 0000000..a5eac8c --- /dev/null +++ b/tests/test_webhook_worker.py @@ -0,0 +1,73 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from clayde.webhook.queue import JobQueue, PebbleJob +from clayde.webhook.worker import process_job, worker_loop + + +async def test_process_job_calls_runner(monkeypatch, tmp_path): + monkeypatch.setattr("clayde.webhook.worker.SKILLS_ROOT", tmp_path) + captured = {} + + async def fake_invoke(*, system_prompt, user_text, cwd, timeout_s): + captured["system_prompt"] = system_prompt + captured["user_text"] = user_text + captured["cwd"] = cwd + return "did the thing" + + monkeypatch.setattr("clayde.webhook.worker.invoke_claude_pebble", fake_invoke) + + job = PebbleJob(id="job-1", text="hello", timestamp=1778) + await process_job(job, timeout_s=30) + + assert captured["user_text"].endswith("hello") + assert "1778" in captured["user_text"] + assert "Pebble watch" in captured["system_prompt"] + # cwd should exist during the call but be cleaned up after + assert captured["cwd"].startswith("/tmp/") + + +async def test_worker_loop_processes_until_cancelled(monkeypatch, tmp_path): + monkeypatch.setattr("clayde.webhook.worker.SKILLS_ROOT", tmp_path) + invocations = [] + + async def fake_invoke(**kwargs): + invocations.append(kwargs["user_text"]) + return "" + + monkeypatch.setattr("clayde.webhook.worker.invoke_claude_pebble", fake_invoke) + q = JobQueue(maxsize=4) + q.enqueue(PebbleJob(id="a", text="one", timestamp=1)) + q.enqueue(PebbleJob(id="b", text="two", timestamp=2)) + + task = asyncio.create_task(worker_loop(q, timeout_s=30)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert len(invocations) == 2 + + +async def test_worker_swallows_exceptions(monkeypatch, tmp_path): + monkeypatch.setattr("clayde.webhook.worker.SKILLS_ROOT", tmp_path) + + async def fake_invoke(**kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr("clayde.webhook.worker.invoke_claude_pebble", fake_invoke) + q = JobQueue(maxsize=2) + q.enqueue(PebbleJob(id="a", text="x", timestamp=0)) + + task = asyncio.create_task(worker_loop(q, timeout_s=30)) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + # Loop must have remained alive long enough to be cancelled, not crash From ba686983a98fee7d21eba54c71856575e302a515 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/130316780464048" Date: Thu, 7 May 2026 14:10:52 +0000 Subject: [PATCH 15/18] feat(pebble): FastAPI app with bearer auth, queue, and OTel enqueue span Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/webhook/__init__.py | 3 ++ src/clayde/webhook/app.py | 64 ++++++++++++++++++++++++++++ tests/test_webhook_app.py | 78 ++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/clayde/webhook/app.py create mode 100644 tests/test_webhook_app.py diff --git a/src/clayde/webhook/__init__.py b/src/clayde/webhook/__init__.py index 2d7e380..d1c91e2 100644 --- a/src/clayde/webhook/__init__.py +++ b/src/clayde/webhook/__init__.py @@ -1,12 +1,15 @@ """Pebble webhook + skill framework.""" +from clayde.webhook.app import PebblePayload, create_app from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError from clayde.webhook.worker import process_job, worker_loop __all__ = [ "JobQueue", "PebbleJob", + "PebblePayload", "QueueFullError", + "create_app", "process_job", "worker_loop", ] diff --git a/src/clayde/webhook/app.py b/src/clayde/webhook/app.py new file mode 100644 index 0000000..11c04d4 --- /dev/null +++ b/src/clayde/webhook/app.py @@ -0,0 +1,64 @@ +"""FastAPI app, payload model, and routes for the Pebble webhook.""" + +from __future__ import annotations + +import logging +import uuid + +from fastapi import FastAPI, Header, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from clayde.telemetry import get_tracer +from clayde.webhook.auth import verify_bearer +from clayde.webhook.queue import JobQueue, PebbleJob, QueueFullError + +log = logging.getLogger("clayde.webhook") + + +class PebblePayload(BaseModel): + text: str + timestamp: int + + +def create_app(*, queue: JobQueue, expected_token: str) -> FastAPI: + """Build a FastAPI app bound to the given queue and bearer token.""" + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + @app.get("/health") + async def health() -> dict: + return {"ok": True} + + @app.post("/webhook/pebble") + async def receive( + payload: PebblePayload, + authorization: str | None = Header(default=None), + ): + verify_bearer(authorization, expected=expected_token) + + job_id = str(uuid.uuid4()) + job = PebbleJob(id=job_id, text=payload.text, timestamp=payload.timestamp) + + tracer = get_tracer() + with tracer.start_as_current_span("clayde.pebble.enqueue") as span: + span.set_attribute("pebble.job_id", job_id) + span.set_attribute("pebble.text", payload.text) + span.set_attribute("pebble.text_len", len(payload.text)) + span.set_attribute("pebble.timestamp", payload.timestamp) + try: + queue.enqueue(job) + except QueueFullError: + span.set_attribute("http.status_code", 503) + log.warning("[%s] queue full — rejecting", job_id) + return JSONResponse( + status_code=503, + content={"queued": False, "reason": "full"}, + ) + span.set_attribute("http.status_code", 200) + log.info( + "[%s] enqueued (text_len=%d, ts=%d)", + job_id, len(payload.text), payload.timestamp, + ) + return {"queued": True, "id": job_id} + + return app diff --git a/tests/test_webhook_app.py b/tests/test_webhook_app.py new file mode 100644 index 0000000..79b7cb8 --- /dev/null +++ b/tests/test_webhook_app.py @@ -0,0 +1,78 @@ +import pytest +from fastapi.testclient import TestClient + +from clayde.webhook.app import PebblePayload, create_app +from clayde.webhook.queue import JobQueue + + +@pytest.fixture +def queue(): + return JobQueue(maxsize=2) + + +@pytest.fixture +def client(queue): + app = create_app(queue=queue, expected_token="test-token") + return TestClient(app) + + +def test_health_no_auth(client): + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"ok": True} + + +def test_post_unknown_path_returns_404(client): + r = client.post("/webhook/unknown", json={"text": "x", "timestamp": 1}) + assert r.status_code == 404 + + +def test_pebble_accepts_valid_request(client, queue): + r = client.post( + "/webhook/pebble", + json={"text": "hello", "timestamp": 1778068506}, + headers={"Authorization": "Bearer test-token"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["queued"] is True + assert "id" in body and isinstance(body["id"], str) and len(body["id"]) > 0 + + +def test_pebble_rejects_missing_token(client): + r = client.post( + "/webhook/pebble", + json={"text": "hi", "timestamp": 1}, + ) + assert r.status_code == 401 + + +def test_pebble_rejects_wrong_token(client): + r = client.post( + "/webhook/pebble", + json={"text": "hi", "timestamp": 1}, + headers={"Authorization": "Bearer wrong"}, + ) + assert r.status_code == 401 + + +def test_pebble_rejects_bad_payload(client): + r = client.post( + "/webhook/pebble", + json={"text": "hi"}, # missing timestamp + headers={"Authorization": "Bearer test-token"}, + ) + assert r.status_code == 422 + + +def test_pebble_returns_503_when_full(queue): + # Fill the queue using a smaller capacity so 503 is reachable. + small = JobQueue(maxsize=1) + app = create_app(queue=small, expected_token="t") + client = TestClient(app) + headers = {"Authorization": "Bearer t"} + r1 = client.post("/webhook/pebble", json={"text": "a", "timestamp": 1}, headers=headers) + r2 = client.post("/webhook/pebble", json={"text": "b", "timestamp": 2}, headers=headers) + assert r1.status_code == 200 + assert r2.status_code == 503 + assert r2.json() == {"queued": False, "reason": "full"} From 8d46c643ff192b402e559816fff4ec6562b5a487 Mon Sep 17 00:00:00 2001 From: "MagicMock/mock.effective_git_name/130437091259600" Date: Thu, 7 May 2026 14:14:15 +0000 Subject: [PATCH 16/18] feat(pebble): async orchestrator entry that gates webhook on pebble_enabled Co-Authored-By: Claude Sonnet 4.6 --- src/clayde/orchestrator.py | 53 +++++++++++++++++++++++++++++++++++--- tests/test_orchestrator.py | 43 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/clayde/orchestrator.py b/src/clayde/orchestrator.py index 3ede831..421e612 100644 --- a/src/clayde/orchestrator.py +++ b/src/clayde/orchestrator.py @@ -12,6 +12,7 @@ run_loop() — continuous loop with configurable sleep interval (container mode) """ +import asyncio import logging import os import signal @@ -20,6 +21,7 @@ import time from datetime import datetime +import uvicorn from opentelemetry import trace from opentelemetry.trace import StatusCode @@ -28,6 +30,7 @@ from clayde.claude import is_claude_available from clayde.config import get_github_client, get_settings, setup_logging +from clayde.webhook import JobQueue, create_app, worker_loop from clayde.github import ( fetch_issue, fetch_issue_comments, @@ -353,17 +356,61 @@ def _handle_signal(signum, frame): log.info("Received signal %s — will shut down after current cycle", signum) +async def _run_with_pebble() -> None: + """Async entry point that runs the GitHub tick loop, the Pebble webhook, + and the Pebble worker concurrently. + """ + setup_logging() + settings = get_settings() + interval = settings.loop_interval_s + log.info( + "Starting Clayde with Pebble webhook (port=%d, queue_max=%d)", + settings.pebble_port, settings.pebble_queue_max, + ) + + queue = JobQueue(maxsize=settings.pebble_queue_max) + app = create_app(queue=queue, expected_token=settings.pebble_token) + config = uvicorn.Config( + app, host="0.0.0.0", port=settings.pebble_port, + log_level="info", access_log=False, lifespan="off", + ) + server = uvicorn.Server(config) + + async def tick_loop() -> None: + while not _shutdown: + try: + await asyncio.to_thread(main) + except SystemExit: + pass + except Exception: + log.exception("Unhandled error in main loop") + for _ in range(interval): + if _shutdown: + break + await asyncio.sleep(1) + + async def worker_task() -> None: + await worker_loop(queue, timeout_s=settings.pebble_timeout) + + await asyncio.gather(server.serve(), tick_loop(), worker_task()) + + def run_loop(): """Run main() in a loop with a configurable sleep interval. - This is the container entry point. Handles SIGTERM/SIGINT for graceful - shutdown and guarantees no overlapping work sessions. + This is the container entry point. When ``pebble_enabled`` is true, + also serves the Pebble webhook + worker on the same event loop. """ signal.signal(signal.SIGTERM, _handle_signal) signal.signal(signal.SIGINT, _handle_signal) + settings = get_settings() + if settings.pebble_enabled: + asyncio.run(_run_with_pebble()) + return + setup_logging() - interval = get_settings().loop_interval_s + interval = settings.loop_interval_s log.info("Starting Clayde loop (interval=%ds)", interval) while not _shutdown: diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 9053d69..0dd863d 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -537,3 +537,46 @@ def test_main_calls_prune(self): patch("clayde.orchestrator._handle_new_issue"): main() mock_prune.assert_called_once() + + +def test_run_loop_without_pebble_uses_legacy_path(monkeypatch): + """When pebble_enabled is False, run_loop must use the existing sync path.""" + from clayde import orchestrator + + calls = [] + monkeypatch.setattr(orchestrator, "main", lambda: calls.append("tick")) + monkeypatch.setattr(orchestrator, "_shutdown", True) # exit after first iteration + monkeypatch.setattr(orchestrator, "setup_logging", lambda: None) + + class _S: + loop_interval_s = 0 + pebble_enabled = False + + monkeypatch.setattr(orchestrator, "get_settings", lambda: _S()) + orchestrator.run_loop() + # _shutdown=True from start means main() never runs; that's fine — the + # important assertion is no exception and no asyncio.run call. + + +def test_run_loop_with_pebble_invokes_async_entry(monkeypatch): + """When pebble_enabled is True, run_loop must hand off to the async entry.""" + from clayde import orchestrator + + invoked = {} + + async def fake_async_main(): + invoked["called"] = True + + monkeypatch.setattr(orchestrator, "_run_with_pebble", fake_async_main) + + class _S: + loop_interval_s = 0 + pebble_enabled = True + pebble_token = "x" + pebble_port = 8080 + pebble_timeout = 10 + pebble_queue_max = 2 + + monkeypatch.setattr(orchestrator, "get_settings", lambda: _S()) + orchestrator.run_loop() + assert invoked.get("called") is True From a8a8265df3e4ade514c7cad07f91c575b09f5027 Mon Sep 17 00:00:00 2001 From: Clayde Date: Thu, 7 May 2026 17:52:32 +0000 Subject: [PATCH 17/18] feat(pebble): docker-compose with Traefik, private network, /skills mounts Co-Authored-By: Claude Opus 4.7 --- config.env.template | 16 ++++++++++++++++ docker-compose.yml | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/config.env.template b/config.env.template index 38f3227..99eafd0 100644 --- a/config.env.template +++ b/config.env.template @@ -18,3 +18,19 @@ CLAYDE_WHITELISTED_USERS=your-username,your-bot-username # "cli" (Claude Code CLI, requires OAuth credentials mounted) CLAYDE_CLAUDE_BACKEND=api CLAYDE_CLAUDE_API_KEY= + +# --- Pebble webhook --- +# Set to true to enable the FastAPI webhook on port 8080 (routed via Traefik). +CLAYDE_PEBBLE_ENABLED=false +# Bearer token the Pebble app sends in Authorization: Bearer . +# Generate a long random string and configure it in the Pebble app's settings. +CLAYDE_PEBBLE_TOKEN= +# Public hostname for Traefik routing (e.g. clayde.example.com). +# Required when CLAYDE_PEBBLE_ENABLED=true. +CLAYDE_PEBBLE_HOST= +# Internal HTTP port (default 8080; Traefik backend target). +CLAYDE_PEBBLE_PORT=8080 +# Per-request CLI timeout in seconds. +CLAYDE_PEBBLE_TIMEOUT=600 +# Maximum queued Pebble jobs before 503. +CLAYDE_PEBBLE_QUEUE_MAX=100 diff --git a/docker-compose.yml b/docker-compose.yml index dcb73de..cb0e919 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,55 @@ +networks: + web: + internal: + services: + traefik: + image: traefik:v3 + restart: unless-stopped + networks: [web, internal] + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=internal + - --entrypoints.websecure.address=:443 + - --entrypoints.web.address=:80 + - --certificatesresolvers.le.acme.email=${CLAYDE_GIT_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./data/letsencrypt:/letsencrypt + labels: + - "com.centurylinklabs.watchtower.enable=true" + clayde: image: ghcr.io/claydecode/me:main restart: unless-stopped user: "1000:1000" + networks: [internal] + expose: + - "8080" environment: - CLAYDE_ENABLED=true volumes: - ./data:/data # Mount Claude CLI OAuth credentials (required when CLAYDE_CLAUDE_BACKEND=cli) - ~/.claude/.credentials.json:/home/clayde/.claude/.credentials.json + # Pebble skill directories — mount one or more host dirs read-only + # under /skills/. Subdirectory layout is free; discovery is recursive. + - ~/skills/personal:/skills/personal:ro + - ~/skills/shared:/skills/shared:ro labels: - "com.centurylinklabs.watchtower.enable=true" + - "traefik.enable=true" + - "traefik.http.routers.clayde.rule=Host(`${CLAYDE_PEBBLE_HOST}`) && PathPrefix(`/webhook`)" + - "traefik.http.routers.clayde.entrypoints=websecure" + - "traefik.http.routers.clayde.tls.certresolver=le" + - "traefik.http.services.clayde.loadbalancer.server.port=8080" watchtower: image: containrrr/watchtower From d19d95cc8d388c54efc9e9ff61bd41273bb7da9d Mon Sep 17 00:00:00 2001 From: Clayde Date: Thu, 7 May 2026 17:53:40 +0000 Subject: [PATCH 18/18] docs(pebble): document webhook endpoint, skill format, and operator setup Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 33e98f8..0346683 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,14 @@ src/clayde/ plan.py # run_preliminary(url), run_thorough(url), run_update(url, phase) implement.py # run(issue_url) — implement + open PR + assign reviewer review.py # run(issue_url) — address PR review comments + webhook/ + __init__.py + app.py # FastAPI app, /webhook/pebble, /health, OTel enqueue span + auth.py # constant-time bearer-token verification + queue.py # PebbleJob, JobQueue (in-memory asyncio.Queue), QueueFullError + runner.py # invoke_claude_pebble — async CLI subprocess, fresh session + skills.py # Skill model, /skills/ discovery, system + user prompt builders + worker.py # worker_loop, process_job — pop jobs, OTel process span # Container paths /opt/clayde/ # application code (WORKDIR) @@ -103,6 +111,12 @@ Plain `KEY=VALUE` file (no shell quoting). All keys use `CLAYDE_` prefix and are | `CLAYDE_CLAUDE_API_KEY` | Anthropic API key for Claude SDK calls (required when backend=`api`) | | `CLAYDE_CLAUDE_MODEL` | Model to use (default: `claude-opus-4-6`) | | `CLAYDE_CLAUDE_BACKEND` | `api` (default) or `cli` — selects Anthropic SDK or Claude Code CLI | +| `CLAYDE_PEBBLE_ENABLED` | Set to `true` to enable the Pebble webhook | +| `CLAYDE_PEBBLE_TOKEN` | Bearer token the Pebble app sends | +| `CLAYDE_PEBBLE_HOST` | Public hostname for Traefik routing | +| `CLAYDE_PEBBLE_PORT` | Internal HTTP port (default 8080) | +| `CLAYDE_PEBBLE_TIMEOUT` | Per-request CLI timeout seconds (default 600) | +| `CLAYDE_PEBBLE_QUEUE_MAX` | Max queued jobs before 503 (default 100) | Config is loaded via `get_settings()` (singleton). `GH_TOKEN` is exported at startup for the `gh` CLI. @@ -287,6 +301,45 @@ Logger names: `clayde.orchestrator`, `clayde.tasks.plan`, `clayde.tasks.implemen --- +## Pebble Webhook + +When `CLAYDE_PEBBLE_ENABLED=true`, the container also serves a FastAPI +webhook for a Pebble watch app, alongside the existing GitHub poll loop +(both run on the same asyncio event loop). + +- `POST /webhook/pebble` — accepts `{"text": str, "timestamp": int}` with + `Authorization: Bearer `. Returns 200 with a job id. +- `GET /health` — liveness probe (no auth). + +The text is dispatched to the Claude CLI with a system prompt listing +*skills* found under the in-container path `/skills/`. Each skill is a +single markdown file with frontmatter: + +```markdown +--- +name: my-skill +description: One-line description used in skill catalog. +--- + +(Body: instructions for Claude.) +``` + +Mount one or more host directories read-only under `/skills/` in +`docker-compose.yml`. Discovery is recursive; subdirectory layout is +free. Duplicate `name` fields are logged and only the first-discovered +skill is used. + +Claude must pick AT MOST ONE skill per request, or respond exactly +"No matching skill". Each request gets a fresh `claude` session — no +context carries between requests. + +Traefik handles TLS (Let's Encrypt) and routes +`https:///webhook/pebble` over a private docker +network. The `clayde` service is not attached to any externally-reachable +network — the only ingress path is through Traefik. + +--- + ## Testing Run the test suite after any feature development or bug fix: diff --git a/README.md b/README.md index 09c1608..671bede 100644 --- a/README.md +++ b/README.md @@ -198,3 +198,37 @@ In any repository the bot has access to, assign issues to the bot account. Clayd | `CLAYDE_CLAUDE_BACKEND` | `api` (default) or `cli` | | `CLAYDE_CLAUDE_API_KEY` | Anthropic API key (required when backend=`api`) | | `CLAYDE_CLAUDE_MODEL` | Model to use (default: `claude-opus-4-6`) | +| `CLAYDE_PEBBLE_ENABLED` | Set to `true` to enable the Pebble webhook | +| `CLAYDE_PEBBLE_TOKEN` | Bearer token the Pebble app sends | +| `CLAYDE_PEBBLE_HOST` | Public hostname for Traefik routing | +| `CLAYDE_PEBBLE_PORT` | Internal HTTP port (default `8080`) | +| `CLAYDE_PEBBLE_TIMEOUT` | Per-request CLI timeout seconds (default `600`) | +| `CLAYDE_PEBBLE_QUEUE_MAX` | Max queued jobs before 503 (default `100`) | + +--- + +## Pebble Watch Integration + +Clayde can also receive voice commands from a Pebble watch app via an +HTTPS webhook. When enabled, the container additionally serves a FastAPI +endpoint alongside the existing GitHub poll loop. + +To enable: + +1. Set `CLAYDE_PEBBLE_ENABLED=true` and a strong random + `CLAYDE_PEBBLE_TOKEN` in `data/config.env`. +2. Set `CLAYDE_PEBBLE_HOST` to the public hostname Traefik should serve + (e.g. `clayde.example.com`). The hostname must resolve to the host's + public IP and ports `80` + `443` must be open for Let's Encrypt + HTTP-01 challenges. +3. Mount one or more skill directories under `/skills/` in + `docker-compose.yml`. Each skill is a markdown file with frontmatter + `name` and `description` (see `CLAUDE.md` for the full format). +4. Configure the Pebble app to POST to + `https:///webhook/pebble` with the bearer token. + +The webhook is fire-and-forget: requests return `200` with a job id and +work happens asynchronously in a single serial worker. Each request +spawns a fresh Claude CLI session (no context carries between requests) +and the system prompt instructs Claude to choose at most one matching +skill or respond `"No matching skill"`.