Metadata-Version: 2.4
Name: spryng-agent
Version: 0.1.17
Summary: Run Claude Code or Codex on your machine against your Spryng board (the Spryng Local Agent / claude-code-runner).
Project-URL: Homepage, https://github.com/ScrumDoLLC/claude-code-runner
Project-URL: Source, https://github.com/ScrumDoLLC/claude-code-runner
Author-email: Spryng <support@spryng.io>
License: MIT
Keywords: agent,ai,claude,codex,runner,spryng
Classifier: Environment :: Console
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.11
Requires-Dist: fastapi==0.115.0
Requires-Dist: httpx==0.27.0
Requires-Dist: pydantic==2.13.4
Requires-Dist: python-dotenv==1.0.1
Requires-Dist: starlette==0.38.6
Requires-Dist: uvicorn[standard]==0.30.6
Provides-Extra: dev
Requires-Dist: httpx==0.27.0; extra == 'dev'
Requires-Dist: pytest-asyncio==0.23.7; extra == 'dev'
Requires-Dist: pytest==8.2.2; extra == 'dev'
Requires-Dist: respx==0.21.1; extra == 'dev'
Description-Content-Type: text/markdown

# claude-code-runner

Out-of-process sidecar that runs the `claude` CLI on behalf of
Spryng AgentRuns.

Spryng's `apps/agent_runs/runtimes/claude_code.py` adapter speaks
the protocol; this service owns the subprocess and posts progress
back. Deploy it once per environment (one container per runtime
config), point Spryng at it via Settings → AI → Agent Runtimes,
and agents start driving themselves end-to-end.

---

## Quick start

```bash
# 1. Provision the auth token both sides will share.
export RUNNER_AUTH_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
echo "Runner token: $RUNNER_AUTH_TOKEN"

# 2. Provision the Anthropic API key the `claude` CLI uses.
export ANTHROPIC_API_KEY=sk-ant-...

# 3. Build + run the container.
docker build -t claude-code-runner .
docker run --rm -p 8088:8088 \
    -e RUNNER_AUTH_TOKEN \
    -e ANTHROPIC_API_KEY \
    -e RUNNER_ALLOWED_CALLBACK_HOSTS=app.spryng.io \
    claude-code-runner

# 4. In Spryng, AI Settings → Agent Runtimes → Add:
#    type:        claude_code
#    endpoint:    http://<runner-host>:8088
#    credential:  $RUNNER_AUTH_TOKEN
```

Local dev without Docker:

```bash
pip install -e .[dev]
cp .env.example .env
# edit RUNNER_AUTH_TOKEN
uvicorn runner.main:app --reload --host 0.0.0.0 --port 8088
```

---

## Protocol

The runner exposes four endpoints. All except `/healthz` require
`Authorization: Bearer $RUNNER_AUTH_TOKEN`.

### `POST /jobs`

Spryng's adapter calls this to enqueue a job. Returns 202 with a
`job_id` immediately; the work happens in the background.

Request body (see [`models.py`](runner/models.py) for the full schema):

```json
{
  "run_id": 4242,
  "card_ref": "ON-914",
  "card_id": 12345,
  "project_slug": "onboarding",
  "org_slug": "spryng-internal",
  "agent": {
    "username": "pilot-claude",
    "display_name": "Pilot Claude"
  },
  "spec": {
    "content": "---\nfeature_identity: …\n---\n\n## Context\n…",
    "format": "md",
    "complexity_score": 12
  },
  "replan_context": null,
  "spryng_callback": {
    "base_url": "https://app.spryng.io",
    "bearer_token": "agent-run-sub-token-xxxxx",
    "progress_path": "/api/scrumdo/organizations/spryng-internal/agent-runs/4242/progress/",
    "agent_run_header_name": "X-Spryng-Agent-Run",
    "agent_run_header_value": "4242"
  },
  "runtime": {
    "model_slug": "claude-sonnet-4-7",
    "max_wallclock_seconds": 1800,
    "max_cost_cents": 500
  }
}
```

Response:

```json
{ "job_id": "1f8c…", "url": "/jobs/1f8c…" }
```

### `GET /jobs/<job_id>`

Returns the runner's view of the job — runner-local state, plan text
(once generated), outcome (once executed), exit code, cumulative cost
in cents, and the live PID if a subprocess is currently running.

### `POST /jobs/<job_id>/cancel`

