Skill: lludlow/clawctl (version 0.1.0)

--- name: clawctl description: Coordination layer for OpenClaw agent fleets (tasks, messaging, activity feed, dashboard). metadata: {"openclaw":{"emoji":"🛰️","requires":{"bins":["clawctl"]}}} --- # Setup ```bash clawctl init # create the database export CLAW_AGENT=your-name # set identity (falls back to $USER with warning) export CLAW_DB=~/.openclaw/clawctl.db # optional, this is the default ``` # Operational Rhythm Follow this pattern every session: 1. `clawctl checkin` — register presence, see unread count 2. `clawctl inbox --unread` — read messages before picking up work 3. `clawctl next` — find highest-priority actionable task (or `clawctl list --mine`) 4. `clawctl claim <id>` then `clawctl start <id>` — take ownership and begin 5. `clawctl msg <agent> "update" --task <id>` — coordinate during work 6. `clawctl done <id> -m "what I did"` then `clawctl next` — complete and move on Only claim tasks assigned to you or matching your role. Completing an already-done task is a safe no-op. # Decision Tree | Situation | Command | |-----------|---------| | New task | `clawctl add "Subject" -d "Details"` | | Find work | `clawctl next` then `clawctl claim <id>` | | Blocked | `clawctl block <id> --by <blocker-id>` and notify via `clawctl msg` | | Finished | `clawctl done <id> -m "Result"` | | Hand off | `clawctl msg <agent> "Ready for you" --task <id> --type handoff` | | Ready for review | `clawctl review <id>` | | Catch up | `clawctl feed --last 20` or `clawctl summary` | | Link artifacts | Add `--meta '{"note":"path/to/file"}'` to `done`, `claim`, `start`, or `block` | # Task Statuses ``` pending → claimed → in_progress → done ↘ blocked ↗ ↘ cancelled ↘ review ↗ ``` `list` excludes done/cancelled by default. Use `--all` for history (newest first). # Commands ## Tasks | Command | Description | |---------|-------------| | `add SUBJECT` | Create task. `-d` desc, `-p 0\|1\|2` priority, `--for AGENT` assign, `--parent ID` | | `list` | Active tasks. `--mine`, `--status STATUS`, `--owner AGENT`, `--all` | | `next` | Highest-priority actionable task for current agent | | `claim ID` | Claim task. `--force` overrides ownership, `--meta JSON` | | `start ID` | Begin work (in_progress). `--meta JSON` | | `done ID` | Complete. `-m` note, `--force`, `--meta JSON` | | `review ID` | Mark ready for review. `--meta JSON` | | `cancel ID` | Cancel task. `--meta JSON` | | `block ID --by OTHER` | Mark blocked. `--meta JSON` | | `board` | Kanban board grouped by status | ## Messages | Command | Description | |---------|-------------| | `msg AGENT BODY` | Send message. `--task ID`, `--type TYPE` (comment, status, handoff, question, answer, alert) | | `broadcast BODY` | Alert all agents | | `inbox` | Read messages. `--unread` for unread only | ## Fleet | Command | Description | |---------|-------------| | `checkin` | Heartbeat — update presence, report unread count | | `register NAME` | Register agent. `--role TEXT` | | `fleet` | All agents with status and current task | | `whoami` | Identity, role, and DB path | ## Monitoring | Command | Description | |---------|-------------| | `feed` | Activity log. `--last N`, `--agent NAME`, `--meta` | | `summary` | Fleet overview with counts and recent events | | `dashboard` | Web UI. `--port INT`, `--stop`, `--verbose` |

path: CLAUDE.md
content: # CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

`clawctl` — coordination layer for OpenClaw agent fleets. Task board, messaging, activity feed via CLI. Python package with Click, backed by SQLite (WAL mode).

## Development Setup

```bash
python3 -m venv .venv
.venv/bin/pip install -e .
```

The `clawctl` entrypoint is registered via `pyproject.toml` `[project.scripts]`. After install, use `.venv/bin/clawctl` or activate the venv.

## Testing

No test framework yet. Manual verification against a temp database:

```bash
CLAW_DB=/tmp/test.db .venv/bin/clawctl init
CLAW_DB=/tmp/test.db .venv/bin/clawctl add "Test task" -p 1
CLAW_DB=/tmp/test.db CLAW_AGENT=agent1 .venv/bin/clawctl claim 1
CLAW_DB=/tmp/test.db .venv/bin/clawctl board
rm -f /tmp/test.db
```

Always use `CLAW_DB=/tmp/test.db` to avoid touching the real database at `~/.openclaw/clawctl.db`.

## Architecture

Two separate layers share `clawctl.db` — the CLI writes directly to SQLite, Flask is a read-mostly viewer:

- **`clawctl/db.py`** — All SQL lives here. Every query uses `?` parameterized placeholders. Mutating functions return `(ok: bool, payload)` tuples. The `get_db()` context manager handles commit/rollback/close. This module is imported by both the CLI and the Flask server.
- **`clawctl/cli.py`** — Click commands. Each subcommand is a thin wrapper that calls `db.*` functions and formats output. The `print_columnar()` helper handles aligned table output with Unicode-aware width calculation.
- **`clawctl/schema.sql`** — Loaded by `db.init_db()` via `Path(__file__).parent`. Uses `CREATE TABLE IF NOT EXISTS` so re-running init is safe.
- **`dashboard/server.py`** — Flask app that imports `clawctl.db`. Read-only except for claim/complete endpoints. Persistent auth token saved to `~/.openclaw/.clawctl-token`. Not part of the installable package.
- **`dashboard/index.html`** — Single-file vanilla JS web UI. Tailwind via CDN. SSE for live updates.

## Key Design Decisions

**Race safety**: `claim_task()` and `complete_task()` use atomic single-UPDATE patterns with `WHERE` guards and `rowcount` checks — no read-then-write races. This matters because multiple agents hit the same DB concurrently.

**Blocking is normalized**: Task dependencies live in the `task_deps` join table (not JSON columns). The `UNIQUE(task_id, blocked_by)` constraint prevents duplicate deps.

**`list` excludes done/cancelled by default** and sorts active work by status priority. `--all` flips to newest-first for history browsing.

**`CLAW_AGENT` fallback**: Falls back to `$USER` silently in `db.py` but `cli.py` warns once via `_warn_agent_fallback()` on identity-sensitive commands.

**Activity metadata**: Mutating commands accept `--meta` (JSON string) stored in `activity.meta`. Shown in feed with `--meta` flag.

## Environment Variables

- `CLAW_DB` — Database path (default: `~/.openclaw/clawctl.db`)
- `CLAW_AGENT` — Agent identity (default: `$USER`)

## Conventions

- SQL goes in `db.py`, never in `cli.py` or `server.py`
- All new SQL must use `?` parameterized queries — no string interpolation
- `db.py` functions accept a `conn` parameter (from `get_db()` context manager), not a path
- CLI functions call `sys.exit(1)` on conflicts/not-found, exit 0 on success including idempotent no-ops


path: README.md
content: # clawctl

