# Gitea Webhook Agent Dispatch ## Question How should bot-bottle spawn and manage agents in response to Gitea PR events — and how do we reuse the same agent (with its full session context) across every event in a PR's lifecycle? ## Summary A lightweight webhook receiver maps Gitea PR events to `cli.py` invocations. Spawning is straightforward: the existing work on non-interactive run mode (see [host-dispatch-to-container-agents.md](host-dispatch-to-container-agents.md)) is the missing piece. Session continuity is harder: it requires tracking two identifiers per open PR — the **bottle identity** (bot-bottle's slug for the container state dir) and the **Claude session ID** (the UUID Claude writes to its JSONL transcript). The transcript snapshot mechanism already used by capability-block is the right foundation; it just needs a non-interactive path and a PR-keyed store. ## Gitea Webhook Events for PR Lifecycle Gitea fires `X-Gitea-Event: pull_request` (with an `action` field) for most PR state changes. The payload always includes `pull_request.number`, which is the stable key for correlating events to a running agent. | `X-Gitea-Event` value | Relevant `action` values | When it fires | |---|---|---| | `pull_request` | `opened`, `reopened`, `closed`, `synchronized` | PR created, closed, or pushed to | | `pull_request_comment` | `created`, `edited` | Timeline comment posted | | `pull_request_review_approved` | — | Review submitted with approval | | `pull_request_review_rejected` | — | Review submitted requesting changes | | `pull_request_review_comment` | — | Inline code review comment | | `pull_request_sync` | — | New commits pushed to the PR branch | `pull_request` with `action: synchronized` and `pull_request_sync` both fire on push; they carry the same information but are separate subscriptions in the webhook config UI. Subscribe to `pull_request` and `pull_request_review` (the umbrella) plus `pull_request_comment` to cover the full lifecycle. The webhook receiver validates the `X-Gitea-Signature-256` HMAC header (SHA-256 of the raw body, keyed by the configured secret) before dispatching. ## Spawning an Agent From a Webhook ### What we need from bot-bottle The current `cli.py start` is interactive — it prompts y/N and attaches a tty. A webhook handler needs a non-interactive mode that: 1. Starts the container for a named agent. 2. Runs `claude -p "" --output-format json --dangerously-skip-permissions` inside it (no tty, no session picker). 3. Captures stdout as JSON, extracts `session_id`. 4. Blocks until Claude exits, then tears down. The [host-dispatch-to-container-agents](host-dispatch-to-container-agents.md) research proposes `cli.py run ` for exactly this. That command is the prerequisite for everything below. It should return the Claude JSON output so callers can extract `session_id`. ### Webhook receiver sketch The receiver is a small HTTP service (Flask, FastAPI, or a Go net/http handler) running alongside bot-bottle on the host. It: 1. Validates the HMAC signature. 2. Extracts `pull_request.number` and `X-Gitea-Event` / `action`. 3. Looks up whether a bottle already exists for this PR number. 4. Spawns or resumes accordingly (see next section). 5. Optionally posts a comment back to the PR via Gitea API once Claude finishes. The receiver does not need to be async or queue-based for a single-repo bot, but should at minimum serialize events for the same PR number (a per-PR lock) to avoid two concurrent sessions clobbering each other's transcript. ## Reusing the Same Agent Across a PR This is the harder problem. Two separate identities need to be tracked and connected: ### Identity 1: bottle identity (bot-bottle slug) The slug is the per-bottle state directory name (`~/.bot-bottle/state//`). It's what `cli.py resume ` uses to relaunch a container and mount the preserved state — including the transcript snapshot. This already works for the capability-block flow. ### Identity 2: Claude session ID Claude Code's `--output-format json` response includes a `session_id` UUID. Passing `--resume ` on a subsequent non-interactive run makes Claude continue from exactly that conversation, with full memory of prior tool calls. `--continue` (which maps to `resume_args` in `agent_provider.py`) only picks up the *most recent* session in the project directory — unsafe when multiple sessions may be running concurrently. The session JSONL lives at `~/.claude/projects//.jsonl` inside the container guest. The transcript snapshot (`snapshot_transcript(slug)` in `capability_apply.py`) copies all of `~/.claude` out of the container before teardown, so the JSONL is preserved in `~/.bot-bottle/state//transcript/.claude/`. When the bottle is relaunched and the transcript remounted, `claude --resume ` can find the JSONL at the right path. ### Per-PR session registry The receiver needs a small persistent map: ``` PR number → { bottle_identity: str, claude_session_id: str, agent_name: str } ``` The simplest implementation is a JSON file at `~/.bot-bottle/pr-sessions.json`, written after each successful first-run and updated with each resume. A sqlite database is better if concurrent multi-repo support is needed. ### Full lifecycle flow ``` PR opened → webhook: action=opened → no entry in pr-sessions.json → cli.py run "Review PR #N: \n<diff URL>" → starts container, runs claude -p ... --output-format json → on success: captures session_id from JSON output → snapshot_transcript(slug) → tears down container → write pr-sessions.json: { pr: N, slug: <slug>, session_id: <uuid> } PR gets new commit → webhook: action=synchronized OR pull_request_sync → look up pr-sessions.json: found slug + session_id → cli.py run-resume <slug> --claude-session <session_id> "New commits pushed. Review the diff." → relaunches container with transcript snapshot mounted → runs claude -p ... --resume <session_id> --output-format json → captures new session_id (same or rotated) → snapshot_transcript(slug) again → update pr-sessions.json with latest session_id Comment @-mentions bot → webhook: pull_request_comment, action=created → extract comment body, check for bot mention → same resume flow as above with comment as the prompt PR closed / merged → webhook: action=closed → cli.py cleanup <slug> (or equivalent) → remove from pr-sessions.json ``` ### What needs to be built | Piece | Status | Notes | |---|---|---| | `cli.py run <agent> <task>` | Missing | Non-interactive start; see host-dispatch research | | `cli.py run-resume <slug> --claude-session <id> <task>` | Missing | Like `resume` but non-interactive, passes `--resume <id>` to claude | | `snapshot_transcript` on clean exit | Exists (PRD 0012) | Already called from `start.py`'s session-end path | | Transcript remount on resume | Exists | `bottle_state.py::transcript_snapshot_dir` → docker cp in on launch | | PR session registry | Missing | Needs to be designed; `~/.bot-bottle/pr-sessions.json` is the simplest start | | Webhook receiver service | Missing | New service; needs to be a declared bottle or run as a host process | ## Known Rough Edges **Session ID is not available from within the session.** The ID is only in the `--output-format json` result, readable after the process exits. There is no env var or hook that exposes it mid-session ([upstream issue #44607](https://github.com/anthropics/claude-code/issues/44607)). For the webhook bot this is fine — the outer receiver reads it from the subprocess result. **`--continue` vs `--resume <id>`:** The existing `resume_args = ("--continue",)` in `agent_provider.py` picks up the *most recent* session. For an interactive single-user resume this is fine. For a webhook bot that may have multiple open PRs, it is not safe — two PRs' transcripts would collide if they share a project directory encoding. Use `--resume <session_id>` explicitly. **Project directory encoding.** Claude stores sessions keyed by the absolute cwd, encoded as a path. Inside the container the cwd is always `/home/node` or a subdir. As long as every run for the same PR uses the same cwd, `--resume <session_id>` will find the right JSONL. The cwd should be pinned per PR entry in the session registry. **Concurrent events for the same PR.** If two webhooks arrive close together (e.g., push + CI comment), the receiver must serialize them. A per-PR asyncio lock or a simple file lock on the session registry entry is enough. **Context window growth.** Each resume appends to the same session. A PR with many round trips will eventually hit the context limit. Mitigation options: start a fresh Claude session (new `cli.py run`) periodically and carry forward a summary; or rely on Claude's built-in compaction. The session registry could include a turn count to trigger rotation. **Webhook delivery ordering.** Gitea does not guarantee ordered delivery or exactly-once delivery. The receiver should be idempotent (same PR event processed twice should not create two bottles) and should ignore events for closed PRs. ## Relationship to Existing Bot-Bottle Infrastructure The transcript snapshot + bottle identity system (PRD 0012, `capability_apply.py`) was designed for the capability-block flow: an operator-triggered resume after a security event. The webhook flow is the same mechanism on a faster loop driven by Gitea events instead of operator action. The implementation delta is: 1. Non-interactive run mode (the `cli.py run` gap already identified in host-dispatch research). 2. Passing `--resume <session_id>` explicitly rather than `--continue`. 3. A PR-keyed registry to connect PR numbers to bottle identities and session IDs. 4. A webhook receiver to drive the loop. These are additive changes that sit on top of the existing transcript preservation machinery without altering it. ## Recommendation Start with the non-interactive run mode (`cli.py run`) since everything else depends on it. Once that exists, the webhook receiver and session registry are straightforward glue. The receiver should run as a host process (not inside a bottle) since it needs to call `cli.py` and manage the session registry file. Serialize per-PR to avoid concurrency bugs. Use `--resume <session_id>` (not `--continue`) for all resume paths. The PR session registry is deliberately minimal to start — a JSON file is fine. If multi-repo or multi-agent scenarios appear, migrating to sqlite is a one-file change.