Cloud Dispatch

workflow ~238 tokens updated 2026-08-04

Delegate Claude Code agent work to Hetzner Cloud VMs. Provides golden image pipeline, VM lifecycle management, secret injection, workspace provisioning, and /dispatch command for one-command multi-agent dispatch.

Tags

  • cloud
  • hetzner
  • packer
  • agents
  • infrastructure
  • dispatch

README

cloud-dispatch

Delegate Claude Code agent work to Hetzner Cloud VMs. Boot ephemeral agents from a pre-baked golden image in 30-90 seconds, dispatch GitHub issues across them in parallel, and collect PRs when done.

Up to 12 agents (3 VMs x 4 agents each) can run simultaneously, each working an independent GitHub issue from its own isolated user account.

Overview

The dispatch pipeline runs entirely from your local machine via SSH:

  1. Create cx22 VMs from a pre-baked Hetzner snapshot (30-90 seconds boot)
  2. Inject credentials at session time via tmpfs (never in git or cloud-init)
  3. Provision a git clone per agent slot with an issue assignment
  4. Launch Claude Code via SSH into a named tmux session
  5. Monitor via /dispatch-status or agent-status.sh --all
  6. Collect PR URLs and commit hashes when work is done
  7. Destroy VMs to stop billing

Prerequisites

Tool Install Purpose
hcloud brew install hcloud Hetzner Cloud CLI
terraform brew install terraform Infrastructure provisioning (optional)
packer brew install packer Build golden images
gh brew install gh GitHub CLI
jq brew install jq JSON parsing

You also need:

  • A Hetzner Cloud account with an API token
  • An Anthropic API key for agents, or an active Claude Max subscription
  • A GitHub fine-grained PAT with contents:write and pull_requests:write scoped to the target repo
  • ssh-agent running with SSH_AUTH_SOCK set

Set your Hetzner token:

export HCLOUD_TOKEN=your-token-here
# Or use named contexts:
hcloud context create my-project

Quick Start

1. Build the Golden Image (one-time setup)

cd modules/cloud-dispatch/packer
packer init agent-image.pkr.hcl
HCLOUD_TOKEN=$HCLOUD_TOKEN packer build agent-image.pkr.hcl

Build takes 5-10 minutes. Creates a Hetzner snapshot labeled purpose=ccgm-agent.

2. (Optional) Apply Terraform Infrastructure

cd modules/cloud-dispatch/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your SSH public key and firewall settings
terraform init
terraform apply

This creates the named SSH key and firewall rules in Hetzner that the VM scripts expect.

3. Dispatch Issues

In Claude Code, run:

/dispatch owner/repo --issues 42,43,44

Or run the pipeline directly:

# Create VMs
bash lib/vm-create.sh 1

# Initialize session credentials
bash lib/secrets-init.sh

# Inject GitHub token into all agents
GITHUB_TOKEN=$(gh auth token)
bash lib/secrets-inject-all.sh --github-token "$GITHUB_TOKEN"

# Clone repo and assign issues
bash lib/workspace-setup-all.sh "https://github.com/owner/repo.git" --issues "42,43"

# Launch agents
bash lib/agent-launch-all.sh --max-turns 200

4. Monitor Progress

/dispatch-status

Or directly:

bash lib/agent-status.sh --all

5. Collect Results and Clean Up

/dispatch-stop

Or directly:

bash lib/workspace-collect.sh --all
bash lib/agent-stop.sh --all
bash lib/secrets-cleanup.sh
bash lib/vm-destroy.sh --all --force

Commands

/dispatch

Orchestrates the full pipeline: creates VMs if needed, injects credentials, provisions workspaces, and launches agents.

/dispatch owner/repo --issues 42,43,44
/dispatch owner/repo --issues 42,43,44 --vm-count 2 --max-turns 100

Arguments extracted from the message:

  • REPO - GitHub repo in owner/repo format
  • ISSUES - Comma-separated issue numbers
  • VM_COUNT - Number of VMs to create (default: 3, or ceil(issues/4), whichever is smaller)
  • MAX_TURNS - Max turns per agent (default: 200)

/dispatch-status

Checks the status of all running agents and reports PR URLs, last log line, and elapsed time.

/dispatch-status

/dispatch-stop

Stops all running agents and optionally destroys VMs.

/dispatch-stop
/dispatch-stop --destroy    # also destroys VMs after stopping

/vm-manage

Low-level VM management. Accepts sub-commands:

Sub-command Description
create [N] Create N VMs from golden image (default: 3)
destroy [--all | name] Destroy one or all VMs
status List all VMs with state and IP
health Run health checks on all VMs
ssh <name> Open SSH session into a named VM

Architecture

System Overview

MacBook (orchestrator)
  |
  +-- /dispatch owner/repo --issues 42,43,44
        |
        +-- vm-create.sh          # Boot cx22 VMs from golden snapshot
        +-- secrets-init.sh       # Generate session SSH keypair
        +-- secrets-inject-all.sh # GitHub PAT -> /run/secrets/agent-N/ on each VM
        +-- workspace-setup-all.sh# git clone + issue assignment per agent slot
        +-- agent-launch-all.sh   # SSH -> tmux -> claude --dangerously-skip-permissions
              |
              +-- VM 1 (fsn1): agent-0 (issue 42), agent-1 (issue 43)
              +-- VM 2 (nbg1): agent-2 (issue 44), agent-3 (idle)
              +-- VM 3 (hel1): idle

VMs are spread round-robin across three Hetzner datacenters (fsn1, nbg1, hel1) for availability.

VM Layout

Each VM runs Ubuntu 22.04 LTS with 4 isolated agent user accounts:

/
+-- home/
|   +-- agent-0/         # agent user 0
|   |   +-- workspace/   # git clone lives here
|   |   +-- assignment.json  # issue metadata
|   |   +-- status       # AGENT_RUNNING / AGENT_STOPPED / AGENT_TIMEOUT
|   |   +-- run.log      # stdout from claude process
|   +-- agent-1/  ...
|   +-- agent-2/  ...
|   +-- agent-3/  ...
+-- opt/
|   +-- ccgm/
|       +-- auto-shutdown.sh  # cron: idle shutdown after 15m, forced after 8h
+-- run/
|   +-- secrets/         # tmpfs - credentials live here only at runtime
|       +-- agent-0/
|       |   +-- github_token
|       |   +-- claude_auth
|       +-- agent-1/ ...
+-- var/
    +-- lib/ccgm/        # auto-shutdown state (last-active, vm-start timestamps)
    +-- log/ccgm-shutdown.log

Security Model

  • Credentials not in git or cloud-init: GitHub PAT and Anthropic key are injected at session time via SSH, written only to tmpfs at /run/secrets/agent-N/
  • Ephemeral session keys: secrets-init.sh generates a per-session ed25519 keypair, loads it into ssh-agent, and registers the public key with Hetzner. secrets-cleanup.sh revokes it.
  • Agent isolation: Each agent runs as a dedicated Linux user with no sudo access, chmod 700 home directory, and HISTFILE=/dev/null
  • Network egress allowlist: iptables rules allow only github.com (TCP 443+22), api.anthropic.com (TCP 443), and registry.npmjs.org (TCP 443)
  • Hetzner metadata API blocked: The metadata endpoint (169.254.169.254) is blocked for non-root processes
  • Fine-grained PAT scoping: Scope the GitHub token to the specific repo being worked on

Network

The Terraform config creates a Hetzner firewall (ccgm-dispatch-firewall) that:

  • Allows inbound SSH (port 22) from any source
  • Blocks all other inbound traffic

On each VM, iptables rules (set by the golden image security hardening script) control egress:

  • Allowlisted outbound: github.com, api.anthropic.com, registry.npmjs.org
  • Everything else blocked for non-root users

VM Sizing

VM Type vCPU RAM Price/hr Recommended agents
cx22 2 4 GB ~$0.006 1-2
cx32 4 8 GB ~$0.011 4
ccx63 48 192 GB ~$0.58 4 (memory-rich)

Price/hr figures come from the canonical HOURLY_RATES table in lib/budget-track.sh (the single source of truth for module pricing).

Default is ccx63 (set in lib/common.sh as CCGM_SERVER_TYPE). Override with:

CCGM_SERVER_TYPE=cx22 bash lib/vm-create.sh 3

Cost Reference

Cost depends on VM type, session length, and whether you're using a Claude API key or Max subscription. VM costs only (Claude API/Max subscription is separate):

VM type VMs Hours/day VM cost/month estimate
cx22 3 8 ~$4
cx32 3 8 ~$8
ccx63 3 8 ~$418

VMs are billed per second on Hetzner. Destroy them when done to stop charges. The auto-shutdown cron job on each VM shuts it down after 15 minutes of inactivity or 8 hours of wall-clock time (whichever comes first).

Script Reference

VM Lifecycle

Script Usage Description
lib/vm-create.sh vm-create.sh [count] [--type TYPE] Boot N VMs from golden snapshot
lib/vm-destroy.sh vm-destroy.sh --all [--force] Destroy VMs; prompts unless --force
lib/vm-status.sh vm-status.sh List all ccgm-agent-* VMs with IP and state
lib/vm-health.sh vm-health.sh --all SSH reachability + agent user + disk + memory
lib/vm-ssh.sh vm-ssh.sh <name> Interactive SSH into a named VM

Secret Management

Script Usage Description
lib/secrets-init.sh secrets-init.sh Generate session keypair, register with Hetzner
lib/secrets-inject.sh secrets-inject.sh <ip> <agent-index> --github-token TOKEN Inject credentials to one agent slot
lib/secrets-inject-all.sh secrets-inject-all.sh --github-token TOKEN Inject to all agents on all VMs
lib/secrets-cleanup.sh secrets-cleanup.sh Revoke session key from Hetzner, clear tmpfs
lib/secrets-rotate.sh secrets-rotate.sh --github-token TOKEN Rotate credentials without destroying VMs

Workspace Management

Script Usage Description
lib/workspace-setup.sh workspace-setup.sh <ip> <agent-index> <repo-url> Clone repo on one agent slot
lib/workspace-setup-all.sh workspace-setup-all.sh <repo-url> --issues "42,43" Clone and assign across all VMs
lib/workspace-assign.sh workspace-assign.sh <ip> <agent-index> <issue-num> <title> Write assignment.json to one slot
lib/workspace-collect.sh workspace-collect.sh --all [--json] Pull PR URLs and git state from all agents
lib/workspace-cleanup.sh workspace-cleanup.sh --all Remove workspace dirs from VMs
lib/ccgm-headless-install.sh ccgm-headless-install.sh <ccgm-repo-path> <preset> <target-user-home> Non-interactive CCGM installer for a cloud agent user; runs on the VM, invoked internally by workspace-setup.sh

Agent Management

Script Usage Description
lib/agent-launch.sh agent-launch.sh <ip> <agent-index> [--max-turns N] Launch one agent via SSH + tmux
lib/agent-launch-all.sh agent-launch-all.sh [--max-turns N] [--jitter N] [--dry-run] Launch all assigned agents with jitter
lib/agent-status.sh agent-status.sh --all Report status, issue, last log, PR URL per agent
lib/agent-stop.sh agent-stop.sh --all Kill tmux sessions and write AGENT_STOPPED
lib/agent-collect.sh agent-collect.sh --all [--json] Collect agent results (status, PR, log tail)
lib/recovery.sh recovery.sh check | recovery.sh retry <ip> <agent-index> | recovery.sh retry-all Scan agents for failures (rate-limited, crashed, timeout, error) and re-dispatch failed ones

Cost Reporting

Script Usage Description
lib/cost-report.sh cost-report.sh session [--json] | cost-report.sh monthly [--json] Cost report for the current/last session or the current calendar month, from /tmp/ccgm-budget*.json

VM Auto-Shutdown

Script Location Description
lib/auto-shutdown.sh Installed on VM at /opt/ccgm/auto-shutdown.sh Cron-based shutdown: idle after 15m, forced after 8h

Configure via environment variables on the VM (/etc/ccgm/env):

Variable Default Description
MAX_HOURS 8 Wall-clock hours before forced shutdown
IDLE_MINUTES 15 Minutes of no active tmux sessions before idle shutdown

Golden Image

Building

cd modules/cloud-dispatch/packer
packer init agent-image.pkr.hcl
HCLOUD_TOKEN=$HCLOUD_TOKEN packer build agent-image.pkr.hcl

Build takes 5-10 minutes. Creates a Hetzner snapshot with label purpose=ccgm-agent. The vm-create.sh script selects the most recently created snapshot with this label.

Building with Custom Versions

HCLOUD_TOKEN=$HCLOUD_TOKEN packer build \
  -var "image_version=1.1.0" \
  -var "node_version=22" \
  -var "claude_code_version=1.5.0" \
  packer/agent-image.pkr.hcl

When to Rebuild

Rebuild the golden image when:

  • A new major version of Claude Code is released
  • Node.js LTS version changes
  • Security patches are needed (or allow unattended-upgrades to handle them)
  • Every 4-8 weeks as general maintenance

What Is Baked In

Component Notes
OS Ubuntu 22.04 LTS
Node.js 22 LTS (via NodeSource)
pnpm Latest stable
Claude Code CLI Pinned version in agent-image.pkr.hcl
Playwright + Chromium Pinned version
git, tmux, jq, curl, python3 System packages
Agent users agent-0 through agent-3 created
/opt/ccgm/auto-shutdown.sh Installed and registered in cron
iptables egress rules Applied via security-hardening.sh

Packer Files

File Description
packer/agent-image.pkr.hcl Main Packer template
packer/scripts/install-tools.sh Node.js, pnpm, Claude Code, Playwright
packer/scripts/setup.sh Agent users, /opt/ccgm, SSH config
packer/scripts/security-hardening.sh iptables, sshd hardening, unattended-upgrades
packer/scripts/validate.sh Post-build verification

Terraform Infrastructure

The Terraform config in terraform/ creates Hetzner resources that the scripts depend on:

  • SSH key (ccgm-dispatch-key) - a placeholder used when creating VMs. The actual session key is generated by secrets-init.sh.
  • Firewall (ccgm-dispatch-firewall) - inbound SSH only, all other inbound blocked

These resources are optional if you create them manually in the Hetzner dashboard, but Terraform makes it reproducible.

cd modules/cloud-dispatch/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars
terraform init && terraform apply

Jitter

The --jitter option in agent-launch-all.sh (default: 90 seconds) staggers agent starts to avoid simultaneous Claude API requests hitting the rate limiter. With 4 agents and a 90s max jitter, total launch time is up to 4.5 minutes.

Increase jitter if you see rate limit errors. Set to 0 to disable (not recommended with more than 2 agents).

E2E Testing

A manual end-to-end test script is provided at tests/e2e-dispatch.sh. It validates the full pipeline against real Hetzner infrastructure.

# Preview without executing (no cost)
bash tests/e2e-dispatch.sh --dry-run

# Full run (~$2-3 cost, auto-cleans up)
bash tests/e2e-dispatch.sh

# Leave VMs running after test (for manual inspection)
bash tests/e2e-dispatch.sh --skip-cleanup

Prerequisites: HCLOUD_TOKEN set, golden image built, gh authenticated, ssh-agent running.

Troubleshooting

"hcloud not authenticated"

Set HCLOUD_TOKEN in your environment or run hcloud context create.

"No snapshot found matching label purpose=ccgm-agent"

Build the golden image first:

cd packer && packer build agent-image.pkr.hcl

VM boots but health check fails

SSH may not be ready yet. Wait 60 seconds and retry /vm-manage health. Check whether the VM console in the Hetzner dashboard shows a boot error.

Agent exits immediately after launch

Check that secrets were injected:

bash lib/vm-ssh.sh VM_NAME "ls /run/secrets/agent-0/"

If the directory is empty, rerun secrets-inject-all.sh.

"SSH_AUTH_SOCK is not set"

secrets-init.sh requires a running ssh-agent. Start one:

eval "$(ssh-agent -s)"

Then rerun secrets-init.sh.

Rate limit errors across agents

Increase the --jitter value when launching:

bash lib/agent-launch-all.sh --jitter 120

The default is 90 seconds. At 120 seconds, 4 agents take up to 6 minutes to start.

"No running ccgm-agent-* VMs found"

Run vm-status.sh to see VM states. VMs may have auto-shutdown. Create new ones with vm-create.sh.

Secrets-cleanup fails to find session file

If /tmp/ccgm-session.json was removed (e.g., VM reboot), manually delete the session key from Hetzner:

hcloud ssh-key list
hcloud ssh-key delete ccgm-session-TIMESTAMP

Manual Installation

If you are not using the CCGM installer, copy the files manually. The lib/ scripts install into their own ~/.claude/lib/cloud-dispatch/ subdirectory — the slash commands invoke them by that hardcoded path (bash ~/.claude/lib/cloud-dispatch/<script>.sh), and a flat ~/.claude/lib/ would collide with other modules' files (e.g. common.sh).