Coordination layer for [OpenClaw](https://github.com/openclaw/openclaw) agent fleets. Task board, inter-agent messaging, activity feed, and a live web dashboard — all backed by a single SQLite database.

```
$ clawctl board

═══ CLAWCTL ═══  agent: chat

── ○ pending (2) ──
  #4 Summarize weekly spending from transaction exports
  #6 Find showtimes for new releases this weekend [movie]

── ▶ in_progress (2) ──
  #1 Research best noise-cancelling headphones under $300 [research]
  #5 Write a Python script to rename photos by EXIF date [coding]

── ✗ blocked (1) ──
  #3 File notes from the headphone research [notes]

── ✓ done (1) ──
  #2 Check portfolio risk exposure for earnings week [trading]
```

## Why this exists

Multiple agents working in parallel need a shared source of truth. Without one, you get duplicate work, missed handoffs, and no audit trail.

clawctl is the answer for OpenClaw fleets:

- **Zero infrastructure.** Local SQLite in WAL mode. No cloud, no signup, no build step.
- **SSH-queryable.** `ssh your-vps clawctl board` works out of the box.
- **Race-safe.** Atomic claims and completions via single-UPDATE patterns with WHERE guards. No read-then-write races.
- **Auditable.** Every mutation hits an append-only activity log with optional JSON metadata for linking PRs, issues, test results.
- **OpenClaw-native.** Install as a skill, drop into any agent's workflow.

## Install

```bash
# As an OpenClaw skill (coming soon)
# openclaw skills install clawctl

# Standalone
git clone https://github.com/lludlow/clawctl.git
cd clawctl
pip install .

# For development
pip install -e .
```

**Requirements:** Python >= 3.9, Click, Flask

## Quick start

```bash
# Initialize
clawctl init

# Register the fleet
clawctl register chat --role "everyday triage & delegation"
clawctl register research --role "deep reasoning & web search"
clawctl register coding --role "sandboxed code execution"
clawctl register notes --role "knowledge graph & note-taking"
clawctl register trading --role "read-only market analysis"
clawctl register family --role "mention-gated secure responder"
clawctl register movie --role "watchlists & recommendations"

# Chat agent delegates work to specialists
CLAW_AGENT=chat clawctl add "Research best noise-cancelling headphones under $300" --for research
CLAW_AGENT=chat clawctl add "Write a Python script to rename photos by EXIF date" --for coding -p 1
CLAW_AGENT=chat clawctl add "File notes from the headphone research" --for notes

# Research agent picks up its task
CLAW_AGENT=research clawctl claim 1
CLAW_AGENT=research clawctl start 1
CLAW_AGENT=research clawctl done 1 -m "Top 3 picks with comparison table" \
  --meta '{"note":"~/notes/headphone-research.md"}'

# Research hands off to notes for filing
CLAW_AGENT=research clawctl msg notes "Research complete, ready to file" --task 3

# Monitor
clawctl board
clawctl fleet
clawctl feed --last 10
```

## Agent integration

The typical agent loop:

```bash
# On startup — check messages, find work
CLAW_AGENT=coding clawctl checkin
CLAW_AGENT=coding clawctl inbox --unread
CLAW_AGENT=coding clawctl next

# Do the work, then close out
CLAW_AGENT=coding clawctl done <id> -m "Script written and tested" \
  --meta '{"script":"~/scripts/rename-photos.py","tests":"passed"}'
```

Add a heartbeat to each agent's cron:

```
*/10 * * * * CLAW_AGENT=chat clawctl checkin
*/10 * * * * CLAW_AGENT=research clawctl checkin
*/10 * * * * CLAW_AGENT=coding clawctl checkin
```

Add to each agent's system prompt or AGENTS.md:

```
Before starting work: clawctl inbox --unread && clawctl list --mine
After completing work: clawctl done <id> -m "what I did"
Only claim tasks assigned to you or matching your role.
```

> If `CLAW_AGENT` is not set, clawctl falls back to `$USER` and prints a one-time warning on identity-sensitive commands.

## Commands

### Tasks

| Command | Description |
|---------|-------------|
| `add SUBJECT` | Create a task. Options: `-d` description, `-p 0\|1\|2` priority, `--for AGENT` pre-assign, `--parent ID` subtask |
| `list` | List active tasks. Options: `--mine`, `--status STATUS`, `--owner AGENT`, `--all` (include done/cancelled) |
| `next` | Show the highest-priority actionable task for the current agent |
| `claim ID` | Claim a task. Options: `--force` to override, `--meta JSON` |
| `start ID` | Begin work (transitions to in_progress). Options: `--meta JSON` |
| `done ID` | Complete a task. Options: `-m` note, `--force`, `--meta JSON` |
| `review ID` | Mark task as ready for review. Options: `--meta JSON` |
| `cancel ID` | Cancel a task. Options: `--meta JSON` |
| `block ID --by OTHER` | Mark task as blocked. Options: `--meta JSON` |
| `board` | Kanban board view grouped by status |

### Messages

| Command | Description |
|---------|-------------|
| `msg AGENT BODY` | Send a message. Options: `--task ID`, `--type TYPE` |
| `broadcast BODY` | Message all agents (type: alert) |
| `inbox` | Read messages. Options: `--unread` |

### Fleet

| Command | Description |
|---------|-------------|
| `register NAME` | Register an agent. Options: `--role TEXT` |
| `checkin` | Heartbeat — update presence, check for unread |
| `fleet` | Show all agents with status and current task |
| `whoami` | Show identity, role, and DB path |

### Monitoring

| Command | Description |
|---------|-------------|
| `feed` | Activity log. Options: `--last N`, `--agent NAME`, `--meta` |
| `summary` | Fleet overview with counts and recent events |

### Dashboard

| Command | Description |
|---------|-------------|
| `dashboard` | Start the web UI. Options: `--port INT` (default: 3737), `--verbose` |
| `dashboard --stop` | Stop the running dashboard |

## Task statuses

```
pending ─→ claimed ─→ in_progress ─→ done
                    ↘ blocked ↗     ↘ cancelled
                    ↘ review  ↗
```

`list` excludes done/cancelled by default and sorts by status priority (in_progress > claimed > blocked > review > pending), oldest first. `--all` flips to newest-first for history browsing.

## Activity metadata

Mutating commands (`claim`, `start`, `done`, `block`) accept `--meta` with a JSON string stored in the activity log. Use it to link back to external artifacts:

```bash
clawctl claim 1 --meta '{"source":"whatsapp","channel":"general"}'
clawctl done 1 -m "Top 3 picks with pros/cons" --meta '{"note":"~/notes/headphone-research.md"}'

# Review what happened overnight
clawctl feed --last 50 --agent research --meta
```

## Web dashboard

A live web UI served by Flask with token authentication and SSE for real-time updates.

```bash
clawctl dashboard
# Opens at http://localhost:3737/?[REDACTED_SECRET]
```

Features:
- Live board with SSE push updates (no polling)
- Task detail view with messages and metadata grid
- Complete and delete actions from the UI
- Terminal/hacker aesthetic with optional CRT effects
- Keyboard accessible (Esc to close, Tab trapping in modals)
- Works on narrow viewports
- Token persisted at `~/.openclaw/.clawctl-token` across restarts

The dashboard is read-mostly — it shares the same SQLite database the CLI writes to. The CLI is the primary interface; the dashboard is for monitoring.

## Architecture

```
┌─────────────────────────────┐
│  clawctl CLI (Python/Click) │ ← Every agent calls this
├─────────────────────────────┤
│  db.py — all SQL lives here │ ← Shared by CLI + Flask
├─────────────────────────────┤
│  SQLite (WAL mode)          │ ← ~/.openclaw/clawctl.db
├─────────────────────────────┤
│  5 tables + indexes:        │
│  tasks          task_deps   │ ← Board + blocking graph
│  messages       agents      │ ← Comms + fleet registry
│  activity                   │ ← Append-only audit log
├─────────────────────────────┤
│  Flask dashboard (optional) │ ← dashboard/server.py
└─────────────────────────────┘
```

**Key design decisions:**

- **All SQL in `db.py`.** The CLI and Flask server import it. No queries in `cli.py` or `server.py`.
- **Race safety.** `claim_task()` and `complete_task()` use atomic single-UPDATE with WHERE guards and rowcount checks. No read-then-write.
- **Normalized blocking.** Dependencies live in the `task_deps` join table with a UNIQUE constraint. Not JSON columns.
- **Parameterized queries.** Every query uses `?` placeholders. No string interpolation.
- **Idempotent completions.** `done` on an already-done task is a safe no-op.

## Environment variables

| Variable | Default | Description |
|----------|---------|-------------|
| `CLAW_AGENT` | `$USER` (with warning) | Agent identity for all commands |
| `CLAW_DB` | `~/.openclaw/clawctl.db` | Database file path |

## License

MIT


path: clawctl/__init__.py
content: """clawctl — Coordination layer for OpenClaw agent fleets"""
__version__ = "0.2.0"


path: clawctl/cli.py
content: """clawctl — Click-based command interface for OpenClaw agent fleets."""

import json
import os
import signal
import subprocess
import sys
import unicodedata
from pathlib import Path

import click

from clawctl import db

# ANSI colors
R = "\033[0;31m"
G = "\033[0;32m"
Y = "\033[1;33m"
C = "\033[0;36m"
B = "\033[1m"
N = "\033[0m"

_agent_warned = False


def _warn_agent_fallback():
    """Warn once if CLAW_AGENT wasn't explicitly set."""
    global _agent_warned
    if not db.AGENT_EXPLICIT and not _agent_warned:
        click.echo(
            f"{Y}Note: CLAW_AGENT not set, using $USER ({db.AGENT}){N}", err=True
        )
        _agent_warned = True


def _char_width(ch):
    """Return display width of a character (2 for wide/fullwidth, 1 otherwise)."""
    w = unicodedata.east_asian_width(ch)
    return 2 if w in ("F", "W") else 1


def _str_width(s):
    """Return display width of a string, accounting for multi-byte characters."""
    return sum(_char_width(ch) for ch in s)


def print_columnar(rows, columns):
    """Print rows as aligned columns. columns is a list of (header, key) tuples."""
    if not rows:
        return
    # Convert sqlite3.Row to dicts
    data = [dict(r) for r in rows]
    # Calculate widths
    widths = {}
    for header, key in columns:
        vals = [str(v) if (v := d.get(key)) is not None else "" for d in data]
        widths[key] = max(
            _str_width(header), max((_str_width(v) for v in vals), default=0)
        )
    # Print header
    hdr = "  ".join(h.ljust(widths[k]) for h, k in columns)
    click.echo(hdr)
    click.echo("  ".join("-" * widths[k] for _, k in columns))
    # Print rows
    for d in data:
        parts = []
        for _, k in columns:
            val = str(v) if (v := d.get(k)) is not None else ""
            pad = widths[k] - _str_width(val)
            parts.append(val + " " * pad)
        click.echo("  ".join(parts))


@click.group(invoke_without_command=True)
@click.pass_context
def cli(ctx):
    """clawctl — Coordination layer for OpenClaw agent fleets"""
    if ctx.invoked_subcommand is None:
        click.echo(ctx.get_help())
        return
    if ctx.invoked_subcommand not in ("init", "help"):
        if not os.path.exists(db.DB_PATH):
            click.echo(f"{Y}No database found. Run: clawctl init{N}", err=True)
            ctx.exit(1)


@cli.command()
def init():
    """Initialize the database"""
    db.init_db()
    click.echo(f"{G}Initialized{N} {db.DB_PATH}")


@cli.command()
@click.argument("name")
@click.option("--role", default="", help="Agent role")
def register(name, role):
    """Register an agent"""
    with db.get_db() as conn:
        db.register_agent(conn, name, role)
    click.echo(f"{G}Registered{N} {name}{f' as {role}' if role else ''}")


@cli.command()
def checkin():
    """Heartbeat — register presence"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        db.checkin_agent(conn, agent)
        unread = db.get_unread_count(conn, agent)
    if unread > 0:
        click.echo(f"{Y}{unread} unread messages{N} — run: clawctl inbox --unread")
    else:
        click.echo(f"{G}HEARTBEAT_OK{N} ({agent})")


@cli.command()
@click.argument("subject")
@click.option("-d", "--desc", default="", help="Description")
@click.option("-p", "--priority", type=int, default=0, help="Priority (0, 1, 2)")
@click.option("--for", "assignee", default="", help="Assign to agent")
@click.option("--parent", type=int, default=None, help="Parent task ID")
def add(subject, desc, priority, assignee, parent):
    """Create a task"""
    with db.get_db() as conn:
        ok, task_id = db.add_task(
            conn, subject, desc, priority, assignee, db.AGENT, parent
        )
    click.echo(f"{G}#{task_id}{N} {subject}{f' → {assignee}' if assignee else ''}")


@cli.command("list")
@click.option("--status", default=None, help="Filter by status")
@click.option("--owner", default=None, help="Filter by owner")
@click.option("--mine", is_flag=True, help="Show only my tasks")
@click.option("--all", "include_all", is_flag=True, help="Include done/cancelled")
def list_cmd(status, owner, mine, include_all):
    """List tasks (excludes done/cancelled by default)"""
    if mine:
        _warn_agent_fallback()
    with db.get_db() as conn:
        rows = db.list_tasks(
            conn, status, owner, db.AGENT if mine else None, include_all
        )
    if not rows:
        click.echo("No tasks found.")
        return
    print_columnar(
        rows,
        [
            ("ID", "id"),
            ("Subject", "subject"),
            ("Status", "icon"),
            ("Owner", "owner"),
            ("Pri", "pri"),
        ],
    )
    scope = "mine" if mine else "all agents"
    if not include_all and not status:
        click.echo(
            f"\n{len(rows)} active ({scope}). Use --all to include done/cancelled."
        )


@cli.command("next")
def next_cmd():
    """Show the next task to work on (highest priority, actionable)"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        row = db.get_next_task(conn, agent)
    if not row:
        click.echo("No actionable tasks.")
        return
    pri = "!!!" if row["priority"] == 2 else "!" if row["priority"] == 1 else ""
    click.echo(
        f"#{row['id']} {row['subject']} [{row['status']}]{f' {pri}' if pri else ''}"
    )


@cli.command()
@click.argument("id", type=int)
@click.option("--force", is_flag=True, help="Force claim even if owned by another")
@click.option("--meta", default=None, help="JSON metadata blob for activity log")
def claim(id, force, meta):
    """Claim a task"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        ok, err = db.claim_task(conn, id, agent, force, meta=meta)
    if ok:
        click.echo(f"{G}Claimed #{id}{N}")
    else:
        click.echo(f"{R}{err}{N}", err=True)
        sys.exit(1)


@cli.command()
@click.argument("id", type=int)
@click.option("--meta", default=None, help="JSON metadata blob for activity log")
def start(id, meta):
    """Begin work on a task"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        ok, err = db.start_task(conn, id, agent, meta=meta)
    if ok:
        click.echo(f"{C}▶ Working on #{id}{N}")
    else:
        click.echo(f"{R}{err}{N}", err=True)
        sys.exit(1)


@cli.command()
@click.argument("id", type=int)
@click.option("-m", "--message", "note", default="", help="Completion note")
@click.option("--force", is_flag=True, help="Complete even if not the owner")
@click.option("--meta", default=None, help="JSON metadata blob for activity log")
def done(id, note, force, meta):
    """Complete a task"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        ok, info = db.complete_task(conn, id, agent, note, meta=meta, force=force)
    if not ok:
        click.echo(f"{R}{info}{N}", err=True)
        sys.exit(1)
    if info == "already done":
        click.echo(f"{Y}#{id} already done{N}")
    else:
        msg = f"{G}✓ Done #{id}{N}"
        if note:
            msg += f" — {note}"
        click.echo(msg)


@cli.command()
@click.argument("id", type=int)
@click.option("--meta", default=None, help="JSON metadata blob for activity log")
def review(id, meta):
    """Mark a task as ready for review"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        ok, err = db.review_task(conn, id, agent, meta=meta)
    if ok:
        click.echo(f"{C}⟳ #{id} ready for review{N}")
    else:
        click.echo(f"{R}{err}{N}", err=True)
        sys.exit(1)


@cli.command()
@click.argument("id", type=int)
@click.option("--meta", default=None, help="JSON metadata blob for activity log")
def cancel(id, meta):
    """Cancel a task"""
    _warn_agent_fallback()
    agent = db.AGENT
    with db.get_db() as conn:
        ok, info = db.cancel_task(conn, id, agent, meta=meta)
    if not ok:
        click.echo(f"{R}{info}{N}", err=True)
        sys.exit(1)
    if info and info.startswith("already"):
        click.echo(f"{Y}#{id} {info}{N}")
    else:
        click.echo(f"{Y}✗ Cancelled #{id}{N}")


@cli.command()
@click.argument("id", type=int)
@click.option("--by", "blocked_by", type=int, required=True, help="Blocking task ID")
@click.option("--meta", default=None, help="JSON metadata blob for activity log")
def block(id, blocked_by, meta):
    """Mark a task as blocked by another task"""
    with db.get_db() as conn:
        ok, err = db.block_task(conn, id, blocked_by, meta=meta)
    if ok:
        click.echo(f"{R}✗ #{id} blocked by #{blocked_by}{N}")
    else:
        click.echo(f"{R}{err}{N}", err=True)
        sys.exit(1)


@cli.command()
def board():
    """Kanban board view"""
    agent = db.AGENT
    click.echo(f"{B}═══ CLAWCTL ═══{N}  agent: {C}{agent}{N}")
    click.echo()
    icons = {
        "pending": "○",
        "claimed": "◉",
        "in_progress": "▶",
        "review": "⟳",
        "blocked": "✗",
        "done": "✓",
    }
    with db.get_db() as conn:
        board_data = db.get_board(conn)
    for status in ("pending", "claimed", "in_progress", "review", "blocked", "done"):
        if status not in board_data:
            continue
        info = board_data[status]
        icon = icons[status]
        click.echo(f"{B}── {icon} {status} ({info['count']}) ──{N}")
        for t in info["tasks"]:
            owner_str = f" [{t['owner']}]" if t["owner"] else ""
            click.echo(f"  #{t['id']} {t['subject']}{owner_str}")
        click.echo()


@cli.command()
@click.argument("to")
@click.argument("body")
@click.option("--task", "task_id", type=int, default=None, help="Related task ID")
@click.option("--type", "msg_type", default="comment", help="Message type")
def msg(to, body, task_id, msg_type):
    """Send a message to an agent"""
    with db.get_db() as conn:
        db.send_message(conn, db.AGENT, to, body, task_id, msg_type)
    click.echo(f"{G}→ {to}{N}: {body}")


@cli.command()
@click.argument("body")
def broadcast(body):
    """Broadcast a message to all agents"""
    with db.get_db() as conn:
        db.broadcast(conn, db.AGENT, body)
    click.echo(f"{Y}Broadcast:{N} {body}")


@cli.command()
@click.option("--unread", is_flag=True, help="Show only unread messages")
def inbox(unread):
    """Read messages"""
    agent = db.AGENT
    with db.get_db() as conn:
        rows = db.get_inbox(conn, agent, unread)
        if rows:
            displayed_ids = [row["id"] for row in rows]
            db.mark_messages_read(conn, agent, displayed_ids)
    if not rows:
        click.echo("No messages.")
        return
    print_columnar(
        rows,
        [
            ("ID", "id"),
            ("From", "from_agent"),
            ("Body", "body"),
            ("Type", "msg_type"),
            ("New", "new"),
            ("At", "at"),
        ],
    )


@cli.command()
def fleet():
    """Show fleet status"""
    click.echo(f"{B}═══ FLEET STATUS ═══{N}")
    with db.get_db() as conn:
        rows = db.get_fleet(conn)
    if not rows:
        click.echo("No agents registered.")
        return
    print_columnar(
        rows,
        [
            ("Name", "name"),
            ("Role", "role"),
            ("Status", "status"),
            ("Working On", "working_on"),
            ("Last Seen", "last_seen"),
        ],
    )


@cli.command()
@click.option("--last", "limit", type=int, default=20, help="Number of entries")
@click.option("--agent", "agent_filter", default=None, help="Filter by agent")
@click.option("--meta", "show_meta", is_flag=True, help="Show metadata column")
def feed(limit, agent_filter, show_meta):
    """Activity log"""
    with db.get_db() as conn:
        rows = db.get_feed(conn, limit, agent_filter)
    if not rows:
        click.echo("No activity.")
        return
    cols = [
        ("At", "at"),
        ("Agent", "agent"),
        ("Action", "action"),
        ("Detail", "detail"),
    ]
    if show_meta:
        cols.append(("Meta", "meta"))
    print_columnar(rows, cols)


@cli.command()
def summary():
    """Fleet summary"""
    with db.get_db() as conn:
        data = db.get_summary(conn)
    click.echo(f"{B}═══ SUMMARY ═══{N}")
    click.echo()
    click.echo(f"{C}Fleet:{N}")
    for a in data["agents"]:
        role_str = f" — {a['role']}" if a["role"] else ""
        click.echo(f"  {a['name']} ({a['status']}){role_str}")
    click.echo()
    click.echo(f"{C}Open tasks:{N} {data['open']}")
    click.echo(f"{C}In progress:{N} {data['in_progress']}")
    click.echo(f"{C}Blocked:{N} {data['blocked']}")
    click.echo()
    click.echo(f"{C}Last 5 events:{N}")
    for e in data["recent"]:
        detail_str = f" — {e['detail']}" if e["detail"] else ""
        click.echo(f"  [{e['at']}] {e['agent']}: {e['action']}{detail_str}")


@cli.command()
def whoami():
    """Show agent identity"""
    agent = db.AGENT
    click.echo(f"Agent: {C}{agent}{N}")
    click.echo(f"DB:    {db.DB_PATH}")
    with db.get_db() as conn:
        role = db.get_agent_info(conn, agent)
    click.echo(f"Role:  {role}")


@cli.command()
@click.option("--port", default=3737, help="Port to run on")
@click.option("--stop", is_flag=True, help="Stop the running dashboard")
@click.option(
    "--verbose", is_flag=True, help="Log dashboard output to ~/.openclaw/dashboard.log"
)
def dashboard(port, stop, verbose):
    """Start (or stop) the web dashboard"""
    pid_file = Path(db.DB_PATH).parent / ".dashboard.pid"

    if stop:
        if not pid_file.exists():
            click.echo("No dashboard running.")
            return
        pid = int(pid_file.read_text().strip())
        try:
            os.kill(pid, signal.SIGTERM)
            click.echo(f"{G}Dashboard stopped{N} (pid {pid})")
        except ProcessLookupError:
            click.echo(f"{Y}Dashboard was not running{N} (stale pid {pid})")
        pid_file.unlink(missing_ok=True)
        return

    if pid_file.exists():
        pid = int(pid_file.read_text().strip())
        try:
            os.kill(pid, 0)
            from dashboard.server import load_or_create_token

            [REDACTED_SECRET])
            click.echo(f"{Y}Dashboard already running{N} (pid {pid})")
            click.echo(f"\n  {C}http://localhost:{port}/?[REDACTED_SECRET]")
            return
        except ProcessLookupError:
            pid_file.unlink(missing_ok=True)

    # Ensure token exists before starting (server creates it on import)
    from dashboard.server import load_or_create_token

    [REDACTED_SECRET])

    if verbose:
        log_path = Path(db.DB_PATH).parent / "dashboard.log"
        log_file = open(log_path, "a")
        stdout_target = log_file
        stderr_target = log_file
    else:
        log_file = None
        stdout_target = subprocess.DEVNULL
        stderr_target = subprocess.DEVNULL

    proc = subprocess.Popen(
        [sys.executable, "-m", "dashboard", "--port", str(port)],
        stdout=stdout_target,
        stderr=stderr_target,
        start_new_session=True,
    )
    if log_file:
        log_file.close()

    pid_file.write_text(str(proc.pid))
    click.echo(f"{G}Dashboard started{N} on port {port} (pid {proc.pid})")
    click.echo(f"\n  {C}http://localhost:{port}/?[REDACTED_SECRET]")
    if verbose:
        click.echo(f"Logging to: {log_path}")
    click.echo(f"Stop with: clawctl dashboard --stop")