Cooperative cancel. Sets `cancel_requested`, cancels the orchestration
task, SIGTERMs any live subprocess (then SIGKILL after 10s), and posts
`state=cancelled` back to Spryng.

Idempotent — calling cancel on a terminal job returns ok with
`note: "already terminal"`.

### `POST /jobs/by-run/<run_id>/cancel`

Same as above but looked up by Spryng's `run_id` rather than the
runner-local `job_id`. Used by Spryng's adapter when it doesn't have
the runner job id (e.g. when the original `start` callback failed).

### `GET /healthz`

Liveness probe. No auth required. Returns
`{ "ok": true, "version": "0.1.0" }`.

---

## Orchestration loop

Per job, the runner walks through these states:

```
                              ┌──────────────────────┐
queued (Spryng) ──► planning ─┤                      │
                              │   claude `generate   │
                              │   plan` subprocess   │
                              │                      │
                              └────────┬─────────────┘
                                       │ plan text
                                       ▼
                              ┌──────────────────────┐
                              │ POST progress to     │
                              │ Spryng with plan     │
                              └────────┬─────────────┘
                                       │
                                       ▼
                              awaiting_approval ──┐
                                                  │ poll Spryng's
                                                  │ /agent-runs/<id>/
                                                  │ every 15s
                                                  ▼
                                          state=executing observed
                                                  │
                                                  ▼
                              ┌──────────────────────┐
                              │   claude `execute    │
                              │   plan` subprocess   │
                              └────────┬─────────────┘
                                       │ outcome text
                                       ▼
                              ┌──────────────────────┐
                              │ finalize: stage /    │
                              │ commit / push branch │
                              │ / open-or-reuse PR   │
                              └────────┬─────────────┘
                                       │ result {pr_url, diffstat, …}
                                       ▼
                              ┌──────────────────────┐
                              │ POST progress to     │
                              │ Spryng: completed +  │
                              │ outcome + cost +     │
                              │ result              │
                              └──────────────────────┘
```

Any failure / timeout / cancel along the way posts the appropriate
`state=failed | cancelled` callback so Spryng's audit log stays
correct.

### Finalize — commit / push / PR (deterministic)

After a successful `execute`, `runner/finalize.py::finalize_execution` makes
the artifacts a property of the **runner**, not of the agent's behaviour:

1. `git add -A`, then detect a no-op (no staged diff *and* no new commits vs.
   the checkout base) → report `result.no_changes` and open no PR.
2. Commit any uncommitted edits on `expected_branch` with a `Fixes <card-ref>`
   message so Spryng's webhook always links it.
3. `git push` the branch using the write-scoped token embedded in the clone URL.
4. Open (or **reuse**, idempotently) a PR via the GitHub REST API.
5. Return a structured `result` (`pr_url`, `pr_number`, diffstat, commits) on
   the `completed` callback. Spryng folds it into `AgentRun.evidence['result']`
   and links the PR immediately — the card shows it without waiting on the
   webhook. On a push/PR failure the run still completes, with
   `result.finalize_error` set so the failure is visible.

### Idempotency & restart behaviour

- **`POST /jobs` is idempotent on `run_id`.** Spryng mints a unique `run_id`
  per real attempt, so a second POST for a `run_id` already in the store
  (active *or* recently terminal, within `RUNNER_JOB_TTL_SECONDS`) is a
  duplicate at-least-once delivery — it returns the existing job and never
  starts a second `claude` run or double-charges.
- **No resume across restart.** The job store is in-memory (`runner/jobs.py`),
  so a container restart loses in-flight jobs. This is intentional — the
  monolith **reaper** is the agreed backstop: it sweeps runs stuck in
  `planning` / `executing` to `failed` (`wallclock_timeout`) once they pass
  `max_wallclock_seconds + 5 min` grace (`apps/agent_runs/reaper.py`), freeing
  the card and revoking the run token. Keep `max_wallclock_seconds` realistic
  so abandoned runs free their card promptly. To survive restarts without the
  reaper delay, swap `JobStore` for a Redis/SQLite-backed implementation.

### What the runner still does NOT do

- **Auto-merge.** The human reviews and merges the PR. Spryng's large-diff
  approval gate still applies.
- **PR discovery from a webhook.** Finalize opens the PR itself; the webhook
  remains the reconciler/source of truth for review + check state.

---

## Configuration

All env vars; defaults shown.