mkdir -p ~/.claude/commands ~/.claude/rules ~/.claude/lib/cloud-dispatch
cp commands/*.md ~/.claude/commands/
cp rules/cloud-dispatch.md ~/.claude/rules/cloud-dispatch.md
cp lib/*.sh ~/.claude/lib/cloud-dispatch/
chmod +x ~/.claude/lib/cloud-dispatch/*.sh

Then:

  1. Set HCLOUD_TOKEN in your environment
  2. Build the golden image (see Quick Start)

The packer/, terraform/, and tests/ directories stay in the repo — they are build and infrastructure sources, not installed files. Run them from a checkout.

Will install

Path Action Target Type
commands/dispatch.md commands/dispatch.md command
commands/dispatch-status.md commands/dispatch-status.md command
commands/dispatch-stop.md commands/dispatch-stop.md command
commands/vm-manage.md commands/vm-manage.md command
rules/cloud-dispatch.md rules/cloud-dispatch.md rule
lib/agent-collect.sh lib/cloud-dispatch/agent-collect.sh lib
lib/agent-launch.sh lib/cloud-dispatch/agent-launch.sh lib
lib/agent-launch-all.sh lib/cloud-dispatch/agent-launch-all.sh lib
lib/agent-status.sh lib/cloud-dispatch/agent-status.sh lib
lib/agent-stop.sh lib/cloud-dispatch/agent-stop.sh lib
lib/auto-shutdown.sh lib/cloud-dispatch/auto-shutdown.sh lib
lib/budget-track.sh lib/cloud-dispatch/budget-track.sh lib
lib/ccgm-headless-install.sh lib/cloud-dispatch/ccgm-headless-install.sh lib
lib/cost-report.sh lib/cloud-dispatch/cost-report.sh lib
lib/recovery.sh lib/cloud-dispatch/recovery.sh lib
lib/common.sh lib/cloud-dispatch/common.sh lib
lib/vm-create.sh lib/cloud-dispatch/vm-create.sh lib
lib/vm-destroy.sh lib/cloud-dispatch/vm-destroy.sh lib
lib/vm-health.sh lib/cloud-dispatch/vm-health.sh lib
lib/vm-ssh.sh lib/cloud-dispatch/vm-ssh.sh lib
lib/vm-status.sh lib/cloud-dispatch/vm-status.sh lib
lib/secrets-cleanup.sh lib/cloud-dispatch/secrets-cleanup.sh lib
lib/secrets-init.sh lib/cloud-dispatch/secrets-init.sh lib
lib/secrets-inject-all.sh lib/cloud-dispatch/secrets-inject-all.sh lib
lib/secrets-inject.sh lib/cloud-dispatch/secrets-inject.sh lib
lib/secrets-rotate.sh lib/cloud-dispatch/secrets-rotate.sh lib
lib/workspace-assign.sh lib/cloud-dispatch/workspace-assign.sh lib
lib/workspace-cleanup.sh lib/cloud-dispatch/workspace-cleanup.sh lib
lib/workspace-collect.sh lib/cloud-dispatch/workspace-collect.sh lib
lib/workspace-setup-all.sh lib/cloud-dispatch/workspace-setup-all.sh lib
lib/workspace-setup.sh lib/cloud-dispatch/workspace-setup.sh lib

Dependencies

No dependencies.

Required by

No other module depends on this one.

Asks during install

  • Set HCLOUD_TOKEN in your shell environment (e.g. export HCLOUD_TOKEN=... in ~/.zshrc). See https://console.hetzner.cloud -> Security -> API Tokens.

    Default:

Included in presets

Install this module

Agent prompt

Recommended for agent users -- hands the whole install off to your assistant.

Fetch https://cd23a9be.ccgm-site.pages.dev/modules/cloud-dispatch.md and install this module into my Claude Code setup.

Native plugin marketplace

One command via the native plugin marketplace -- additive, does not merge settings.json.

claude plugin install cloud-dispatch@ccgm

The marketplace path is additive, not a replacement: it installs commands, agents, and skills as native plugin components, but it does not perform the bash installer's deep settings.json merge, and it does not write the always-loaded global CLAUDE.md context. Rules are only injected via an opt-in SessionStart hook rather than being auto-loaded. Use the bash installer when those pieces matter to you.

Manual, per file

Full control -- copy exactly the files you want from the sections below.

Files

Files

rule (1)

rules/cloud-dispatch.md

# Cloud Dispatch Rules

Rules that apply when working with cloud-dispatched agents.

## VM Agent Behavior

When running as an agent on a cloud VM:
- Always create a feature branch before making changes
- Commit frequently (every significant change) for git-based recovery
- Create a PR when work is complete
- Never push directly to main
- Use --max-turns to prevent runaway execution

## Dispatch Workflow

When dispatching work to cloud VMs:
- Verify VMs are healthy before dispatching
- Use jittered starts to avoid rate limit thundering herd
- Monitor agent status periodically
- Collect results and clean up VMs when done
- Track costs and stay within budget

## Security

- Never embed secrets in cloud-init or commit them to git
- Use fine-grained GitHub PATs scoped to the target repo
- Session SSH keys are ephemeral - generated per session, revoked on cleanup
- Agent isolation: each agent runs as a separate Linux user with no sudo access
command (4)

commands/dispatch.md

---
description: Delegate work to cloud VMs - dispatch GitHub issues to autonomous Claude Code agents running on Hetzner Cloud
allowed-tools: Bash
---

# /dispatch - Delegate work to cloud VMs

Dispatch GitHub issues to autonomous Claude Code agents running on Hetzner Cloud VMs.

## Usage

The user will provide:
- Which repo to work on
- Which issues to dispatch (by number)
- Optionally: number of VMs, max turns, time limit

## Execution Steps

Follow these steps in order. Use the Bash tool to run the shell scripts.

### Step 1: Parse Arguments

Extract from the user's message:
- `REPO`: GitHub repo (owner/repo format, or just repo name to resolve from ~/code/)
- `ISSUES`: Comma-separated issue numbers
- `VM_COUNT`: Number of VMs (default: 3, or issues / 4 rounded up, whichever is smaller)
- `MAX_TURNS`: Max turns per agent (default: 200)
- `MAX_HOURS`: Max hours before auto-shutdown (default: 4)

### Step 2: Validate Prerequisites

```bash
# Check required tools
command -v hcloud >/dev/null 2>&1 || { echo "ERROR: hcloud CLI not installed. Run: brew install hcloud"; exit 1; }
command -v gh >/dev/null 2>&1 || { echo "ERROR: gh CLI not installed. Run: brew install gh"; exit 1; }

# Check Hetzner auth
hcloud server-type list >/dev/null 2>&1 || { echo "ERROR: hcloud not authenticated. Set HCLOUD_TOKEN env var or run: hcloud context create"; exit 1; }

# Check GitHub auth
gh auth status >/dev/null 2>&1 || { echo "ERROR: gh not authenticated. Run: gh auth login"; exit 1; }
```

### Step 3: Check VM Status

```bash
source ~/.claude/lib/cloud-dispatch/common.sh
bash ~/.claude/lib/cloud-dispatch/vm-status.sh
```

If no VMs are running, create them:
```bash
bash ~/.claude/lib/cloud-dispatch/vm-create.sh $VM_COUNT
```

If VMs exist, health-check them:
```bash
bash ~/.claude/lib/cloud-dispatch/vm-health.sh --all
```

### Step 4: Initialize Session Secrets

```bash
bash ~/.claude/lib/cloud-dispatch/secrets-init.sh
```

Ask the user for their GitHub token if not already configured:

"I need a GitHub fine-grained PAT with `contents:write` and `pull_requests:write` scoped to the target repo. Provide it now, or press Enter to use the token from `gh auth token`."

If the user provides a token, set `GITHUB_TOKEN` to that value. Otherwise:
```bash
GITHUB_TOKEN=$(gh auth token)
```

Then inject secrets to all VMs:
```bash
bash ~/.claude/lib/cloud-dispatch/secrets-inject-all.sh --github-token "$GITHUB_TOKEN"
```

### Step 5: Set Up Workspaces

```bash
bash ~/.claude/lib/cloud-dispatch/workspace-setup-all.sh "https://github.com/$REPO.git" --issues "$ISSUES"
```

### Step 6: Launch Agents

```bash
bash ~/.claude/lib/cloud-dispatch/agent-launch-all.sh --max-turns $MAX_TURNS --jitter 75
```

> **Concurrency — avoid the 429 throttle.** The org-level rate limit applies across *all* VMs at once, not per machine — every remote agent's requests bill against the same ceiling. Keep `VM_COUNT` modest and the launch `--jitter` on (it staggers VM starts so they don't burst together). If a run reports `Server is temporarily limiting requests · Rate limited`, that is the server throttle, not a usage cap — reduce `VM_COUNT` and re-dispatch the affected issues rather than relaunching the whole fleet. See `~/.claude/rules/concurrency-and-rate-limits.md`.

### Step 7: Report

Print a summary of what was dispatched:
- Number of agents launched
- Which issues were assigned to which VM/agent
- How to check status: "Run /dispatch-status to check progress"
- How to stop: "Run /dispatch-stop to terminate all agents"
- Estimated cost: roughly $0.006/hour per cx22 VM (3 VMs = $0.018/hour). For the default ccx63, ~$0.58/hour per VM. See the canonical rate table in `~/.claude/lib/cloud-dispatch/budget-track.sh`.

commands/dispatch-status.md

---
description: Check the status of dispatched agents across all cloud VMs
allowed-tools: Bash
---

# /dispatch-status - Check agent status

Check the status of dispatched agents across all cloud VMs.

## Execution

### Step 1: Check Agent Status

```bash
bash ~/.claude/lib/cloud-dispatch/agent-status.sh --all
```

### Step 2: Collect Results

Pull PR URLs and completed work from all VMs:

```bash
bash ~/.claude/lib/cloud-dispatch/workspace-collect.sh --all
```

### Step 3: Present Summary

Format and present the results showing each agent's:
- VM name and agent slot (e.g. ccgm-agent-1 / agent-0)
- Assigned issue number
- Status: running / completed / failed / idle
- PR URL (if a PR was opened)
- Last git commit message (if available)

If all agents have completed, remind the user they can run `/dispatch-stop` to clean up.

commands/dispatch-stop.md

---
description: Stop all dispatched agents and optionally destroy cloud VMs
allowed-tools: Bash
---

# /dispatch-stop - Stop dispatched agents

Stop all running agents and optionally destroy VMs.

## Execution

### Step 1: Stop Agents

```bash
bash ~/.claude/lib/cloud-dispatch/agent-stop.sh --all
```

### Step 2: Collect Results

Pull any final results, PRs, or uncommitted work before cleanup:

```bash
bash ~/.claude/lib/cloud-dispatch/workspace-collect.sh --all
```

### Step 3: Ask About Cleanup

Ask the user:

"Agents stopped. What do you want to do with the VMs?
1. Keep running (reuse for another dispatch later - saves ~2 min boot time)
2. Destroy VMs (clean shutdown, stops billing)"

If the user chooses destroy:

```bash
# Revoke session SSH keys and clear secrets from tmpfs
bash ~/.claude/lib/cloud-dispatch/secrets-cleanup.sh

# Destroy all dispatch VMs
bash ~/.claude/lib/cloud-dispatch/vm-destroy.sh --all --force
```

Confirm destruction with a final status:
```bash
bash ~/.claude/lib/cloud-dispatch/vm-status.sh
```

commands/vm-manage.md

---
description: Manage Hetzner Cloud VMs for agent dispatch - create, destroy, status, health checks, and SSH access
allowed-tools: Bash
---

# /vm-manage - Manage cloud VMs

Manage Hetzner Cloud VMs for agent dispatch.

## Usage

The user will specify an action:
- `create [N]` - Create N VMs (default 3)
- `destroy [--all | name]` - Destroy one or all VMs
- `status` - List all VMs and their current state
- `health` - Run health checks on all VMs
- `ssh <name>` - Open an SSH session into a VM

## Execution

Parse the action from the user's message and run the corresponding script:

```bash
# For create:
bash ~/.claude/lib/cloud-dispatch/vm-create.sh $N

# For destroy (all):
bash ~/.claude/lib/cloud-dispatch/vm-destroy.sh --all

# For destroy (specific VM):
bash ~/.claude/lib/cloud-dispatch/vm-destroy.sh $VM_NAME

# For status:
bash ~/.claude/lib/cloud-dispatch/vm-status.sh

# For health:
bash ~/.claude/lib/cloud-dispatch/vm-health.sh --all

# For ssh:
bash ~/.claude/lib/cloud-dispatch/vm-ssh.sh $VM_NAME
```

After running each command, display the output clearly. For `status`, format it as a table showing VM name, IP, state, and uptime.
lib (26)

lib/agent-collect.sh

#!/usr/bin/env bash
# agent-collect.sh — Collect run results from one or all Claude Code agents.
#
# Usage:
#   agent-collect.sh --all [--json] [--log-lines N]
#   agent-collect.sh <vm-ip> <agent-index> [--json] [--log-lines N]
#
# Output:
#   Formatted summary table (or JSON with --json) containing:
#     - Agent status (from ~/status)
#     - PR URL (from run.log or gh CLI)
#     - Branch name and last commit
#     - Exit status
#     - Last N lines of run.log (default: 20)
#
# Requires:
#   - hcloud CLI (when --all is used)
#   - SSH key in ~/.ssh/ccgm-dispatch-session or $SSH_KEY_PATH

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
  echo "Usage: $0 --all [--json] [--log-lines N]" >&2
  echo "       $0 <vm-ip> <agent-index> [--json] [--log-lines N]" >&2
  exit 1
fi

COLLECT_ALL=false
VM_IP=""
AGENT_INDEX=""
JSON_OUTPUT=false
LOG_LINES=20

while [[ $# -gt 0 ]]; do
  case "$1" in
    --all)
      COLLECT_ALL=true
      shift
      ;;
    --json)
      JSON_OUTPUT=true
      shift
      ;;
    --log-lines)
      LOG_LINES="$2"
      shift 2
      ;;
    -*)
      log_error "Unknown option: $1"
      exit 1
      ;;
    *)
      if [[ -z "${VM_IP}" ]]; then
        VM_IP="$1"
      elif [[ -z "${AGENT_INDEX}" ]]; then
        AGENT_INDEX="$1"
      else
        log_error "Unexpected argument: $1"
        exit 1
      fi
      shift
      ;;
  esac
done

if [[ "${COLLECT_ALL}" == "false" ]]; then
  if [[ -z "${VM_IP}" || -z "${AGENT_INDEX}" ]]; then
    log_error "Provide --all or both <vm-ip> and <agent-index>"
    exit 1
  fi
  if ! [[ "${AGENT_INDEX}" =~ ^[0-3]$ ]]; then
    log_error "agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})"
    exit 1
  fi
fi

if ! [[ "${LOG_LINES}" =~ ^[0-9]+$ ]]; then
  log_error "--log-lines must be a positive integer (got: ${LOG_LINES})"
  exit 1
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
if [[ "${COLLECT_ALL}" == "true" ]]; then
  require_cmd hcloud
  if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
    log_error "HCLOUD_TOKEN is not set."
    exit 1
  fi
fi

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ssh_root_quiet() {
  local ip="$1"; shift
  # shellcheck disable=SC2206
  read -ra _opts <<< "$(ssh_opts)"
  # SC2029: expansion on client side is intentional
  # shellcheck disable=SC2029
  ssh "${_opts[@]}" "root@${ip}" "$@" 2>/dev/null
}

json_escape() {
  printf '%s' "$1" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" 2>/dev/null \
    || printf '"%s"' "$1"
}

collect_one() {
  local ip="$1"
  local vm_name="$2"
  local idx="$3"
  local agent_user="agent-${idx}"
  local agent_home="/home/${agent_user}"
  local assignment_file="${agent_home}/assignment.json"
  local status_file="${agent_home}/status"
  local run_log="${agent_home}/run.log"

  # --- assignment ---
  local issue_number="" issue_title="" repo="" branch=""
  if ssh_root_quiet "${ip}" "test -f '${assignment_file}'"; then
    local raw_assignment
    raw_assignment=$(ssh_root_quiet "${ip}" "cat '${assignment_file}'" || echo "{}")
    issue_number=$(echo "${raw_assignment}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_number',''))" 2>/dev/null || true)
    issue_title=$(echo "${raw_assignment}"  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_title',''))"  2>/dev/null || true)
    repo=$(echo "${raw_assignment}"         | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('repo',''))"         2>/dev/null || true)
    branch=$(echo "${raw_assignment}"       | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('branch',''))"       2>/dev/null || true)
  fi

  # --- status file ---
  local agent_status="unknown"
  if ssh_root_quiet "${ip}" "test -f '${status_file}'"; then
    agent_status=$(ssh_root_quiet "${ip}" "cat '${status_file}'" 2>/dev/null | tr -d '[:space:]' || echo "unknown")
  fi

  # --- git state ---
  local current_branch="" last_commit=""
  if [[ -n "${repo}" ]]; then
    local repo_name
    repo_name=$(basename "${repo}")
    local clone_dir="${agent_home}/workspace/${repo_name}"
    if ssh_root_quiet "${ip}" "test -d '${clone_dir}/.git'"; then
      current_branch=$(ssh_root_quiet "${ip}" \
        "su - ${agent_user} -c 'git -C ${clone_dir} rev-parse --abbrev-ref HEAD'" \
        2>/dev/null || echo "")
      local sha msg
      sha=$(ssh_root_quiet "${ip}" \
        "su - ${agent_user} -c 'git -C ${clone_dir} rev-parse --short HEAD'" \
        2>/dev/null || echo "")
      msg=$(ssh_root_quiet "${ip}" \
        "su - ${agent_user} -c 'git -C ${clone_dir} log -1 --pretty=%s'" \
        2>/dev/null || echo "")
      [[ -n "${sha}" ]] && last_commit="${sha} ${msg}"
    fi
  fi

  # --- PR URL ---
  local pr_url=""
  if [[ -n "${branch}" && -n "${repo}" ]]; then
    pr_url=$(ssh_root_quiet "${ip}" \
      "su - ${agent_user} -c 'gh pr list --repo ${repo} --head ${branch} --json url --jq .[0].url 2>/dev/null'" \
      2>/dev/null || true)
  fi
  if [[ -z "${pr_url}" ]]; then
    pr_url=$(ssh_root_quiet "${ip}" \
      "grep -oE 'https://github.com/[^/]+/[^/]+/pull/[0-9]+' '${run_log}' 2>/dev/null | tail -1" \
      2>/dev/null || true)
  fi

  # --- log tail ---
  local log_tail=""
  if ssh_root_quiet "${ip}" "test -f '${run_log}'"; then
    log_tail=$(ssh_root_quiet "${ip}" "tail -n ${LOG_LINES} '${run_log}'" 2>/dev/null || true)
  fi

  # ---------------------------------------------------------------------------
  # Output
  # ---------------------------------------------------------------------------
  if [[ "${JSON_OUTPUT}" == "true" ]]; then
    printf '{\n'
    printf '  "vm": %s,\n'            "$(json_escape "${vm_name}")"
    printf '  "agent": %s,\n'         "$(json_escape "${agent_user}")"
    printf '  "issue_number": %s,\n'  "${issue_number:-null}"
    printf '  "issue_title": %s,\n'   "$(json_escape "${issue_title}")"
    printf '  "repo": %s,\n'          "$(json_escape "${repo}")"
    printf '  "branch": %s,\n'        "$(json_escape "${branch}")"
    printf '  "current_branch": %s,\n' "$(json_escape "${current_branch}")"
    printf '  "last_commit": %s,\n'   "$(json_escape "${last_commit}")"
    printf '  "pr_url": %s,\n'        "$(json_escape "${pr_url}")"
    printf '  "status": %s,\n'        "$(json_escape "${agent_status}")"
    printf '  "log_tail": %s\n'       "$(json_escape "${log_tail}")"
    printf '}'
  else
    echo "--- ${vm_name} / ${agent_user} ---"
    printf "Status:       %s\n" "${agent_status}"
    if [[ -n "${issue_number}" ]]; then
      printf "Issue:        #%s - %s\n" "${issue_number}" "${issue_title}"
    else
      printf "Issue:        (no assignment)\n"
    fi
    printf "Repo:         %s\n" "${repo:-(none)}"
    printf "Branch:       %s\n" "${branch:-${current_branch:-(none)}}"
    if [[ -n "${last_commit}" ]]; then
      printf "Last commit:  %s\n" "${last_commit}"
    fi
    if [[ -n "${pr_url}" ]]; then
      printf "PR:           %s\n" "${pr_url}"
    fi
    if [[ -n "${log_tail}" ]]; then
      echo ""
      echo "Log (last ${LOG_LINES} lines):"
      while IFS= read -r line; do
        printf '  %s\n' "${line}"
      done <<< "${log_tail}"
    fi
    echo ""
  fi
}

# ---------------------------------------------------------------------------
# Single agent
# ---------------------------------------------------------------------------
if [[ "${COLLECT_ALL}" == "false" ]]; then
  VM_NAME="${VM_IP}"
  if command -v hcloud &>/dev/null && [[ -n "${HCLOUD_TOKEN:-}" ]]; then
    VM_NAME=$(hcloud server list --output columns=name,ipv4 2>/dev/null \
      | awk -v ip="${VM_IP}" '$2==ip {print $1}' || echo "${VM_IP}")
  fi
  collect_one "${VM_IP}" "${VM_NAME}" "${AGENT_INDEX}"
  exit 0
fi

# ---------------------------------------------------------------------------
# All agents across all running VMs
# ---------------------------------------------------------------------------
mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status \
  | awk '$2=="running" && /ccgm-agent/ {print $1}' \
  | sort)

if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
  log_warn "No running ccgm-agent-* VMs found."
  exit 0
fi

if [[ "${JSON_OUTPUT}" == "true" ]]; then
  echo "["
  first_entry=true
fi

for vm_name in "${VM_NAMES[@]}"; do
  ip=$(hcloud server describe "${vm_name}" --output format='{{.PublicNet.IPv4.IP}}')
  for idx in $(seq 0 $(( CCGM_AGENTS_PER_VM - 1 ))); do
    if [[ "${JSON_OUTPUT}" == "true" && "${first_entry}" != "true" ]]; then
      echo ","
    fi
    collect_one "${ip}" "${vm_name}" "${idx}"
    first_entry=false
  done
done

if [[ "${JSON_OUTPUT}" == "true" ]]; then
  echo ""
  echo "]"
fi

lib/agent-launch.sh

#!/usr/bin/env bash
# agent-launch.sh — Launch a single Claude Code agent in a tmux session on a VM.
#
# Usage:
#   agent-launch.sh <vm-ip> <agent-index> [--max-turns N] [--prompt PROMPT]
#
# Arguments:
#   vm-ip          Public IP of the Hetzner Cloud VM
#   agent-index    0-3, selects agent-N user on the VM
#   --max-turns    Maximum turns before Claude stops (default: 200)
#   --prompt       Override default Claude prompt
#
# The agent's assignment is read from /home/agent-N/assignment.json on the VM.
# The tmux session is named agent-N and output is appended to ~/run.log.
# On completion, ~/status is set to AGENT_DONE (or AGENT_ERROR on failure).
#
# Requires:
#   - SSH key in ~/.ssh/ccgm-dispatch-session or $SSH_KEY_PATH
#   - assignment.json already written (run workspace-assign.sh first)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -lt 2 ]]; then
  echo "Usage: $0 <vm-ip> <agent-index> [--max-turns N] [--prompt PROMPT]" >&2
  exit 1
fi

VM_IP="$1"
AGENT_INDEX="$2"
shift 2

MAX_TURNS=200
CUSTOM_PROMPT=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --max-turns)
      MAX_TURNS="$2"
      shift 2
      ;;
    --prompt)
      CUSTOM_PROMPT="$2"
      shift 2
      ;;
    *)
      log_error "Unknown argument: $1"
      exit 1
      ;;
  esac
done

if ! [[ "${AGENT_INDEX}" =~ ^[0-3]$ ]]; then
  log_error "agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})"
  exit 1
fi

if ! [[ "${MAX_TURNS}" =~ ^[0-9]+$ ]]; then
  log_error "--max-turns must be a positive integer (got: ${MAX_TURNS})"
  exit 1
fi

AGENT_USER="agent-${AGENT_INDEX}"
AGENT_HOME="/home/${AGENT_USER}"
ASSIGNMENT_FILE="${AGENT_HOME}/assignment.json"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ssh_root() {
  # shellcheck disable=SC2206
  read -ra _opts <<< "$(ssh_opts)"
  # SC2029: expansion on client side is intentional (remote command args are built locally)
  # shellcheck disable=SC2029
  ssh "${_opts[@]}" "root@${VM_IP}" "$@"
}

# ---------------------------------------------------------------------------
# Step 1: Verify assignment.json exists
# ---------------------------------------------------------------------------
log_info "Checking assignment for ${AGENT_USER} on ${VM_IP}"

if ! ssh_root "test -f '${ASSIGNMENT_FILE}'" 2>/dev/null; then
  log_error "No assignment.json found at ${ASSIGNMENT_FILE}. Run workspace-assign.sh first."
  exit 1
fi

# ---------------------------------------------------------------------------
# Step 2: Read assignment fields
# ---------------------------------------------------------------------------
ASSIGNMENT=$(ssh_root "cat '${ASSIGNMENT_FILE}'" 2>/dev/null)

ISSUE_NUMBER=$(echo "${ASSIGNMENT}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_number',''))" 2>/dev/null || true)
ISSUE_TITLE=$(echo "${ASSIGNMENT}"  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_title',''))"  2>/dev/null || true)
REPO=$(echo "${ASSIGNMENT}"         | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('repo',''))"         2>/dev/null || true)
BRANCH=$(echo "${ASSIGNMENT}"       | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('branch',''))"       2>/dev/null || true)

if [[ -z "${ISSUE_NUMBER}" || -z "${REPO}" ]]; then
  log_error "assignment.json is missing required fields (issue_number, repo)."
  exit 1
fi

REPO_NAME=$(basename "${REPO}")
WORKSPACE_DIR="${AGENT_HOME}/workspace/${REPO_NAME}"

# ---------------------------------------------------------------------------
# Step 3: Build the Claude prompt
# ---------------------------------------------------------------------------
if [[ -n "${CUSTOM_PROMPT}" ]]; then
  PROMPT="${CUSTOM_PROMPT}"
else
  PROMPT="You are an autonomous Claude Code agent. Work on GitHub issue #${ISSUE_NUMBER}: ${ISSUE_TITLE}. \
Create a branch named ${BRANCH}, implement the changes with tests, and create a PR that closes #${ISSUE_NUMBER}. \
Follow the repo's CLAUDE.md instructions. Commit with message format: #${ISSUE_NUMBER}: description."
fi

# ---------------------------------------------------------------------------
# Step 4: Kill any existing session with the same name
# ---------------------------------------------------------------------------
log_info "Launching tmux session '${AGENT_USER}' on ${VM_IP}"

# Silently kill a stale session if present
ssh_root "su - ${AGENT_USER} -c 'tmux kill-session -t ${AGENT_USER} 2>/dev/null || true'" 2>/dev/null || true

# ---------------------------------------------------------------------------
# Step 5: Build the inner shell command
#
# The command runs as agent-N inside tmux. It:
#   1. Sources the injected secrets env file
#   2. cd's into the workspace clone
#   3. Runs claude in print mode
#   4. Writes AGENT_DONE (or AGENT_ERROR) to ~/status
# ---------------------------------------------------------------------------

# Single-quote the prompt for safe embedding in the outer double-quoted string.
# We use printf %q which escapes for bash, then wrap in single quotes for the
# su -c argument.
ESCAPED_PROMPT=$(printf '%s' "${PROMPT}" | sed "s/'/'\\\\''/g")

INNER_CMD="source /run/secrets/${AGENT_USER}/env 2>/dev/null || true; \
cd '${WORKSPACE_DIR}'; \
claude -p '${ESCAPED_PROMPT}' --dangerously-skip-permissions --max-turns ${MAX_TURNS} \
  >> ~/run.log 2>&1 \
  && echo AGENT_DONE > ~/status \
  || echo AGENT_ERROR > ~/status"

# Wrap inner command for su -c (needs single outer quotes)
SU_CMD="tmux new-session -d -s ${AGENT_USER} 'bash -lc $(printf '%q' "${INNER_CMD}")'"

ssh_root "su - ${AGENT_USER} -c $(printf '%q' "${SU_CMD}")"

# ---------------------------------------------------------------------------
# Step 6: Verify the session was created
# ---------------------------------------------------------------------------
if ssh_root "su - ${AGENT_USER} -c 'tmux has-session -t ${AGENT_USER} 2>/dev/null'" 2>/dev/null; then
  log_success "Session '${AGENT_USER}' is running on ${VM_IP}"
  echo "    Agent:  ${AGENT_USER}@${VM_IP}"
  echo "    Issue:  #${ISSUE_NUMBER} - ${ISSUE_TITLE}"
  echo "    Branch: ${BRANCH}"
  echo "    Repo:   ${REPO}"
else
  log_error "tmux session '${AGENT_USER}' did not start on ${VM_IP}"
  exit 1
fi

lib/agent-launch-all.sh

#!/usr/bin/env bash
# agent-launch-all.sh — Launch all assigned agents across all running VMs with jitter.
#
# Usage:
#   agent-launch-all.sh [--jitter SECONDS] [--max-turns N] [--prompt PROMPT] [--dry-run]
#
# Options:
#   --jitter N    Random sleep max between agent launches in seconds (default: 90).
#                 Actual sleep is a random value between 60 and JITTER (min 60).
#                 Set to 0 to disable jitter.
#   --max-turns N Maximum turns per agent (default: 200)
#   --prompt P    Claude prompt override (passed to agent-launch.sh)
#   --dry-run     Print what would be launched without executing
#
# Jitter is critical: it staggers Claude API calls so all agents don't hit the
# rate limiter simultaneously.
#
# Requires:
#   - hcloud CLI installed and authenticated (HCLOUD_TOKEN set)
#   - agent-launch.sh in the same directory as this script
#   - SSH key in ~/.ssh/ccgm-dispatch-session or $SSH_KEY_PATH

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
MAX_TURNS=200
JITTER=90
CUSTOM_PROMPT=""
DRY_RUN=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --jitter)
      JITTER="$2"
      shift 2
      ;;
    --max-turns)
      MAX_TURNS="$2"
      shift 2
      ;;
    --prompt)
      CUSTOM_PROMPT="$2"
      shift 2
      ;;
    --dry-run)
      DRY_RUN=true
      shift
      ;;
    *)
      log_error "Unknown argument: $1"
      exit 1
      ;;
  esac
done

if ! [[ "${MAX_TURNS}" =~ ^[0-9]+$ ]]; then
  log_error "--max-turns must be a positive integer (got: ${MAX_TURNS})"
  exit 1
fi

if ! [[ "${JITTER}" =~ ^[0-9]+$ ]]; then
  log_error "--jitter must be a non-negative integer (got: ${JITTER})"
  exit 1
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
require_cmd hcloud
require_cmd python3

if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
  log_error "HCLOUD_TOKEN is not set. Export it before running this script."
  exit 1
fi

LAUNCH_SCRIPT="${SCRIPT_DIR}/agent-launch.sh"
if [[ ! -x "${LAUNCH_SCRIPT}" ]]; then
  log_error "agent-launch.sh not found or not executable at ${LAUNCH_SCRIPT}"
  exit 1
fi

# ---------------------------------------------------------------------------
# Discover running agent VMs
# ---------------------------------------------------------------------------
log_info "Discovering running ccgm-agent-* VMs..."

mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status \
  | awk '$2=="running" && /ccgm-agent/ {print $1}' \
  | sort)

if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
  log_error "No running ccgm-agent-* VMs found."
  exit 1
fi

log_info "Found ${#VM_NAMES[@]} VM(s): ${VM_NAMES[*]}"

# ---------------------------------------------------------------------------
# Build launch list: only slots that have an assignment.json
# ---------------------------------------------------------------------------
ssh_root() {
  local ip="$1"; shift
  # shellcheck disable=SC2206
  read -ra _opts <<< "$(ssh_opts)"
  # SC2029: expansion on client side is intentional
  # shellcheck disable=SC2029
  ssh "${_opts[@]}" "root@${ip}" "$@" 2>/dev/null
}

declare -a LAUNCH_VM_IPS
declare -a LAUNCH_VM_NAMES
declare -a LAUNCH_AGENT_INDEXES

log_info "Checking agent assignments..."

for vm_name in "${VM_NAMES[@]}"; do
  vm_ip=$(hcloud server describe "${vm_name}" --output format='{{.PublicNet.IPv4.IP}}')
  for agent_index in $(seq 0 $(( CCGM_AGENTS_PER_VM - 1 ))); do
    agent_user="agent-${agent_index}"
    assignment_file="/home/${agent_user}/assignment.json"
    if ssh_root "${vm_ip}" "test -f '${assignment_file}'"; then
      LAUNCH_VM_IPS+=("${vm_ip}")
      LAUNCH_VM_NAMES+=("${vm_name}")
      LAUNCH_AGENT_INDEXES+=("${agent_index}")
      log_info "  ${vm_name} / ${agent_user} - has assignment"
    else
      log_warn "  ${vm_name} / ${agent_user} - no assignment, skipping"
    fi
  done
done

TOTAL="${#LAUNCH_VM_IPS[@]}"

if [[ "${TOTAL}" -eq 0 ]]; then
  log_warn "No agents have assignments. Run workspace-assign.sh (or workspace-setup-all.sh) first."
  exit 0
fi

log_info "Agents to launch: ${TOTAL}"

if [[ "${DRY_RUN}" == "true" ]]; then
  echo ""
  echo "Dry run - agents that would be launched:"
  for i in "${!LAUNCH_VM_IPS[@]}"; do
    printf "  %s / agent-%s\n" "${LAUNCH_VM_NAMES[$i]}" "${LAUNCH_AGENT_INDEXES[$i]}"
  done
  echo ""
  echo "(--dry-run: no changes made)"
  exit 0
fi

# ---------------------------------------------------------------------------
# Launch agents with jitter between each
# ---------------------------------------------------------------------------
LAUNCHED=0
FAILED=0
START_TIME=$(date +%s)

for i in "${!LAUNCH_VM_IPS[@]}"; do
  vm_ip="${LAUNCH_VM_IPS[$i]}"
  vm_name="${LAUNCH_VM_NAMES[$i]}"
  agent_index="${LAUNCH_AGENT_INDEXES[$i]}"
  agent_user="agent-${agent_index}"

  log_info "[$(( i + 1 ))/${TOTAL}] Launching ${vm_name} / ${agent_user}"

  launch_args=("${vm_ip}" "${agent_index}" --max-turns "${MAX_TURNS}")
  if [[ -n "${CUSTOM_PROMPT}" ]]; then
    launch_args+=(--prompt "${CUSTOM_PROMPT}")
  fi

  if "${LAUNCH_SCRIPT}" "${launch_args[@]}"; then
    LAUNCHED=$(( LAUNCHED + 1 ))
  else
    log_error "Launch failed for ${vm_name} / ${agent_user}"
    FAILED=$(( FAILED + 1 ))
  fi

  # Jitter: sleep a random amount between 60s and JITTER (skip after last agent)
  if [[ "${JITTER}" -gt 0 && $(( i + 1 )) -lt "${TOTAL}" ]]; then
    if [[ "${JITTER}" -gt 60 ]]; then
      sleep_secs=$(( 60 + RANDOM % (JITTER - 60 + 1) ))
    else
      sleep_secs="${JITTER}"
    fi
    log_info "Jitter: sleeping ${sleep_secs}s before next launch..."
    sleep "${sleep_secs}"
  fi
done

END_TIME=$(date +%s)
ELAPSED=$(( END_TIME - START_TIME ))

echo ""
log_info "Launch complete"
echo "  Total agents launched: ${LAUNCHED}"
if [[ "${FAILED}" -gt 0 ]]; then
  echo "  Failed:                ${FAILED}"
fi
echo "  Total elapsed:         ${ELAPSED}s"

[[ "${FAILED}" -eq 0 ]]

lib/agent-status.sh

#!/usr/bin/env bash
# agent-status.sh — Report the status of one or all Claude Code agents.
#
# Usage:
#   agent-status.sh --all
#   agent-status.sh <vm-ip> <agent-index>
#
# Output per agent:
#   VM: ccgm-agent-fsn1-0 | Agent: agent-0 | Status: running | Issue: #42
#   Last log: "Creating PR for branch 42-habit-streaks..."
#   Duration: 23m
#
# Requires:
#   - hcloud CLI (when --all is used)
#   - SSH key in ~/.ssh/ccgm-dispatch-session or $SSH_KEY_PATH

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
  echo "Usage: $0 --all" >&2
  echo "       $0 <vm-ip> <agent-index>" >&2
  exit 1
fi

STATUS_ALL=false
VM_IP=""
AGENT_INDEX=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --all)
      STATUS_ALL=true
      shift
      ;;
    -*)
      log_error "Unknown option: $1"
      exit 1
      ;;
    *)
      if [[ -z "${VM_IP}" ]]; then
        VM_IP="$1"
      elif [[ -z "${AGENT_INDEX}" ]]; then
        AGENT_INDEX="$1"
      else
        log_error "Unexpected argument: $1"
        exit 1
      fi
      shift
      ;;
  esac
done

if [[ "${STATUS_ALL}" == "false" ]]; then
  if [[ -z "${VM_IP}" || -z "${AGENT_INDEX}" ]]; then
    log_error "Provide --all or both <vm-ip> and <agent-index>"
    exit 1
  fi
  if ! [[ "${AGENT_INDEX}" =~ ^[0-3]$ ]]; then
    log_error "agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})"
    exit 1
  fi
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
if [[ "${STATUS_ALL}" == "true" ]]; then
  require_cmd hcloud
  if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
    log_error "HCLOUD_TOKEN is not set."
    exit 1
  fi
fi

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ssh_root_quiet() {
  local ip="$1"; shift
  # shellcheck disable=SC2206
  read -ra _opts <<< "$(ssh_opts)"
  # SC2029: expansion on client side is intentional
  # shellcheck disable=SC2029
  ssh "${_opts[@]}" "root@${ip}" "$@" 2>/dev/null
}

# status_one <vm-ip> <vm-name> <agent-index>
status_one() {
  local ip="$1"
  local name="$2"
  local idx="$3"
  local agent_user="agent-${idx}"
  local agent_home="/home/${agent_user}"
  local assignment_file="${agent_home}/assignment.json"
  local status_file="${agent_home}/status"
  local run_log="${agent_home}/run.log"

  # --- assignment ---
  local issue_number="" issue_title="" assigned_at=""
  if ssh_root_quiet "${ip}" "test -f '${assignment_file}'"; then
    local raw_assignment
    raw_assignment=$(ssh_root_quiet "${ip}" "cat '${assignment_file}'" || echo "{}")
    issue_number=$(echo "${raw_assignment}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_number',''))" 2>/dev/null || true)
    issue_title=$(echo "${raw_assignment}"  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_title',''))"  2>/dev/null || true)
    assigned_at=$(echo "${raw_assignment}"  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('assigned_at',''))"  2>/dev/null || true)
  fi

  # --- tmux / status ---
  local agent_state="no-session"
  if ssh_root_quiet "${ip}" "su - ${agent_user} -c 'tmux has-session -t ${agent_user} 2>/dev/null'"; then
    agent_state="running"
  elif ssh_root_quiet "${ip}" "test -f '${status_file}'"; then
    agent_state=$(ssh_root_quiet "${ip}" "cat '${status_file}'" 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]' || echo "unknown")
  fi

  # --- last log line ---
  local last_log_line=""
  if ssh_root_quiet "${ip}" "test -f '${run_log}'"; then
    last_log_line=$(ssh_root_quiet "${ip}" "tail -1 '${run_log}'" 2>/dev/null | tr -d '\r' || true)
  fi

  # --- PR URL ---
  local pr_url=""
  if ssh_root_quiet "${ip}" "test -f '${run_log}'"; then
    pr_url=$(ssh_root_quiet "${ip}" "grep -oE 'https://github.com/[^/]+/[^/]+/pull/[0-9]+' '${run_log}' | tail -1" 2>/dev/null || true)
  fi

  # --- duration ---
  local duration=""
  if [[ -n "${assigned_at}" ]]; then
    duration=$(python3 - "${assigned_at}" <<'PYEOF'
import sys
from datetime import datetime, timezone
try:
    assigned = datetime.fromisoformat(sys.argv[1].replace("Z", "+00:00"))
    now = datetime.now(timezone.utc)
    total = int((now - assigned).total_seconds())
    h, rem = divmod(total, 3600)
    m = rem // 60
    if h > 0:
        print(f"{h}h {m}m")
    else:
        print(f"{m}m")
except Exception:
    print("")
PYEOF
    )
  fi

  # --- output ---
  printf "VM: %-24s | Agent: %-8s | Status: %-14s | Issue: %s\n" \
    "${name}" "${agent_user}" "${agent_state}" "${issue_number:-(none)}"

  if [[ -n "${issue_title}" ]]; then
    printf "  Title:    %s\n" "${issue_title}"
  fi
  if [[ -n "${last_log_line}" ]]; then
    printf "  Last log: %s\n" "${last_log_line}"
  fi
  if [[ -n "${pr_url}" ]]; then
    printf "  PR:       %s\n" "${pr_url}"
  fi
  if [[ -n "${duration}" ]]; then
    printf "  Duration: %s\n" "${duration}"
  fi
  echo ""
}

# ---------------------------------------------------------------------------
# Single agent
# ---------------------------------------------------------------------------
if [[ "${STATUS_ALL}" == "false" ]]; then
  # Resolve VM name from IP (best effort)
  VM_NAME="${VM_IP}"
  if command -v hcloud &>/dev/null && [[ -n "${HCLOUD_TOKEN:-}" ]]; then
    VM_NAME=$(hcloud server list --output columns=name,ipv4 2>/dev/null \
      | awk -v ip="${VM_IP}" '$2==ip {print $1}' || echo "${VM_IP}")
  fi
  status_one "${VM_IP}" "${VM_NAME}" "${AGENT_INDEX}"
  exit 0
fi

# ---------------------------------------------------------------------------
# All agents across all running VMs
# ---------------------------------------------------------------------------
mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status \
  | awk '$2=="running" && /ccgm-agent/ {print $1}' \
  | sort)

if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
  log_warn "No running ccgm-agent-* VMs found."
  exit 0
fi

for vm_name in "${VM_NAMES[@]}"; do
  ip=$(hcloud server describe "${vm_name}" --output format='{{.PublicNet.IPv4.IP}}')
  for idx in $(seq 0 $(( CCGM_AGENTS_PER_VM - 1 ))); do
    status_one "${ip}" "${vm_name}" "${idx}"
  done
done

lib/agent-stop.sh

#!/usr/bin/env bash
# agent-stop.sh — Stop one or all running Claude Code agent tmux sessions.
#
# Usage:
#   agent-stop.sh --all
#   agent-stop.sh <vm-ip> <agent-index>
#
# For each target agent:
#   1. Kills the tmux session named agent-N on the VM
#   2. Writes AGENT_STOPPED to ~/status
#
# Requires:
#   - hcloud CLI (when --all is used)
#   - SSH key in ~/.ssh/ccgm-dispatch-session or $SSH_KEY_PATH

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
  echo "Usage: $0 --all" >&2
  echo "       $0 <vm-ip> <agent-index>" >&2
  exit 1
fi

STOP_ALL=false
VM_IP=""
AGENT_INDEX=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --all)
      STOP_ALL=true
      shift
      ;;
    -*)
      log_error "Unknown option: $1"
      exit 1
      ;;
    *)
      if [[ -z "${VM_IP}" ]]; then
        VM_IP="$1"
      elif [[ -z "${AGENT_INDEX}" ]]; then
        AGENT_INDEX="$1"
      else
        log_error "Unexpected argument: $1"
        exit 1
      fi
      shift
      ;;
  esac
done

if [[ "${STOP_ALL}" == "false" ]]; then
  if [[ -z "${VM_IP}" || -z "${AGENT_INDEX}" ]]; then
    log_error "Provide --all or both <vm-ip> and <agent-index>"
    exit 1
  fi
  if ! [[ "${AGENT_INDEX}" =~ ^[0-3]$ ]]; then
    log_error "agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})"
    exit 1
  fi
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
if [[ "${STOP_ALL}" == "true" ]]; then
  require_cmd hcloud
  if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
    log_error "HCLOUD_TOKEN is not set."
    exit 1
  fi
fi

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ssh_root_quiet() {
  local ip="$1"; shift
  # shellcheck disable=SC2206
  read -ra _opts <<< "$(ssh_opts)"
  # SC2029: expansion on client side is intentional
  # shellcheck disable=SC2029
  ssh "${_opts[@]}" "root@${ip}" "$@" 2>/dev/null
}

stop_one() {
  local ip="$1"
  local idx="$2"
  local agent_user="agent-${idx}"
  local agent_home="/home/${agent_user}"
  local status_file="${agent_home}/status"

  log_info "Stopping ${agent_user} on ${ip}"

  # Kill the tmux session (ignore errors if not running)
  ssh_root_quiet "${ip}" "su - ${agent_user} -c 'tmux kill-session -t ${agent_user} 2>/dev/null || true'"

  # Write stopped status
  ssh_root_quiet "${ip}" "echo AGENT_STOPPED > '${status_file}' && chown '${agent_user}:${agent_user}' '${status_file}'"

  log_success "Stopped ${agent_user} on ${ip}"
}

# ---------------------------------------------------------------------------
# Single agent
# ---------------------------------------------------------------------------
if [[ "${STOP_ALL}" == "false" ]]; then
  stop_one "${VM_IP}" "${AGENT_INDEX}"
  exit 0
fi

# ---------------------------------------------------------------------------
# All agents across all running VMs
# ---------------------------------------------------------------------------
mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status \
  | awk '$2=="running" && /ccgm-agent/ {print $1}' \
  | sort)

if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
  log_warn "No running ccgm-agent-* VMs found."
  exit 0
fi

STOPPED=0

for vm_name in "${VM_NAMES[@]}"; do
  ip=$(hcloud server describe "${vm_name}" --output format='{{.PublicNet.IPv4.IP}}')
  for idx in $(seq 0 $(( CCGM_AGENTS_PER_VM - 1 ))); do
    stop_one "${ip}" "${idx}"
    STOPPED=$(( STOPPED + 1 ))
  done
done

log_info "Stopped ${STOPPED} agent session(s)."

lib/auto-shutdown.sh

#!/usr/bin/env bash
# auto-shutdown.sh — Auto-shutdown script that runs ON the VM (not the orchestrator).
#
# Installed as a cron job on the VM (every 5 minutes):
#   */5 * * * * /opt/ccgm/auto-shutdown.sh >> /var/log/ccgm-shutdown.log 2>&1
#
# Behavior:
#   1. If any agent tmux sessions are still active, record the time and exit.
#   2. If no sessions have been active for 15+ minutes, initiate shutdown.
#   3. If MAX_HOURS wall-clock time has been exceeded, kill all agents and shut down.
#
# Environment variables (read from /etc/ccgm/env if present):
#   MAX_HOURS    Maximum wall-clock hours before forced shutdown (default: 8)
#   IDLE_MINUTES Minutes of no active sessions before idle shutdown (default: 15)
#
# State files (on the VM):
#   /var/lib/ccgm/last-active   Timestamp of last observed active session (epoch seconds)
#   /var/lib/ccgm/vm-start      Timestamp of VM start / script first run (epoch seconds)
#   /var/log/ccgm-shutdown.log  Shutdown reason and timing (written by this script)
#
# Install in the golden image or via workspace-setup.sh:
#   install -m 0755 auto-shutdown.sh /opt/ccgm/auto-shutdown.sh