@cli.command("help")
@click.pass_context
def help_cmd(ctx):
    """Show help"""
    click.echo(ctx.parent.get_help())


path: clawctl/db.py
content: """clawctl — shared database layer.

All SQL uses parameterized queries. Mutating functions return (ok, payload):
  Success: (True, result_data)
  Failure: (False, error_string)
"""

import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path

DB_PATH = os.environ.get("CLAW_DB", os.path.expanduser("~/.openclaw/clawctl.db"))
AGENT_EXPLICIT = "CLAW_AGENT" in os.environ
AGENT = os.environ.get("CLAW_AGENT", os.getenv("USER", "unknown"))


@contextmanager
def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA busy_timeout=5000")
    conn.execute("PRAGMA foreign_keys=ON")
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def init_db():
    Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    schema_path = Path(__file__).parent / "schema.sql"
    conn.executescript(schema_path.read_text())
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=NORMAL")
    conn.close()


def log_activity(conn, agent, action, target_type, target_id, detail="", meta=None):
    conn.execute(
        "INSERT INTO activity(agent,action,target_type,target_id,detail,meta) VALUES(?,?,?,?,?,?)",
        (agent, action, target_type, target_id, detail, meta),
    )


# ── Agents ──────────────────────────────────────────────


def register_agent(conn, name, role=""):
    conn.execute(
        "INSERT OR REPLACE INTO agents(name,role,last_seen,status) VALUES(?,?,datetime('now'),'idle')",
        (name, role),
    )
    log_activity(conn, name, "agent_registered", "agent", 0, f"{name} ({role})")
    return True, None


def checkin_agent(conn, agent):
    conn.execute(
        """INSERT OR REPLACE INTO agents(name,role,last_seen,status,session_id,registered_at)
        VALUES(?,
          COALESCE((SELECT role FROM agents WHERE name=?),''),
          datetime('now'),
          COALESCE((SELECT CASE WHEN (SELECT COUNT(*) FROM tasks WHERE owner=? AND status='in_progress')>0 THEN 'busy' ELSE 'idle' END),'idle'),
          COALESCE((SELECT session_id FROM agents WHERE name=?),''),
          COALESCE((SELECT registered_at FROM agents WHERE name=?),datetime('now')))""",
        (agent, agent, agent, agent, agent),
    )
    log_activity(conn, agent, "checkin", "agent", 0)
    return True, None


def get_unread_count(conn, agent):
    row = conn.execute(
        "SELECT COUNT(*) AS cnt FROM messages WHERE to_agent=? AND read_at IS NULL",
        (agent,),
    ).fetchone()
    return row["cnt"]


def get_agent_info(conn, agent):
    row = conn.execute("SELECT role FROM agents WHERE name=?", (agent,)).fetchone()
    return row["role"] if row else "unregistered"


# ── Tasks ───────────────────────────────────────────────


