Metadata-Version: 2.5
Name: athena-claude-coder
Version: 0.4.1
Summary: In-guest Agent Protocol server that drives the Claude Agent SDK for Athena coding tasks
License: Proprietary
Requires-Python: >=3.12
Requires-Dist: aiosqlite<1,>=0.20
Requires-Dist: claude-agent-sdk==0.2.151
Requires-Dist: fastapi<1,>=0.115
Requires-Dist: pydantic<3,>=2.7
Requires-Dist: uvicorn<1,>=0.30
Provides-Extra: dev
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: langgraph-sdk==0.4.2; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# athena-claude-coder

The in-guest **Agent Protocol** server that drives the **Claude Agent SDK** for
Athena coding tasks ([ADR 0006](../../docs/decisions/0006-claude-code-coding-worker.md)).

The Athena deep agent delegates repository work to a `claude-coder` async
subagent (`deepagents` `AsyncSubAgent`). Its calls travel through the agora
coding-worker proxy (`/api/coding-worker/{asset_id}/…`), which authorizes every
request, wakes the computer, ensures this server is running, mints a router
grant, and forwards to loopback port `46100` inside the tenant's Talos v2
computer. Here, one `ClaudeSDKClient` per thread works in the checkout at
`/workspace/template` and answers with a compact structured result.

| Route | Who calls it | What it does |
|---|---|---|
| `GET /ok` | the ensure command, the proxy | **unauthenticated** liveness `{ok, version}`; adds `busy`, `active_thread_id`, `owner`, `owner_since`, `owner_thread_id` only when the guest token header matches (never 401) |
| `POST /threads` | `threads.create` | create a task thread (the proxy supplies the id) |
| `GET /threads/{thread_id}` | `threads.get` | the `Thread` shape; `values.messages[-1].content` is the result JSON |
| `GET /threads/{thread_id}/state` | `threads.get_state` | `{values, next, metadata}` (parity) |
| `POST /threads/{thread_id}/runs` | `runs.create` | start a Claude turn; `multitask_strategy=interrupt` supersedes a live one |
| `GET /threads/{thread_id}/runs/{run_id}` | `runs.get` | the `Run` shape; `error` carries the reason on `error`/`interrupted` |
| `POST /threads/{thread_id}/runs/{run_id}/cancel` | `runs.cancel` | interrupt + bounded drain → 204; idempotent |
| `PUT /internal/credential` | the proxy | rotate the task owner key on disk → 204 |
| `POST /internal/handoff/request` | the `athena-claude` launcher | interrupt + drain the live run, lock the session for a human → `{thread_id, claude_session_id, cwd, owner}` |
| `POST /internal/handoff/return` | the launcher on exit | release the lock, record the session id the human ended on → `{thread_id, owner}` |
| `GET /threads/{thread_id}/transcript?after&limit` | the proxy (task card) | Claude Code's own session transcript for the thread, projected and redacted → `{thread_id, claude_session_id, total, entries}` |

Nothing else exists: no LangGraph in the guest, no streaming endpoints, no
OpenAPI docs.

## Install

From a checkout — what the environment bake does today:

```bash
uv tool install --python 3.12 /workspace/template/python-sdk/athena-claude-coder
athena-claude-coder --version
athena-claude-coder serve
install -m 0755 /workspace/template/python-sdk/athena-claude-coder/scripts/athena-claude /opt/athena/bin/athena-claude
```

From PyPI — **only after the first human-triggered publish**. Nothing publishes
automatically: a human dispatches
`.github/workflows/publish-athena-claude-coder.yml` from `staging` with
`confirm=publish` and the `version` that equals `__version__` in
`src/athena_claude_coder/__init__.py` (the upload job runs in the `pypi` GitHub
environment, which holds the token and its approval/branch rules); until that
has happened for a version, `athena-claude-coder==<version>` does not resolve.

```bash
uv tool install --python 3.12 athena-claude-coder==<version>
athena-claude-coder --version    # must print that version; the proxy refuses guests below its minimum
```

The wheel carries the server only. The `athena-claude` human launcher
(`scripts/athena-claude`) is not in it — install it from the checkout (the line
above) or from the bake runbook's inline copy.

`claude-agent-sdk==0.2.151` is pinned exactly and bundles the Claude Code CLI.

## Configuration

Everything comes from the environment; the proxy's ensure command exports it
before `pm2` starts the server.