set -euo pipefail

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
MAX_HOURS="${MAX_HOURS:-8}"
IDLE_MINUTES="${IDLE_MINUTES:-15}"

STATE_DIR="/var/lib/ccgm"
LAST_ACTIVE_FILE="${STATE_DIR}/last-active"
VM_START_FILE="${STATE_DIR}/vm-start"
SHUTDOWN_LOG="/var/log/ccgm-shutdown.log"
ENV_FILE="/etc/ccgm/env"

# ---------------------------------------------------------------------------
# Logging (to shutdown log only - stdout is redirected there by cron)
# ---------------------------------------------------------------------------
log() {
  echo "$(date '+%Y-%m-%dT%H:%M:%S') [auto-shutdown] $*"
}

# ---------------------------------------------------------------------------
# Load optional env overrides
# ---------------------------------------------------------------------------
if [[ -f "${ENV_FILE}" ]]; then
  # shellcheck source=/dev/null
  source "${ENV_FILE}"
fi

# ---------------------------------------------------------------------------
# Ensure state directory exists
# ---------------------------------------------------------------------------
mkdir -p "${STATE_DIR}"

NOW=$(date +%s)

# ---------------------------------------------------------------------------
# Record VM start time on first run
# ---------------------------------------------------------------------------
if [[ ! -f "${VM_START_FILE}" ]]; then
  echo "${NOW}" > "${VM_START_FILE}"
  log "VM start time recorded: ${NOW}"
fi

VM_START=$(cat "${VM_START_FILE}")
WALL_CLOCK_SECS=$(( NOW - VM_START ))
WALL_CLOCK_HOURS=$(( WALL_CLOCK_SECS / 3600 ))

# ---------------------------------------------------------------------------
# Check wall-clock cap first (hard limit)
# ---------------------------------------------------------------------------
MAX_SECS=$(( MAX_HOURS * 3600 ))
if [[ "${WALL_CLOCK_SECS}" -ge "${MAX_SECS}" ]]; then
  log "SHUTDOWN: wall-clock limit reached (${WALL_CLOCK_HOURS}h >= ${MAX_HOURS}h limit)"
  log "Killing all agent tmux sessions..."

  # Kill all agent-N tmux sessions
  for i in 0 1 2 3; do
    agent_user="agent-${i}"
    if id "${agent_user}" &>/dev/null; then
      su - "${agent_user}" -c "tmux kill-server 2>/dev/null || true" 2>/dev/null || true
      echo "AGENT_TIMEOUT" > "/home/${agent_user}/status" || true
      chown "${agent_user}:${agent_user}" "/home/${agent_user}/status" 2>/dev/null || true
    fi
  done

  log "SHUTDOWN: initiating poweroff (reason: max-hours)"
  echo "shutdown" > "${SHUTDOWN_LOG}.reason"
  /sbin/poweroff
  exit 0
fi

# ---------------------------------------------------------------------------
# Check for active tmux sessions across all agent users
# ---------------------------------------------------------------------------
ACTIVE_SESSIONS=0
for i in 0 1 2 3; do
  agent_user="agent-${i}"
  if id "${agent_user}" &>/dev/null; then
    if su - "${agent_user}" -c "tmux has-session 2>/dev/null"; then
      ACTIVE_SESSIONS=$(( ACTIVE_SESSIONS + 1 ))
    fi
  fi
done

# ---------------------------------------------------------------------------
# Update last-active or check idle timeout
# ---------------------------------------------------------------------------
if [[ "${ACTIVE_SESSIONS}" -gt 0 ]]; then
  echo "${NOW}" > "${LAST_ACTIVE_FILE}"
  log "Active sessions: ${ACTIVE_SESSIONS} (wall-clock: ${WALL_CLOCK_HOURS}h, max: ${MAX_HOURS}h)"
  exit 0
fi

# No active sessions - check idle duration
if [[ -f "${LAST_ACTIVE_FILE}" ]]; then
  LAST_ACTIVE=$(cat "${LAST_ACTIVE_FILE}")
else
  # Never been active - use VM start as baseline
  LAST_ACTIVE="${VM_START}"
  echo "${LAST_ACTIVE}" > "${LAST_ACTIVE_FILE}"
fi

IDLE_SECS=$(( NOW - LAST_ACTIVE ))
IDLE_MINS=$(( IDLE_SECS / 60 ))
IDLE_THRESHOLD_SECS=$(( IDLE_MINUTES * 60 ))

log "No active sessions. Idle for ${IDLE_MINS}m (threshold: ${IDLE_MINUTES}m, wall-clock: ${WALL_CLOCK_HOURS}h)"

if [[ "${IDLE_SECS}" -ge "${IDLE_THRESHOLD_SECS}" ]]; then
  log "SHUTDOWN: idle threshold reached (${IDLE_MINS}m >= ${IDLE_MINUTES}m)"
  log "SHUTDOWN: initiating poweroff (reason: idle-timeout)"
  echo "idle-timeout" > "${SHUTDOWN_LOG}.reason"
  /sbin/poweroff
  exit 0
fi

log "Idle timer running: ${IDLE_MINS}m / ${IDLE_MINUTES}m until shutdown"

lib/budget-track.sh

#!/usr/bin/env bash
# budget-track.sh — Track VM costs for CCGM cloud-dispatch sessions.
#
# Usage:
#   budget-track.sh start
#   budget-track.sh stop
#   budget-track.sh status
#   budget-track.sh report
#
# Subcommands:
#   start   Record session start time and active VM details to BUDGET_FILE.
#   stop    Finalize session cost and append to MONTHLY_FILE.
#   status  Print current session runtime and running cost estimate.
#   report  Print full cost report for the current month.
#
# State files:
#   /tmp/ccgm-budget.json         Current session data
#   /tmp/ccgm-budget-monthly.json Monthly session log (append-only array)
#
# CANONICAL HOURLY RATES (USD, approximate EUR->USD at 1.08).
# This HOURLY_RATES table is the single source of truth for VM pricing in
# cloud-dispatch. README.md and commands/dispatch.md must match these figures.
#   ccx63: $0.58/hr  (48 vCPU, 192 GB)  dedicated
#   ccx43: $0.28/hr  (16 vCPU,  64 GB)  dedicated
#   ccx33: $0.20/hr  ( 8 vCPU,  32 GB)  dedicated
#   cx42:  $0.022/hr ( 8 vCPU,  16 GB)  shared
#   cx32:  $0.011/hr ( 4 vCPU,   8 GB)  shared
#   cx22:  $0.006/hr ( 2 vCPU,   4 GB)  shared
#
# Requires: jq, hcloud (for start subcommand)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BUDGET_FILE="${CCGM_BUDGET_FILE:-/tmp/ccgm-budget.json}"
MONTHLY_FILE="${CCGM_MONTHLY_FILE:-/tmp/ccgm-budget-monthly.json}"
MONTHLY_BUDGET_USD="${CCGM_MONTHLY_BUDGET:-2000}"
CLAUDE_MAX_USD="${CCGM_CLAUDE_MAX_USD:-200}"

# Hourly rates by server type (USD). Canonical source of truth for the module.
declare -A HOURLY_RATES=(
  [ccx63]="0.58"
  [ccx43]="0.28"
  [ccx33]="0.20"
  [cx42]="0.022"
  [cx32]="0.011"
  [cx22]="0.006"
)
DEFAULT_RATE="0.58"

# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
  echo "Usage: $0 <start|stop|status|report>" >&2
  exit 1
}