def add_task(
    conn, subject, desc="", priority=0, assignee="", created_by="", parent_id=None
):
    status = "claimed" if assignee else "pending"
    claimed_at = "datetime('now')" if assignee else None

    if assignee:
        cur = conn.execute(
            """INSERT INTO tasks(subject,description,status,owner,created_by,priority,parent_id,claimed_at)
            VALUES(?,?,?,?,?,?,?,datetime('now'))""",
            (subject, desc, status, assignee, created_by, priority, parent_id),
        )
    else:
        cur = conn.execute(
            """INSERT INTO tasks(subject,description,status,owner,created_by,priority,parent_id)
            VALUES(?,?,?,NULL,?,?,?)""",
            (subject, desc, status, created_by, priority, parent_id),
        )
    task_id = cur.lastrowid
    log_activity(conn, created_by, "task_created", "task", task_id, subject)
    return True, task_id


def list_tasks(conn, status=None, owner=None, mine_agent=None, include_all=False):
    clauses = []
    params = []
    if status:
        clauses.append("status=?")
        params.append(status)
    elif not include_all:
        clauses.append("status NOT IN ('done','cancelled')")
    if owner:
        clauses.append("owner=?")
        params.append(owner)
    if mine_agent:
        clauses.append("owner=?")
        params.append(mine_agent)
    where = " AND ".join(clauses) if clauses else "1=1"
    # Active work: status order then oldest first. --all: newest first.
    if include_all:
        order = "id DESC"
    else:
        order = """CASE status
            WHEN 'in_progress' THEN 1 WHEN 'claimed' THEN 2 WHEN 'blocked' THEN 3
            WHEN 'review' THEN 4 WHEN 'pending' THEN 5
            ELSE 6 END, created_at ASC"""
    rows = conn.execute(
        f"""SELECT id, subject,
            CASE status WHEN 'done' THEN '✓' WHEN 'in_progress' THEN '▶' WHEN 'claimed' THEN '◉'
                 WHEN 'blocked' THEN '✗' WHEN 'review' THEN '⟳' ELSE '○' END AS icon,
            status, COALESCE(owner,'-') AS owner,
            CASE priority WHEN 2 THEN '!!!' WHEN 1 THEN '!' ELSE '' END AS pri
            FROM tasks WHERE {where} ORDER BY {order}""",
        params,
    ).fetchall()
    return rows


def get_next_task(conn, agent):
    """Return the single highest-priority actionable task for this agent.

    Priority: tasks already owned by agent (in_progress > claimed) first,
    then unowned pending tasks. Within each group: highest priority, oldest first.
    """
    row = conn.execute(
        """SELECT id, subject, status, priority
        FROM tasks
        WHERE status NOT IN ('done','cancelled','blocked')
          AND (owner=? OR owner IS NULL OR owner='')
        ORDER BY
            CASE WHEN owner=? THEN 0 ELSE 1 END,
            CASE status
                WHEN 'in_progress' THEN 1 WHEN 'claimed' THEN 2
                WHEN 'review' THEN 3 WHEN 'pending' THEN 4
                ELSE 5 END,
            priority DESC,
            created_at ASC
        LIMIT 1""",
        (agent, agent),
    ).fetchone()
    return row


def claim_task(conn, task_id, agent, force=False, meta=None):
    if force:
        cur = conn.execute(
            """UPDATE tasks SET owner=?, status='claimed',
                claimed_at=datetime('now'), updated_at=datetime('now')
            WHERE id=?""",
            (agent, task_id),
        )
    else:
        cur = conn.execute(
            """UPDATE tasks SET owner=?, status='claimed',
                claimed_at=datetime('now'), updated_at=datetime('now')
            WHERE id=? AND (owner IS NULL OR owner='' OR owner=?)""",
            (agent, task_id, agent),
        )
    if cur.rowcount == 1:
        log_activity(conn, agent, "task_claimed", "task", task_id, meta=meta)
        return True, None
    row = conn.execute("SELECT owner FROM tasks WHERE id=?", (task_id,)).fetchone()
    if not row:
        return False, "not found"
    return False, f"already claimed by {row['owner']}"


def start_task(conn, task_id, agent, meta=None):
    cur = conn.execute(
        "UPDATE tasks SET status='in_progress', updated_at=datetime('now') WHERE id=? AND owner=?",
        (task_id, agent),
    )
    if cur.rowcount == 0:
        row = conn.execute("SELECT owner FROM tasks WHERE id=?", (task_id,)).fetchone()
        if not row:
            return False, "not found"
        return False, f"not owned by you (owner: {row['owner']})"
    conn.execute("UPDATE agents SET status='busy' WHERE name=?", (agent,))
    log_activity(conn, agent, "task_started", "task", task_id, meta=meta)
    return True, None


def complete_task(conn, task_id, agent, note="", meta=None, force=False):
    if force:
        cur = conn.execute(
            """UPDATE tasks SET status='done', completed_at=datetime('now'),
                updated_at=datetime('now')
            WHERE id=? AND status != 'done'""",
            (task_id,),
        )
    else:
        cur = conn.execute(
            """UPDATE tasks SET status='done', completed_at=datetime('now'),
                updated_at=datetime('now')
            WHERE id=? AND owner=? AND status != 'done'""",
            (task_id, agent),
        )
    if cur.rowcount == 0:
        row = conn.execute(
            "SELECT id, owner, status FROM tasks WHERE id=?", (task_id,)
        ).fetchone()
        if not row:
            return False, "not found"
        if row["status"] == "done":
            return True, "already done"
        if row["owner"] != agent:
            return (
                False,
                f"not owned by you (owner: {row['owner'] or 'unassigned'}). Use --force to override",
            )
    conn.execute("UPDATE agents SET status='idle' WHERE name=?", (agent,))
    log_activity(conn, agent, "task_completed", "task", task_id, note, meta=meta)
    if note:
        conn.execute(
            "INSERT INTO messages(from_agent,task_id,body,msg_type) VALUES(?,?,?,'status')",
            (agent, task_id, note),
        )
    return True, None


def cancel_task(conn, task_id, agent, meta=None):
    cur = conn.execute(
        """UPDATE tasks SET status='cancelled', updated_at=datetime('now')
        WHERE id=? AND status NOT IN ('done','cancelled')""",
        (task_id,),
    )
    if cur.rowcount == 0:
        row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
        if not row:
            return False, "not found"
        return True, f"already {row['status']}"
    log_activity(conn, agent, "task_cancelled", "task", task_id, meta=meta)
    return True, None


def review_task(conn, task_id, agent, meta=None):
    cur = conn.execute(
        "UPDATE tasks SET status='review', updated_at=datetime('now') WHERE id=? AND owner=?",
        (task_id, agent),
    )
    if cur.rowcount == 0:
        row = conn.execute("SELECT owner FROM tasks WHERE id=?", (task_id,)).fetchone()
        if not row:
            return False, "not found"
        return False, f"not owned by you (owner: {row['owner']})"
    log_activity(conn, agent, "task_reviewed", "task", task_id, meta=meta)
    return True, None


def block_task(conn, task_id, blocked_by_id, meta=None):
    try:
        conn.execute(
            "INSERT INTO task_deps(task_id, blocked_by) VALUES(?,?)",
            (task_id, blocked_by_id),
        )
        conn.execute(
            "UPDATE tasks SET status='blocked', updated_at=datetime('now') WHERE id=?",
            (task_id,),
        )
        log_activity(
            conn,
            AGENT,
            "task_blocked",
            "task",
            task_id,
            f"by #{blocked_by_id}",
            meta=meta,
        )
        return True, None
    except sqlite3.IntegrityError:
        return False, "dependency already exists"


def get_blockers(conn, task_id):
    rows = conn.execute(
        """SELECT d.blocked_by AS id, t.subject, t.status
        FROM task_deps d JOIN tasks t ON t.id = d.blocked_by
        WHERE d.task_id=?""",
        (task_id,),
    ).fetchall()
    return rows


def get_board(conn):
    result = {}
    for status in ("pending", "claimed", "in_progress", "review", "blocked", "done"):
        rows = conn.execute(
            """SELECT id, subject, owner, priority FROM tasks
            WHERE status=? ORDER BY priority DESC, id LIMIT 10""",
            (status,),
        ).fetchall()
        count = conn.execute(
            "SELECT COUNT(*) AS cnt FROM tasks WHERE status=?", (status,)
        ).fetchone()["cnt"]
        if count > 0:
            result[status] = {"count": count, "tasks": rows}
    return result


# ── Messages ────────────────────────────────────────────


def send_message(conn, from_agent, to_agent, body, task_id=None, msg_type="comment"):
    conn.execute(
        "INSERT INTO messages(from_agent,to_agent,task_id,body,msg_type) VALUES(?,?,?,?,?)",
        (from_agent, to_agent, task_id, body, msg_type),
    )
    log_activity(
        conn, from_agent, "message_sent", "message", 0, f"to:{to_agent} type:{msg_type}"
    )
    return True, None


def broadcast(conn, from_agent, body):
    conn.execute(
        "INSERT INTO messages(from_agent,to_agent,body,msg_type) VALUES(?,NULL,?,'alert')",
        (from_agent, body),
    )
    log_activity(conn, from_agent, "broadcast", "message", 0, body)
    return True, None


def get_inbox(conn, agent, unread_only=False):
    where = "(to_agent=? OR to_agent IS NULL)"
    params = [agent]
    if unread_only:
        where += " AND read_at IS NULL"
    rows = conn.execute(
        f"""SELECT id, from_agent, body, msg_type,
            CASE WHEN read_at IS NULL THEN '●' ELSE '' END AS new,
            substr(created_at,1,16) AS at
        FROM messages WHERE {where} ORDER BY created_at DESC LIMIT 20""",
        params,
    ).fetchall()
    return rows


def mark_messages_read(conn, agent, message_ids=None):
    if message_ids:
        placeholders = ",".join("?" for _ in message_ids)
        conn.execute(
            f"UPDATE messages SET read_at=datetime('now') WHERE id IN ({placeholders}) AND read_at IS NULL",
            message_ids,
        )
    else:
        conn.execute(
            "UPDATE messages SET read_at=datetime('now') WHERE to_agent=? AND read_at IS NULL",
            (agent,),
        )


# ── Fleet ───────────────────────────────────────────────


def get_fleet(conn):
    rows = conn.execute(
        """SELECT name, role,
            CASE status WHEN 'busy' THEN '▶ busy' WHEN 'idle' THEN '○ idle' ELSE '✗ offline' END AS status,
            COALESCE((SELECT subject FROM tasks WHERE owner=agents.name AND status='in_progress' LIMIT 1), '-') AS working_on,
            substr(last_seen,1,16) AS last_seen
        FROM agents ORDER BY status, name"""
    ).fetchall()
    return rows


# ── Feed ────────────────────────────────────────────────


def get_feed(conn, limit=20, agent_filter=None):
    where = "1=1"
    params = []
    if agent_filter:
        where = "agent=?"
        params.append(agent_filter)
    params.append(limit)
    rows = conn.execute(
        f"""SELECT substr(created_at,1,16) AS at, agent, action, detail, meta
        FROM activity WHERE {where} ORDER BY id DESC LIMIT ?""",
        params,
    ).fetchall()
    return rows


def get_summary(conn):
    agents = conn.execute("SELECT name, status, role FROM agents").fetchall()
    open_count = conn.execute(
        "SELECT COUNT(*) AS cnt FROM tasks WHERE status NOT IN ('done','cancelled')"
    ).fetchone()["cnt"]
    in_progress = conn.execute(
        "SELECT COUNT(*) AS cnt FROM tasks WHERE status='in_progress'"
    ).fetchone()["cnt"]
    blocked = conn.execute(
        "SELECT COUNT(*) AS cnt FROM tasks WHERE status='blocked'"
    ).fetchone()["cnt"]
    recent = conn.execute(
        """SELECT substr(created_at,1,16) AS at, agent, action, detail
        FROM activity ORDER BY id DESC LIMIT 5"""
    ).fetchall()
    return {
        "agents": agents,
        "open": open_count,
        "in_progress": in_progress,
        "blocked": blocked,
        "recent": recent,
    }