| Variable | Default | Meaning |
|---|---|---|
| `ATHENA_CODING_WORKER_PORT` | `46100` | loopback port (never public; the router grant targets it) |
| `ATHENA_CODING_WORKER_STATE_DIR` | `/workspace/.claude-state` | SQLite, run logs, credential, `settings.json`; also `CLAUDE_CONFIG_DIR` |
| `ATHENA_CODING_WORKER_REPO_DIR` | `/workspace/template` | Claude's `cwd` |
| `ATHENA_CODING_WORKER_DRAIN_SECONDS` | `30` | how long an interrupt waits for the turn's terminal message before killing the task |
| `ATHENA_CODING_WORKER_MAX_RUN_HOURS` | `6` | runs older than this are interrupted by the sweep |
| `ATHENA_CODING_WORKER_TOKEN` | — | shared secret; **required** for every route except `GET /ok` |

## The gate

This server is not the security boundary — the microVM, the agora proxy
(per-request `get_asset_with_permission_check` + ABAC EDIT) and the gateway
budget are. Two things here are still load-bearing:

- **Guest token.** Every route except `GET /ok` requires
  `X-Athena-Coding-Worker-Token` to equal `ATHENA_CODING_WORKER_TOKEN`
  (constant-time compare) → otherwise `401`. If the variable is unset the
  server fails closed: those routes answer `503 {"detail": "guest token not
  configured"}`. `GET /ok` never 401s, but without a matching token it says
  only `{ok, version}`; the proxy's ensure command proves the running server
  adopted the current token with `GET
  /threads/00000000-0000-4000-8000-000000000000` → `404` (wrong token `401`,
  unset `503`).
- **`can_use_tool`.** Fail-closed defence in depth for Claude's tool calls:
  read-only tools pass, writes are confined to the checkout, the state dir and
  `/tmp`, destructive shell verbs are never allowed, and `git push`,
  `gh pr …`, publishing and force operations need an explicit
  `AUTHORIZED: <action>` line in the task text. The credential files under the
  state dir are off-limits to every tool (and the search tools may not be
  pointed at the state dir root); shell references to them or to the
  `athena-claude-key` helper are refused.
- **One live task per computer.** Every task shares one checkout and one
  credential file, so the envelope's `max_concurrent_tasks` is clamped to 1
  until worktree-per-task lands (plan P2.4); a second thread gets `409
  computer busy`. The slot is reserved before anything is written, so a
  create that loses a race leaves no transcript entry, run row or credential.

## Credentials

The run's `config.configurable.athena` envelope — injected by the proxy, never
trusted from anywhere else — carries the task owner key. The server writes it
to `<state_dir>/credential` (mode `0600`, atomic temp+rename, raw key only) and
`<state_dir>/credential.json` (`expires_at`, `budget_user_id`,
`anthropic_base_url`). Claude reads the key through the `apiKeyHelper`
(`/opt/athena/bin/athena-claude-key`, installed by the bake) named in the
`settings.json` that `serve` writes; the server process never passes it as an
environment variable.