if [[ $# -lt 1 ]]; then
  usage
fi

SUBCOMMAND="$1"
shift

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

require_cmd jq

# hourly_rate <server-type> — print the USD/hr rate for a given server type
hourly_rate() {
  local stype
  # printf '%s', not echo: bash's builtin echo flag-parses a leading -n/-e,
  # so a server-type of exactly "-n" would silently normalize to empty and
  # fall through to DEFAULT_RATE for the wrong reason.
  stype=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
  echo "${HOURLY_RATES[$stype]:-${DEFAULT_RATE}}"
}

# now_iso — current UTC time in ISO 8601 format
now_iso() {
  date -u +"%Y-%m-%dT%H:%M:%SZ"
}

# now_epoch — current time as Unix epoch seconds
now_epoch() {
  date +%s
}

# iso_to_epoch <iso-string> — convert ISO 8601 UTC string to epoch seconds
iso_to_epoch() {
  python3 -c "
from datetime import datetime, timezone
s = '$1'.replace('Z', '+00:00')
try:
    dt = datetime.fromisoformat(s)
    print(int(dt.timestamp()))
except Exception:
    print(0)
"
}

# elapsed_human <start_epoch> — return human-readable elapsed time
elapsed_human() {
  local start="$1"
  local now
  now=$(now_epoch)
  local total=$(( now - start ))
  local h=$(( total / 3600 ))
  local m=$(( (total % 3600) / 60 ))
  if [[ ${h} -gt 0 ]]; then
    echo "${h}h ${m}m"
  else
    echo "${m}m"
  fi
}

# session_id — generate a session ID from current timestamp
session_id() {
  date -u +"%Y%m%d-%H%M%S"
}

# current_month — YYYY-MM
current_month() {
  date -u +"%Y-%m"
}

# month_label — "Month YYYY" format for display
month_label() {
  date -u +"%B %Y"
}

# ---------------------------------------------------------------------------
# Subcommand: start
# ---------------------------------------------------------------------------
cmd_start() {
  require_cmd hcloud

  if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
    log_error "HCLOUD_TOKEN is not set. Cannot list VMs."
    exit 1
  fi

  log_info "Scanning for running ccgm-agent-* VMs..."

  # Collect VM details via hcloud
  local vm_json
  vm_json=$(hcloud server list --output json 2>/dev/null \
    | python3 -c "
import json, sys
servers = json.load(sys.stdin)
result = []
for s in servers:
    name = s.get('name', '')
    if not name.startswith('ccgm-agent'):
        continue
    if s.get('status', '') != 'running':
        continue
    stype = ''
    st = s.get('server_type', {})
    if st:
        stype = st.get('name', '')
    created = s.get('created', '')
    result.append({'name': name, 'type': stype, 'started_at': created})
print(json.dumps(result))
" 2>/dev/null || echo "[]")

  local sid
  sid=$(session_id)
  local ts
  ts=$(now_iso)

  # Build per-VM entries with hourly rates using jq
  local rate_map_json
  rate_map_json=$(jq -n \
    --argjson ccx63 "${HOURLY_RATES[ccx63]:-${DEFAULT_RATE}}" \
    --argjson ccx43 "${HOURLY_RATES[ccx43]:-${DEFAULT_RATE}}" \
    --argjson ccx33 "${HOURLY_RATES[ccx33]:-${DEFAULT_RATE}}" \
    --argjson cx42 "${HOURLY_RATES[cx42]:-${DEFAULT_RATE}}" \
    --argjson cx32 "${HOURLY_RATES[cx32]:-${DEFAULT_RATE}}" \
    --argjson cx22 "${HOURLY_RATES[cx22]:-${DEFAULT_RATE}}" \
    '{ccx63: $ccx63, ccx43: $ccx43, ccx33: $ccx33, cx42: $cx42, cx32: $cx32, cx22: $cx22}')

  local default_rate="${DEFAULT_RATE}"
  local vms_json
  vms_json=$(echo "${vm_json}" | jq \
    --argjson rates "${rate_map_json}" \
    --argjson default_rate "${default_rate}" \
    --arg fallback_ts "${ts}" \
    '[.[] | {
       name: .name,
       type: (.type | ascii_downcase),
       hourly_rate: ($rates[(.type | ascii_downcase)] // $default_rate),
       started_at: (if .started_at == "" or .started_at == null then $fallback_ts else .started_at end)
     }]' 2>/dev/null || echo "[]")

  jq -n \
    --arg sid "${sid}" \
    --arg ts "${ts}" \
    --argjson vms "${vms_json}" \
    '{
      session_id: $sid,
      started_at: $ts,
      vms: $vms,
      vm_hours_total: 0,
      estimated_cost_usd: 0
    }' > "${BUDGET_FILE}"

  local vm_count
  vm_count=$(jq '.vms | length' "${BUDGET_FILE}")
  log_success "Session ${sid} started. Tracking ${vm_count} VM(s)."
  echo "  Budget file: ${BUDGET_FILE}"
}

# ---------------------------------------------------------------------------
# Subcommand: stop
# ---------------------------------------------------------------------------
cmd_stop() {
  if [[ ! -f "${BUDGET_FILE}" ]]; then
    log_error "No active session found at ${BUDGET_FILE}. Run 'start' first."
    exit 1
  fi

  local now_ts
  now_ts=$(now_iso)

  # Calculate total VM-hours and cost
  local total_hours total_cost
  total_hours=$(jq --arg now "${now_ts}" '
    [.vms[] |
      (($now | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime) -
       (.started_at | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) / 3600
    ] | add // 0
  ' "${BUDGET_FILE}" 2>/dev/null || echo "0")

  total_cost=$(jq --arg now "${now_ts}" '
    [.vms[] |
      ((($now | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime) -
        (.started_at | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) / 3600) * .hourly_rate
    ] | add // 0
  ' "${BUDGET_FILE}" 2>/dev/null || echo "0")

  # Update session file with final values
  local session_data
  session_data=$(jq \
    --arg stopped "${now_ts}" \
    --argjson hours "${total_hours}" \
    --argjson cost "${total_cost}" \
    '. + {stopped_at: $stopped, vm_hours_total: $hours, estimated_cost_usd: $cost}' \
    "${BUDGET_FILE}")

  # Append to monthly log
  local month
  month=$(current_month)

  if [[ ! -f "${MONTHLY_FILE}" ]]; then
    echo '{"sessions": []}' > "${MONTHLY_FILE}"
  fi

  jq --argjson entry "${session_data}" --arg month "${month}" \
    '.sessions += [$entry] | .last_updated = now | .month = $month' \
    "${MONTHLY_FILE}" > "${MONTHLY_FILE}.tmp" && mv "${MONTHLY_FILE}.tmp" "${MONTHLY_FILE}"

  # Print session summary
  local sid
  sid=$(jq -r '.session_id' "${BUDGET_FILE}")
  printf "\nSession %s complete.\n" "${sid}"
  printf "  VM-hours:  %.2f\n" "${total_hours}"
  printf "  Cost:      \$%.2f\n" "${total_cost}"
  printf "  Saved to:  %s\n\n" "${MONTHLY_FILE}"

  # Clean up session file
  rm -f "${BUDGET_FILE}"
}

# ---------------------------------------------------------------------------
# Subcommand: status
# ---------------------------------------------------------------------------
cmd_status() {
  if [[ ! -f "${BUDGET_FILE}" ]]; then
    log_warn "No active session. Run 'start' to begin tracking."
    exit 0
  fi

  local started_at
  started_at=$(jq -r '.started_at' "${BUDGET_FILE}")
  local start_ep
  start_ep=$(iso_to_epoch "${started_at}")
  local elapsed
  elapsed=$(elapsed_human "${start_ep}")

  local vm_count
  vm_count=$(jq '.vms | length' "${BUDGET_FILE}")

  # Running cost: sum of (elapsed_hours * hourly_rate) per VM
  local now_ts
  now_ts=$(now_iso)
  local running_cost
  running_cost=$(jq --arg now "${now_ts}" '
    [.vms[] |
      ((($now | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime) -
        (.started_at | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) / 3600) * .hourly_rate
    ] | add // 0
  ' "${BUDGET_FILE}" 2>/dev/null || echo "0")

  local hourly_rate_total
  hourly_rate_total=$(jq '[.vms[].hourly_rate] | add // 0' "${BUDGET_FILE}")

  # Monthly stats
  local monthly_total="0"
  if [[ -f "${MONTHLY_FILE}" ]]; then
    monthly_total=$(jq '[.sessions[].estimated_cost_usd // 0] | add // 0' "${MONTHLY_FILE}" 2>/dev/null || echo "0")
  fi

  # Session-hours so far (for monthly projection)
  local month_seconds_elapsed
  month_seconds_elapsed=$(python3 -c "
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
import calendar
days_in_month = calendar.monthrange(now.year, now.month)[1]
day = now.day + now.hour/24.0
print(int(day / days_in_month * 86400 * days_in_month))
" 2>/dev/null || echo "1")

  local month_seconds_total
  month_seconds_total=$(python3 -c "
from datetime import datetime, timezone
import calendar
now = datetime.now(timezone.utc)
days_in_month = calendar.monthrange(now.year, now.month)[1]
print(days_in_month * 86400)
" 2>/dev/null || echo "2592000")

  local grand_total
  grand_total=$(python3 -c "print(round(${monthly_total} + ${running_cost}, 2))" 2>/dev/null || echo "${monthly_total}")

  local projected
  projected=$(python3 -c "
elapsed = ${month_seconds_elapsed}
total = ${month_seconds_total}
spent = ${grand_total}
if elapsed > 0:
    print(round(spent / elapsed * total, 2))
else:
    print(0)
" 2>/dev/null || echo "0")

  printf "Session: %s | VMs: %s | Rate: \$%.2f/hr | Session cost: \$%.2f\n" \
    "${elapsed}" "${vm_count}" "${hourly_rate_total}" "${running_cost}"
  printf "Monthly total: \$%.2f (est. \$%.2f/mo at current pace)\n" \
    "${grand_total}" "${projected}"
}

# ---------------------------------------------------------------------------
# Subcommand: report
# ---------------------------------------------------------------------------
cmd_report() {
  local month_label
  month_label=$(month_label)

  echo "=== CCGM Cloud Dispatch - Cost Report ==="
  echo "Month: ${month_label}"

  if [[ ! -f "${MONTHLY_FILE}" ]]; then
    echo "No session data found for this month."
    exit 0
  fi

  # Session stats
  local session_count total_vm_hours total_vm_cost issues_completed
  session_count=$(jq '.sessions | length' "${MONTHLY_FILE}")
  total_vm_hours=$(jq '[.sessions[].vm_hours_total // 0] | add // 0' "${MONTHLY_FILE}")
  total_vm_cost=$(jq '[.sessions[].estimated_cost_usd // 0] | add // 0' "${MONTHLY_FILE}")

  # Count issues (vms * agents * sessions as a proxy - sum vm counts per session)
  issues_completed=$(jq '[.sessions[] | (.vms | length)] | add // 0' "${MONTHLY_FILE}")

  local total_cost
  total_cost=$(python3 -c "print(round(${total_vm_cost} + ${CLAUDE_MAX_USD}, 2))" 2>/dev/null || echo "${total_vm_cost}")

  local budget_remaining
  budget_remaining=$(python3 -c "print(round(${MONTHLY_BUDGET_USD} - ${total_cost}, 2))" 2>/dev/null || echo "0")

  # Projection
  local month_seconds_total
  month_seconds_total=$(python3 -c "
from datetime import datetime, timezone
import calendar
now = datetime.now(timezone.utc)
days_in_month = calendar.monthrange(now.year, now.month)[1]
print(days_in_month * 86400)
" 2>/dev/null || echo "2592000")

  local month_seconds_elapsed
  month_seconds_elapsed=$(python3 -c "
from datetime import datetime, timezone
import calendar
now = datetime.now(timezone.utc)
days_in_month = calendar.monthrange(now.year, now.month)[1]
elapsed = (now.day - 1) * 86400 + now.hour * 3600 + now.minute * 60 + now.second
print(max(1, elapsed))
" 2>/dev/null || echo "86400")

  local projected
  projected=$(python3 -c "
elapsed = ${month_seconds_elapsed}
total = ${month_seconds_total}
spent = ${total_cost}
print(round(spent / elapsed * total, 2))
" 2>/dev/null || echo "0")

  printf "Sessions:               %s\n" "${session_count}"
  printf "Total VM-hours:         %.1f\n" "${total_vm_hours}"
  printf "Total VM cost:          \$%.2f\n" "${total_vm_cost}"
  printf "Claude Max subscription:\$%.2f\n" "${CLAUDE_MAX_USD}"
  printf "Estimated total:        \$%.2f\n" "${total_cost}"
  printf "Budget remaining:       \$%.2f (of \$%s)\n" "${budget_remaining}" "${MONTHLY_BUDGET_USD}"
  printf "Projected month-end:    \$%.2f\n" "${projected}"

  # Cost per issue (if any completed)
  if [[ "${issues_completed}" -gt 0 && $(python3 -c "print(1 if ${total_cost} > 0 else 0)") == "1" ]]; then
    local cost_per_issue
    cost_per_issue=$(python3 -c "print(round(${total_vm_cost} / ${issues_completed}, 2))" 2>/dev/null || echo "N/A")
    printf "Cost per issue (VM):    \$%s\n" "${cost_per_issue}"
  fi
}

# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
case "${SUBCOMMAND}" in
  start)  cmd_start  ;;
  stop)   cmd_stop   ;;
  status) cmd_status ;;
  report) cmd_report ;;
  *)
    log_error "Unknown subcommand: ${SUBCOMMAND}"
    usage
    ;;
esac

lib/ccgm-headless-install.sh

#!/usr/bin/env bash
# ccgm-headless-install.sh — Non-interactive CCGM installer for cloud agent users.
#
# Installs a CCGM preset into a target user's ~/.claude/ directory by copying
# rule and command files from the CCGM repo. No interactive prompts, no template
# expansion (cloud agents don't need user-specific config).
#
# Usage:
#   ccgm-headless-install.sh <ccgm-repo-path> <preset-name> <target-user-home>
#
# Example:
#   ccgm-headless-install.sh /opt/ccgm/repo cloud-agent /home/agent-0
#
# This script is designed to run ON the VM (not from the orchestrator).
set -euo pipefail

if [[ $# -lt 3 ]]; then
  echo "Usage: $0 <ccgm-repo-path> <preset-name> <target-user-home>" >&2
  exit 1
fi

CCGM_REPO="$1"
PRESET_NAME="$2"
TARGET_HOME="$3"

CLAUDE_DIR="${TARGET_HOME}/.claude"
PRESET_FILE="${CCGM_REPO}/presets/${PRESET_NAME}.json"

if [[ ! -f "${PRESET_FILE}" ]]; then
  echo "Error: preset not found: ${PRESET_FILE}" >&2
  exit 1
fi

if [[ ! -d "${CCGM_REPO}/modules" ]]; then
  echo "Error: CCGM modules directory not found: ${CCGM_REPO}/modules" >&2
  exit 1
fi

# Parse preset JSON to get module list
if ! command -v jq &>/dev/null; then
  echo "Error: jq is required but not installed" >&2
  exit 1
fi

mapfile -t MODULES < <(jq -r '.[]' "${PRESET_FILE}")

echo "==> Installing CCGM preset '${PRESET_NAME}' to ${CLAUDE_DIR}"
echo "    Modules: ${MODULES[*]}"

# Create target directories
mkdir -p "${CLAUDE_DIR}/rules"
mkdir -p "${CLAUDE_DIR}/commands"

INSTALLED_RULES=0
INSTALLED_COMMANDS=0

for module in "${MODULES[@]}"; do
  MODULE_DIR="${CCGM_REPO}/modules/${module}"
  MANIFEST="${MODULE_DIR}/module.json"

  if [[ ! -f "${MANIFEST}" ]]; then
    echo "  WARN: module '${module}' not found, skipping"
    continue
  fi

  echo "  --> Installing module: ${module}"

  # Extract file mappings from module.json
  # Each file entry has: source path (key), target path, and type
  while IFS=$'\t' read -r src target ftype; do
    SRC_PATH="${MODULE_DIR}/${src}"

    if [[ ! -f "${SRC_PATH}" ]]; then
      echo "    WARN: source file not found: ${SRC_PATH}"
      continue
    fi

    case "${ftype}" in
      rule)
        cp "${SRC_PATH}" "${CLAUDE_DIR}/${target}"
        INSTALLED_RULES=$((INSTALLED_RULES + 1))
        ;;
      command)
        cp "${SRC_PATH}" "${CLAUDE_DIR}/${target}"
        INSTALLED_COMMANDS=$((INSTALLED_COMMANDS + 1))
        ;;
      # Skip lib, hook, settings, and other non-rule/command types
      # Cloud agents get lib scripts via the cloud-dispatch module's own paths
      *)
        ;;
    esac
  done < <(jq -r '.files | to_entries[] | [.key, .value.target, .value.type] | @tsv' "${MANIFEST}")
done

echo "==> CCGM headless install complete"
echo "    Rules installed: ${INSTALLED_RULES}"
echo "    Commands installed: ${INSTALLED_COMMANDS}"
echo "    Target: ${CLAUDE_DIR}"

lib/cost-report.sh

#!/usr/bin/env bash
# cost-report.sh — Generate cost reports for CCGM cloud-dispatch sessions.
#
# Usage:
#   cost-report.sh session [--json]
#   cost-report.sh monthly [--json]
#
# Subcommands:
#   session   Report for the current or last completed session.
#   monthly   Report for the current calendar month.
#
# Flags:
#   --json    Output machine-readable JSON instead of formatted text.
#
# Reads from:
#   /tmp/ccgm-budget.json         Current or last session data
#   /tmp/ccgm-budget-monthly.json Monthly session log
#
# Requires: jq

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BUDGET_FILE="${CCGM_BUDGET_FILE:-/tmp/ccgm-budget.json}"
MONTHLY_FILE="${CCGM_MONTHLY_FILE:-/tmp/ccgm-budget-monthly.json}"
MONTHLY_BUDGET_USD="${CCGM_MONTHLY_BUDGET:-2000}"
CLAUDE_MAX_USD="${CCGM_CLAUDE_MAX_USD:-200}"

# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
  echo "Usage: $0 <session|monthly> [--json]" >&2
  exit 1
}

if [[ $# -lt 1 ]]; then
  usage
fi

SUBCOMMAND="$1"
shift

JSON_OUTPUT=false
while [[ $# -gt 0 ]]; do
  case "$1" in
    --json)
      JSON_OUTPUT=true
      shift
      ;;
    *)
      log_error "Unknown argument: $1"
      usage
      ;;
  esac
done

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
require_cmd jq

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

# month_label — "Month YYYY" format for display
month_label() {
  date -u +"%B %Y"
}

# current_month — YYYY-MM
current_month() {
  date -u +"%Y-%m"
}

# safe_jq <filter> <file> <default> — run jq filter and return default on failure
safe_jq() {
  local filter="$1"
  local file="$2"
  local default="${3:-0}"
  jq -r "${filter}" "${file}" 2>/dev/null || echo "${default}"
}

# py_calc <expr> — evaluate a Python arithmetic expression and print result
py_calc() {
  python3 -c "print(round(${1}, 2))" 2>/dev/null || echo "0"
}

# duration_label <hours_float> — convert decimal hours to human-readable string
duration_label() {
  python3 -c "
h = float('${1}')
hours = int(h)
mins = int((h - hours) * 60)
if hours > 0:
    print(f'{hours}h {mins}m')
else:
    print(f'{mins}m')
" 2>/dev/null || echo "${1}h"
}

# ---------------------------------------------------------------------------
# Compute session-level stats from a session JSON object
# Returns a JSON object with derived fields
# ---------------------------------------------------------------------------
session_stats() {
  local session_json="$1"

  # Extract base fields
  local sid started_at stopped_at vm_count vm_hours_total cost
  sid=$(echo "${session_json}"           | jq -r '.session_id // "unknown"')
  started_at=$(echo "${session_json}"    | jq -r '.started_at // ""')
  stopped_at=$(echo "${session_json}"    | jq -r '.stopped_at // ""')
  vm_count=$(echo "${session_json}"      | jq '.vms | length')
  vm_hours_total=$(echo "${session_json}" | jq '.vm_hours_total // 0')
  cost=$(echo "${session_json}"          | jq '.estimated_cost_usd // 0')

  # Compute wall-clock duration
  local duration_hours="0"
  if [[ -n "${started_at}" && -n "${stopped_at}" ]]; then
    duration_hours=$(python3 -c "
from datetime import datetime, timezone
def parse(s):
    return datetime.fromisoformat(s.replace('Z', '+00:00'))
try:
    s = parse('${started_at}')
    e = parse('${stopped_at}')
    print(round((e - s).total_seconds() / 3600, 3))
except Exception:
    print(0)
" 2>/dev/null || echo "0")
  fi

  # VM breakdown
  local vm_breakdown
  vm_breakdown=$(echo "${session_json}" | jq -c '[.vms[] | {name: .name, type: .type, hourly_rate: .hourly_rate}]')

  jq -n \
    --arg sid "${sid}" \
    --arg started_at "${started_at}" \
    --arg stopped_at "${stopped_at}" \
    --argjson vm_count "${vm_count}" \
    --argjson vm_hours "${vm_hours_total}" \
    --argjson cost "${cost}" \
    --argjson duration "${duration_hours}" \
    --argjson vms "${vm_breakdown}" \
    '{
      session_id: $sid,
      started_at: $started_at,
      stopped_at: $stopped_at,
      vm_count: $vm_count,
      vm_hours_total: $vm_hours,
      estimated_cost_usd: $cost,
      duration_hours: $duration,
      vms: $vms
    }'
}

# ---------------------------------------------------------------------------
# Subcommand: session
# ---------------------------------------------------------------------------
cmd_session() {
  local session_json

  if [[ -f "${BUDGET_FILE}" ]]; then
    # Active session
    session_json=$(cat "${BUDGET_FILE}")
    local is_active=true

    # For active sessions, compute running cost from current time
    local now_ts
    now_ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    local running_cost
    running_cost=$(echo "${session_json}" | jq --arg now "${now_ts}" '
      [.vms[] |
        ((($now | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime) -
          (.started_at | gsub("Z$";"") | strptime("%Y-%m-%dT%H:%M:%S") | mktime)) / 3600) * .hourly_rate
      ] | add // 0
    ' 2>/dev/null || echo "0")

    session_json=$(echo "${session_json}" | jq --argjson cost "${running_cost}" '.estimated_cost_usd = $cost')
  elif [[ -f "${MONTHLY_FILE}" ]]; then
    # Use last completed session
    session_json=$(jq '.sessions[-1]' "${MONTHLY_FILE}" 2>/dev/null || echo "null")
    if [[ "${session_json}" == "null" ]]; then
      log_warn "No session data found."
      exit 0
    fi
    local is_active=false
  else
    log_warn "No session data found. Run 'budget-track.sh start' to begin tracking."
    exit 0
  fi

  local stats
  stats=$(session_stats "${session_json}")

  if [[ "${JSON_OUTPUT}" == "true" ]]; then
    echo "${stats}"
    return
  fi

  # Human-readable output
  local sid vm_count vm_hours cost duration
  sid=$(echo "${stats}"      | jq -r '.session_id')
  vm_count=$(echo "${stats}" | jq '.vm_count')
  vm_hours=$(echo "${stats}" | jq '.vm_hours_total')
  cost=$(echo "${stats}"     | jq '.estimated_cost_usd')
  duration=$(echo "${stats}" | jq '.duration_hours')

  local duration_str
  duration_str=$(duration_label "${duration}")

  echo "=== CCGM Cloud Dispatch - Session Report ==="
  printf "Session ID:    %s\n" "${sid}"

  if [[ "${is_active}" == "true" ]]; then
    printf "Status:        ACTIVE (running)\n"
  else
    printf "Status:        Completed\n"
  fi

  printf "Duration:      %s\n" "${duration_str}"
  printf "VMs active:    %s\n" "${vm_count}"
  printf "VM-hours:      %.2f\n" "${vm_hours}"
  printf "Session cost:  \$%.2f\n" "${cost}"

  # VM breakdown
  local vm_entries
  vm_entries=$(echo "${stats}" | jq -r '.vms[] | "  \(.name) (\(.type)): \$\(.hourly_rate)/hr"')
  if [[ -n "${vm_entries}" ]]; then
    echo ""
    echo "VM Breakdown:"
    echo "${vm_entries}"
  fi
}

# ---------------------------------------------------------------------------
# Subcommand: monthly
# ---------------------------------------------------------------------------
cmd_monthly() {
  if [[ ! -f "${MONTHLY_FILE}" ]]; then
    log_warn "No monthly data found at ${MONTHLY_FILE}."
    exit 0
  fi

  # Aggregate session stats
  local session_count avg_duration_hours total_vm_hours total_vm_cost
  session_count=$(jq '.sessions | length' "${MONTHLY_FILE}")
  total_vm_hours=$(jq '[.sessions[].vm_hours_total // 0] | add // 0' "${MONTHLY_FILE}")
  total_vm_cost=$(jq '[.sessions[].estimated_cost_usd // 0] | add // 0' "${MONTHLY_FILE}")

  if [[ "${session_count}" -gt 0 ]]; then
    avg_duration_hours=$(python3 -c "print(round(${total_vm_hours} / ${session_count}, 2))" 2>/dev/null || echo "0")
  else
    avg_duration_hours="0"
  fi

  # Count total issues dispatched (sum of VM counts across sessions as proxy)
  local issues_dispatched
  issues_dispatched=$(jq '[.sessions[] | (.vms | length)] | add // 0' "${MONTHLY_FILE}")

  # Total cost including Claude subscription
  local total_cost
  total_cost=$(py_calc "${total_vm_cost} + ${CLAUDE_MAX_USD}")

  # Budget utilization
  local budget_remaining budget_pct_used
  budget_remaining=$(py_calc "${MONTHLY_BUDGET_USD} - ${total_cost}")
  budget_pct_used=$(python3 -c "print(round(${total_cost} / ${MONTHLY_BUDGET_USD} * 100, 1))" 2>/dev/null || echo "0")

  # Cost per issue
  local cost_per_issue="N/A"
  if [[ "${issues_dispatched}" -gt 0 ]]; then
    cost_per_issue=$(python3 -c "print(f'\${round(${total_vm_cost} / ${issues_dispatched}, 2)}')" 2>/dev/null || echo "N/A")
  fi

  # Month-end projection based on days elapsed
  local month_days_total month_days_elapsed projected_cost
  month_days_total=$(python3 -c "
from datetime import datetime, timezone
import calendar
now = datetime.now(timezone.utc)
print(calendar.monthrange(now.year, now.month)[1])
" 2>/dev/null || echo "30")

  month_days_elapsed=$(python3 -c "
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
print(max(1, now.day))
" 2>/dev/null || echo "1")

  projected_cost=$(python3 -c "
print(round(${total_cost} / ${month_days_elapsed} * ${month_days_total}, 2))
" 2>/dev/null || echo "0")

  # Per-session type breakdown (by server type)
  local type_breakdown
  type_breakdown=$(jq -r '
    [.sessions[].vms[]] |
    group_by(.type) |
    map({type: .[0].type, count: length, rate: .[0].hourly_rate}) |
    .[] |
    "  \(.type): \(.count) VM-slot(s) @ $\(.rate)/hr"
  ' "${MONTHLY_FILE}" 2>/dev/null || true)

  if [[ "${JSON_OUTPUT}" == "true" ]]; then
    jq -n \
      --arg month "$(current_month)" \
      --argjson sessions "${session_count}" \
      --argjson issues "${issues_dispatched}" \
      --argjson vm_hours "${total_vm_hours}" \
      --argjson vm_cost "${total_vm_cost}" \
      --argjson claude_cost "${CLAUDE_MAX_USD}" \
      --argjson total "${total_cost}" \
      --argjson remaining "${budget_remaining}" \
      --argjson budget "${MONTHLY_BUDGET_USD}" \
      --argjson projected "${projected_cost}" \
      --argjson pct "${budget_pct_used}" \
      '{
        month: $month,
        session_count: $sessions,
        issues_dispatched: $issues,
        vm_hours_total: $vm_hours,
        vm_cost_usd: $vm_cost,
        claude_subscription_usd: $claude_cost,
        total_cost_usd: $total,
        budget_usd: $budget,
        budget_remaining_usd: $remaining,
        budget_pct_used: $pct,
        projected_month_end_usd: $projected
      }'
    return
  fi

  # Human-readable output
  local month_lbl
  month_lbl=$(month_label)

  echo "=== CCGM Cloud Dispatch - Cost Report ==="
  printf "Month:                  %s\n" "${month_lbl}"
  printf "Sessions:               %s\n" "${session_count}"
  printf "Issues dispatched:      %s\n" "${issues_dispatched}"
  printf "Total VM-hours:         %.1f\n" "${total_vm_hours}"

  if [[ "${session_count}" -gt 0 ]]; then
    local avg_str
    avg_str=$(duration_label "${avg_duration_hours}")
    printf "Avg session duration:   %s\n" "${avg_str}"
  fi

  echo ""
  printf "Total VM cost:          \$%.2f\n" "${total_vm_cost}"
  printf "Claude Max subscription:\$%.2f\n" "${CLAUDE_MAX_USD}"
  printf "Estimated total:        \$%.2f\n" "${total_cost}"
  echo ""
  printf "Budget:                 \$%s\n" "${MONTHLY_BUDGET_USD}"
  printf "Budget used:            %.1f%%\n" "${budget_pct_used}"
  printf "Budget remaining:       \$%.2f\n" "${budget_remaining}"
  printf "Projected month-end:    \$%.2f\n" "${projected_cost}"

  if [[ "${cost_per_issue}" != "N/A" ]]; then
    echo ""
    printf "Cost per issue (VM):    %s\n" "${cost_per_issue}"
  fi

  if [[ -n "${type_breakdown}" ]]; then
    echo ""
    echo "VM Type Usage:"
    echo "${type_breakdown}"
  fi

  # Budget warning
  if python3 -c "exit(0 if ${budget_pct_used} >= 80 else 1)" 2>/dev/null; then
    echo ""
    log_warn "Budget utilization is ${budget_pct_used}%. Consider reviewing dispatch frequency."
  fi
}

# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
case "${SUBCOMMAND}" in
  session) cmd_session ;;
  monthly) cmd_monthly ;;
  *)
    log_error "Unknown subcommand: ${SUBCOMMAND}"
    usage
    ;;
esac

lib/recovery.sh

#!/usr/bin/env bash
# recovery.sh — Failure recovery for CCGM cloud-dispatch agents.
#
# Usage:
#   recovery.sh check
#   recovery.sh retry <vm-ip> <agent-index>
#   recovery.sh retry-all
#
# Subcommands:
#   check              Scan all agents for failures and classify them.
#   retry <ip> <idx>   Re-dispatch a specific failed agent.
#   retry-all          Retry all agents currently classified as failed.
#
# Failure classifications:
#   rate-limited  Agent hit Claude API rate limits
#   crashed       Agent process died unexpectedly (tmux gone, no status file)
#   timeout       Auto-shutdown killed the agent (AGENT_TIMEOUT status)
#   error         Agent completed but with errors (AGENT_ERROR status)
#   success       Agent completed successfully (AGENT_DONE status, PR created)
#   running       Agent is still running
#   unknown       Cannot determine state
#
# Requires: hcloud, jq, SSH key at ~/.ssh/ccgm-dispatch-session

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
RETRY_LOG="${CCGM_RETRY_LOG:-/tmp/ccgm-recovery.json}"

# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
usage() {
  echo "Usage: $0 check" >&2
  echo "       $0 retry <vm-ip> <agent-index>" >&2
  echo "       $0 retry-all" >&2
  exit 1
}

if [[ $# -lt 1 ]]; then
  usage
fi

SUBCOMMAND="$1"
shift

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
require_cmd jq

# ---------------------------------------------------------------------------
# SSH helpers
# ---------------------------------------------------------------------------
ssh_root_quiet() {
  local ip="$1"; shift
  # shellcheck disable=SC2206
  read -ra _opts <<< "$(ssh_opts)"
  # SC2029: expansion is intentional - building command on client side
  # shellcheck disable=SC2029
  ssh "${_opts[@]}" "root@${ip}" "$@" 2>/dev/null
}

ssh_agent_quiet() {
  local ip="$1"
  local agent_user="$2"
  local cmd="$3"
  # SC2029: expansion is intentional
  # shellcheck disable=SC2029
  ssh_root_quiet "${ip}" "su - ${agent_user} -c $(printf '%q' "${cmd}")" 2>/dev/null || true
}

# ---------------------------------------------------------------------------
# classify_agent <vm-ip> <vm-name> <agent-index>
# Prints a JSON object describing the agent's failure state.
# ---------------------------------------------------------------------------
classify_agent() {
  local ip="$1"
  local vm_name="$2"
  local idx="$3"
  local agent_user="agent-${idx}"
  local agent_home="/home/${agent_user}"
  local assignment_file="${agent_home}/assignment.json"
  local status_file="${agent_home}/status"
  local run_log="${agent_home}/run.log"

  # --- assignment ---
  local issue_number="" issue_title="" repo="" branch=""
  if ssh_root_quiet "${ip}" "test -f '${assignment_file}'" 2>/dev/null; then
    local raw
    raw=$(ssh_root_quiet "${ip}" "cat '${assignment_file}'" || echo "{}")
    issue_number=$(echo "${raw}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_number',''))" 2>/dev/null || true)
    issue_title=$(echo "${raw}"  | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_title',''))"  2>/dev/null || true)
    repo=$(echo "${raw}"         | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('repo',''))"         2>/dev/null || true)
    branch=$(echo "${raw}"       | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('branch',''))"       2>/dev/null || true)
  fi

  if [[ -z "${issue_number}" ]]; then
    # No assignment - skip this agent slot
    echo "null"
    return
  fi

  # --- tmux session state ---
  local tmux_running=false
  if ssh_root_quiet "${ip}" "su - ${agent_user} -c 'tmux has-session -t ${agent_user} 2>/dev/null'" 2>/dev/null; then
    tmux_running=true
  fi

  # --- status file ---
  local status_content=""
  if ssh_root_quiet "${ip}" "test -f '${status_file}'" 2>/dev/null; then
    status_content=$(ssh_root_quiet "${ip}" "cat '${status_file}'" 2>/dev/null | tr -d '[:space:]' || true)
  fi

  # --- log analysis for rate limits ---
  local rate_limited=false
  if ssh_root_quiet "${ip}" "test -f '${run_log}'" 2>/dev/null; then
    if ssh_root_quiet "${ip}" "grep -qiE 'rate.?limit|429|too many requests|overloaded' '${run_log}'" 2>/dev/null; then
      rate_limited=true
    fi
  fi

  # --- PR existence check ---
  local has_pr=false
  if [[ -n "${branch}" && -n "${repo}" ]]; then
    local pr_url
    pr_url=$(ssh_agent_quiet "${ip}" "${agent_user}" \
      "gh pr list --repo ${repo} --head ${branch} --json url --jq '.[0].url' 2>/dev/null" || true)
    [[ -n "${pr_url}" ]] && has_pr=true
  fi

  # --- git commits check ---
  local has_commits=false
  if [[ -n "${repo}" ]]; then
    local repo_name
    repo_name=$(basename "${repo}")
    local clone_dir="${agent_home}/workspace/${repo_name}"
    if ssh_root_quiet "${ip}" "test -d '${clone_dir}/.git'" 2>/dev/null; then
      local commit_count
      commit_count=$(ssh_agent_quiet "${ip}" "${agent_user}" \
        "git -C '${clone_dir}' rev-list --count HEAD ^origin/main 2>/dev/null" || echo "0")
      [[ "${commit_count:-0}" -gt 0 ]] && has_commits=true
    fi
  fi

  # --- classify ---
  local classification
  if [[ "${tmux_running}" == "true" ]]; then
    classification="running"
  elif [[ "${status_content}" == "AGENT_DONE" && "${has_pr}" == "true" ]]; then
    classification="success"
  elif [[ "${status_content}" == "AGENT_TIMEOUT" ]]; then
    classification="timeout"
  elif [[ "${rate_limited}" == "true" ]]; then
    classification="rate-limited"
  elif [[ "${status_content}" == "AGENT_ERROR" ]]; then
    classification="error"
  elif [[ "${tmux_running}" == "false" && -z "${status_content}" ]]; then
    classification="crashed"
  else
    classification="unknown"
  fi

  # Output JSON
  jq -n \
    --arg vm "${vm_name}" \
    --arg ip "${ip}" \
    --arg agent "${agent_user}" \
    --argjson idx "${idx}" \
    --arg issue "${issue_number}" \
    --arg title "${issue_title}" \
    --arg repo "${repo}" \
    --arg branch "${branch}" \
    --arg status "${status_content}" \
    --arg class "${classification}" \
    --argjson has_pr "${has_pr}" \
    --argjson has_commits "${has_commits}" \
    '{
      vm: $vm,
      ip: $ip,
      agent: $agent,
      agent_index: $idx,
      issue_number: $issue,
      issue_title: $title,
      repo: $repo,
      branch: $branch,
      status: $status,
      classification: $class,
      has_pr: $has_pr,
      has_commits: $has_commits
    }'
}

# ---------------------------------------------------------------------------
# Subcommand: check
# ---------------------------------------------------------------------------
cmd_check() {
  require_cmd hcloud

  if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
    log_error "HCLOUD_TOKEN is not set."
    exit 1
  fi

  mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status \
    | awk '$2=="running" && /ccgm-agent/ {print $1}' \
    | sort)

  if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
    log_warn "No running ccgm-agent-* VMs found."
    exit 0
  fi

  local results=()
  local total=0
  local running=0
  local success=0
  local failed=0

  for vm_name in "${VM_NAMES[@]}"; do
    local ip
    ip=$(hcloud server describe "${vm_name}" --output format='{{.PublicNet.IPv4.IP}}')
    for idx in $(seq 0 $(( CCGM_AGENTS_PER_VM - 1 ))); do
      log_info "Checking ${vm_name} agent-${idx}..."
      local result
      result=$(classify_agent "${ip}" "${vm_name}" "${idx}")
      if [[ "${result}" == "null" ]]; then
        continue
      fi
      results+=("${result}")
      total=$(( total + 1 ))

      local class
      class=$(echo "${result}" | jq -r '.classification')
      case "${class}" in
        running) running=$(( running + 1 )) ;;
        success) success=$(( success + 1 )) ;;
        *)       failed=$(( failed + 1 ))  ;;
      esac
    done
  done

  # Save results to retry log
  printf '[%s]' "$(IFS=','; echo "${results[*]}")" \
    | jq '.' > "${RETRY_LOG}" 2>/dev/null || true

  # Print summary
  echo ""
  echo "=== Agent Recovery Check ==="
  printf "Total agents: %s | Running: %s | Success: %s | Failed: %s\n\n" \
    "${total}" "${running}" "${success}" "${failed}"

  # Print per-agent status
  for result in "${results[@]}"; do
    local vm agent issue class has_pr has_commits
    vm=$(echo "${result}"          | jq -r '.vm')
    agent=$(echo "${result}"       | jq -r '.agent')
    issue=$(echo "${result}"       | jq -r '.issue_number')
    class=$(echo "${result}"       | jq -r '.classification')
    has_pr=$(echo "${result}"      | jq -r '.has_pr')
    has_commits=$(echo "${result}" | jq -r '.has_commits')

    local status_icon
    case "${class}" in
      success)      status_icon="${_COLOR_GREEN}[OK]${_COLOR_RESET}   " ;;
      running)      status_icon="${_COLOR_CYAN}[RUN]${_COLOR_RESET}  " ;;
      rate-limited) status_icon="${_COLOR_YELLOW}[RATE]${_COLOR_RESET} " ;;
      crashed)      status_icon="${_COLOR_RED}[CRASH]${_COLOR_RESET}" ;;
      timeout)      status_icon="${_COLOR_YELLOW}[TIME]${_COLOR_RESET} " ;;
      error)        status_icon="${_COLOR_RED}[ERR]${_COLOR_RESET}  " ;;
      *)            status_icon="[???]  " ;;
    esac

    printf "%s %-24s %-8s #%-6s %s" \
      "${status_icon}" "${vm}" "${agent}" "${issue}" "${class}"
    [[ "${has_commits}" == "true" ]] && printf " [has-commits]"
    [[ "${has_pr}" == "true" ]] && printf " [pr-created]"
    echo ""
  done

  echo ""
  if [[ ${failed} -gt 0 ]]; then
    echo "Run '$0 retry-all' to re-dispatch all failed agents."
  fi
}

# ---------------------------------------------------------------------------
# retry_one <vm-ip> <agent-index>
# Re-dispatch a single failed agent, resuming from last commit if possible.
# ---------------------------------------------------------------------------
retry_one() {
  local ip="$1"
  local idx="$2"
  local agent_user="agent-${idx}"
  local agent_home="/home/${agent_user}"
  local assignment_file="${agent_home}/assignment.json"

  log_info "Preparing retry for ${agent_user}@${ip}"

  # Read assignment
  if ! ssh_root_quiet "${ip}" "test -f '${assignment_file}'" 2>/dev/null; then
    log_error "No assignment.json for ${agent_user}@${ip}. Cannot retry."
    return 1
  fi

  local raw
  raw=$(ssh_root_quiet "${ip}" "cat '${assignment_file}'" || echo "{}")
  local issue_number repo branch
  issue_number=$(echo "${raw}" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('issue_number',''))" 2>/dev/null || true)
  repo=$(echo "${raw}"         | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('repo',''))"         2>/dev/null || true)
  branch=$(echo "${raw}"       | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('branch',''))"       2>/dev/null || true)

  if [[ -z "${issue_number}" || -z "${repo}" || -z "${branch}" ]]; then
    log_error "Incomplete assignment.json for ${agent_user}@${ip}."
    return 1
  fi

  local repo_name
  repo_name=$(basename "${repo}")
  local clone_dir="${agent_home}/workspace/${repo_name}"

  # Check if the branch has commits beyond origin/main
  local has_commits=false
  local commit_count
  commit_count=$(ssh_agent_quiet "${ip}" "${agent_user}" \
    "git -C '${clone_dir}' rev-list --count HEAD ^origin/main 2>/dev/null" || echo "0")
  [[ "${commit_count:-0}" -gt 0 ]] && has_commits=true

  # Choose prompt based on commit state
  local prompt
  if [[ "${has_commits}" == "true" ]]; then
    log_info "Branch has commits - resuming from checkpoint."
    prompt="You are resuming work on issue #${issue_number}. \
Check the current state of the branch '${branch}' and continue from where the previous agent left off. \
Review any existing commits, assess what remains to be done, and complete the implementation. \
Create a PR when complete that closes #${issue_number}."
  else
    log_info "No commits found - starting fresh."
    # Reset branch to origin/main
    ssh_agent_quiet "${ip}" "${agent_user}" \
      "git -C '${clone_dir}' checkout -B '${branch}' origin/main 2>/dev/null" || true
    prompt="You are an autonomous Claude Code agent. Work on GitHub issue #${issue_number}. \
Create a branch named ${branch}, implement the changes with tests, and create a PR that closes #${issue_number}. \
Follow the repo's CLAUDE.md instructions. Commit with message format: #${issue_number}: description."
  fi

  # Clear previous status and log (append retry marker)
  ssh_root_quiet "${ip}" "rm -f '${agent_home}/status'" || true
  ssh_root_quiet "${ip}" \
    "echo '--- RETRY $(date -u +"%Y-%m-%dT%H:%M:%SZ") ---' >> '${agent_home}/run.log'" || true

  # Re-launch via agent-launch.sh
  "${SCRIPT_DIR}/agent-launch.sh" "${ip}" "${idx}" --prompt "${prompt}"
}

# ---------------------------------------------------------------------------
# Subcommand: retry <vm-ip> <agent-index>
# ---------------------------------------------------------------------------
cmd_retry() {
  if [[ $# -lt 2 ]]; then
    echo "Usage: $0 retry <vm-ip> <agent-index>" >&2
    exit 1
  fi
  local ip="$1"
  local idx="$2"

  if ! [[ "${idx}" =~ ^[0-3]$ ]]; then
    log_error "agent-index must be 0, 1, 2, or 3 (got: ${idx})"
    exit 1
  fi

  retry_one "${ip}" "${idx}"
}

# ---------------------------------------------------------------------------
# Subcommand: retry-all
# ---------------------------------------------------------------------------
cmd_retry_all() {
  if [[ ! -f "${RETRY_LOG}" ]]; then
    log_warn "No recovery data found at ${RETRY_LOG}. Run 'check' first."
    exit 0
  fi

  local retry_count=0
  local skip_count=0

  # Read failed agents from the check results
  while IFS= read -r entry; do
    local class ip idx
    class=$(echo "${entry}" | jq -r '.classification')
    ip=$(echo "${entry}"    | jq -r '.ip')
    idx=$(echo "${entry}"   | jq -r '.agent_index')

    case "${class}" in
      success|running)
        skip_count=$(( skip_count + 1 ))
        log_info "Skipping agent-${idx}@${ip} (${class})"
        ;;
      *)
        log_info "Retrying agent-${idx}@${ip} (${class})"
        if retry_one "${ip}" "${idx}"; then
          retry_count=$(( retry_count + 1 ))
        else
          log_warn "Retry failed for agent-${idx}@${ip}"
        fi
        ;;
    esac
  done < <(jq -c '.[]' "${RETRY_LOG}" 2>/dev/null || true)

  echo ""
  log_success "Retry complete: ${retry_count} agent(s) re-dispatched, ${skip_count} skipped."
}

# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
case "${SUBCOMMAND}" in
  check)      cmd_check                ;;
  retry)      cmd_retry "$@"           ;;
  retry-all)  cmd_retry_all            ;;
  *)
    log_error "Unknown subcommand: ${SUBCOMMAND}"
    usage
    ;;