# ── API helpers (for Flask) ─────────────────────────────


def get_board_api(conn):
    tasks = conn.execute(
        """SELECT id, subject, description, status, owner, priority,
               created_at, updated_at, claimed_at, completed_at
        FROM tasks
        ORDER BY
            CASE status
                WHEN 'in_progress' THEN 1
                WHEN 'claimed' THEN 2
                WHEN 'pending' THEN 3
                WHEN 'blocked' THEN 4
                WHEN 'review' THEN 5
                WHEN 'done' THEN 6
            END,
            priority DESC, id"""
    ).fetchall()
    agents = conn.execute(
        "SELECT name, role, status, last_seen FROM agents ORDER BY status, name"
    ).fetchall()
    return tasks, agents


def get_task_detail(conn, task_id):
    task = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
    messages = conn.execute(
        """SELECT id, from_agent, body, msg_type, created_at
        FROM messages WHERE task_id=? ORDER BY created_at""",
        (task_id,),
    ).fetchall()
    return task, messages


path: clawctl/schema.sql
content: -- clawctl schema v0.2
-- Shared coordination layer for OpenClaw agent fleets

CREATE TABLE IF NOT EXISTS tasks (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  subject     TEXT NOT NULL,
  description TEXT,
  status      TEXT DEFAULT 'pending'
                CHECK(status IN ('pending','claimed','in_progress','blocked','review','done','cancelled')),
  owner       TEXT,
  created_by  TEXT,
  priority    INTEGER DEFAULT 0,
  parent_id   INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
  tags        TEXT DEFAULT '[]',
  created_at  TEXT DEFAULT (datetime('now')),
  updated_at  TEXT DEFAULT (datetime('now')),
  claimed_at  TEXT,
  completed_at TEXT
);

CREATE TABLE IF NOT EXISTS task_deps (
  task_id     INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  blocked_by  INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  created_at  TEXT DEFAULT (datetime('now')),
  UNIQUE(task_id, blocked_by)
);

CREATE TABLE IF NOT EXISTS messages (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  from_agent  TEXT NOT NULL,
  to_agent    TEXT,
  task_id     INTEGER REFERENCES tasks(id),
  body        TEXT NOT NULL,
  msg_type    TEXT DEFAULT 'comment'
                CHECK(msg_type IN ('comment','status','handoff','question','answer','alert')),
  created_at  TEXT DEFAULT (datetime('now')),
  read_at     TEXT
);

CREATE TABLE IF NOT EXISTS agents (
  name        TEXT PRIMARY KEY,
  role        TEXT,
  last_seen   TEXT,
  status      TEXT DEFAULT 'idle'
                CHECK(status IN ('idle','busy','offline')),
  session_id  TEXT,
  registered_at TEXT DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS activity (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  agent       TEXT,
  action      TEXT NOT NULL,
  target_type TEXT,
  target_id   INTEGER,
  detail      TEXT,
  meta        TEXT,
  created_at  TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);
CREATE INDEX IF NOT EXISTS idx_tasks_status_owner ON tasks(status, owner);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_deps(task_id);
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent);
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(to_agent, read_at);
CREATE INDEX IF NOT EXISTS idx_activity_time ON activity(created_at);


path: dashboard/__main__.py
content: from dashboard.server import main

main()


path: dashboard/index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <title>clawctl</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script>
    tailwind.config = {
      theme: {
        extend: {
          colors: {
            'cc-bg-0': 'var(--bg-0)',
            'cc-bg-1': 'var(--bg-1)',
            'cc-bg-2': 'var(--bg-2)',
            'cc-border': 'var(--border)',
            'cc-fg': 'var(--fg)',
            'cc-fg-dim': 'var(--fg-dim)',
            'cc-green': 'var(--green)',
            'cc-green-dim': 'var(--green-dim)',
            'cc-amber': 'var(--amber)',
            'cc-cyan': 'var(--cyan)',
            'cc-cyan-bright': 'var(--cyan-bright)',
            'cc-red': 'var(--red)',
            'cc-gray': 'var(--gray)',
          }
        }
      }
    }
  </script>
  <style>
/* ═══════════════════════════════════════════
   1. TOKENS
   ═══════════════════════════════════════════ */
:root {
  --bg-0: #050a05;
  --bg-1: #0a120a;
  --bg-2: #0f1a0f;
  --border: #1a2e1a;
  --fg: #b0d0b0;
  --fg-dim: #4a7a4a;
  --green: #00ff41;
  --green-dim: #00cc33;
  --amber: #ffb000;
  --cyan: #00bfff;
  --cyan-bright: #00ffcc;
  --red: #ff3333;
  --gray: #3a5f3a;
}

/* ═══════════════════════════════════════════
   2. BASE / RESET
   ═══════════════════════════════════════════ */
* { -webkit-tap-highlight-color: transparent; box-sizing: border-box; }

body {
  font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
  background: var(--bg-0);
  color: var(--fg);
  overscroll-behavior-y: contain;
  min-height: 100vh;
  margin: 0;
  line-height: 1.5;
}

::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-0); }
::-webkit-scrollbar-thumb { background: var(--gray); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }

::selection { background: var(--green); color: var(--bg-0); }

/* ═══════════════════════════════════════════
   3. LAYOUT
   ═══════════════════════════════════════════ */
.header {
  position: sticky;
  top: 0;
  background: var(--bg-0);
  border-bottom: 1px solid var(--border);
  padding: 0.75rem 1rem;
  z-index: 10;
}

.header-inner {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
}

.header-title {
  font-size: 1.125rem;
  font-weight: 700;
  white-space: nowrap;
}

.header-stats {
  font-size: 0.75rem;
  color: var(--fg-dim);
  margin-top: 2px;
}

.header-right {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.main-board {
  padding: 1rem 1rem 6rem;
}

/* ═══════════════════════════════════════════
   4. COMPONENTS
   ═══════════════════════════════════════════ */

/* --- Buttons --- */
.btn-terminal {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--fg);
  font-family: inherit;
  font-size: 0.75rem;
  padding: 0.375rem 0.75rem;
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
}

.btn-terminal:hover {
  border-color: var(--green);
  color: var(--green);
  box-shadow: 0 0 6px rgba(0, 255, 65, 0.15);
}

.btn-terminal:active {
  background: var(--bg-2);
}

.btn-terminal.active {
  border-color: var(--green);
  color: var(--green);
}

/* --- Banners --- */
.banner {
  font-size: 0.75rem;
  padding: 0.375rem 0.75rem;
  margin-top: 0.5rem;
  border: 1px solid;
  display: none;
}

.banner.visible { display: block; }

.banner-sse {
  border-color: var(--green-dim);
  color: var(--green);
  background: rgba(0, 255, 65, 0.05);
}

.banner-warn {
  border-color: var(--amber);
  color: var(--amber);
  background: rgba(255, 176, 0, 0.05);
}

/* --- Section Headers --- */
.section-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-weight: 600;
  margin-bottom: 0.75rem;
  min-height: 32px;
}

.section-bracket {
  white-space: nowrap;
}

.section-count {
  font-size: 0.65rem;
  padding: 0.125rem 0.5rem;
  border: 1px solid var(--border);
  color: var(--fg-dim);
}

.section-divider {
  flex: 1;
  height: 1px;
  background: var(--border);
}

.section-empty {
  color: var(--fg-dim);
  font-size: 0.75rem;
  padding: 0.75rem 0;
  font-style: italic;
}

.section-empty-hint {
  color: var(--gray);
  font-size: 0.65rem;
  font-style: normal;
}

/* --- Task Cards --- */
.task-card {
  --accent: var(--fg-dim);
  position: relative;
  background: var(--bg-1);
  border: 1px solid var(--border);
  padding: 0.75rem 0.75rem 0.75rem 1rem;
  margin-bottom: 0.5rem;
  cursor: pointer;
  border-left: 3px solid var(--accent);
  transition: border-color 0.15s, box-shadow 0.15s, opacity 0.3s, transform 0.3s;
}

.task-card[data-status="pending"]     { --accent: var(--amber); }
.task-card[data-status="in_progress"],
.task-card[data-status="claimed"]     { --accent: var(--cyan); }
.task-card[data-status="blocked"]     { --accent: var(--red); }
.task-card[data-status="done"]        { --accent: var(--green); }

.task-card:hover {
  border-color: var(--accent);
  box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 20%, transparent);
}

.task-card:active {
  background: var(--bg-2);
}

.task-card-top {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 0.5rem;
}

.task-id {
  color: var(--fg-dim);
  font-size: 0.75rem;
}

.task-subject {
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--fg);
  word-break: break-word;
}

.task-badge {
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  padding: 0.125rem 0.375rem;
  border: 1px solid var(--accent);
  color: var(--accent);
  white-space: nowrap;
  flex-shrink: 0;
}

.task-priority {
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0.0625rem 0.3125rem;
  font-weight: 700;
  display: inline-block;
  margin-left: 0.375rem;
}

.task-priority.crit { color: var(--red); border: 1px solid var(--red); }
.task-priority.high { color: var(--amber); border: 1px solid var(--amber); }

.task-meta {
  display: flex;
  align-items: center;
  gap: 0.375rem;
  font-size: 0.7rem;
  color: var(--fg-dim);
  margin-top: 0.375rem;
  flex-wrap: wrap;
}

.task-meta-sep {
  color: var(--gray);
}

.task-agent {
  color: var(--green-dim);
  display: flex;
  align-items: center;
  gap: 0.25rem;
}

.task-tags {
  display: flex;
  gap: 0.25rem;
  flex-wrap: wrap;
  margin-top: 0.25rem;
}

.task-tag {
  font-size: 0.6rem;
  color: var(--fg-dim);
  border: 1px solid var(--gray);
  padding: 0 0.25rem;
}

/* --- Agent Dot --- */
.agent-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  display: inline-block;
  flex-shrink: 0;
}

.agent-dot.active { background: var(--green); }
.agent-dot.idle { background: var(--amber); }
.agent-dot.offline { background: var(--gray); }

/* --- Card entrance animation --- */
.card-enter {
  opacity: 0;
  transform: translateY(8px);
}

.card-enter-active {
  opacity: 1;
  transform: translateY(0);
}

/* --- Bottom Sheet --- */
.sheet-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.7);
  z-index: 20;
  display: none;
  opacity: 0;
  transition: opacity 0.2s;
}

.sheet-backdrop.visible {
  display: block;
  opacity: 1;
}

.sheet {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: var(--bg-1);
  border-top: 2px solid var(--green);
  border-radius: 0.5rem 0.5rem 0 0;
  z-index: 30;
  transform: translateY(100%);
  transition: transform 0.3s ease-out;
  max-height: 85vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.5);
}

.sheet.visible {
  transform: translateY(0);
}

.sheet-header {
  padding: 0.75rem 1rem 0;
  color: var(--fg-dim);
  font-size: 0.75rem;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.5rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.sheet-content {
  padding: 1rem;
  overflow-y: auto;
  flex: 1;
}

.sheet-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0;
  border: 1px solid var(--border);
  margin-bottom: 1rem;
}

.sheet-grid-cell {
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--border);
  font-size: 0.75rem;
}

.sheet-grid-label {
  color: var(--fg-dim);
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 0.125rem;
}

.sheet-grid-value {
  font-weight: 500;
}