`serve` also **removes** `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
`CLAUDE_CODE_OAUTH_TOKEN` and `CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR` from its
own environment before any client is built: `ClaudeAgentOptions.env` is merged
*over* `os.environ` and cannot unset a key, and the computer's own gateway key
is in the inherited environment.

## Runs, results, durability

- **One consumer.** Only the run task iterates `client.receive_response()`.
  Interrupts (cancel, `multitask_strategy=interrupt`, the sweep, shutdown) set a
  flag and call `client.interrupt()`; the same loop drains the interrupted
  turn's `ResultMessage` and records `interrupted`. The drain is bounded by
  `ATHENA_CODING_WORKER_DRAIN_SECONDS`; past it the task is cancelled and the
  SDK closes the subprocess.
- **Result JSON.** The last AI message is the compact result
  (`status ∈ completed|blocked|failed`, `summary`, `branch`, `commit_sha`,
  `changed_files`, `tests`, `artifacts`, `blockers`, `questions`, plus
  `claude_session_id`, `cost_usd`, `num_turns`, `terminal_reason`, `run_id`).
  Budget exhaustion is `status: blocked` with `blockers: ["budget exhausted"]`.
  A run is `success` whenever Claude produced a terminal result — including
  error results — because the parent only reads the message on `success`; a
  run is `error` only when the SDK or process failed.
- **Same thread = same Claude session.** The session id from the `init` message
  is persisted immediately; every later run on the thread resumes it.
- **Suspend-safe.** State is SQLite plus files on the persisted tree. At boot
  every `pending`/`running` row becomes `interrupted` (`error: restart`) with
  its session id intact; the sweep interrupts runs older than
  `ATHENA_CODING_WORKER_MAX_RUN_HOURS`.

## Human handoff (P2.1)

A human in the computer terminal (Olympus terminal tab or ssh) can take a
task's Claude session over and give it back, on the **same** session: both
sides share `CLAUDE_CONFIG_DIR` and the checkout, so the next SDK run resumes
exactly where the human stopped.

| State (`sessions.owner`) | Entered by | What the SDK may do |
|---|---|---|
| `sdk` | a run starting | — (it is the run) |
| `human` | `POST /internal/handoff/request`: the live run is interrupted with reason `handoff`, drained through the single stream consumer, and recorded as a **`success`** whose last AI message is `{"status": "blocked", "summary": "Handed off to a human in the computer terminal", "blockers": ["human_active"], "claude_session_id", "run_id"}` — `success` so `check_async_task` reads it | `runs.create`/`reserve` → `409 human holds the session`, nothing written |
| `none` | `POST /internal/handoff/return` (records the session id the human ended on), a run finishing, or a restart clearing a dead `sdk` holder | start a run; it passes `resume=<claude_session_id>` |

Both routes are idempotent and need the guest token. `owner_since` is the
moment the lock was taken (`null` for `none`) and doubles as the lock's
**lease**: the request returns it (with `acquired: true` the first time,
`false` while a human already holds the session), the return must present
it, and a return with a stale lease — or one arriving while the SDK owns the
thread — is a no-op. A human lock also occupies the computer's one task slot,
so other threads are refused with `409 computer busy` while it is held. The
request carries the launcher's `launcher_pid`; a lock whose launcher process
is gone (closed terminal, suspended computer) is released at startup, before
every reservation and by the sweep. `takeover: true` re-mints the lease for a
second terminal; the earlier launcher's return then does nothing. The
authenticated `GET /ok` reports `owner` and `owner_since` for the live thread,
else the human-held or most recent session, and names that thread in
`owner_thread_id` (`null` when no session exists) — once a handoff has
drained the run and `active_thread_id` is gone, this is what ties a human
lock to its task.

`scripts/athena-claude` is the launcher the bake installs at
`/opt/athena/bin/athena-claude`: it reads the guest token from the running
server's pm2 environment (`PM2_HOME=/workspace/.pm2-athena-coder pm2 jlist`,
never printed), requests the handoff, runs `claude --resume <session>` (or a
fresh `--session-id` it chooses) with the SDK's exact environment and cwd, and
on exit — any exit code, via `trap`, retried while the worker is unreachable —
posts the return with the lease and the session id it ended on. It refuses
to start a second writer when another terminal holds the session
(`ATHENA_CLAUDE_TAKEOVER=1` takes it over). `ATHENA_CODING_WORKER_THREAD_ID`
picks a thread explicitly; the default is the live one, else the most recent
session.

## Concurrency

The run envelope's `max_concurrent_tasks` (the workspace policy) sets the
number of **task slots**, capped at `HARD_MAX_CONCURRENT_TASKS = 4`. With one
slot (the default) a task runs in the checkout itself (`ATHENA_CODING_WORKER_REPO_DIR`),
exactly as before. With more, every task thread gets its own **git worktree**:

- `git worktree add /workspace/worktrees/<thread12> -b athena/<thread12>` from the
  checkout's current `HEAD` on the thread's first run (`thread12` = first 12
  characters of the thread id); the path is persisted as the thread's `cwd`, so
  every later run — and a human handoff — lands in the same worktree. An
  existing worktree or branch (crash between create and persist, or a
  garbage-collected worktree) is adopted / re-attached, never recreated.
- A thread occupies one slot while it has a live run, a reservation or a human
  holder; the (N+1)th concurrent thread is refused with `409 computer busy`, a
  follow-up run on a thread that holds a slot reuses it. Slot indices are
  stable while a thread holds its slot; `GET /ok` reports `slots: {used, total}`,
  `active_thread_ids` (oldest first) and keeps `active_thread_id` (the oldest
  live one); `busy` means every slot is taken.
- Each task's system prompt names its dev-server port, `46200 + slot index`,
  and warns that other tasks may be using the neighbouring ports.
- Each task reads **its own credential**: `runs.create` writes the envelope's
  owner key to `<state_dir>/credentials/<thread_id>` as well as to the shared
  `credential` file, and the run's `--settings` file
  (`settings-<thread_id>.json`) names an `apiKeyHelper` that `cat`s that copy,
  so concurrent tasks never bill each other's key. The shared file and the
  baked helper stay for the human launcher; `PUT /internal/credential` may
  carry `thread_id` to rotate a task's copy too.
- A human handoff takes a slot exactly like a run (refused with `409 computer
  busy` beyond the policy) and a never-run thread handed to a human gets its
  own worktree under a multi-slot policy.
- Threads that already work in the checkout (first run under one slot, or any
  0.2.0 row) keep it for transcript continuity; when the policy grows, at most
  one such thread may be active at a time — a second is refused with `409
  computer busy` rather than allowed to share the tree. Two thread ids that
  share their first 12 characters get distinct worktrees (the second takes a
  longer name).
- **GC** runs on the sweep cadence and removes a worktree only when its thread
  is terminal (no live run, no reservation, owner `none`), the worktree is clean
  (`git status --porcelain` empty *and* no commit ahead of the checkout's
  `HEAD`) and its session has been idle for more than 24 h; eligibility is
  re-checked under the runner lock right before the removal, so a thread
  claimed meanwhile keeps its worktree. A dirty or ahead worktree is kept and
  logged once. The `athena/<thread12>` branch is never deleted — it is the
  work product — and the checkout itself is never touched.

## Transcript route

`GET /threads/{thread_id}/transcript?after=<int>&limit=<int>` (guest token
required) reads Claude Code's own session transcript for the thread — the
JSONL under `CLAUDE_CONFIG_DIR/projects/<project-slug>/<claude_session_id>.jsonl`,
where the project slug follows the thread's cwd (its worktree under a
multi-slot policy). Agora reads a task's turns *through* this route instead of
the guest pushing them into the Athena session: the session bridge has no
credential a guest could hold. The read is never taken under the runner lock
and runs in a worker thread.

- `404 thread not found` for an unknown thread; `404 no transcript for this
  thread yet` while the thread has no `claude_session_id` or the file does not
  exist yet.
- One entry per content block of every user/assistant message:
  `{seq, role: "user"|"assistant", kind: "text"|"tool_use"|"tool_result"|"thinking",
  text, tool_name?, ts?}`. `seq` is a stable 0-based index over the whole file,
  `text` is clipped to 4000 characters (a `tool_use` renders compactly — the
  Bash command, the Read/Edit/Write path, the Grep pattern — and a
  `tool_result` carries the result text and the name of the tool it answers),
  `ts` is the entry timestamp. Summary, system and meta lines are skipped.
- The projection follows the **live conversation** the way the pinned SDK's
  session reader finds it: the most recent turn no later record descends
  from, and its `parentUuid` ancestors. Turns of an abandoned branch (a
  rewind in the terminal during a handoff) and subagent `isSidechain` records
  are left out. Unlike the SDK, a `compact_boundary` is crossed through its
  `logicalParentUuid`, so the turns before an auto-compaction stay in the
  card and the `isCompactSummary` message appears in their flow — following
  the SDK here would make `total` collapse at every compaction. `seq` is
  therefore stable while the conversation only grows; should the live leaf
  move to another branch, earlier seqs can shift and `total` can shrink — a
  consumer that sees `total` below its cursor re-reads from `after=0`.
- Two passes over the file: one for the `uuid`/`parentUuid` graph (a few
  fields per record), one that projects only live records and redacts and
  clips only the requested window — memory is the graph plus the page, not
  the transcript.
- `after` returns entries with `seq >= after` (negative → 0); `limit` is
  clamped to `1..500`, default 200; `total` always counts the whole file.
- Credential shapes are redacted before anything leaves the guest
  (`redact.py`, mirroring the dcode mirror's list): `sk-…` keys, Bearer
  and Basic authorization, `x-api-key` headers, environment-style assignments
  whose name says credential (`…_API_KEY=`, `…_ACCESS_KEY=`, `…_SECRET=`,
  `…_TOKEN=`, `…_PASSWORD=` — the guest's own `ATHENA_*`/`ANTHROPIC_*`
  variables, `AWS_SESSION_TOKEN`, `GITHUB_TOKEN`, …), GitHub/AWS/Slack/Stripe
  shapes, PEM blocks and URL userinfo.

## Tests

```bash
cd python-sdk/athena-claude-coder
pip install -e ".[dev]"
python -m pytest -v --tb=short && ruff check . && ruff format --check .
```

The suite never spawns a Claude process: a fake `ClaudeSDKClient` drives the
runner, and the protocol contract is exercised through the real
`langgraph_sdk` client on an ASGI transport. Tests marked `post_merge` need a
real Claude and are skipped unless `ATHENA_CODING_WORKER_POST_MERGE=1`.