esac

lib/common.sh

#!/usr/bin/env bash
# common.sh - Shared utilities for CCGM cloud-dispatch VM lifecycle scripts
# Source this file from all other cloud-dispatch scripts.

set -euo pipefail

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

# Server type for agent VMs (CCX63: 48 vCPU, 192 GB RAM)
CCGM_SERVER_TYPE="${CCGM_SERVER_TYPE:-ccx63}"

# Datacenter locations, in round-robin order for VM placement
# exported so sourcing scripts see it without shellcheck SC2034 false-positives
export CCGM_LOCATIONS=("fsn1" "nbg1" "hel1")

# Snapshot label selector used to find the latest golden image
export CCGM_IMAGE_LABEL="${CCGM_IMAGE_LABEL:-purpose=ccgm-agent}"

# SSH key name as stored in Hetzner (set by Terraform)
export CCGM_SSH_KEY_NAME="${CCGM_SSH_KEY_NAME:-ccgm-dispatch-key}"

# Firewall name as created by Terraform
export CCGM_FIREWALL_NAME="${CCGM_FIREWALL_NAME:-ccgm-dispatch-firewall}"

# VM name prefix and pattern
export CCGM_VM_PREFIX="ccgm-agent"
export CCGM_VM_PATTERN="ccgm-agent-*"

# Number of agent users per VM
export CCGM_AGENTS_PER_VM=4

# SSH identity file for dispatch connections
export CCGM_SSH_KEY="${CCGM_SSH_KEY:-${HOME}/.ssh/ccgm-dispatch-session}"

# ControlMaster socket directory
export CCGM_SSH_CTL_DIR="/tmp"

# Minimum free disk space (GB) required for a VM to be healthy
export CCGM_MIN_DISK_GB=20

# Minimum free memory (MB) per agent slot for a VM to be healthy
export CCGM_MIN_MEM_MB_PER_AGENT=4096

# ---------------------------------------------------------------------------
# Color output helpers
# ---------------------------------------------------------------------------

# Detect whether we have a terminal that supports color
if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then
  _COLOR_RESET=$'\033[0m'
  _COLOR_RED=$'\033[0;31m'
  _COLOR_GREEN=$'\033[0;32m'
  _COLOR_YELLOW=$'\033[0;33m'
  _COLOR_CYAN=$'\033[0;36m'
  _COLOR_BOLD=$'\033[1m'
else
  _COLOR_RESET=''
  _COLOR_RED=''
  _COLOR_GREEN=''
  _COLOR_YELLOW=''
  _COLOR_CYAN=''
  _COLOR_BOLD=''
fi

# ---------------------------------------------------------------------------
# Logging utilities
# ---------------------------------------------------------------------------

log_info() {
  echo "${_COLOR_CYAN}[INFO]${_COLOR_RESET}  $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2
}

log_success() {
  echo "${_COLOR_GREEN}[OK]${_COLOR_RESET}    $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2
}

log_warn() {
  echo "${_COLOR_YELLOW}[WARN]${_COLOR_RESET}  $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2
}

log_error() {
  echo "${_COLOR_RED}[ERROR]${_COLOR_RESET} $(date '+%Y-%m-%dT%H:%M:%S') $*" >&2
}

# ---------------------------------------------------------------------------
# Prerequisite check
# ---------------------------------------------------------------------------

# require_cmd <command> - exits with error if <command> is not found in PATH
require_cmd() {
  local cmd="$1"
  if ! command -v "${cmd}" >/dev/null 2>&1; then
    log_error "Required command not found: ${cmd}"
    log_error "Install it and retry."
    exit 1
  fi
}

# ---------------------------------------------------------------------------
# VM naming convention
# ---------------------------------------------------------------------------

# vm_name <location> <index>
# Returns a deterministic VM name: ccgm-agent-<location>-<index>
# e.g. vm_name fsn1 0 -> ccgm-agent-fsn1-0
vm_name() {
  local location="$1"
  local index="$2"
  echo "${CCGM_VM_PREFIX}-${location}-${index}"
}

# ---------------------------------------------------------------------------
# SSH configuration
# ---------------------------------------------------------------------------

# ssh_config_snippet - prints an SSH config Host block for ccgm-agent-* VMs.
# Append this to ~/.ssh/config or write to a dedicated include file.
ssh_config_snippet() {
  cat <<'EOF'
Host ccgm-agent-*
  User root
  IdentityFile ~/.ssh/ccgm-dispatch-session
  StrictHostKeyChecking accept-new
  ControlMaster auto
  ControlPath /tmp/ccgm-ssh-%r@%h:%p
  ControlPersist 600
  ServerAliveInterval 30
  ServerAliveCountMax 3
  BatchMode yes
  ConnectTimeout 10
EOF
}

# ssh_opts - common SSH options array, suitable for use with ssh/scp.
# Usage: ssh "${ssh_opts[@]}" root@<ip> <command>
ssh_opts() {
  echo \
    -i "${CCGM_SSH_KEY}" \
    -o StrictHostKeyChecking=accept-new \
    -o ControlMaster=auto \
    -o "ControlPath=${CCGM_SSH_CTL_DIR}/ccgm-ssh-%r@%h:%p" \
    -o ControlPersist=600 \
    -o ServerAliveInterval=30 \
    -o ServerAliveCountMax=3 \
    -o BatchMode=yes \
    -o ConnectTimeout=10
}

# ---------------------------------------------------------------------------
# Hetzner helpers
# ---------------------------------------------------------------------------

# latest_image_id - returns the snapshot ID of the most recent ccgm-agent image.
# Selects by label type=ccgm-agent, sorted by created date descending.
latest_image_id() {
  hcloud image list \
    --type snapshot \
    --selector "${CCGM_IMAGE_LABEL}" \
    --output json \
    | python3 -c "
import json, sys
images = json.load(sys.stdin)
if not images:
    print('', end='')
    sys.exit(0)
images.sort(key=lambda x: x.get('created', ''), reverse=True)
print(images[0]['id'])
"
}

# wait_for_vm_running <vm-name> [timeout_secs]
# Polls hcloud until the VM is in 'running' state or timeout is reached.
wait_for_vm_running() {
  local name="$1"
  local timeout="${2:-120}"
  local elapsed=0
  local interval=5

  log_info "Waiting for ${name} to reach 'running' state (timeout ${timeout}s)..."
  while [[ ${elapsed} -lt ${timeout} ]]; do
    local status
    status=$(hcloud server describe "${name}" --output json 2>/dev/null \
      | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('status','unknown'))" \
      || echo "unknown")

    if [[ "${status}" == "running" ]]; then
      log_success "${name} is running."
      return 0
    fi
    sleep "${interval}"
    elapsed=$(( elapsed + interval ))
  done

  log_error "${name} did not reach 'running' within ${timeout}s (last status: ${status:-unknown})."
  return 1
}

# wait_for_ssh <ip> [timeout_secs]
# Retries SSH connection until it succeeds or timeout is reached.
wait_for_ssh() {
  local ip="$1"
  local timeout="${2:-120}"
  local elapsed=0
  local interval=5

  log_info "Waiting for SSH on ${ip} (timeout ${timeout}s)..."
  while [[ ${elapsed} -lt ${timeout} ]]; do
    # ssh_opts returns space-separated options; read into array to avoid word splitting
    # shellcheck disable=SC2206
    read -ra _ssh_wait_opts <<< "$(ssh_opts)"
    if ssh "${_ssh_wait_opts[@]}" "root@${ip}" "true" 2>/dev/null; then
      log_success "SSH is reachable on ${ip}."
      return 0
    fi
    sleep "${interval}"
    elapsed=$(( elapsed + interval ))
  done

  log_error "SSH on ${ip} did not become reachable within ${timeout}s."
  return 1
}

# vm_ip <vm-name> - prints the public IPv4 address of a named VM.
vm_ip() {
  local name="$1"
  hcloud server describe "${name}" --output json \
    | python3 -c "
import json, sys
d = json.load(sys.stdin)
nets = d.get('public_net', {})
ipv4 = nets.get('ipv4', {})
print(ipv4.get('ip', ''))
"
}

# ---------------------------------------------------------------------------
# Trap / cleanup registration
# ---------------------------------------------------------------------------

# cleanup_fns is an array of function names to call on EXIT.
# Use register_cleanup to add entries.
declare -a _cleanup_fns=()

register_cleanup() {
  _cleanup_fns+=("$1")
}

_run_cleanup() {
  for fn in "${_cleanup_fns[@]+"${_cleanup_fns[@]}"}"; do
    "${fn}" || true
  done
}

trap '_run_cleanup' EXIT

lib/vm-create.sh

#!/usr/bin/env bash
# vm-create.sh - Create CCGM agent VMs from the golden image snapshot.
#
# Usage:
#   vm-create.sh [count] [--type TYPE] [--image IMAGE_ID]
#
# Arguments:
#   count        Number of VMs to create (default: 3)
#
# Options:
#   --type TYPE       Server type (default: ccx63)
#   --image IMAGE_ID  Snapshot ID to use (default: latest ccgm-agent snapshot)
#
# Environment:
#   HCLOUD_TOKEN  Hetzner Cloud API token (required)
#
# VMs are named ccgm-agent-<location>-<index> and spread round-robin across
# the configured datacenter locations (fsn1, nbg1, hel1 by default).

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------

COUNT=3
SERVER_TYPE="${CCGM_SERVER_TYPE}"
IMAGE_ID=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --type)
      SERVER_TYPE="$2"; shift 2 ;;
    --image)
      IMAGE_ID="$2"; shift 2 ;;
    --*)
      log_error "Unknown option: $1"; exit 1 ;;
    *)
      COUNT="$1"; shift ;;
  esac
done

if ! [[ "${COUNT}" =~ ^[0-9]+$ ]] || [[ "${COUNT}" -lt 1 ]]; then
  log_error "count must be a positive integer, got: ${COUNT}"
  exit 1
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------

require_cmd hcloud
require_cmd python3

if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
  log_error "HCLOUD_TOKEN is not set. Export it before running this script."
  exit 1
fi

# Resolve image ID if not provided
if [[ -z "${IMAGE_ID}" ]]; then
  log_info "Resolving latest ccgm-agent snapshot..."
  IMAGE_ID="$(latest_image_id)"
  if [[ -z "${IMAGE_ID}" ]]; then
    log_error "No snapshot found matching label '${CCGM_IMAGE_LABEL}'."
    log_error "Build the golden image first with: packer build packer/agent-image.pkr.hcl"
    exit 1
  fi
  log_info "Using snapshot ID: ${IMAGE_ID}"
fi

# Resolve SSH key and firewall IDs from Hetzner
log_info "Resolving SSH key '${CCGM_SSH_KEY_NAME}'..."
SSH_KEY_ID="$(hcloud ssh-key describe "${CCGM_SSH_KEY_NAME}" --output json \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")"

log_info "Resolving firewall '${CCGM_FIREWALL_NAME}'..."
FIREWALL_ID="$(hcloud firewall describe "${CCGM_FIREWALL_NAME}" --output json \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")"

# ---------------------------------------------------------------------------
# Create VMs
# ---------------------------------------------------------------------------

log_info "Creating ${COUNT} VM(s) with type=${SERVER_TYPE}, image=${IMAGE_ID}..."

declare -a CREATED_NAMES=()
declare -a FAILED_NAMES=()

for (( i=0; i<COUNT; i++ )); do
  location="${CCGM_LOCATIONS[$(( i % ${#CCGM_LOCATIONS[@]} ))]}"
  name="$(vm_name "${location}" "${i}")"

  log_info "Creating ${name} in ${location}..."

  if hcloud server create \
    --name "${name}" \
    --type "${SERVER_TYPE}" \
    --image "${IMAGE_ID}" \
    --location "${location}" \
    --ssh-key "${SSH_KEY_ID}" \
    --firewall "${FIREWALL_ID}" \
    --output json >/dev/null 2>&1; then
    CREATED_NAMES+=("${name}")
    log_success "Created ${name}."
  else
    FAILED_NAMES+=("${name}")
    log_error "Failed to create ${name}."
  fi
done