.sheet-section-title {
  font-size: 0.75rem;
  color: var(--fg-dim);
  margin-bottom: 0.5rem;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.sheet-section-title::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--border);
}

.sheet-message {
  border-left: 2px solid var(--fg-dim);
  padding: 0.5rem 0.75rem;
  margin-bottom: 0.5rem;
  font-size: 0.75rem;
  background: var(--bg-0);
}

.sheet-message[data-type="alert"] { border-left-color: var(--amber); }
.sheet-message[data-type="error"] { border-left-color: var(--red); }
.sheet-message[data-type="info"] { border-left-color: var(--cyan); }

.sheet-message .msg-type-badge {
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding: 0 0.25rem;
  border: 1px solid var(--fg-dim);
  margin-right: 0.375rem;
}

.sheet-message[data-type="alert"] .msg-type-badge { border-color: var(--amber); color: var(--amber); }
.sheet-message[data-type="error"] .msg-type-badge { border-color: var(--red); color: var(--red); }
.sheet-message[data-type="info"] .msg-type-badge  { border-color: var(--cyan); color: var(--cyan); }

.sheet-buttons {
  display: flex;
  gap: 0.5rem;
  padding-top: 0.75rem;
  position: sticky;
  bottom: 0;
  background: var(--bg-1);
  padding-bottom: 0.5rem;
}

.btn-sheet {
  flex: 1;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--fg);
  font-family: inherit;
  font-size: 0.75rem;
  padding: 0.5rem 1rem;
  cursor: pointer;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  min-height: 40px;
  transition: border-color 0.15s, color 0.15s, box-shadow 0.15s;
}

.btn-sheet:hover {
  border-color: var(--green);
  color: var(--green);
}

.btn-sheet.delete {
  border-color: var(--red);
  color: var(--red);
}

.btn-sheet.delete:hover {
  box-shadow: 0 0 8px rgba(255, 51, 51, 0.2);
}

.btn-sheet.complete {
  border-color: var(--green);
  color: var(--green);
}

.btn-sheet.complete:hover {
  box-shadow: 0 0 8px rgba(0, 255, 65, 0.2);
}

.btn-sheet.close-btn {
  flex: 0;
  min-width: 60px;
  border-color: var(--gray);
  color: var(--fg-dim);
}

.completed-badge {
  text-align: center;
  padding: 0.5rem;
  color: var(--green);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.05em;
}

/* --- Loading Overlay --- */
.loading-overlay {
  position: fixed;
  inset: 0;
  background: var(--bg-0);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 50;
  flex-direction: column;
  gap: 1.5rem;
}

.loading-ascii {
  color: var(--green);
  font-size: 0.6rem;
  line-height: 1.2;
  white-space: pre;
  text-align: center;
}

.loading-bar {
  width: 200px;
  height: 3px;
  background: var(--border);
  overflow: hidden;
  position: relative;
}

.loading-bar-inner {
  position: absolute;
  height: 100%;
  width: 40%;
  background: var(--green);
  animation: loadbar 1s ease-in-out infinite;
}

@keyframes loadbar {
  0% { left: -40%; }
  100% { left: 100%; }
}

.loading-text {
  color: var(--fg-dim);
  font-size: 0.75rem;
}

/* --- Skeleton --- */
.skeleton-line {
  height: 14px;
  background: linear-gradient(90deg, var(--bg-1) 25%, var(--bg-2) 50%, var(--bg-1) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  margin-bottom: 0.5rem;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

.skeleton-card {
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-left: 3px solid var(--gray);
  padding: 0.75rem 0.75rem 0.75rem 1rem;
  margin-bottom: 0.5rem;
}

/* --- Error --- */
.error-container {
  border: 1px solid var(--red);
  padding: 1.5rem;
  text-align: center;
  max-width: 400px;
}

.error-label {
  color: var(--red);
  font-size: 0.875rem;
  font-weight: 600;
  margin-bottom: 0.5rem;
}

.error-hint {
  color: var(--fg-dim);
  font-size: 0.75rem;
}

/* ═══════════════════════════════════════════
   5. EFFECTS (guarded)
   ═══════════════════════════════════════════ */

/* Scanlines overlay */
.scanlines {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 100;
  background: repeating-linear-gradient(
    0deg,
    transparent,
    transparent 2px,
    rgba(0, 0, 0, 0.06) 2px,
    rgba(0, 0, 0, 0.06) 4px
  );
  display: none;
}

.effects-on .scanlines { display: block; }

/* Glow utilities */
.effects-on .glow-green { text-shadow: 0 0 6px rgba(0, 255, 65, 0.4); }
.effects-on .glow-amber { text-shadow: 0 0 6px rgba(255, 176, 0, 0.4); }
.effects-on .glow-red { text-shadow: 0 0 6px rgba(255, 51, 51, 0.4); }
.effects-on .glow-cyan { text-shadow: 0 0 6px rgba(0, 191, 255, 0.4); }

/* Matrix canvas */
#matrix-canvas {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 99;
  opacity: 0.03;
  display: none;
}

.effects-on #matrix-canvas { display: block; }

/* ═══════════════════════════════════════════
   6. ANIMATIONS
   ═══════════════════════════════════════════ */

@keyframes dot-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

.effects-on .agent-dot.active {
  animation: dot-pulse 2s ease-in-out infinite;
}

@keyframes cursor-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

.cursor-block {
  display: inline-block;
  width: 0.55em;
  height: 1.1em;
  background: var(--green);
  vertical-align: text-bottom;
  margin-left: 1px;
}

.effects-on .cursor-block {
  animation: cursor-blink 1s step-end infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }
.animate-spin { animation: spin 1s linear infinite; }

/* ═══════════════════════════════════════════
   7. ACCESSIBILITY
   ═══════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .scanlines { display: none !important; }
  #matrix-canvas { display: none !important; }
  .card-enter { opacity: 1; transform: none; }
}

/* Focus visible for keyboard nav */
*:focus-visible {
  outline: 1px solid var(--green);
  outline-offset: 2px;
}
  </style>
</head>
<body>

  <!-- Scanlines overlay -->
  <div class="scanlines" aria-hidden="true"></div>

  <!-- Matrix rain canvas -->
  <canvas id="matrix-canvas" aria-hidden="true"></canvas>

  <!-- Header -->
  <header class="header">
    <div class="header-inner">
      <div>
        <h1 class="header-title">
          <span style="color: var(--green);" class="glow-green">&gt;_ claw</span><span style="color: var(--fg-dim);">ctl</span><span class="cursor-block" aria-hidden="true"></span>
        </h1>
        <p id="header-stats" class="header-stats">$ initializing...</p>
      </div>
      <div class="header-right">
        <button id="effects-toggle" class="btn-terminal" onclick="toggleEffects()" aria-label="Toggle visual effects">
          FX: <span id="effects-label">OFF</span>
        </button>
        <button onclick="fetchBoard()" class="btn-terminal" aria-label="Refresh board">
          <span id="refresh-icon">&#x21bb;</span> SYNC
        </button>
      </div>
    </div>
    <div id="sse-banner" class="banner banner-sse">[SSE] Live updates connected</div>
    <div id="stale-banner" class="banner banner-warn">[WARN] Offline — showing cached data from <span id="stale-time"></span></div>
  </header>

  <!-- Board -->
  <main id="board" class="main-board">
    <!-- Skeleton loading -->
    <div id="skeleton">
      <div style="color: var(--fg-dim); font-size: 0.75rem; margin-bottom: 0.75rem;">$ clawctl board --format=json</div>
      <div class="skeleton-card">
        <div class="skeleton-line" style="width: 30%;"></div>
        <div class="skeleton-line" style="width: 60%;"></div>
      </div>
      <div class="skeleton-card">
        <div class="skeleton-line" style="width: 45%;"></div>
        <div class="skeleton-line" style="width: 55%;"></div>
      </div>
      <div class="skeleton-card" style="margin-top: 1rem;">
        <div class="skeleton-line" style="width: 35%;"></div>
        <div class="skeleton-line" style="width: 50%;"></div>
      </div>
    </div>
  </main>

  <!-- Bottom Sheet Backdrop -->
  <div id="sheet-backdrop" class="sheet-backdrop" onclick="closeDetail()" aria-hidden="true"></div>

  <!-- Bottom Sheet -->
  <div id="sheet" class="sheet" role="dialog" aria-modal="true" aria-label="Task detail">
    <div class="sheet-header">
      <span id="sheet-header-text">$ clawctl show</span>
      <button onclick="closeDetail()" class="btn-terminal close-btn" style="padding: 0.125rem 0.5rem; font-size: 0.65rem;" aria-label="Close detail">ESC</button>
    </div>
    <div id="sheet-content" class="sheet-content">
      <!-- Task detail rendered by JS -->
    </div>
  </div>

  <!-- Loading overlay -->
  <div id="loading" class="loading-overlay">
    <div class="loading-ascii">
 ██████╗██╗      █████╗ ██╗    ██╗
██╔════╝██║     ██╔══██╗██║    ██║
██║     ██║     ███████║██║ █╗ ██║
██║     ██║     ██╔══██║██║███╗██║
╚██████╗███████╗██║  ██║╚███╔███╔╝
 ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝</div>
    <div class="loading-bar"><div class="loading-bar-inner"></div></div>
    <div class="loading-text">$ clawctl dashboard --connect</div>
  </div>

  <!-- Error overlay -->
  <div id="error-overlay" class="loading-overlay" style="display: none;">
    <div class="error-container">
      <div class="error-label glow-red">[ERROR] <span id="error-message">Connection failed</span></div>
      <div class="error-hint" style="margin-top: 0.75rem;">$ clawctl dashboard --help</div>
    </div>
  </div>

  <script>
// ═══════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════
const state = {
  tasks: [],
  agents: [],
  selectedTask: null,
  token: new URLSearchParams(location.search).get('token'),
  sseRetries: 0,
  eventSource: null,
  effectsOn: localStorage.getItem('cc_effects') === 'true',
  matrixRaf: null,
  sheetFocusTrap: null,
  previousFocus: null,
};

// ═══════════════════════════════════════════
// API
// ═══════════════════════════════════════════
const api = {
  async get(endpoint) {
    const res = await fetch(`${endpoint}?[REDACTED_SECRET]`);
    if (res.status === 401) throw new Error('Unauthorized');
    if (!res.ok) throw new Error(`API error: ${res.status}`);
    return res.json();
  },
  async post(endpoint, body = {}) {
    const res = await fetch(`${endpoint}?[REDACTED_SECRET]`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    });
    if (res.status === 401) throw new Error('Unauthorized');
    if (!res.ok) throw new Error(`API error: ${res.status}`);
    return res.json();
  }
};

// ═══════════════════════════════════════════
// BOARD
// ═══════════════════════════════════════════
async function fetchBoard() {
  const icon = document.getElementById('refresh-icon');
  icon.classList.add('animate-spin');

  try {
    const data = await api.get('/api/board');
    state.tasks = data.tasks;
    state.agents = data.agents;
    renderBoard();
    saveCache(data);
    hideStaleIndicator();
    document.getElementById('skeleton').style.display = 'none';
  } catch (e) {
    console.error('Fetch error:', e);
    if (e.message === 'Unauthorized') {
      showError('Invalid or missing token');
      return;
    }
    const cache = loadCache();
    if (cache) {
      state.tasks = cache.data.tasks;
      state.agents = cache.data.agents;
      renderBoard();
      showStaleIndicator(cache.timestamp);
      document.getElementById('skeleton').style.display = 'none';
    } else {
      showError('Cannot connect to server');
    }
  } finally {
    icon.classList.remove('animate-spin');
    document.getElementById('loading').style.display = 'none';
  }
}