| Var | Default | Purpose |
|---|---|---|
| `RUNNER_HOST` | `0.0.0.0` | Bind address |
| `RUNNER_PORT` | `8088` | Bind port |
| `RUNNER_AUTH_TOKEN` | **required** | Bearer token shared with Spryng's AgentRuntimeConfig |
| `CLAUDE_CLI_BINARY` | `claude` | Path to the Claude Code CLI binary |
| `ANTHROPIC_API_KEY` | inherited | Read by the `claude` CLI from env |
| `RUNNER_WORKDIR_ROOT` | `/tmp/spryng-runs` | Per-job working directories |
| `RUNNER_JOB_TTL_SECONDS` | `3600` | How long terminal jobs stay in memory (also the idempotency window) |
| `RUNNER_SPRYNG_POLL_INTERVAL_SECONDS` | `15` | Approval-polling cadence |
| `RUNNER_GITHUB_API_BASE` | `https://api.github.com` | GitHub REST base for the finalize PR open/reuse (override for GHE) |
| `RUNNER_ALLOWED_CALLBACK_HOSTS` | `*` | Comma-separated hostname allow-list for callbacks (SSRF guard) |
| `LOG_LEVEL` | `info` | uvicorn / app logging |

The allow-list is the most important security knob:

```bash
# Prod
RUNNER_ALLOWED_CALLBACK_HOSTS=app.spryng.io,qa.spryng.io

# Multi-tenant: limit to your own Spryng deployments only.
```

When set to `*`, the runner posts callbacks to whatever URL Spryng
sends. Fine for dev; dangerous in prod if a malicious payload could
ever reach the `POST /jobs` endpoint.

---

## Cost tracking

The runner pulls token counts off the `claude --output-format json`
metadata and converts to cents using the same price table as Spryng's
Anthropic provider adapter. The `cost_cents_delta` field on the
progress callback aggregates planning + execution costs.

Update [`claude_runner.py`](runner/claude_runner.py) `_PRICE_TABLE`
when Anthropic pricing changes; mirror the same edit on Spryng's
[`anthropic_adapter.py`](../MassSense/web_app/apps/ai_intelligence/providers/anthropic_adapter.py)
to keep the two sides honest.

---

## Tests

```bash
pip install -e .[dev]
pytest -v
```

The test suite mocks both the `claude` subprocess and Spryng's
callbacks so it runs hermetically. Coverage:

- `/healthz` doesn't require auth, returns version
- All other endpoints reject missing / wrong tokens
- `POST /jobs` accepts the payload shape and starts the orchestration
- `GET /jobs/<id>` returns the correct state shape
- `POST /jobs/<id>/cancel` and `/jobs/by-run/<run_id>/cancel` both work
- Idempotent cancel on terminal jobs
- Pydantic rejects malformed payloads with 422

---

## Production checklist

1. **Generate the auth token** (32+ bytes random) and store both
   ends — `RUNNER_AUTH_TOKEN` in the runner's env, same value in
   Spryng's `AgentRuntimeConfig.api_credential`.
2. **Provision `ANTHROPIC_API_KEY`** in the runner's env (Secrets
   Manager / SSM SecureString — same pattern as Spryng's keys).
3. **Lock the callback allow-list** to your Spryng hostnames.
4. **Set `max_wallclock_seconds`** on the AgentRuntimeConfig to match
   your tolerance for stuck runs (default 1800s).
5. **Set `max_cost_cents_per_run`** on the AgentRuntimeConfig — the
   runner reports cumulative cost and Spryng's dispatcher fails the
   run when it exceeds this cap.
6. **Run behind a reverse proxy** (nginx / ALB) with TLS termination.
   The runner trusts `X-Forwarded-For` via `--proxy-headers`.
7. **Health checks** — point your orchestrator's liveness probe at
   `GET /healthz`.
8. **Logs** — uvicorn writes structured access logs to stdout;
   forward them to your usual aggregator.

---

## Spec deviation note

The unified spec at [`BOARD_AI_AGENTS_UNIFIED_SPEC.md`](../BOARD_AI_AGENTS_UNIFIED_SPEC.md)
§E.4 calls for a strategy-pattern driver layer in Spryng plus an
out-of-process compute that owns the subprocess. The driver layer
lives at [`runtimes/claude_code.py`](../MassSense/web_app/apps/agent_runs/runtimes/claude_code.py);
this repo IS the out-of-process compute. The HTTP protocol is the
contract — both sides version it together.

When Spryng's adapter changes (new fields on the payload, new
endpoint expectations), update [`models.py`](runner/models.py) here
in lockstep and bump the `version` in `pyproject.toml`.