if [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then
  log_warn "Some VMs failed to create: ${FAILED_NAMES[*]}"
fi

if [[ ${#CREATED_NAMES[@]} -eq 0 ]]; then
  log_error "No VMs were created successfully."
  exit 1
fi

# ---------------------------------------------------------------------------
# Wait for VMs to be running and SSH-reachable
# ---------------------------------------------------------------------------

declare -a READY_NAMES=()
declare -a UNREADY_NAMES=()

for name in "${CREATED_NAMES[@]}"; do
  if wait_for_vm_running "${name}" 120; then
    ip="$(vm_ip "${name}")"
    if wait_for_ssh "${ip}" 120; then
      READY_NAMES+=("${name}")
    else
      UNREADY_NAMES+=("${name}")
      log_warn "${name} (${ip}): VM running but SSH unreachable."
    fi
  else
    UNREADY_NAMES+=("${name}")
    log_warn "${name}: did not reach running state in time."
  fi
done

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

echo ""
echo "${_COLOR_BOLD}==== VM Create Summary ====${_COLOR_RESET}"
echo ""

if [[ ${#READY_NAMES[@]} -gt 0 ]]; then
  echo "${_COLOR_GREEN}Ready:${_COLOR_RESET}"
  for name in "${READY_NAMES[@]}"; do
    ip="$(vm_ip "${name}")"
    location="$(hcloud server describe "${name}" --output json \
      | python3 -c "import json,sys; print(json.load(sys.stdin)['datacenter']['location']['name'])")"
    printf "  %-30s  %-15s  %s\n" "${name}" "${ip}" "${location}"
  done
fi

if [[ ${#UNREADY_NAMES[@]} -gt 0 ]]; then
  echo ""
  echo "${_COLOR_YELLOW}Not ready (manual intervention may be needed):${_COLOR_RESET}"
  for name in "${UNREADY_NAMES[@]}"; do
    printf "  %s\n" "${name}"
  done
fi

if [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then
  echo ""
  echo "${_COLOR_RED}Failed to create:${_COLOR_RESET}"
  for name in "${FAILED_NAMES[@]}"; do
    printf "  %s\n" "${name}"
  done
fi

echo ""

if [[ ${#UNREADY_NAMES[@]} -gt 0 ]] || [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then
  exit 1
fi

lib/vm-destroy.sh

#!/usr/bin/env bash
# vm-destroy.sh - Terminate CCGM agent VMs.
#
# Usage:
#   vm-destroy.sh --all [--force]
#   vm-destroy.sh [--force] <vm-name> [<vm-name>...]
#
# Options:
#   --all    Destroy all VMs matching the ccgm-agent-* pattern
#   --force  Skip confirmation prompt
#
# After destruction, removes SSH known_hosts entries and ControlMaster sockets
# for each destroyed VM.
#
# Environment:
#   HCLOUD_TOKEN  Hetzner Cloud API token (required)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------

DESTROY_ALL=false
FORCE=false
declare -a TARGET_NAMES=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    --all)
      DESTROY_ALL=true; shift ;;
    --force)
      FORCE=true; shift ;;
    --*)
      log_error "Unknown option: $1"; exit 1 ;;
    *)
      TARGET_NAMES+=("$1"); shift ;;
  esac
done

if [[ "${DESTROY_ALL}" == "false" ]] && [[ ${#TARGET_NAMES[@]} -eq 0 ]]; then
  echo "Usage: $0 --all [--force]" >&2
  echo "       $0 [--force] <vm-name> [<vm-name>...]" >&2
  exit 1
fi

if [[ "${DESTROY_ALL}" == "true" ]] && [[ ${#TARGET_NAMES[@]} -gt 0 ]]; then
  log_error "--all and explicit VM names are mutually exclusive."
  exit 1
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------

require_cmd hcloud
require_cmd python3

if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
  log_error "HCLOUD_TOKEN is not set. Export it before running this script."
  exit 1
fi

# ---------------------------------------------------------------------------
# Resolve targets
# ---------------------------------------------------------------------------

if [[ "${DESTROY_ALL}" == "true" ]]; then
  log_info "Listing all VMs matching pattern '${CCGM_VM_PATTERN}'..."
  mapfile -t TARGET_NAMES < <(
    hcloud server list --output json \
      | python3 -c "
import json, sys, fnmatch
servers = json.load(sys.stdin)
pattern = '${CCGM_VM_PATTERN}'
for s in servers:
    if fnmatch.fnmatch(s['name'], pattern):
        print(s['name'])
" \
    || true
  )

  if [[ ${#TARGET_NAMES[@]} -eq 0 ]]; then
    log_info "No VMs matching '${CCGM_VM_PATTERN}' found. Nothing to destroy."
    exit 0
  fi
fi

log_info "Targets: ${TARGET_NAMES[*]}"

# ---------------------------------------------------------------------------
# Collect IPs before deletion (needed for known_hosts cleanup)
# ---------------------------------------------------------------------------

declare -A VM_IPS=()

for name in "${TARGET_NAMES[@]}"; do
  ip="$(vm_ip "${name}" 2>/dev/null || true)"
  if [[ -n "${ip}" ]]; then
    VM_IPS["${name}"]="${ip}"
  else
    log_warn "Could not resolve IP for ${name}; known_hosts cleanup may be incomplete."
  fi
done

# ---------------------------------------------------------------------------
# Confirmation prompt
# ---------------------------------------------------------------------------

if [[ "${FORCE}" == "false" ]]; then
  echo ""
  echo "${_COLOR_YELLOW}The following VMs will be permanently destroyed:${_COLOR_RESET}"
  for name in "${TARGET_NAMES[@]}"; do
    ip="${VM_IPS[${name}]:-unknown}"
    printf "  %-30s  %s\n" "${name}" "${ip}"
  done
  echo ""
  read -r -p "Type 'yes' to confirm: " CONFIRM
  if [[ "${CONFIRM}" != "yes" ]]; then
    log_info "Aborted."
    exit 0
  fi
fi

# ---------------------------------------------------------------------------
# Destroy VMs
# ---------------------------------------------------------------------------

declare -a DESTROYED_NAMES=()
declare -a FAILED_NAMES=()

for name in "${TARGET_NAMES[@]}"; do
  log_info "Destroying ${name}..."
  if hcloud server delete "${name}" 2>/dev/null; then
    DESTROYED_NAMES+=("${name}")
    log_success "Destroyed ${name}."
  else
    FAILED_NAMES+=("${name}")
    log_error "Failed to destroy ${name}."
  fi
done

# ---------------------------------------------------------------------------
# Clean up SSH artifacts
# ---------------------------------------------------------------------------

for name in "${DESTROYED_NAMES[@]}"; do
  ip="${VM_IPS[${name}]:-}"

  # Remove known_hosts entry for the IP
  if [[ -n "${ip}" ]] && [[ -f "${HOME}/.ssh/known_hosts" ]]; then
    ssh-keygen -R "${ip}" >/dev/null 2>&1 || true
    log_info "Removed ${ip} from ~/.ssh/known_hosts."
  fi

  # Remove ControlMaster socket files matching this VM
  if [[ -n "${ip}" ]]; then
    # Socket path pattern: /tmp/ccgm-ssh-root@<ip>:<port>
    find "${CCGM_SSH_CTL_DIR}" -maxdepth 1 -name "ccgm-ssh-root@${ip}:*" -exec rm -f {} \; 2>/dev/null || true
  fi

  # Also clean by name-based socket pattern if present
  find "${CCGM_SSH_CTL_DIR}" -maxdepth 1 -name "ccgm-ssh-*${name}*" -exec rm -f {} \; 2>/dev/null || true
done

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

echo ""
echo "${_COLOR_BOLD}==== VM Destroy Summary ====${_COLOR_RESET}"
echo ""

if [[ ${#DESTROYED_NAMES[@]} -gt 0 ]]; then
  echo "${_COLOR_GREEN}Destroyed:${_COLOR_RESET}"
  for name in "${DESTROYED_NAMES[@]}"; do
    printf "  %s\n" "${name}"
  done
fi

if [[ ${#FAILED_NAMES[@]} -gt 0 ]]; then
  echo ""
  echo "${_COLOR_RED}Failed to destroy:${_COLOR_RESET}"
  for name in "${FAILED_NAMES[@]}"; do
    printf "  %s\n" "${name}"
  done
  exit 1
fi

echo ""

lib/vm-health.sh

#!/usr/bin/env bash
# vm-health.sh - Health check CCGM agent VMs.
#
# Usage:
#   vm-health.sh                  Check all ccgm-agent-* VMs
#   vm-health.sh --all            Same as above
#   vm-health.sh <vm-name> [...]  Check specific VM(s) by name
#
# Per-VM checks:
#   - SSH reachability (timeout 10s)
#   - `claude --version` returns successfully
#   - Free disk space > 20 GB
#   - Free memory > 4 GB per agent slot (CCGM_AGENTS_PER_VM slots)
#   - Agent users exist (agent-0 through agent-3)
#   - iptables rules active (non-empty ruleset)
#
# Reports HEALTHY / DEGRADED / UNREACHABLE per VM.
# Exit code: 0 if all VMs are HEALTHY, 1 if any DEGRADED or UNREACHABLE.
#
# Environment:
#   HCLOUD_TOKEN  Hetzner Cloud API token (required)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------

declare -a TARGET_NAMES=()
CHECK_ALL=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --all)
      CHECK_ALL=true; shift ;;
    --*)
      log_error "Unknown option: $1"; exit 1 ;;
    *)
      TARGET_NAMES+=("$1"); shift ;;
  esac
done

# Default: check all if no targets specified
if [[ ${#TARGET_NAMES[@]} -eq 0 ]]; then
  CHECK_ALL=true
fi

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------

require_cmd hcloud
require_cmd ssh
require_cmd python3

if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
  log_error "HCLOUD_TOKEN is not set. Export it before running this script."
  exit 1
fi

# ---------------------------------------------------------------------------
# Resolve target list
# ---------------------------------------------------------------------------

if [[ "${CHECK_ALL}" == "true" ]]; then
  log_info "Discovering ccgm-agent-* VMs..."
  mapfile -t TARGET_NAMES < <(
    hcloud server list --output json \
      | python3 -c "
import json, sys, fnmatch
servers = json.load(sys.stdin)
for s in sorted(servers, key=lambda x: x['name']):
    if fnmatch.fnmatch(s['name'], '${CCGM_VM_PATTERN}'):
        print(s['name'])
" \
    || true
  )

  if [[ ${#TARGET_NAMES[@]} -eq 0 ]]; then
    log_info "No ccgm-agent-* VMs found."
    exit 0
  fi
fi

# ---------------------------------------------------------------------------
# Health check helpers
# ---------------------------------------------------------------------------

# Run a remote command on a VM, capturing output. Returns the exit code.
_remote() {
  local ip="$1"
  shift
  # shellcheck disable=SC2206
  read -ra opts <<< "$(ssh_opts)"
  ssh "${opts[@]}" -o ConnectTimeout=10 "root@${ip}" "$@" 2>/dev/null
}

# check_ssh <ip> - returns 0 if SSH is reachable within 10s
check_ssh() {
  local ip="$1"
  # shellcheck disable=SC2206
  read -ra opts <<< "$(ssh_opts)"
  ssh "${opts[@]}" -o ConnectTimeout=10 "root@${ip}" "true" 2>/dev/null
}

# check_claude <ip> - returns 0 if `claude --version` succeeds
check_claude() {
  _remote "$1" "claude --version" >/dev/null 2>&1
}

# check_disk <ip> - returns 0 if free space on / exceeds CCGM_MIN_DISK_GB
check_disk() {
  local ip="$1"
  local free_kb
  free_kb="$(_remote "${ip}" "df -k / | awk 'NR==2{print \$4}'")" || return 1
  local free_gb=$(( free_kb / 1024 / 1024 ))
  [[ "${free_gb}" -ge "${CCGM_MIN_DISK_GB}" ]]
}

# check_memory <ip> - returns 0 if free memory satisfies per-agent requirement
check_memory() {
  local ip="$1"
  local free_mb
  free_mb="$(_remote "${ip}" "free -m | awk '/^Mem:/{print \$7}'")" || return 1
  local required_mb=$(( CCGM_MIN_MEM_MB_PER_AGENT * CCGM_AGENTS_PER_VM ))
  [[ "${free_mb}" -ge "${required_mb}" ]]
}

# check_agent_users <ip> - returns 0 if agent-0 through agent-N-1 all exist
check_agent_users() {
  local ip="$1"
  local script=""
  for (( u=0; u<CCGM_AGENTS_PER_VM; u++ )); do
    script+="id agent-${u} >/dev/null 2>&1 || exit 1; "
  done
  _remote "${ip}" "bash -c '${script}exit 0'" >/dev/null 2>&1
}

# check_iptables <ip> - returns 0 if iptables has at least one non-default chain rule
check_iptables() {
  local ip="$1"
  local rule_count
  rule_count="$(_remote "${ip}" "iptables -L -n | grep -c '^[A-Z]' || true")" 2>/dev/null || return 1
  [[ "${rule_count}" -gt 3 ]]
}

# ---------------------------------------------------------------------------
# Run health checks on each VM
# ---------------------------------------------------------------------------

declare -a HEALTHY_NAMES=()
declare -a DEGRADED_NAMES=()
declare -a UNREACHABLE_NAMES=()

echo ""
echo "${_COLOR_BOLD}==== CCGM Agent VM Health Check ====${_COLOR_RESET}"
echo ""

for name in "${TARGET_NAMES[@]}"; do
  ip="$(vm_ip "${name}" 2>/dev/null || true)"
  if [[ -z "${ip}" ]]; then
    log_warn "${name}: could not resolve IP (VM may not exist)."
    UNREACHABLE_NAMES+=("${name}")
    printf "  %-32s  %s\n" "${name}" "${_COLOR_RED}UNREACHABLE${_COLOR_RESET} (no IP)"
    continue
  fi

  # SSH reachability is a hard requirement
  if ! check_ssh "${ip}" 2>/dev/null; then
    UNREACHABLE_NAMES+=("${name}")
    printf "  %-32s  %s\n" "${name}" "${_COLOR_RED}UNREACHABLE${_COLOR_RESET} (SSH timeout)"
    continue
  fi

  declare -a failures=()

  check_claude "${ip}"   || failures+=("claude-not-found")
  check_disk "${ip}"     || failures+=("low-disk (<${CCGM_MIN_DISK_GB}GB)")
  check_memory "${ip}"   || failures+=("low-memory (<$(( CCGM_MIN_MEM_MB_PER_AGENT * CCGM_AGENTS_PER_VM ))MB free)")
  check_agent_users "${ip}" || failures+=("missing-agent-users")
  check_iptables "${ip}" || failures+=("iptables-inactive")

  if [[ ${#failures[@]} -eq 0 ]]; then
    HEALTHY_NAMES+=("${name}")
    printf "  %-32s  %s\n" "${name}" "${_COLOR_GREEN}HEALTHY${_COLOR_RESET}"
  else
    DEGRADED_NAMES+=("${name}")
    printf "  %-32s  %s  [%s]\n" \
      "${name}" \
      "${_COLOR_YELLOW}DEGRADED${_COLOR_RESET}" \
      "${failures[*]}"
  fi
done

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

echo ""
echo "${_COLOR_BOLD}Summary:${_COLOR_RESET}"
echo "  Healthy:     ${#HEALTHY_NAMES[@]}"
echo "  Degraded:    ${#DEGRADED_NAMES[@]}"
echo "  Unreachable: ${#UNREACHABLE_NAMES[@]}"
echo "  Total:       ${#TARGET_NAMES[@]}"
echo ""

if [[ ${#DEGRADED_NAMES[@]} -gt 0 ]] || [[ ${#UNREACHABLE_NAMES[@]} -gt 0 ]]; then
  exit 1
fi

lib/vm-ssh.sh

#!/usr/bin/env bash
# vm-ssh.sh - SSH into a CCGM agent VM.
#
# Usage:
#   vm-ssh.sh <vm-name> [command [args...]]
#
# If a command is provided, it is executed non-interactively and the script
# exits with its return code. If no command is given, an interactive shell
# session is opened.
#
# ControlMaster sockets are reused when available (see SSH config in common.sh).
#
# Environment:
#   HCLOUD_TOKEN  Hetzner Cloud API token (required to resolve the VM IP)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Parse arguments
# ---------------------------------------------------------------------------

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <vm-name> [command [args...]]" >&2
  exit 1
fi

VM_NAME="$1"
shift
REMOTE_CMD=("$@")

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------

require_cmd hcloud
require_cmd ssh
require_cmd python3

if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
  log_error "HCLOUD_TOKEN is not set. Export it before running this script."
  exit 1
fi

# ---------------------------------------------------------------------------
# Resolve IP
# ---------------------------------------------------------------------------

log_info "Resolving IP for ${VM_NAME}..."
VM_IP="$(vm_ip "${VM_NAME}")"

if [[ -z "${VM_IP}" ]]; then
  log_error "Could not resolve IP for VM '${VM_NAME}'. Is it running?"
  exit 1
fi

log_info "${VM_NAME} -> ${VM_IP}"

# ---------------------------------------------------------------------------
# Build SSH options array
# ---------------------------------------------------------------------------

# Using read -ra to split the opts string into an array
# shellcheck disable=SC2206
read -ra SSH_OPTS <<< "$(ssh_opts)"

# ---------------------------------------------------------------------------
# Connect
# ---------------------------------------------------------------------------

if [[ ${#REMOTE_CMD[@]} -gt 0 ]]; then
  # Non-interactive: run command and exit
  exec ssh "${SSH_OPTS[@]}" "root@${VM_IP}" "${REMOTE_CMD[@]}"
else
  # Interactive session: allocate a TTY
  exec ssh -t "${SSH_OPTS[@]}" "root@${VM_IP}"
fi

lib/vm-status.sh

#!/usr/bin/env bash
# vm-status.sh - List all running CCGM agent VMs with status details.
#
# Usage:
#   vm-status.sh
#
# Output:
#   Formatted table: NAME | IP | LOCATION | STATUS | UPTIME | TYPE
#
# Environment:
#   HCLOUD_TOKEN  Hetzner Cloud API token (required)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "${SCRIPT_DIR}/common.sh"

# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------

require_cmd hcloud
require_cmd python3

if [[ -z "${HCLOUD_TOKEN:-}" ]]; then
  log_error "HCLOUD_TOKEN is not set. Export it before running this script."
  exit 1
fi

# ---------------------------------------------------------------------------
# Fetch and format VM list
# ---------------------------------------------------------------------------

log_info "Fetching VM list..."

python3 - <<'PYEOF'
import json
import subprocess
import sys
from datetime import datetime, timezone

result = subprocess.run(
    ["hcloud", "server", "list", "--output", "json"],
    capture_output=True,
    text=True,
)
if result.returncode != 0:
    print(f"ERROR: hcloud server list failed: {result.stderr}", file=sys.stderr)
    sys.exit(1)

servers = json.loads(result.stdout)

# Filter to ccgm-agent-* VMs only
servers = [s for s in servers if s.get("name", "").startswith("ccgm-agent-")]

if not servers:
    print("No ccgm-agent-* VMs found.")
    sys.exit(0)

# Sort by name for stable output
servers.sort(key=lambda s: s["name"])

# Column widths
col_name = max(len(s["name"]) for s in servers) + 2
col_ip   = 16
col_loc  = 8
col_stat = 10
col_up   = 16
col_type = 8

header = (
    f"{'NAME':<{col_name}}  {'IP':<{col_ip}}  {'LOC':<{col_loc}}"
    f"  {'STATUS':<{col_stat}}  {'UPTIME':<{col_up}}  {'TYPE':<{col_type}}"
)
sep = "-" * len(header)

print(header)
print(sep)

now = datetime.now(timezone.utc)
for s in servers:
    name   = s.get("name", "")
    ip     = s.get("public_net", {}).get("ipv4", {}).get("ip", "")
    loc    = s.get("datacenter", {}).get("location", {}).get("name", "")
    status = s.get("status", "")
    stype  = s.get("server_type", {}).get("name", "")

    created_str = s.get("created", "")
    if created_str:
        try:
            created = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
            delta   = now - created
            total   = int(delta.total_seconds())
            hours, rem = divmod(total, 3600)
            mins        = rem // 60
            uptime  = f"{hours}h {mins}m"
        except ValueError:
            uptime = "unknown"
    else:
        uptime = "unknown"

    print(
        f"{name:<{col_name}}  {ip:<{col_ip}}  {loc:<{col_loc}}"
        f"  {status:<{col_stat}}  {uptime:<{col_up}}  {stype:<{col_type}}"
    )

print()
print(f"Total: {len(servers)} VM(s)")
PYEOF

lib/secrets-cleanup.sh

#!/usr/bin/env bash
# secrets-cleanup.sh — Revoke session credentials and wipe VM secrets.
#
# Reads session metadata from /tmp/ccgm-session.json (written by secrets-init.sh),
# deletes the Hetzner SSH key, removes it from ssh-agent, and optionally wipes
# /run/secrets/ on all running VMs.
#
# Usage:
#   secrets-cleanup.sh [--wipe-vms]
#
# Options:
#   --wipe-vms   SSH into each running ccgm-agent-* VM and wipe /run/secrets/
#
# Requirements:
#   - hcloud CLI authenticated
#   - jq installed

set -euo pipefail

SESSION_FILE="/tmp/ccgm-session.json"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

log()  { printf '[secrets-cleanup] %s\n' "$*" >&2; }
warn() { printf '[secrets-cleanup] WARN: %s\n' "$*" >&2; }
die()  { printf '[secrets-cleanup] ERROR: %s\n' "$*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

WIPE_VMS=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --wipe-vms)
      WIPE_VMS=true
      shift
      ;;
    --help|-h)
      grep '^#' "$0" | sed 's/^# \?//'
      exit 0
      ;;
    *)
      die "Unknown argument: $1"
      ;;
  esac
done

# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------

command -v hcloud >/dev/null 2>&1 || die "hcloud CLI not found in PATH"
command -v jq >/dev/null 2>&1 || die "jq not found in PATH"
command -v ssh-add >/dev/null 2>&1 || die "ssh-add not found in PATH"

# ---------------------------------------------------------------------------
# Read session metadata
# ---------------------------------------------------------------------------

if [[ ! -f "${SESSION_FILE}" ]]; then
  warn "Session file not found at ${SESSION_FILE} — nothing to clean up"
  exit 0
fi

KEY_ID="$(jq -r '.key_id' "${SESSION_FILE}")"
KEY_NAME="$(jq -r '.key_name' "${SESSION_FILE}")"
FINGERPRINT="$(jq -r '.fingerprint' "${SESSION_FILE}")"

[[ -n "${KEY_ID}" && "${KEY_ID}" != "null" ]] \
  || die "Could not read key_id from ${SESSION_FILE}"

log "Session: key_name=${KEY_NAME} key_id=${KEY_ID} fingerprint=${FINGERPRINT}"

# ---------------------------------------------------------------------------
# Remove key from ssh-agent
# ---------------------------------------------------------------------------

log "Removing session key from ssh-agent (fingerprint: ${FINGERPRINT})"
if ssh-add -l 2>/dev/null | grep -q "${FINGERPRINT}"; then
  ssh-add -d - 2>/dev/null <<< "" || {
    # ssh-add -d requires the public key or fingerprint; use -D as fallback
    # only if no other keys are loaded, to avoid revoking unrelated keys.
    LOADED_COUNT="$(ssh-add -l 2>/dev/null | grep -c . || true)"
    if [[ "${LOADED_COUNT}" -eq 1 ]]; then
      log "  Using ssh-add -D (only session key is loaded)"
      ssh-add -D
    else
      warn "  Could not remove specific key; ${LOADED_COUNT} keys loaded — manual cleanup may be needed"
    fi
  }
else
  log "  Key not found in ssh-agent (may have already been removed)"
fi

# ---------------------------------------------------------------------------
# Delete key from Hetzner
# ---------------------------------------------------------------------------

log "Deleting SSH key from Hetzner Cloud (ID: ${KEY_ID})"
if hcloud ssh-key describe "${KEY_ID}" &>/dev/null; then
  hcloud ssh-key delete "${KEY_ID}"
  log "  Deleted Hetzner SSH key ${KEY_NAME} (${KEY_ID})"
else
  log "  Key ID ${KEY_ID} not found in Hetzner — may have been deleted already"
fi

# ---------------------------------------------------------------------------
# Optional: wipe /run/secrets/ on all running VMs
# ---------------------------------------------------------------------------

if [[ "${WIPE_VMS}" == "true" ]]; then
  log "Discovering running ccgm-agent-* VMs for secret wipe"

  RUNNING_VMS="$(hcloud server list --output json 2>/dev/null \
    | jq -r '.[] | select(.name | startswith("ccgm-agent")) | select(.status == "running") | "\(.name) \(.public_net.ipv4.ip)"' \
    || true)"

  if [[ -z "${RUNNING_VMS}" ]]; then
    log "No running VMs found — nothing to wipe"
  else
    SSH_OPTS=(
      -o BatchMode=yes
      -o StrictHostKeyChecking=accept-new
      -o ConnectTimeout=10
    )

    while IFS=' ' read -r vm_name vm_ip; do
      [[ -z "${vm_name}" ]] && continue
      log "Wiping /run/secrets/ on ${vm_name} (${vm_ip})"

      if HISTFILE=/dev/null ssh -n "${SSH_OPTS[@]}" "root@${vm_ip}" \
          'find /run/secrets -mindepth 2 -type f -exec shred -u {} \; 2>/dev/null; echo "wiped"' 2>/dev/null; then
        log "  ${vm_name}: wiped"
      else
        warn "  ${vm_name}: wipe failed or VM unreachable"
      fi
    done <<< "${RUNNING_VMS}"
  fi
fi

# ---------------------------------------------------------------------------
# Remove session metadata file
# ---------------------------------------------------------------------------

log "Removing session metadata file ${SESSION_FILE}"
rm -f "${SESSION_FILE}"

log "secrets-cleanup complete"

lib/secrets-init.sh

#!/usr/bin/env bash
# secrets-init.sh — Initialize session SSH credentials for cloud-dispatch.
#
# Generates a per-session ed25519 SSH keypair, loads it into macOS ssh-agent,
# and registers the public key with Hetzner Cloud. Session metadata is written
# to /tmp/ccgm-session.json for use by secrets-cleanup.sh.
#
# Usage: secrets-init.sh
#
# Requirements:
#   - hcloud CLI authenticated (HCLOUD_TOKEN env or hcloud context)
#   - macOS ssh-agent running (SSH_AUTH_SOCK set)
#   - jq installed

set -euo pipefail

SESSION_FILE="/tmp/ccgm-session.json"
TMPKEY="/tmp/ccgm-session-key-$$"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

log()  { printf '[secrets-init] %s\n' "$*" >&2; }
die()  { printf '[secrets-init] ERROR: %s\n' "$*" >&2; exit 1; }

cleanup_tmpkey() {
  rm -f "${TMPKEY}" "${TMPKEY}.pub" 2>/dev/null || true
}
trap cleanup_tmpkey EXIT

# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------

command -v hcloud >/dev/null 2>&1 || die "hcloud CLI not found in PATH"
command -v ssh-add >/dev/null 2>&1 || die "ssh-add not found in PATH"
command -v ssh-keygen >/dev/null 2>&1 || die "ssh-keygen not found in PATH"
command -v jq >/dev/null 2>&1 || die "jq not found in PATH"

[[ -n "${SSH_AUTH_SOCK:-}" ]] || die "SSH_AUTH_SOCK is not set — is ssh-agent running?"

if [[ -f "${SESSION_FILE}" ]]; then
  log "WARNING: session file already exists at ${SESSION_FILE}"
  log "Run secrets-cleanup.sh first to revoke the previous session."
  exit 1
fi

# ---------------------------------------------------------------------------
# Generate session keypair
# ---------------------------------------------------------------------------

SESSION_TS="$(date +%s)"
KEY_NAME="ccgm-session-${SESSION_TS}"

log "Generating ed25519 session keypair (${KEY_NAME})"
# -N "" means no passphrase so the key can be used by automation without prompting.
ssh-keygen -t ed25519 -f "${TMPKEY}" -N "" -C "${KEY_NAME}" -q

log "Adding private key to ssh-agent"
ssh-add "${TMPKEY}"

# Public key is safe to keep until Hetzner upload completes; trap removes both.
PUBKEY_CONTENT="$(cat "${TMPKEY}.pub")"

# ---------------------------------------------------------------------------
# Register public key with Hetzner
# ---------------------------------------------------------------------------

log "Uploading public key to Hetzner Cloud as '${KEY_NAME}'"
HCLOUD_OUTPUT="$(hcloud ssh-key create --name "${KEY_NAME}" --public-key "${PUBKEY_CONTENT}" --output json 2>/dev/null)"
HCLOUD_KEY_ID="$(printf '%s' "${HCLOUD_OUTPUT}" | jq -r '.ssh_key.id // .id')"
HCLOUD_FINGERPRINT="$(printf '%s' "${HCLOUD_OUTPUT}" | jq -r '.ssh_key.fingerprint // .fingerprint')"

[[ -n "${HCLOUD_KEY_ID}" && "${HCLOUD_KEY_ID}" != "null" ]] \
  || die "Failed to parse key ID from hcloud output"

log "Hetzner SSH key registered: ID=${HCLOUD_KEY_ID} fingerprint=${HCLOUD_FINGERPRINT}"

# ---------------------------------------------------------------------------
# Write session metadata
# ---------------------------------------------------------------------------

jq -n \
  --arg key_name "${KEY_NAME}" \
  --arg key_id "${HCLOUD_KEY_ID}" \
  --arg fingerprint "${HCLOUD_FINGERPRINT}" \
  --arg timestamp "${SESSION_TS}" \
  '{
    key_name:    $key_name,
    key_id:      $key_id,
    fingerprint: $fingerprint,
    timestamp:   $timestamp
  }' > "${SESSION_FILE}"

chmod 0600 "${SESSION_FILE}"

log "Session metadata written to ${SESSION_FILE}"
log "secrets-init complete"
log ""
log "  Key name:    ${KEY_NAME}"
log "  Key ID:      ${HCLOUD_KEY_ID}"
log "  Fingerprint: ${HCLOUD_FINGERPRINT}"
log ""
log "Run secrets-cleanup.sh to revoke this session when done."

lib/secrets-inject-all.sh

#!/usr/bin/env bash
# secrets-inject-all.sh — Inject credentials into all agents across all running VMs.
#
# Discovers running ccgm-agent-* Hetzner VMs, then calls secrets-inject.sh for
# each agent (0-3) on each VM. Reports per-agent success/failure.
#
# Usage:
#   secrets-inject-all.sh --github-token TOKEN [--claude-auth TOKEN]
#
# Requirements:
#   - hcloud CLI authenticated
#   - SSH key loaded in ssh-agent (run secrets-init.sh first)
#   - secrets-inject.sh in the same directory

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INJECT_SCRIPT="${SCRIPT_DIR}/secrets-inject.sh"
AGENT_COUNT=4

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

log()  { printf '[secrets-inject-all] %s\n' "$*" >&2; }
die()  { printf '[secrets-inject-all] ERROR: %s\n' "$*" >&2; exit 1; }

usage() {
  cat >&2 <<'USAGE'
Usage: secrets-inject-all.sh --github-token TOKEN [--claude-auth TOKEN]

Options:
  --github-token TOKEN   GitHub personal access token (required)
  --claude-auth TOKEN    Claude authentication token (optional)
USAGE
  exit 1
}

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

[[ $# -ge 1 ]] || usage

GITHUB_TOKEN=""
CLAUDE_AUTH=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --github-token)
      GITHUB_TOKEN="$2"
      shift 2
      ;;
    --claude-auth)
      CLAUDE_AUTH="$2"
      shift 2
      ;;
    --help|-h)
      usage
      ;;
    *)
      die "Unknown argument: $1"
      ;;
  esac
done

[[ -n "${GITHUB_TOKEN}" ]] || die "--github-token is required"

# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------

command -v hcloud >/dev/null 2>&1 || die "hcloud CLI not found in PATH"
[[ -x "${INJECT_SCRIPT}" ]] || die "secrets-inject.sh not found or not executable at ${INJECT_SCRIPT}"
[[ -n "${SSH_AUTH_SOCK:-}" ]] || die "SSH_AUTH_SOCK is not set — is ssh-agent running?"

# ---------------------------------------------------------------------------
# Discover running VMs
# ---------------------------------------------------------------------------

log "Discovering running ccgm-agent-* VMs"

# hcloud server list outputs name, status, public IP etc.
# Filter for VMs named ccgm-agent-* that are in 'running' status.
RUNNING_VMS="$(hcloud server list --selector 'ccgm-role=agent' --output columns=name,public_net 2>/dev/null \
  | tail -n +2 \
  | awk '{print $1, $2}' \
  || true)"

# Fallback: list by name prefix if no label selector works
if [[ -z "${RUNNING_VMS}" ]]; then
  log "Label selector returned no results; falling back to name-prefix discovery"
  RUNNING_VMS="$(hcloud server list --output json 2>/dev/null \
    | jq -r '.[] | select(.name | startswith("ccgm-agent")) | select(.status == "running") | "\(.name) \(.public_net.ipv4.ip)"' \
    || true)"
fi

if [[ -z "${RUNNING_VMS}" ]]; then
  log "No running ccgm-agent-* VMs found."
  exit 0
fi

VM_COUNT="$(printf '%s\n' "${RUNNING_VMS}" | grep -c . || true)"
log "Found ${VM_COUNT} VM(s)"

# ---------------------------------------------------------------------------
# Inject secrets into each VM / agent combination
# ---------------------------------------------------------------------------

PASS=0
FAIL=0
FAIL_DETAILS=()

while IFS=' ' read -r vm_name vm_ip; do
  [[ -z "${vm_name}" ]] && continue
  log "Processing VM: ${vm_name} (${vm_ip})"

  for i in $(seq 0 $((AGENT_COUNT - 1))); do
    INJECT_ARGS=("${vm_ip}" "${i}" "--github-token" "${GITHUB_TOKEN}")
    [[ -n "${CLAUDE_AUTH}" ]] && INJECT_ARGS+=("--claude-auth" "${CLAUDE_AUTH}")

    if bash "${INJECT_SCRIPT}" "${INJECT_ARGS[@]}" 2>&1; then
      PASS=$(( PASS + 1 ))
      log "  agent-${i}: OK"
    else
      FAIL=$(( FAIL + 1 ))
      FAIL_DETAILS+=("${vm_name}/agent-${i}")
      log "  agent-${i}: FAILED"
    fi
  done
done <<< "${RUNNING_VMS}"

# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------

TOTAL=$(( PASS + FAIL ))
log ""
log "Injection complete: ${PASS}/${TOTAL} agents succeeded"

if [[ "${FAIL}" -gt 0 ]]; then
  log "Failed agents:"
  for detail in "${FAIL_DETAILS[@]}"; do
    log "  - ${detail}"
  done
  exit 1
fi

lib/secrets-inject.sh

#!/usr/bin/env bash
# secrets-inject.sh — Inject credentials into a single agent on a VM.
#
# Writes secrets to the agent's tmpfs directory (/run/secrets/agent-N/) via SSH.
# Secrets are written using heredocs over the SSH connection so they never appear
# in process arguments (ps output). HISTFILE is disabled for all SSH sessions.
#
# Usage:
#   secrets-inject.sh <vm-ip> <agent-index> [--github-token TOKEN] [--claude-auth TOKEN]
#
# Example:
#   secrets-inject.sh 1.2.3.4 0 --github-token ghp_xxx --claude-auth sk-ant-xxx
#
# Requirements:
#   - SSH key for root access already loaded in ssh-agent (run secrets-init.sh first)

set -euo pipefail

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

log()  { printf '[secrets-inject] %s\n' "$*" >&2; }
die()  { printf '[secrets-inject] ERROR: %s\n' "$*" >&2; exit 1; }

usage() {
  cat >&2 <<'USAGE'
Usage: secrets-inject.sh <vm-ip> <agent-index> [options]

Options:
  --github-token TOKEN   GitHub personal access token
  --claude-auth TOKEN    Claude authentication token (API key or subscription token)

At least one of --github-token or --claude-auth must be provided.
USAGE
  exit 1
}

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

[[ $# -ge 2 ]] || usage

VM_IP="$1"
AGENT_INDEX="$2"
shift 2

GITHUB_TOKEN=""
CLAUDE_AUTH=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --github-token)
      GITHUB_TOKEN="$2"
      shift 2
      ;;
    --claude-auth)
      CLAUDE_AUTH="$2"
      shift 2
      ;;
    *)
      die "Unknown argument: $1"
      ;;
  esac
done

# Validate inputs
[[ "${AGENT_INDEX}" =~ ^[0-3]$ ]] || die "agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})"
[[ -n "${VM_IP}" ]] || die "vm-ip is required"
[[ -n "${GITHUB_TOKEN}" || -n "${CLAUDE_AUTH}" ]] \
  || die "At least one of --github-token or --claude-auth must be provided"

AGENT_USER="agent-${AGENT_INDEX}"
SECRETS_DIR="/run/secrets/${AGENT_USER}"

# ---------------------------------------------------------------------------
# SSH helper — HISTFILE disabled for all sessions to prevent secret leakage
# ---------------------------------------------------------------------------

# Common SSH flags: batch mode, key-only auth, no stdin consumption.
SSH_OPTS=(
  -n
  -o BatchMode=yes
  -o StrictHostKeyChecking=accept-new
  -o ConnectTimeout=10
)

vm_exec() {
  HISTFILE=/dev/null ssh "${SSH_OPTS[@]}" "root@${VM_IP}" "$@"
}

# Variant that reads stdin (for piping secrets); -n flag must be omitted.
SSH_STDIN_OPTS=(
  -o BatchMode=yes
  -o StrictHostKeyChecking=accept-new
  -o ConnectTimeout=10
)

vm_exec_stdin() {
  HISTFILE=/dev/null ssh "${SSH_STDIN_OPTS[@]}" "root@${VM_IP}" "$@"
}

# ---------------------------------------------------------------------------
# Verify connectivity
# ---------------------------------------------------------------------------

log "Verifying SSH connectivity to ${VM_IP}"
vm_exec true || die "Cannot SSH to ${VM_IP} — check that secrets-init.sh has been run"

# ---------------------------------------------------------------------------
# Ensure secrets directory exists with correct permissions
# ---------------------------------------------------------------------------

log "Ensuring ${SECRETS_DIR} exists with correct permissions"

# Remote script body stored in a variable so it can be passed via stdin.
# Variables are expanded on the client side before sending — this is intentional
# for non-secret values like AGENT_USER and SECRETS_DIR.
SETUP_SCRIPT="$(cat <<SETUP
set -euo pipefail
if mountpoint -q /run 2>/dev/null; then
  FSTYPE=\"\$(findmnt -n -o FSTYPE /run 2>/dev/null || true)\"
  if [[ \"\${FSTYPE}\" != \"tmpfs\" ]]; then
    echo \"WARNING: /run is not tmpfs (found: \${FSTYPE})\" >&2
  fi
fi
mkdir -p '${SECRETS_DIR}'
chmod 0700 '${SECRETS_DIR}'
chown '${AGENT_USER}:${AGENT_USER}' '${SECRETS_DIR}'
SETUP
)"

printf '%s' "${SETUP_SCRIPT}" | vm_exec_stdin bash

# ---------------------------------------------------------------------------
# Inject secrets via stdin (never in command arguments)
# ---------------------------------------------------------------------------

inject_secret() {
  local filename="$1"
  local content="$2"
  local filepath="${SECRETS_DIR}/${filename}"

  log "  Writing ${filepath}"

  # The remote script reads the secret from stdin.
  # The secret is piped in and never appears in command arguments or ps output.
  # The remote script body is passed as a -c argument so stdin is free for the secret.
  local remote_script
  remote_script="$(cat <<RSCRIPT
set -euo pipefail
FILEPATH='${filepath}'
AGENT='${AGENT_USER}'
SECRET="\$(cat)"
printf '%s' "\${SECRET}" > "\${FILEPATH}"
chmod 0600 "\${FILEPATH}"
chown "\${AGENT}:\${AGENT}" "\${FILEPATH}"
RSCRIPT
)"

  printf '%s' "${content}" \
    | HISTFILE=/dev/null ssh "${SSH_STDIN_OPTS[@]}" "root@${VM_IP}" bash -c "${remote_script}"
}

if [[ -n "${GITHUB_TOKEN}" ]]; then
  inject_secret "github_token" "${GITHUB_TOKEN}"
fi

if [[ -n "${CLAUDE_AUTH}" ]]; then
  inject_secret "claude_auth" "${CLAUDE_AUTH}"
fi

# ---------------------------------------------------------------------------
# Write env file that the agent sources at startup
# ---------------------------------------------------------------------------

log "  Writing ${SECRETS_DIR}/env"

ENV_SCRIPT="$(cat <<ENVSCRIPT
set -euo pipefail
ENV_FILE='${SECRETS_DIR}/env'
{
  printf '# Auto-generated by secrets-inject.sh -- do not edit\n'
  printf '# Source this file to load agent credentials into the current shell.\n'
  printf '\n'
ENVSCRIPT
)"

if [[ -n "${GITHUB_TOKEN}" ]]; then
  ENV_SCRIPT+="$(cat <<GITHUB
  printf 'export GITHUB_TOKEN="\$(cat ${SECRETS_DIR}/github_token)"\n'
GITHUB
)"
fi

if [[ -n "${CLAUDE_AUTH}" ]]; then
  ENV_SCRIPT+="$(cat <<CLAUDE
  printf 'export ANTHROPIC_API_KEY="\$(cat ${SECRETS_DIR}/claude_auth)"\n'
  printf '# Uncomment for subscription-mode auth:\n'
  printf '# export CLAUDE_AUTH_TOKEN="\$(cat ${SECRETS_DIR}/claude_auth)"\n'
CLAUDE
)"
fi

ENV_SCRIPT+="$(cat <<ENVEND
} > "\${ENV_FILE}"
chmod 0600 "\${ENV_FILE}"
chown '${AGENT_USER}:${AGENT_USER}' "\${ENV_FILE}"
ENVEND
)"

printf '%s' "${ENV_SCRIPT}" | vm_exec_stdin bash

# ---------------------------------------------------------------------------
# Verify injection
# ---------------------------------------------------------------------------

log "Verifying injection for ${AGENT_USER}"

# Build a list of expected secret files to verify
EXPECTED_FILES=()
[[ -n "${GITHUB_TOKEN}" ]] && EXPECTED_FILES+=("github_token")
[[ -n "${CLAUDE_AUTH}" ]]  && EXPECTED_FILES+=("claude_auth")
EXPECTED_FILES+=("env")

VERIFY_SCRIPT="$(cat <<VERIFYSCRIPT
set -euo pipefail
ALL_OK=true
AGENT_USER='${AGENT_USER}'
SECRETS_DIR='${SECRETS_DIR}'
VERIFYSCRIPT
)"

for f in "${EXPECTED_FILES[@]}"; do
  VERIFY_SCRIPT+="$(cat <<VFILE
fp="\${SECRETS_DIR}/${f}"
if [[ ! -f "\${fp}" ]]; then
  printf 'FAIL: %s does not exist\n' "\${fp}" >&2
  ALL_OK=false
else
  perms="\$(stat -c '%a' "\${fp}" 2>/dev/null || stat -f '%OLp' "\${fp}" 2>/dev/null)"
  owner="\$(stat -c '%U' "\${fp}" 2>/dev/null || stat -f '%Su' "\${fp}" 2>/dev/null)"
  if [[ "\${perms}" != "600" ]]; then
    printf 'FAIL: %s has permissions %s (expected 600)\n' "\${fp}" "\${perms}" >&2
    ALL_OK=false
  fi
  if [[ "\${owner}" != "\${AGENT_USER}" ]]; then
    printf 'FAIL: %s owned by %s (expected %s)\n' "\${fp}" "\${owner}" "\${AGENT_USER}" >&2
    ALL_OK=false
  fi
fi
VFILE
)"
done

VERIFY_SCRIPT+='
if [[ "${ALL_OK}" == "true" ]]; then
  printf "OK: all secrets verified\n" >&2
else
  exit 1
fi'

printf '%s' "${VERIFY_SCRIPT}" | vm_exec_stdin bash

log "secrets-inject complete for ${AGENT_USER} on ${VM_IP}"

lib/secrets-rotate.sh

#!/usr/bin/env bash
# secrets-rotate.sh — Rotate a GitHub PAT for a specific agent on a VM.
#
# Re-injects a new GitHub token for a single agent and verifies the token
# works by testing git remote access from the agent's workspace.
#
# Usage:
#   secrets-rotate.sh <vm-ip> <agent-index> --github-token NEW_TOKEN
#
# Example:
#   secrets-rotate.sh 1.2.3.4 0 --github-token ghp_newtoken
#
# Requirements:
#   - SSH key loaded in ssh-agent (run secrets-init.sh first)
#   - secrets-inject.sh in the same directory

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INJECT_SCRIPT="${SCRIPT_DIR}/secrets-inject.sh"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

log()  { printf '[secrets-rotate] %s\n' "$*" >&2; }
die()  { printf '[secrets-rotate] ERROR: %s\n' "$*" >&2; exit 1; }

usage() {
  cat >&2 <<'USAGE'
Usage: secrets-rotate.sh <vm-ip> <agent-index> --github-token NEW_TOKEN

Arguments:
  vm-ip          IP address of the VM
  agent-index    Agent index (0-3)

Options:
  --github-token TOKEN   New GitHub personal access token (required)
USAGE
  exit 1
}

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------

[[ $# -ge 3 ]] || usage

VM_IP="$1"
AGENT_INDEX="$2"
shift 2

GITHUB_TOKEN=""

while [[ $# -gt 0 ]]; do
  case "$1" in
    --github-token)
      GITHUB_TOKEN="$2"
      shift 2
      ;;
    --help|-h)
      usage
      ;;
    *)
      die "Unknown argument: $1"
      ;;
  esac
done

[[ -n "${GITHUB_TOKEN}" ]] || die "--github-token is required"
[[ "${AGENT_INDEX}" =~ ^[0-3]$ ]] || die "agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})"
[[ -n "${VM_IP}" ]] || die "vm-ip is required"

# ---------------------------------------------------------------------------
# Preflight checks
# ---------------------------------------------------------------------------

[[ -x "${INJECT_SCRIPT}" ]] || die "secrets-inject.sh not found or not executable at ${INJECT_SCRIPT}"
[[ -n "${SSH_AUTH_SOCK:-}" ]] || die "SSH_AUTH_SOCK is not set — is ssh-agent running?"

AGENT_USER="agent-${AGENT_INDEX}"
SECRETS_DIR="/run/secrets/${AGENT_USER}"

# ---------------------------------------------------------------------------
# Re-inject the new token
# ---------------------------------------------------------------------------

log "Rotating GitHub token for ${AGENT_USER} on ${VM_IP}"
bash "${INJECT_SCRIPT}" "${VM_IP}" "${AGENT_INDEX}" --github-token "${GITHUB_TOKEN}"

# ---------------------------------------------------------------------------
# Verify the new token works
# ---------------------------------------------------------------------------

log "Verifying new token with git ls-remote"

SSH_OPTS=(
  -o BatchMode=yes
  -o StrictHostKeyChecking=accept-new
  -o ConnectTimeout=10
)

# Run git ls-remote as the agent user to confirm the new GITHUB_TOKEN works.
# The token is read from the secrets file so it never appears in the process list.
VERIFY_RESULT="$(HISTFILE=/dev/null ssh "${SSH_OPTS[@]}" "root@${VM_IP}" \
  bash -s "${AGENT_USER}" "${SECRETS_DIR}" <<'VERIFY'
set -euo pipefail
AGENT_USER="$1"
SECRETS_DIR="$2"

# Run as the agent user so the test mirrors actual agent conditions
if [[ ! -f "${SECRETS_DIR}/github_token" ]]; then
  echo "FAIL: github_token file not found" >&2
  exit 1
fi

TOKEN_FILE="${SECRETS_DIR}/github_token"

# Test API access using the token (does not clone, just checks auth)
HTTP_STATUS="$(curl -s -o /dev/null -w '%{http_code}' \
  -H "Authorization: token $(cat ${TOKEN_FILE})" \
  https://api.github.com/user 2>/dev/null || echo "000")"

case "${HTTP_STATUS}" in
  200)
    echo "OK: GitHub API responded 200"
    ;;
  401)
    echo "FAIL: token rejected (401)" >&2
    exit 1
    ;;
  403)
    echo "FAIL: token forbidden (403)" >&2
    exit 1
    ;;
  000)
    echo "FAIL: no network response (curl error)" >&2
    exit 1
    ;;
  *)
    echo "WARN: unexpected HTTP status ${HTTP_STATUS}" >&2
    ;;
esac
VERIFY
)"

log "${VERIFY_RESULT}"
log "secrets-rotate complete for ${AGENT_USER} on ${VM_IP}"

lib/workspace-assign.sh

#!/usr/bin/env bash
# workspace-assign.sh — Assign a GitHub issue to an agent on a VM.
#
# Usage:
#   workspace-assign.sh <vm-ip> <agent-index> <issue-number> <issue-title> \
#     [--repo OWNER/REPO] [--max-turns N]
#
# Arguments:
#   vm-ip          Public IP of the Hetzner Cloud VM
#   agent-index    0-3, selects agent-N user on the VM
#   issue-number   GitHub issue number to assign
#   issue-title    Issue title (used to build the branch slug)
#   --repo         GitHub repo in owner/repo format (required)
#   --max-turns    Maximum agent turns before stopping (default: 200)
#
# Side effects:
#   - Writes /home/agent-N/assignment.json on the VM
#   - Creates a feature branch in /home/agent-N/workspace/<repo>
set -euo pipefail

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -lt 4 ]]; then
  echo "Usage: $0 <vm-ip> <agent-index> <issue-number> <issue-title> [--repo OWNER/REPO] [--max-turns N]" >&2
  exit 1
fi

VM_IP="$1"
AGENT_INDEX="$2"
ISSUE_NUMBER="$3"
ISSUE_TITLE="$4"
shift 4

REPO=""
MAX_TURNS=200
while [[ $# -gt 0 ]]; do
  case "$1" in
    --repo)
      REPO="$2"
      shift 2
      ;;
    --max-turns)
      MAX_TURNS="$2"
      shift 2
      ;;
    *)
      echo "Unknown argument: $1" >&2
      exit 1
      ;;
  esac
done

if [[ -z "${REPO}" ]]; then
  echo "Error: --repo OWNER/REPO is required" >&2
  exit 1
fi

if ! [[ "$AGENT_INDEX" =~ ^[0-3]$ ]]; then
  echo "Error: agent-index must be 0, 1, 2, or 3 (got: $AGENT_INDEX)" >&2
  exit 1
fi

if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
  echo "Error: issue-number must be a positive integer (got: $ISSUE_NUMBER)" >&2
  exit 1
fi

if ! [[ "$MAX_TURNS" =~ ^[0-9]+$ ]]; then
  echo "Error: --max-turns must be a positive integer (got: $MAX_TURNS)" >&2
  exit 1
fi

AGENT_USER="agent-${AGENT_INDEX}"
AGENT_HOME="/home/${AGENT_USER}"
REPO_NAME=$(basename "${REPO}")
CLONE_DIR="${AGENT_HOME}/workspace/${REPO_NAME}"
SSH_KEY="${SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}"

# Build branch slug: lowercase, replace spaces/non-alphanumeric with hyphens,
# trim leading/trailing hyphens, collapse repeated hyphens.
# printf '%s', not echo: bash's builtin echo flag-parses a leading -n/-e,
# so a title of exactly "-n" would silently produce an empty slug.
SLUG=$(printf '%s' "${ISSUE_TITLE}" \
  | tr '[:upper:]' '[:lower:]' \
  | sed 's/[^a-z0-9]/-/g' \
  | sed 's/-\{2,\}/-/g' \
  | sed 's/^-//;s/-$//')
BRANCH_NAME="${ISSUE_NUMBER}-${SLUG}"

# ISO 8601 timestamp (UTC)
ASSIGNED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# ---------------------------------------------------------------------------
# Helper: run a command on the VM as root
# ---------------------------------------------------------------------------
ssh_root() {
  ssh -i "${SSH_KEY}" \
      -o StrictHostKeyChecking=accept-new \
      -o BatchMode=yes \
      "root@${VM_IP}" "$@"
}

# ---------------------------------------------------------------------------
# Helper: run a command on the VM as agent-N (via root su)
# ---------------------------------------------------------------------------
ssh_agent() {
  local cmd="$1"
  ssh_root "su - ${AGENT_USER} -c $(printf '%q' "$cmd")"
}

echo "==> Assigning issue #${ISSUE_NUMBER} to ${AGENT_USER} on ${VM_IP}"

# ---------------------------------------------------------------------------
# Step 1: Verify the workspace clone exists before assigning
# ---------------------------------------------------------------------------
if ! ssh_root "test -d '${CLONE_DIR}/.git'"; then
  echo "Error: workspace not found at ${CLONE_DIR}. Run workspace-setup.sh first." >&2
  exit 1
fi

# ---------------------------------------------------------------------------
# Step 2: Write assignment.json
# ---------------------------------------------------------------------------
echo "--> Writing assignment.json"
ASSIGNMENT_FILE="${AGENT_HOME}/assignment.json"

ASSIGNMENT_JSON=$(cat <<JSON
{
  "issue_number": ${ISSUE_NUMBER},
  "issue_title": "${ISSUE_TITLE}",
  "repo": "${REPO}",
  "branch": "${BRANCH_NAME}",
  "max_turns": ${MAX_TURNS},
  "assigned_at": "${ASSIGNED_AT}"
}
JSON
)

ssh_root "printf '%s\n' '${ASSIGNMENT_JSON}' > '${ASSIGNMENT_FILE}' && chown '${AGENT_USER}:${AGENT_USER}' '${ASSIGNMENT_FILE}'"

# ---------------------------------------------------------------------------
# Step 3: Create the feature branch
# ---------------------------------------------------------------------------
echo "--> Creating branch ${BRANCH_NAME} from origin/main"
ssh_agent "git -C '${CLONE_DIR}' fetch origin"
ssh_agent "git -C '${CLONE_DIR}' checkout -b '${BRANCH_NAME}' origin/main"

# ---------------------------------------------------------------------------
# Step 4: Verify
# ---------------------------------------------------------------------------
echo "--> Verifying branch"
CURRENT_BRANCH=$(ssh_agent "git -C '${CLONE_DIR}' rev-parse --abbrev-ref HEAD")
if [[ "${CURRENT_BRANCH}" != "${BRANCH_NAME}" ]]; then
  echo "Error: expected branch ${BRANCH_NAME}, got ${CURRENT_BRANCH}" >&2
  exit 1
fi

echo "==> Assignment complete"
echo "    Agent:  ${AGENT_USER}@${VM_IP}"
echo "    Issue:  #${ISSUE_NUMBER} - ${ISSUE_TITLE}"
echo "    Branch: ${BRANCH_NAME}"
echo "    Repo:   ${REPO}"

lib/workspace-cleanup.sh

#!/usr/bin/env bash
# workspace-cleanup.sh — Remove workspace artifacts from one or all agent users on a VM.
#
# Usage:
#   workspace-cleanup.sh <vm-ip> <agent-index>
#   workspace-cleanup.sh <vm-ip> --all
#
# Arguments:
#   vm-ip         Public IP of the Hetzner Cloud VM
#   agent-index   0-3, selects agent-N user to clean up
#   --all         Clean up all 4 agent users on the VM
#
# What is removed (per agent):
#   - /home/agent-N/workspace/    (repo clone)
#   - /home/agent-N/assignment.json
#   - /home/agent-N/run.log
#
# What is preserved:
#   - /home/agent-N/              (home directory)
#   - /home/agent-N/.gitconfig    (identity config, reused on next assignment)
#   - /home/agent-N/.claude/      (CCGM settings, reused on next assignment)
#   - /home/agent-N/.ssh/         (SSH config)
#   - /home/agent-N/.bashrc
set -euo pipefail

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -lt 2 ]]; then
  echo "Usage: $0 <vm-ip> <agent-index>" >&2
  echo "       $0 <vm-ip> --all" >&2
  exit 1
fi

VM_IP="$1"
AGENT_ARG="$2"
SSH_KEY="${SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}"
AGENTS_PER_VM=4

# Build list of agent indexes to clean
declare -a AGENT_INDEXES
if [[ "${AGENT_ARG}" == "--all" ]]; then
  for i in $(seq 0 $(( AGENTS_PER_VM - 1 ))); do
    AGENT_INDEXES+=("$i")
  done
else
  if ! [[ "${AGENT_ARG}" =~ ^[0-3]$ ]]; then
    echo "Error: agent-index must be 0, 1, 2, or 3 (got: ${AGENT_ARG})" >&2
    exit 1
  fi
  AGENT_INDEXES=("${AGENT_ARG}")
fi

# ---------------------------------------------------------------------------
# Helper: run a command on the VM as root
# ---------------------------------------------------------------------------
ssh_root() {
  ssh -i "${SSH_KEY}" \
      -o StrictHostKeyChecking=accept-new \
      -o BatchMode=yes \
      -o ConnectTimeout=10 \
      "root@${VM_IP}" "$@"
}

# ---------------------------------------------------------------------------
# Clean one agent
# ---------------------------------------------------------------------------
cleanup_one() {
  local agent_index="$1"
  local agent_user="agent-${agent_index}"
  local agent_home="/home/${agent_user}"
  local workspace_dir="${agent_home}/workspace"
  local assignment_file="${agent_home}/assignment.json"
  local run_log="${agent_home}/run.log"

  echo "==> Cleaning up ${agent_user}@${VM_IP}"

  # Stop any running agent process owned by this user before removing files
  local pids
  pids=$(ssh_root "pgrep -u '${agent_user}' -f 'claude' 2>/dev/null || true")
  if [[ -n "${pids}" ]]; then
    echo "--> Stopping running agent process(es): ${pids}"
    ssh_root "pkill -u '${agent_user}' -f 'claude' 2>/dev/null || true"
    # Give process a moment to terminate
    sleep 2
  fi

  # Remove workspace (repo clone)
  if ssh_root "test -d '${workspace_dir}'"; then
    echo "--> Removing workspace: ${workspace_dir}"
    ssh_root "rm -rf '${workspace_dir}'"
  else
    echo "--> Workspace not found, skipping: ${workspace_dir}"
  fi

  # Remove assignment.json
  if ssh_root "test -f '${assignment_file}'"; then
    echo "--> Removing assignment file: ${assignment_file}"
    ssh_root "rm -f '${assignment_file}'"
  else
    echo "--> Assignment file not found, skipping"
  fi

  # Remove run log
  if ssh_root "test -f '${run_log}'"; then
    echo "--> Removing run log: ${run_log}"
    ssh_root "rm -f '${run_log}'"
  else
    echo "--> Run log not found, skipping"
  fi

  echo "--> ${agent_user} cleaned up"
}

# ---------------------------------------------------------------------------
# Execute cleanup
# ---------------------------------------------------------------------------
for agent_index in "${AGENT_INDEXES[@]}"; do
  cleanup_one "${agent_index}"
done

echo ""
echo "==> Cleanup complete on ${VM_IP} (agents: ${AGENT_INDEXES[*]})"

lib/workspace-collect.sh

#!/usr/bin/env bash
# workspace-collect.sh — Collect results from agent workspaces.
#
# Usage:
#   workspace-collect.sh --all
#   workspace-collect.sh <vm-ip> <agent-index>
#
# Options:
#   --all         Collect from all running ccgm-agent-* VMs (all 4 agents each)
#   --json        Emit machine-readable JSON instead of formatted text
#   --log-lines N Number of log tail lines to include (default: 50)
#
# Requires:
#   - hcloud CLI (when --all is used)
#   - SSH key in ~/.ssh/id_ed25519 or $SSH_KEY_PATH
set -euo pipefail

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then
  echo "Usage: $0 --all [--json] [--log-lines N]" >&2
  echo "       $0 <vm-ip> <agent-index> [--json] [--log-lines N]" >&2
  exit 1
fi

COLLECT_ALL=false
VM_IP=""
AGENT_INDEX=""
JSON_OUTPUT=false
LOG_LINES=50

while [[ $# -gt 0 ]]; do
  case "$1" in
    --all)
      COLLECT_ALL=true
      shift
      ;;
    --json)
      JSON_OUTPUT=true
      shift
      ;;
    --log-lines)
      LOG_LINES="$2"
      shift 2
      ;;
    -*)
      echo "Unknown option: $1" >&2
      exit 1
      ;;
    *)
      if [[ -z "${VM_IP}" ]]; then
        VM_IP="$1"
      elif [[ -z "${AGENT_INDEX}" ]]; then
        AGENT_INDEX="$1"
      else
        echo "Unexpected argument: $1" >&2
        exit 1
      fi
      shift
      ;;
  esac
done

if [[ "${COLLECT_ALL}" == "false" ]]; then
  if [[ -z "${VM_IP}" || -z "${AGENT_INDEX}" ]]; then
    echo "Error: provide --all or both <vm-ip> and <agent-index>" >&2
    exit 1
  fi
  if ! [[ "${AGENT_INDEX}" =~ ^[0-3]$ ]]; then
    echo "Error: agent-index must be 0, 1, 2, or 3 (got: ${AGENT_INDEX})" >&2
    exit 1
  fi
fi

SSH_KEY="${SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}"
AGENTS_PER_VM=4

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
ssh_root() {
  local ip="$1"; shift
  ssh -i "${SSH_KEY}" \
      -o StrictHostKeyChecking=accept-new \
      -o BatchMode=yes \
      -o ConnectTimeout=10 \
      "root@${ip}" "$@" 2>/dev/null
}

# Collect data for one agent and emit a result block
collect_one() {
  local vm_ip="$1"
  local agent_index="$2"
  local agent_user="agent-${agent_index}"
  local agent_home="/home/${agent_user}"
  local assignment_file="${agent_home}/assignment.json"
  local run_log="${agent_home}/run.log"

  # --- assignment ---
  local issue_number="" issue_title="" repo="" branch="" assigned_at=""
  if ssh_root "${vm_ip}" "test -f '${assignment_file}'" 2>/dev/null; then
    local assignment
    assignment=$(ssh_root "${vm_ip}" "cat '${assignment_file}'" 2>/dev/null || echo "{}")
    issue_number=$(echo "${assignment}" | grep -o '"issue_number":[^,}]*' | sed 's/"issue_number"://' | tr -d ' "')
    issue_title=$(echo "${assignment}" | grep -o '"issue_title":"[^"]*"' | sed 's/"issue_title":"//;s/"$//')
    repo=$(echo "${assignment}" | grep -o '"repo":"[^"]*"' | sed 's/"repo":"//;s/"$//')
    branch=$(echo "${assignment}" | grep -o '"branch":"[^"]*"' | sed 's/"branch":"//;s/"$//')
    assigned_at=$(echo "${assignment}" | grep -o '"assigned_at":"[^"]*"' | sed 's/"assigned_at":"//;s/"$//')
  fi

  # --- git state ---
  local current_branch="" last_commit_sha="" last_commit_msg=""
  local workspace_dir="${agent_home}/workspace"

  if [[ -n "${repo}" ]]; then
    local repo_name
    repo_name=$(basename "${repo}")
    local clone_dir="${workspace_dir}/${repo_name}"
    if ssh_root "${vm_ip}" "test -d '${clone_dir}/.git'" 2>/dev/null; then
      current_branch=$(ssh_root "${vm_ip}" \
        "su - ${agent_user} -c 'git -C ${clone_dir} rev-parse --abbrev-ref HEAD'" 2>/dev/null || echo "unknown")
      last_commit_sha=$(ssh_root "${vm_ip}" \
        "su - ${agent_user} -c 'git -C ${clone_dir} rev-parse --short HEAD'" 2>/dev/null || echo "unknown")
      last_commit_msg=$(ssh_root "${vm_ip}" \
        "su - ${agent_user} -c 'git -C ${clone_dir} log -1 --pretty=%s'" 2>/dev/null || echo "")
    fi
  fi

  # --- PR detection ---
  local pr_url=""
  if [[ -n "${branch}" && -n "${repo}" ]]; then
    # Check gh CLI on the VM, or fall back to grepping the run log
    pr_url=$(ssh_root "${vm_ip}" \
      "su - ${agent_user} -c 'gh pr list --repo ${repo} --head ${branch} --json url --jq .[0].url 2>/dev/null'" \
      2>/dev/null || true)
    if [[ -z "${pr_url}" && -f "${run_log}" ]]; then
      pr_url=$(ssh_root "${vm_ip}" "grep -oE 'https://github.com/[^ ]*/pull/[0-9]+' '${run_log}' | tail -1" 2>/dev/null || true)
    fi
  fi

  # --- agent process status ---
  local agent_pid="" agent_status="unknown"
  agent_pid=$(ssh_root "${vm_ip}" \
    "pgrep -u ${agent_user} -f 'claude' | head -1" 2>/dev/null || true)
  if [[ -n "${agent_pid}" ]]; then
    agent_status="running"
  elif [[ -f "${run_log}" ]]; then
    # Check last exit code if logged
    local last_exit
    last_exit=$(ssh_root "${vm_ip}" "tail -1 '${run_log}'" 2>/dev/null || true)
    if echo "${last_exit}" | grep -qi "exit.*0\|completed\|success"; then
      agent_status="completed"
    elif echo "${last_exit}" | grep -qi "exit\|error\|fail"; then
      agent_status="failed"
    else
      agent_status="idle"
    fi
  fi

  # --- run log tail ---
  local log_tail=""
  if ssh_root "${vm_ip}" "test -f '${run_log}'" 2>/dev/null; then
    log_tail=$(ssh_root "${vm_ip}" "tail -n ${LOG_LINES} '${run_log}'" 2>/dev/null || true)
  fi

  # --- VM name (best effort) ---
  local vm_name=""
  if command -v hcloud &>/dev/null; then
    vm_name=$(hcloud server list --output columns=name,ipv4 2>/dev/null \
      | awk -v ip="${vm_ip}" '$2==ip {print $1}' || true)
  fi
  [[ -z "${vm_name}" ]] && vm_name="${vm_ip}"

  # ---------------------------------------------------------------------------
  # Output
  # ---------------------------------------------------------------------------
  if [[ "${JSON_OUTPUT}" == "true" ]]; then
    # Escape strings for JSON (basic escaping)
    json_escape() { printf '%s' "$1" | python3 -c "import json,sys; print(json.dumps(sys.stdin.read()))" 2>/dev/null || printf '"%s"' "$1"; }
    printf '{\n'
    printf '  "vm": %s,\n'            "$(json_escape "${vm_name}")"
    printf '  "agent": %s,\n'         "$(json_escape "${agent_user}")"
    printf '  "issue_number": %s,\n'  "${issue_number:-null}"
    printf '  "issue_title": %s,\n'   "$(json_escape "${issue_title}")"
    printf '  "repo": %s,\n'          "$(json_escape "${repo}")"
    printf '  "branch": %s,\n'        "$(json_escape "${branch}")"
    printf '  "current_branch": %s,\n' "$(json_escape "${current_branch}")"
    printf '  "last_commit": %s,\n'   "$(json_escape "${last_commit_sha} ${last_commit_msg}")"
    printf '  "pr_url": %s,\n'        "$(json_escape "${pr_url}")"
    printf '  "status": %s,\n'        "$(json_escape "${agent_status}")"
    printf '  "assigned_at": %s\n'    "$(json_escape "${assigned_at}")"
    printf '}\n'
  else
    echo "---"
    printf "Agent:       %s / %s\n" "${vm_name}" "${agent_user}"
    if [[ -n "${issue_number}" ]]; then
      printf "Issue:       #%s - %s\n" "${issue_number}" "${issue_title}"
      printf "Repo:        %s\n" "${repo}"
    else
      printf "Issue:       (no assignment)\n"
    fi
    printf "Branch:      %s\n" "${branch:-${current_branch:-(none)}}"
    printf "Status:      %s\n" "${agent_status}"
    if [[ -n "${pr_url}" ]]; then
      printf "PR:          %s\n" "${pr_url}"
    fi
    if [[ -n "${last_commit_sha}" && "${last_commit_sha}" != "unknown" ]]; then
      printf "Last commit: %s \"%s\"\n" "${last_commit_sha}" "${last_commit_msg}"
    fi
    if [[ -n "${assigned_at}" ]]; then
      printf "Assigned:    %s\n" "${assigned_at}"
    fi
    if [[ -n "${log_tail}" ]]; then
      echo ""
      echo "Log (last ${LOG_LINES} lines):"
      while IFS= read -r log_line; do printf '  %s\n' "${log_line}"; done <<< "${log_tail}"
    fi
    echo ""
  fi
}

# ---------------------------------------------------------------------------
# Single agent
# ---------------------------------------------------------------------------
if [[ "${COLLECT_ALL}" == "false" ]]; then
  collect_one "${VM_IP}" "${AGENT_INDEX}"
  exit 0
fi

# ---------------------------------------------------------------------------
# All agents across all running VMs
# ---------------------------------------------------------------------------
if ! command -v hcloud &>/dev/null; then
  echo "Error: hcloud CLI is required for --all mode" >&2
  exit 1
fi

mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status | awk '$2=="running" && /ccgm-agent/ {print $1}' | sort)

if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
  echo "No running ccgm-agent-* VMs found." >&2
  exit 0
fi

if [[ "${JSON_OUTPUT}" == "true" ]]; then
  echo "["
  first=true
fi

for vm_name in "${VM_NAMES[@]}"; do
  vm_ip=$(hcloud server describe "${vm_name}" --output format='{{.PublicNet.IPv4.IP}}')
  for agent_index in $(seq 0 $(( AGENTS_PER_VM - 1 ))); do
    if [[ "${JSON_OUTPUT}" == "true" && "${first}" != "true" ]]; then
      echo ","
    fi
    collect_one "${vm_ip}" "${agent_index}"
    first=false
  done
done

if [[ "${JSON_OUTPUT}" == "true" ]]; then
  echo "]"
fi

lib/workspace-setup-all.sh

#!/usr/bin/env bash
# workspace-setup-all.sh — Provision workspaces across all running agent VMs.
#
# Usage:
#   workspace-setup-all.sh <repo-url> --issues "42,43,44,45,46,47,48,49"
#
# Arguments:
#   repo-url     HTTPS URL of the target repo (e.g. https://github.com/owner/repo)
#   --issues     Comma-separated list of issue numbers to distribute
#   --titles     Optional: JSON file mapping issue numbers to titles
#                If omitted, titles are fetched from GitHub via gh CLI
#   --repo       GitHub repo in owner/repo format (derived from repo-url if omitted)
#   --max-turns  Maximum agent turns per assignment (default: 200)
#   --branch     Optional base branch for all workspaces (default: main)
#   --dry-run    Print the assignment plan without executing it
#
# Requires:
#   - Hetzner Cloud CLI (hcloud) installed and authenticated
#   - workspace-setup.sh and workspace-assign.sh in the same directory as this script
#   - SSH key in ~/.ssh/id_ed25519 or $SSH_KEY_PATH
#   - gh CLI authenticated (for fetching issue titles when --titles not provided)
#
# NOTE: If common.sh from Epic 3 is available in the same lib/ directory, source it
#       for shared SSH helpers. This script defines its own helpers as a fallback
#       since Epic 3 may not be merged when this runs.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source common.sh if available (Epic 3 dependency — may not be merged yet)
if [[ -f "${SCRIPT_DIR}/common.sh" ]]; then
  # shellcheck source=/dev/null
  source "${SCRIPT_DIR}/common.sh"
fi

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <repo-url> --issues \"42,43,44\" [--titles FILE] [--repo OWNER/REPO] [--max-turns N] [--branch BRANCH] [--dry-run]" >&2
  exit 1
fi

REPO_URL="$1"
shift

ISSUES_CSV=""
TITLES_FILE=""
REPO_OVERRIDE=""
MAX_TURNS=200
BRANCH=""
DRY_RUN=false

while [[ $# -gt 0 ]]; do
  case "$1" in
    --issues)
      ISSUES_CSV="$2"
      shift 2
      ;;
    --titles)
      TITLES_FILE="$2"
      shift 2
      ;;
    --repo)
      REPO_OVERRIDE="$2"
      shift 2
      ;;
    --max-turns)
      MAX_TURNS="$2"
      shift 2
      ;;
    --branch)
      BRANCH="$2"
      shift 2
      ;;
    --dry-run)
      DRY_RUN=true
      shift
      ;;
    *)
      echo "Unknown argument: $1" >&2
      exit 1
      ;;
  esac
done

if [[ -z "${ISSUES_CSV}" ]]; then
  echo "Error: --issues is required" >&2
  exit 1
fi

# Derive repo in owner/repo format from URL
if [[ -n "${REPO_OVERRIDE}" ]]; then
  REPO="${REPO_OVERRIDE}"
else
  # Strip https://github.com/ prefix and .git suffix. printf '%s', not echo:
  # bash's builtin echo flag-parses a leading -n/-e, so a repo-url of
  # exactly "-n" would silently strip to empty here (#946).
  REPO=$(printf '%s' "${REPO_URL}" | sed 's|https://github.com/||;s|\.git$||')
fi

# SSH_KEY is passed via SSH_KEY_PATH env to child scripts (workspace-setup.sh, workspace-assign.sh)
export SSH_KEY_PATH="${SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}"

# ---------------------------------------------------------------------------
# Parse issue list
# ---------------------------------------------------------------------------
IFS=',' read -ra ISSUE_LIST <<< "${ISSUES_CSV}"
ISSUE_COUNT="${#ISSUE_LIST[@]}"

# ---------------------------------------------------------------------------
# Fetch issue titles
# ---------------------------------------------------------------------------
declare -A ISSUE_TITLES

if [[ -n "${TITLES_FILE}" ]]; then
  # Load from JSON file: {"42": "feat: habit streaks", "43": "fix: login bug"}
  if ! command -v jq &>/dev/null; then
    echo "Error: jq is required when --titles is specified" >&2
    exit 1
  fi
  while IFS= read -r line; do
    num=$(echo "${line}" | jq -r '.key')
    title=$(echo "${line}" | jq -r '.value')
    ISSUE_TITLES["${num}"]="${title}"
  done < <(jq -r 'to_entries[] | {key, value} | @json' "${TITLES_FILE}")
else
  echo "==> Fetching issue titles from GitHub"
  if ! command -v gh &>/dev/null; then
    echo "Error: gh CLI is required to fetch issue titles. Install it or use --titles to provide them." >&2
    exit 1
  fi
  for issue_num in "${ISSUE_LIST[@]}"; do
    # printf '%s', not echo: bash's builtin echo flag-parses a leading -n/-e,
    # so an issue number of exactly "-n" would silently strip to empty here.
    issue_num=$(printf '%s' "${issue_num}" | tr -d '[:space:]')
    title=$(gh issue view "${issue_num}" --repo "${REPO}" --json title --jq '.title' 2>/dev/null || echo "issue-${issue_num}")
    ISSUE_TITLES["${issue_num}"]="${title}"
  done
fi

# ---------------------------------------------------------------------------
# Discover running agent VMs via hcloud CLI
# ---------------------------------------------------------------------------
echo "==> Discovering running agent VMs"
if ! command -v hcloud &>/dev/null; then
  echo "Error: hcloud CLI is required. Install it from https://github.com/hetznercloud/cli" >&2
  exit 1
fi

# VMs created by the ccgm terraform config are named ccgm-agent-{location}-{n}
# List all running servers with the ccgm-agent prefix, sorted by name.
mapfile -t VM_NAMES < <(hcloud server list --output columns=name,status | awk '$2=="running" && /ccgm-agent/ {print $1}' | sort)

if [[ ${#VM_NAMES[@]} -eq 0 ]]; then
  echo "Error: no running ccgm-agent-* VMs found. Create them with Terraform first." >&2
  exit 1
fi

echo "Found ${#VM_NAMES[@]} VM(s): ${VM_NAMES[*]}"

# ---------------------------------------------------------------------------
# Build assignment plan: distribute issues round-robin across agent slots
#
# Slot order: VM0-agent0, VM0-agent1, VM0-agent2, VM0-agent3,
#             VM1-agent0, VM1-agent1, ...
# ---------------------------------------------------------------------------
declare -a PLAN_VM_IPS
declare -a PLAN_AGENT_INDEXES
declare -a PLAN_ISSUE_NUMBERS
AGENTS_PER_VM=4

slot=0
for issue_num in "${ISSUE_LIST[@]}"; do
  # printf '%s', same reasoning as the identical strip above (#946).
  issue_num=$(printf '%s' "${issue_num}" | tr -d '[:space:]')
  vm_index=$(( slot / AGENTS_PER_VM ))
  agent_index=$(( slot % AGENTS_PER_VM ))

  if [[ ${vm_index} -ge ${#VM_NAMES[@]} ]]; then
    echo "Warning: more issues (${ISSUE_COUNT}) than agent slots ($(( ${#VM_NAMES[@]} * AGENTS_PER_VM ))). Stopping at slot ${slot}." >&2
    break
  fi

  VM_NAME="${VM_NAMES[$vm_index]}"
  VM_IP=$(hcloud server describe "${VM_NAME}" --output format='{{.PublicNet.IPv4.IP}}')

  PLAN_VM_IPS+=("${VM_IP}")
  PLAN_AGENT_INDEXES+=("${agent_index}")
  PLAN_ISSUE_NUMBERS+=("${issue_num}")

  slot=$(( slot + 1 ))
done

# ---------------------------------------------------------------------------
# Print assignment plan
# ---------------------------------------------------------------------------
echo ""
echo "Assignment plan:"
echo "  Repo:      ${REPO}"
echo "  Issues:    ${ISSUE_COUNT}"
echo "  VMs:       ${#VM_NAMES[@]}"
echo ""
printf "  %-6s  %-8s  %-20s  %s\n" "Issue" "Agent" "VM" "Branch"
printf "  %-6s  %-8s  %-20s  %s\n" "------" "--------" "--------------------" "------"
for i in "${!PLAN_ISSUE_NUMBERS[@]}"; do
  num="${PLAN_ISSUE_NUMBERS[$i]}"
  title="${ISSUE_TITLES[$num]:-issue-${num}}"
  # printf '%s', not echo: bash's builtin echo flag-parses a leading -n/-e,
  # so a title of exactly "-n" would silently produce an empty slug.
  slug=$(printf '%s' "${title}" \
    | tr '[:upper:]' '[:lower:]' \
    | sed 's/[^a-z0-9]/-/g' \
    | sed 's/-\{2,\}/-/g' \
    | sed 's/^-//;s/-$//')
  branch="${num}-${slug}"
  printf "  %-6s  %-8s  %-20s  %s\n" "#${num}" "agent-${PLAN_AGENT_INDEXES[$i]}" "${PLAN_VM_IPS[$i]}" "${branch}"
done
echo ""

if [[ "${DRY_RUN}" == "true" ]]; then
  echo "(--dry-run: no changes made)"
  exit 0
fi

# ---------------------------------------------------------------------------
# Execute: setup workspace then assign issue for each slot
# ---------------------------------------------------------------------------
SETUP_SCRIPT="${SCRIPT_DIR}/workspace-setup.sh"
ASSIGN_SCRIPT="${SCRIPT_DIR}/workspace-assign.sh"

for script in "${SETUP_SCRIPT}" "${ASSIGN_SCRIPT}"; do
  if [[ ! -x "${script}" ]]; then
    echo "Error: ${script} not found or not executable" >&2
    exit 1
  fi
done

SUCCESS=0
FAILED=0

for i in "${!PLAN_ISSUE_NUMBERS[@]}"; do
  vm_ip="${PLAN_VM_IPS[$i]}"
  agent_index="${PLAN_AGENT_INDEXES[$i]}"
  issue_num="${PLAN_ISSUE_NUMBERS[$i]}"
  title="${ISSUE_TITLES[$issue_num]:-issue-${issue_num}}"

  echo "==> [${i}] Setting up ${vm_ip} agent-${agent_index} for issue #${issue_num}"

  setup_args=("${vm_ip}" "${agent_index}" "${REPO_URL}")
  if [[ -n "${BRANCH}" ]]; then
    setup_args+=(--branch "${BRANCH}")
  fi

  if "${SETUP_SCRIPT}" "${setup_args[@]}"; then
    if "${ASSIGN_SCRIPT}" "${vm_ip}" "${agent_index}" "${issue_num}" "${title}" \
        --repo "${REPO}" --max-turns "${MAX_TURNS}"; then
      SUCCESS=$(( SUCCESS + 1 ))
    else
      echo "Error: workspace-assign.sh failed for issue #${issue_num} on ${vm_ip} agent-${agent_index}" >&2
      FAILED=$(( FAILED + 1 ))
    fi
  else
    echo "Error: workspace-setup.sh failed for ${vm_ip} agent-${agent_index}" >&2
    FAILED=$(( FAILED + 1 ))
  fi
done

echo ""
echo "==> Setup complete: ${SUCCESS} succeeded, ${FAILED} failed"
[[ ${FAILED} -eq 0 ]]

lib/workspace-setup.sh

#!/usr/bin/env bash
# workspace-setup.sh — Set up an agent workspace on a Hetzner Cloud VM.
#
# Usage:
#   workspace-setup.sh <vm-ip> <agent-index> <repo-url> [--branch BRANCH]
#
# Arguments:
#   vm-ip         Public IP of the Hetzner Cloud VM
#   agent-index   0-3, selects agent-N user on the VM
#   repo-url      HTTPS URL of the target repo (e.g. https://github.com/owner/repo)
#   --branch      Optional branch to check out after clone (default: main)
#
# Requires:
#   - SSH access to the VM as root (key in ~/.ssh/id_ed25519 or SSH_KEY_PATH)
#   - /run/secrets/agent-N/github_token populated on the VM before this runs
set -euo pipefail

# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [[ $# -lt 3 ]]; then
  echo "Usage: $0 <vm-ip> <agent-index> <repo-url> [--branch BRANCH]" >&2
  exit 1
fi

VM_IP="$1"
AGENT_INDEX="$2"
REPO_URL="$3"
shift 3

BRANCH=""
while [[ $# -gt 0 ]]; do
  case "$1" in
    --branch)
      BRANCH="$2"
      shift 2
      ;;
    *)
      echo "Unknown argument: $1" >&2
      exit 1
      ;;
  esac
done

# Validate agent index
if ! [[ "$AGENT_INDEX" =~ ^[0-3]$ ]]; then
  echo "Error: agent-index must be 0, 1, 2, or 3 (got: $AGENT_INDEX)" >&2
  exit 1
fi

AGENT_USER="agent-${AGENT_INDEX}"
AGENT_HOME="/home/${AGENT_USER}"
WORKSPACE_DIR="${AGENT_HOME}/workspace"
SECRETS_DIR="/run/secrets/${AGENT_USER}"
SSH_KEY="${SSH_KEY_PATH:-${HOME}/.ssh/id_ed25519}"

# Extract repo name from URL (strip .git suffix if present)
REPO_NAME=$(basename "${REPO_URL}" .git)

# ---------------------------------------------------------------------------
# Helper: run a command on the VM as root
# ---------------------------------------------------------------------------
ssh_root() {
  ssh -i "${SSH_KEY}" \
      -o StrictHostKeyChecking=accept-new \
      -o BatchMode=yes \
      "root@${VM_IP}" "$@"
}

# ---------------------------------------------------------------------------
# Helper: run a command on the VM as agent-N (via root su)
# ---------------------------------------------------------------------------
ssh_agent() {
  local cmd="$1"
  ssh_root "su - ${AGENT_USER} -c $(printf '%q' "$cmd")"
}

echo "==> Setting up workspace for ${AGENT_USER} on ${VM_IP}"

# ---------------------------------------------------------------------------
# Step 1: Create workspace directory
# ---------------------------------------------------------------------------
echo "--> Creating workspace directory"
ssh_root "mkdir -p '${WORKSPACE_DIR}' && chown '${AGENT_USER}:${AGENT_USER}' '${WORKSPACE_DIR}'"

# ---------------------------------------------------------------------------
# Step 2: Clone the repo using the agent's GitHub token
# ---------------------------------------------------------------------------
echo "--> Cloning ${REPO_URL}"
CLONE_DIR="${WORKSPACE_DIR}/${REPO_NAME}"

# Build a token-authenticated URL from the plain HTTPS URL.
# The token is read on the VM from /run/secrets/agent-N/github_token — it
# never appears in the SSH command itself or in local shell variables.
CLONE_SCRIPT=$(cat <<'SCRIPT'
set -euo pipefail
AGENT_USER="__AGENT_USER__"
REPO_URL="__REPO_URL__"
CLONE_DIR="__CLONE_DIR__"
SECRETS_DIR="__SECRETS_DIR__"

TOKEN_FILE="${SECRETS_DIR}/github_token"
if [[ ! -f "${TOKEN_FILE}" ]]; then
  echo "Error: token file not found at ${TOKEN_FILE}" >&2
  exit 1
fi
GIT_TOKEN=$(cat "${TOKEN_FILE}")

# Inject token into HTTPS URL: https://<token>@github.com/owner/repo
AUTH_URL="${REPO_URL/https:\/\//https://${GIT_TOKEN}@}"

if [[ -d "${CLONE_DIR}/.git" ]]; then
  echo "Repo already cloned at ${CLONE_DIR}, skipping"
else
  git clone "${AUTH_URL}" "${CLONE_DIR}"
fi
SCRIPT
)

CLONE_SCRIPT="${CLONE_SCRIPT//__AGENT_USER__/$AGENT_USER}"
CLONE_SCRIPT="${CLONE_SCRIPT//__REPO_URL__/$REPO_URL}"
CLONE_SCRIPT="${CLONE_SCRIPT//__CLONE_DIR__/$CLONE_DIR}"
CLONE_SCRIPT="${CLONE_SCRIPT//__SECRETS_DIR__/$SECRETS_DIR}"

ssh_root "su - ${AGENT_USER} -c 'bash -s'" <<< "${CLONE_SCRIPT}"

# ---------------------------------------------------------------------------
# Step 3: Configure git identity for the agent
# ---------------------------------------------------------------------------
echo "--> Configuring git identity"
ssh_agent "git -C '${CLONE_DIR}' config user.name 'CCGM Agent ${AGENT_INDEX}'"
ssh_agent "git -C '${CLONE_DIR}' config user.email 'ccgm-agent-${AGENT_INDEX}@dispatch.local'"

# ---------------------------------------------------------------------------
# Step 4: Checkout branch (if specified)
# ---------------------------------------------------------------------------
if [[ -n "${BRANCH}" ]]; then
  echo "--> Checking out branch: ${BRANCH}"
  ssh_agent "git -C '${CLONE_DIR}' checkout '${BRANCH}'"
fi

# ---------------------------------------------------------------------------
# Step 5: Set up CCGM config (Claude Code settings for headless mode)
# ---------------------------------------------------------------------------
echo "--> Writing Claude Code settings"
CLAUDE_DIR="${AGENT_HOME}/.claude"
ssh_root "mkdir -p '${CLAUDE_DIR}' && chown '${AGENT_USER}:${AGENT_USER}' '${CLAUDE_DIR}'"

# Write settings.json enabling dangerously-skip-permissions for headless runs.
# See: https://docs.anthropic.com/en/docs/claude-code/settings
SETTINGS_JSON='{
  "permissions": {
    "allow": ["Bash", "Read", "Write", "Edit", "Glob", "Grep"],
    "deny": []
  },
  "dangerouslySkipPermissions": true
}'

ssh_root "printf '%s\n' '${SETTINGS_JSON}' > '${CLAUDE_DIR}/settings.json' && chown '${AGENT_USER}:${AGENT_USER}' '${CLAUDE_DIR}/settings.json'"

# Refresh CCGM installation from the repo on the VM.
# The golden image pre-installs CCGM, but this ensures rules are current
# if the image is stale. Also re-runs headless install to pick up any new modules.
HEADLESS_INSTALLER="/opt/ccgm/repo/modules/cloud-dispatch/lib/ccgm-headless-install.sh"
ssh_root "
if [[ -x '${HEADLESS_INSTALLER}' ]]; then
  echo 'Refreshing CCGM cloud-agent preset for ${AGENT_USER}'
  bash '${HEADLESS_INSTALLER}' /opt/ccgm/repo cloud-agent '${AGENT_HOME}'
  chown -R '${AGENT_USER}:${AGENT_USER}' '${CLAUDE_DIR}'
else
  echo 'WARN: CCGM headless installer not found at ${HEADLESS_INSTALLER}'
  echo 'Agents will run without CCGM rules. Rebuild golden image to fix.'
fi
"

# ---------------------------------------------------------------------------
# Step 6: Verify
# ---------------------------------------------------------------------------
echo "--> Verifying workspace"
ssh_agent "git -C '${CLONE_DIR}' status"
ssh_agent "git -C '${CLONE_DIR}' config user.email"

echo "==> Workspace setup complete: ${AGENT_USER}@${VM_IP} -> ${CLONE_DIR}"