function renderBoard() {
  const board = document.getElementById('board');
  const groups = {
    pending: state.tasks.filter(t => t.status === 'pending' || t.status === 'claimed'),
    in_progress: state.tasks.filter(t => t.status === 'in_progress'),
    blocked: state.tasks.filter(t => t.status === 'blocked'),
    done: state.tasks.filter(t => t.status === 'done')
  };

  const activeAgents = state.agents.filter(a => a.status === 'busy').length;
  const statsEl = document.getElementById('header-stats');
  statsEl.innerHTML = `$ fleet: <span style="color:var(--green)">${state.agents.length} agents</span> <span style="color:var(--fg-dim)">|</span> <span style="color:var(--cyan)">${activeAgents} active</span> <span style="color:var(--fg-dim)">|</span> <span style="color:var(--amber)">${state.tasks.length} tasks</span>`;

  let html = '<div id="skeleton" style="display:none;"></div>';
  html += renderSection('pending', '[ ]', 'Pending', groups.pending, 'var(--amber)');
  html += renderSection('in_progress', '[>]', 'In Progress', groups.in_progress, 'var(--cyan)');
  if (groups.blocked.length > 0) {
    html += renderSection('blocked', '[X]', 'Blocked', groups.blocked, 'var(--red)');
  }
  html += renderSection('done', '[+]', 'Done', groups.done, 'var(--green)', groups.done.length > 3);

  board.innerHTML = html;

  // Animate card entrances
  if (state.effectsOn && !prefersReducedMotion()) {
    requestAnimationFrame(() => {
      const cards = board.querySelectorAll('.card-enter');
      cards.forEach((card, i) => {
        const delay = Math.min(i * 30, 500);
        setTimeout(() => {
          card.classList.add('card-enter-active');
        }, delay);
      });
    });
  }
}

function renderSection(id, bracket, label, tasks, color, collapsed = false) {
  const count = tasks.length;
  return `
    <section style="margin-bottom: 1.5rem;">
      <div class="section-header">
        <span class="section-bracket" style="color: ${color};">${bracket}</span>
        <span style="color: ${color};">${label}</span>
        <span class="section-count">${count}</span>
        <div class="section-divider"></div>
      </div>
      <div id="section-${id}" ${collapsed ? 'style="display:none;"' : ''}>
        ${tasks.map((t, i) => renderCard(t, i)).join('')}
        ${count === 0 ? `<div class="section-empty">-- no tasks --<br><span class="section-empty-hint">$ clawctl add "..."</span></div>` : ''}
      </div>
      ${collapsed ? `
        <button onclick="const s=document.getElementById('section-${id}');s.style.display=s.style.display==='none'?'':'none';this.style.display='none';"
                class="btn-terminal" style="font-size: 0.7rem; margin-top: 0.25rem;">
          show ${count} completed
        </button>
      ` : ''}
    </section>
  `;
}

function renderCard(task, index) {
  const agent = state.agents.find(a => a.name === task.owner);
  const agentStatus = agent ? getAgentStatus(agent) : 'offline';
  const shouldAnimate = state.effectsOn && !prefersReducedMotion();

  let priorityBadge = '';
  if (task.priority >= 2) {
    priorityBadge = '<span class="task-priority crit">CRIT</span>';
  } else if (task.priority === 1) {
    priorityBadge = '<span class="task-priority high">HIGH</span>';
  }

  const tags = task.tags ? (typeof task.tags === 'string' ? task.tags.split(',').map(s=>s.trim()).filter(Boolean) : task.tags) : [];
  const tagsHtml = tags.length > 0 ? `<div class="task-tags">${tags.map(t => `<span class="task-tag">${escapeHtml(t)}</span>`).join('')}</div>` : '';

  return `
    <div class="task-card ${shouldAnimate ? 'card-enter' : ''}" data-status="${task.status}"
         onclick="openDetail(${task.id})" tabindex="0" role="button"
         onkeydown="if(event.key==='Enter')openDetail(${task.id})">
      <div class="task-card-top">
        <div>
          <span class="task-id">#${task.id}</span>
          <span class="task-subject">${escapeHtml(task.subject)}</span>
          ${priorityBadge}
        </div>
        <span class="task-badge">${task.status.replace('_', ' ')}</span>
      </div>
      <div class="task-meta">
        ${task.owner ? `
          <span class="task-agent">
            <span class="agent-dot ${agentStatus}"></span>
            ${escapeHtml(task.owner)}
          </span>
          <span class="task-meta-sep">//</span>
        ` : '<span style="color:var(--fg-dim)">unassigned</span><span class="task-meta-sep">//</span>'}
        <span>${timeAgo(task.updated_at || task.created_at)}</span>
      </div>
      ${tagsHtml}
    </div>
  `;
}

// ═══════════════════════════════════════════
// DETAIL SHEET
// ═══════════════════════════════════════════
async function openDetail(taskId) {
  state.selectedTask = taskId;
  state.previousFocus = document.activeElement;

  const sheet = document.getElementById('sheet');
  const backdrop = document.getElementById('sheet-backdrop');
  const content = document.getElementById('sheet-content');
  const headerText = document.getElementById('sheet-header-text');

  headerText.textContent = `$ clawctl show ${taskId}`;

  // Show sheet with loading
  content.innerHTML = `
    <div style="color: var(--fg-dim); font-size: 0.75rem; margin-bottom: 0.75rem;">$ clawctl show ${taskId}</div>
    <div class="loading-bar" style="width: 100%; margin-bottom: 1rem;"><div class="loading-bar-inner"></div></div>
    <div class="skeleton-card">
      <div class="skeleton-line" style="width: 60%;"></div>
      <div class="skeleton-line" style="width: 40%;"></div>
    </div>
  `;

  backdrop.classList.add('visible');
  backdrop.setAttribute('aria-hidden', 'false');
  requestAnimationFrame(() => {
    sheet.classList.add('visible');
  });

  // Focus trap
  setupFocusTrap(sheet);

  try {
    const data = await api.get(`/api/task/${taskId}`);
    content.innerHTML = renderDetail(data.task, data.messages);
    // Focus the first interactive element in the sheet
    const firstBtn = sheet.querySelector('.btn-sheet');
    if (firstBtn) firstBtn.focus();
  } catch (e) {
    content.innerHTML = `
      <div class="error-container" style="margin: 1rem auto;">
        <div class="error-label glow-red">[ERROR] Failed to load task</div>
        <button onclick="openDetail(${taskId})" class="btn-terminal" style="margin-top: 0.75rem;">RETRY</button>
      </div>
    `;
  }
}

function renderDetail(task, messages) {
  const canDelete = task.status !== 'done' && task.status !== 'cancelled';
  const canComplete = task.status === 'in_progress' || task.status === 'claimed';

  const statusColors = {
    done: 'var(--green)',
    in_progress: 'var(--cyan)',
    claimed: 'var(--cyan)',
    blocked: 'var(--red)',
    pending: 'var(--amber)',
  };
  const statusColor = statusColors[task.status] || 'var(--fg-dim)';

  let priorityLabel = 'Normal';
  let priorityColor = 'var(--fg)';
  if (task.priority >= 2) { priorityLabel = 'CRIT'; priorityColor = 'var(--red)'; }
  else if (task.priority === 1) { priorityLabel = 'HIGH'; priorityColor = 'var(--amber)'; }

  return `
    <div>
      <div style="margin-bottom: 1rem;">
        <span class="task-id">#${task.id}</span>
        <span style="font-size: 1rem; font-weight: 600; color: var(--fg);">${escapeHtml(task.subject)}</span>
      </div>

      <div class="sheet-grid">
        <div class="sheet-grid-cell">
          <div class="sheet-grid-label">Status</div>
          <div class="sheet-grid-value" style="color: ${statusColor};">${task.status.replace('_', ' ')}</div>
        </div>
        <div class="sheet-grid-cell">
          <div class="sheet-grid-label">Owner</div>
          <div class="sheet-grid-value">${task.owner ? escapeHtml(task.owner) : '<span style="color:var(--fg-dim)">unassigned</span>'}</div>
        </div>
        <div class="sheet-grid-cell">
          <div class="sheet-grid-label">Priority</div>
          <div class="sheet-grid-value" style="color: ${priorityColor};">${priorityLabel}</div>
        </div>
        <div class="sheet-grid-cell">
          <div class="sheet-grid-label">Created</div>
          <div class="sheet-grid-value">${timeAgo(task.created_at)}</div>
        </div>
      </div>

      ${task.description ? `
        <div style="margin-bottom: 1rem;">
          <div class="sheet-section-title"># Description</div>
          <div style="background: var(--bg-0); border: 1px solid var(--border); padding: 0.75rem; font-size: 0.75rem; white-space: pre-wrap; color: var(--fg);">${escapeHtml(task.description)}</div>
        </div>
      ` : ''}

      ${messages && messages.length > 0 ? `
        <div style="margin-bottom: 1rem;">
          <div class="sheet-section-title"># Messages (${messages.length})</div>
          ${messages.map(m => `
            <div class="sheet-message" data-type="${escapeHtml(m.msg_type || m.type || '')}">
              <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem;">
                <span>
                  ${m.msg_type || m.type ? `<span class="msg-type-badge">${escapeHtml(m.msg_type || m.type)}</span>` : ''}
                  <span style="color: var(--green-dim); font-weight: 500;">${escapeHtml(m.from_agent)}</span>
                </span>
                <span style="color: var(--fg-dim); font-size: 0.65rem;">${timeAgo(m.created_at)}</span>
              </div>
              <p style="color: var(--fg); margin: 0;">${escapeHtml(m.body)}</p>
            </div>
          `).join('')}
        </div>
      ` : ''}

      <div class="sheet-buttons">
        ${canComplete ? `
          <button onclick="completeTask(${task.id})" class="btn-sheet complete" data-primary="true">
            &gt; COMPLETE
          </button>
        ` : ''}
        ${task.status === 'done' ? `
          <div class="completed-badge glow-green">[COMPLETED]</div>
        ` : ''}
        ${canDelete ? `
          <button onclick="deleteTask(${task.id})" class="btn-sheet delete">
            &gt; DELETE
          </button>
        ` : ''}
        <button onclick="closeDetail()" class="btn-sheet close-btn">ESC</button>
      </div>
    </div>
  `;
}

function closeDetail() {
  const sheet = document.getElementById('sheet');
  const backdrop = document.getElementById('sheet-backdrop');

  sheet.classList.remove('visible');
  backdrop.setAttribute('aria-hidden', 'true');

  setTimeout(() => {
    backdrop.classList.remove('visible');
  }, 300);

  removeFocusTrap();
  if (state.previousFocus) {
    state.previousFocus.focus();
    state.previousFocus = null;
  }
  state.selectedTask = null;
}

// ═══════════════════════════════════════════
// FOCUS TRAP
// ═══════════════════════════════════════════
function setupFocusTrap(container) {
  removeFocusTrap();
  state.sheetFocusTrap = function(e) {
    if (e.key === 'Tab') {
      const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    }
    if (e.key === 'Enter') {
      const primary = container.querySelector('[data-primary="true"]');
      if (primary && document.activeElement === primary) {
        // Let it through naturally
      }
    }
  };
  document.addEventListener('keydown', state.sheetFocusTrap);
}

function removeFocusTrap() {
  if (state.sheetFocusTrap) {
    document.removeEventListener('keydown', state.sheetFocusTrap);
    state.sheetFocusTrap = null;
  }
}

// ═══════════════════════════════════════════
// KEYBOARD
// ═══════════════════════════════════════════
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && state.selectedTask !== null) {
    closeDetail();
  }
});

// ═══════════════════════════════════════════
// ACTIONS
// ═══════════════════════════════════════════
async function deleteTask(taskId) {
  const task = state.tasks.find(t => t.id === taskId);
  if (task) {
    task.status = 'cancelled';
    renderBoard();
  }

  closeDetail();

  try {
    await api.post(`/api/task/${taskId}/delete`, { agent: 'dashboard' });
    await fetchBoard();
  } catch (e) {
    console.error('Delete failed:', e);
    await fetchBoard();
  }
}

async function completeTask(taskId) {
  const task = state.tasks.find(t => t.id === taskId);
  if (task) {
    task.status = 'done';
    renderBoard();
  }

  closeDetail();

  try {
    await api.post(`/api/task/${taskId}/complete`, {});
    await fetchBoard();
  } catch (e) {
    console.error('Complete failed:', e);
    await fetchBoard();
  }
}

// ═══════════════════════════════════════════
// REAL-TIME (SSE)
// ═══════════════════════════════════════════
function connectSSE() {
  if (state.eventSource) {
    state.eventSource.close();
  }

  const es = new EventSource(`/api/heartbeat?[REDACTED_SECRET]`);
  state.eventSource = es;

  es.onopen = () => {
    console.log('SSE: Connected');
    state.sseRetries = 0;
    showSSEBanner(true);
  };

  es.onmessage = (e) => {
    try {
      const data = JSON.parse(e.data);
      if (data.refresh) {
        fetchBoard();
      }
    } catch (err) {
      console.error('SSE parse error:', err);
    }
  };

  es.onerror = () => {
    es.close();
    showSSEBanner(false);
    state.sseRetries++;
    const delay = Math.min(1000 * (2 ** state.sseRetries), 30000);
    console.log(`SSE: Reconnecting in ${delay}ms (attempt ${state.sseRetries})`);
    setTimeout(connectSSE, delay);
  };
}

function showSSEBanner(connected) {
  const banner = document.getElementById('sse-banner');
  if (connected) {
    banner.classList.add('visible');
    setTimeout(() => banner.classList.remove('visible'), 3000);
  }
}

document.addEventListener('visibilitychange', () => {
  if (!document.hidden) {
    fetchBoard();
    if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) {
      connectSSE();
    }
    // Pause/resume matrix rain
    if (state.effectsOn) startMatrix();
  } else {
    stopMatrix();
  }
});

// ═══════════════════════════════════════════
// EFFECTS TOGGLE
// ═══════════════════════════════════════════
function toggleEffects() {
  state.effectsOn = !state.effectsOn;
  localStorage.setItem('cc_effects', state.effectsOn);
  applyEffects();
}

function applyEffects() {
  const label = document.getElementById('effects-label');
  const toggle = document.getElementById('effects-toggle');

  if (state.effectsOn && !prefersReducedMotion()) {
    document.body.classList.add('effects-on');
    label.textContent = 'ON';
    toggle.classList.add('active');
    startMatrix();
  } else {
    document.body.classList.remove('effects-on');
    label.textContent = 'OFF';
    toggle.classList.remove('active');
    stopMatrix();
  }
}

function prefersReducedMotion() {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

// ═══════════════════════════════════════════
// MATRIX RAIN
// ═══════════════════════════════════════════
function startMatrix() {
  if (state.matrixRaf || prefersReducedMotion() || !state.effectsOn) return;

  const canvas = document.getElementById('matrix-canvas');
  const ctx = canvas.getContext('2d');

  // Low resolution, scaled up
  const scale = 3;
  const w = Math.floor(window.innerWidth / scale);
  const h = Math.floor(window.innerHeight / scale);
  canvas.width = w;
  canvas.height = h;
  canvas.style.width = '100%';
  canvas.style.height = '100%';

  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%^&*()';
  const fontSize = 8;
  const columns = Math.floor(w / fontSize);
  const drops = new Array(columns).fill(1);

  let lastTime = 0;
  const interval = 80;

  function draw(time) {
    if (!state.effectsOn) { state.matrixRaf = null; return; }
    state.matrixRaf = requestAnimationFrame(draw);

    if (time - lastTime < interval) return;
    lastTime = time;

    ctx.fillStyle = 'rgba(5, 10, 5, 0.15)';
    ctx.fillRect(0, 0, w, h);

    ctx.fillStyle = '#00ff41';
    ctx.font = fontSize + 'px monospace';

    for (let i = 0; i < drops.length; i++) {
      const text = chars[Math.floor(Math.random() * chars.length)];
      ctx.fillText(text, i * fontSize, drops[i] * fontSize);
      if (drops[i] * fontSize > h && Math.random() > 0.975) {
        drops[i] = 0;
      }
      drops[i]++;
    }
  }

  state.matrixRaf = requestAnimationFrame(draw);
}

function stopMatrix() {
  if (state.matrixRaf) {
    cancelAnimationFrame(state.matrixRaf);
    state.matrixRaf = null;
  }
}

// ═══════════════════════════════════════════
// CACHE
// ═══════════════════════════════════════════
function saveCache(data) {
  try {
    localStorage.setItem('cc_board', JSON.stringify({
      data,
      timestamp: Date.now()
    }));
  } catch (e) {
    console.warn('Cache save failed:', e);
  }
}

function loadCache() {
  try {
    const cached = localStorage.getItem('cc_board');
    return cached ? JSON.parse(cached) : null;
  } catch {
    return null;
  }
}

function showStaleIndicator(timestamp) {
  const banner = document.getElementById('stale-banner');
  const time = document.getElementById('stale-time');
  time.textContent = timeAgo(new Date(timestamp).toISOString());
  banner.classList.add('visible');
}

function hideStaleIndicator() {
  document.getElementById('stale-banner').classList.remove('visible');
}

// ═══════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════
function escapeHtml(text) {
  if (!text) return '';
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

function timeAgo(dateStr) {
  if (!dateStr) return 'unknown';
  const date = new Date(dateStr.replace(' ', 'T') + 'Z');
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);

  if (seconds < 0) return 'just now';
  if (seconds < 60) return 'just now';
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  return `${Math.floor(seconds / 86400)}d ago`;
}

function getAgentStatus(agent) {
  if (!agent || !agent.last_seen) return 'offline';
  const lastSeen = new Date(agent.last_seen.replace(' ', 'T') + 'Z');
  const minutesAgo = (Date.now() - lastSeen.getTime()) / 60000;

  if (minutesAgo > 15) return 'offline';
  if (agent.status === 'busy') return 'active';
  return 'idle';
}

function showError(message) {
  document.getElementById('loading').style.display = 'none';
  document.getElementById('error-message').textContent = message;
  document.getElementById('error-overlay').style.display = 'flex';
}

// ═══════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
  if (!state.token) {
    showError('Missing token in URL');
    return;
  }

  applyEffects();
  fetchBoard();
  connectSSE();

  // Listen for reduced-motion changes
  window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => {
    applyEffects();
  });
});
  </script>
</body>
</html>


path: dashboard/server.py
content: #!/usr/bin/env python3
"""clawctl dashboard server — Token auth + SSE"""

import json
import os
import secrets
import time
from pathlib import Path

from flask import Flask, jsonify, request, Response, send_from_directory

from clawctl import db

app = Flask(__name__)

# Persistent token — saved between restarts
TOKEN_PATH = Path(os.path.expanduser("~/.openclaw/.clawctl-token"))


def load_or_create_token():
    TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
    if TOKEN_PATH.exists():
        [REDACTED_SECRET]).strip()
        if [REDACTED_SECRET] token
    [REDACTED_SECRET])
    TOKEN_PATH.write_text(token)
    return token


[REDACTED_SECRET])

# ═══════════════════════════════════════════
# AUTH
# ═══════════════════════════════════════════


@app.before_request
def check_token():
    if request.path == "/" or request.path.endswith(".html"):
        return
    if request.path.startswith("/api"):
        if request.args.get("token") != [REDACTED_SECRET] jsonify({"error": "unauthorized"}), 401


def row_to_dict(row):
    return dict(row) if row else None


# ═══════════════════════════════════════════
# STATIC FILES
# ═══════════════════════════════════════════


@app.route("/")
def index():
    return send_from_directory(Path(__file__).parent, "index.html")


# ═══════════════════════════════════════════
# API ENDPOINTS
# ═══════════════════════════════════════════


@app.route("/api/board")
def board():
    with db.get_db() as conn:
        tasks, agents = db.get_board_api(conn)
    return jsonify(
        {
            "tasks": [row_to_dict(t) for t in tasks],
            "agents": [row_to_dict(a) for a in agents],
            "timestamp": int(time.time() * 1000),
        }
    )


@app.route("/api/task/<int:task_id>")
def task_detail(task_id):
    with db.get_db() as conn:
        task, messages = db.get_task_detail(conn, task_id)
    return jsonify(
        {
            "task": row_to_dict(task),
            "messages": [row_to_dict(m) for m in messages],
        }
    )


@app.route("/api/task/<int:task_id>/delete", methods=["POST"])
def delete_task(task_id):
    data = request.get_json() or {}
    agent = data.get("agent", "dashboard")
    with db.get_db() as conn:
        ok, err = db.cancel_task(conn, task_id, agent)
    if not ok:
        return jsonify({"success": False, "error": err}), 409
    return jsonify({"success": True})


@app.route("/api/task/<int:task_id>/complete", methods=["POST"])
def complete_task(task_id):
    data = request.get_json() or {}
    note = data.get("note", "")
    agent = data.get("agent", "dashboard")
    with db.get_db() as conn:
        db.complete_task(conn, task_id, agent, note, force=True)
    return jsonify({"success": True})


@app.route("/api/heartbeat")
def heartbeat():
    def stream():
        last_check = None
        while True:
            try:
                with db.get_db() as conn:
                    result = conn.execute(
                        "SELECT MAX(updated_at) as latest FROM tasks"
                    ).fetchone()
                latest = result["latest"] if result else None
                if latest != last_check:
                    last_check = latest
                    yield f"data: {json.dumps({'refresh': True, 'ts': int(time.time())})}\n\n"
                time.sleep(2)
            except GeneratorExit:
                break
            except Exception as e:
                yield f"data: {json.dumps({'error': str(e)})}\n\n"
                time.sleep(5)

    return Response(
        stream(),
        mimetype="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


# ═══════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════


def main():
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, default=3737)
    args = parser.parse_args()

    print(f"\n{'=' * 50}")
    print(f"  clawctl dashboard")
    print(f"{'=' * 50}")
    print(f"\n  Local URL:")
    print(f"  http://localhost:{args.port}/?[REDACTED_SECRET]")
    print(f"\n  For Tailscale/LAN access, use your IP:")
    print(f"  http://<your-ip>:{args.port}/?[REDACTED_SECRET]")
    print(f"\n  [REDACTED_SECRET]")
    print(f"\n{'=' * 50}\n")

    app.run(host="0.0.0.0", port=args.port, threaded=True)


if __name__ == "__main__":
    main()


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"

[project]
name = "clawctl"
version = "0.2.0"
description = "Coordination layer for OpenClaw agent fleets"
requires-python = ">=3.9"
dependencies = ["click", "flask"]

[project.optional-dependencies]
dev = ["pytest", "pytest-cov"]

[project.scripts]
clawctl = "clawctl.cli:cli"

[tool.setuptools.packages.find]
include = ["clawctl*", "dashboard*"]

[tool.setuptools.package-data]
dashboard = ["index.html"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)