Skill: louisval1/tsu-mission-control (version 1.1.2)

--- name: mission-control description: Integrate with Mission Control dashboard to report task progress, publish documents to the Library, request approvals, and submit project requests. Use this skill whenever you start, complete, or fail a task, need human approval, want to publish research/reports/documentation, or need to submit a new project request. This skill provides the HTTP endpoints and event formats for communicating with the Mission Control backend. metadata: { "openclaw": { "emoji": "🎛️", "requires": { "bins": ["node", "npm"] }, "install": [ { "id": "node", "kind": "node", "package": "mission-control-skill", "bins": [], "label": "Install Mission Control skill", }, ], "env": { "MISSION_CONTROL_URL": "http://localhost:8000", "MISSION_CONTROL_HOOK_SECRET": "", }, }, } --- # Mission Control Integration Skill You are connected to a Mission Control dashboard that tracks your work, manages projects and tasks, handles approvals, and stores documents in a Library. This skill tells you how to communicate with it. ## Overview Mission Control is a database-backed dashboard running at `$MISSION_CONTROL_URL` (default: `http://localhost:8000`). It provides: - **Pipeline**: Tasks flow through stages: backlog → todo → doing → review → done - **Inbox**: A unified queue where humans review requests, approve work, and handle workflow gates - **Library**: A curated document store for research, reports, documentation, and reference material - **Costs**: Token spend tracking per session - **Activity**: A chronological feed of all events Your job is to report your progress so the dashboard stays accurate. --- ## Reporting Task Progress When you are assigned a task (you'll see a task ID like `t_abc123`), report your progress at each stage by sending HTTP POST requests to the hooks endpoint. ### Endpoint ``` POST $MISSION_CONTROL_URL/api/hooks/event Content-Type: application/json X-Hook-[REDACTED_SECRET] ``` ### When Starting a Task ```json { "event": "task:started", "agentId": "YOUR_AGENT_ID", "taskId": "TASK_ID", "data": { "message": "Beginning work on [description]" } } ``` ### When Making Progress ```json { "event": "task:progress", "agentId": "YOUR_AGENT_ID", "taskId": "TASK_ID", "data": { "progress": 50, "message": "Completed first draft" } } ``` ### When Submitting for Review Use this when your work is done and ready for human review. This automatically creates a review record in the Inbox with your deliverables and checklist. ```json { "event": "task:review", "agentId": "YOUR_AGENT_ID", "taskId": "TASK_ID", "data": { "work_summary": "Describe what you did and what the human should look at", "deliverables": [ { "name": "report.md", "type": "doc", "path": "/workspace/docs/report.md", "summary": "The main report" }, { "name": "data.json", "type": "file", "path": "/workspace/data/output.json", "summary": "Raw analysis data" } ], "checklist": [ { "label": "Meets requirements", "checked": true }, { "label": "No errors", "checked": true }, { "label": "Well documented", "checked": false } ] } } ``` ### When a Task is Complete (after human approval) ```json { "event": "task:completed", "agentId": "YOUR_AGENT_ID", "taskId": "TASK_ID", "data": { "message": "Task completed successfully" } } ``` ### When a Task Fails ```json { "event": "task:failed", "agentId": "YOUR_AGENT_ID", "taskId": "TASK_ID", "data": { "reason": "Explain what went wrong and why" } } ``` --- ## Publishing to the Library When you produce research, reports, documentation, analysis, or any reference material, publish it to the Library so it's organized and searchable. ### Publishing a New Document ```json { "event": "library:publish", "agentId": "YOUR_AGENT_ID", "data": { "title": "Document Title", "content": "Full document content in markdown, JSON, or plain text", "doc_type": "research", "format": "markdown", "collection_id": "col_research", "projectId": "PROJECT_ID_IF_RELEVANT", "source_path": "/workspace/docs/my-report.md" } } ``` **Document types**: `research`, `report`, `documentation`, `reference`, `template`, `note`, `analysis`, `brief`, `guide`, `other` **Formats**: `markdown`, `html`, `code`, `json`, `text`, `csv` **Default collections** (use these IDs): - `col_research` — Research findings, competitive analysis, market data - `col_reports` — Status reports, weekly summaries, metrics - `col_docs` — Technical documentation, API docs, guides - `col_reference` — Reference material, specifications, standards - `col_templates` — Reusable templates and boilerplate - `col_notes` — Meeting notes, scratch work, ideas ### Updating an Existing Document ```json { "event": "library:update", "agentId": "YOUR_AGENT_ID", "data": { "doc_id": "DOCUMENT_ID", "content": "Updated content", "change_note": "What changed and why" } } ``` --- ## Requesting Approvals When you need human approval before proceeding (deploying to production, making purchases, deleting data, etc.), create a workflow gate: ```json { "event": "approval:needed", "agentId": "YOUR_AGENT_ID", "data": { "type": "workflow_gate", "title": "Deploy to production?", "description": "All tests pass. 3 files changed. Ready to deploy.", "urgency": "urgent", "entityType": "task", "entityId": "TASK_ID", "resumeToken": "UNIQUE_TOKEN_TO_RESUME" } } ``` The human will see this in their Inbox and can approve or reject. If you provided a `resumeToken`, the backend stores it so you can check whether approval was granted. **Urgency levels**: `critical`, `urgent`, `normal`, `low` --- ## Submitting Project Requests When you identify new work that should be done (a bug you found, an improvement idea, research that's needed), submit a project request: ```json { "event": "request:submit", "agentId": "YOUR_AGENT_ID", "data": { "title": "Request title", "description": "Detailed description of what's needed and why", "category": "bug", "urgency": "urgent" } } ``` **Categories**: `feature`, `bug`, `research`, `content`, `ops`, `automation`, `general` --- ## Reporting Session Lifecycle ### When a Session Starts Report session creation so Mission Control tracks active sessions: ```json { "event": "session:created", "agentId": "YOUR_AGENT_ID", "data": { "sessionId": "sess_abc123" } } ``` ### When a Session Ends (Reporting Costs) When your session ends, report token usage so the Costs dashboard stays accurate: ```json { "event": "session:ended", "agentId": "YOUR_AGENT_ID", "data": { "tokens": { "model": "claude-sonnet-4-5", "provider": "anthropic", "input": 45000, "output": 12000, "cacheRead": 30000, "totalCost": 0.82 } } } ``` --- ## Reporting Agent Status ### When Idle (no more tasks) ```json { "event": "agent:idle", "agentId": "YOUR_AGENT_ID", "data": { "message": "All assigned tasks complete" } } ``` ### When an Error Occurs ```json { "event": "agent:error", "agentId": "YOUR_AGENT_ID", "data": { "error": "Description of the error", "context": "What you were trying to do" } } ``` --- ## Using the /mc Shorthand If your session supports slash commands, you can use these shortcuts instead of crafting full HTTP requests: ``` /mc task:started <taskId> /mc task:completed <taskId> /mc task:failed <taskId> <reason> /mc task:review <taskId> /mc task:progress <taskId> <percentage> <message> ``` The lifecycle hook translates these into the proper API calls automatically. --- ## Polling for Pending Events Mission Control pushes events to you via the gateway, but delivery isn't guaranteed (gateway might be down, session might have restarted). At the start of each session, check if any events are waiting for you: ``` GET $MISSION_CONTROL_URL/api/dispatch/pending/YOUR_AGENT_ID ``` Response: ```json { "events": [ { "id": 1, "event": "mc:project_kickoff", "payload": { "type": "mc:event", "event": "mc:project_kickoff", "projectId": "abc", "projectName": "New Feature", "..." }, "created_at": "2026-03-21T20:00:00Z" } ], "count": 1 } ``` Calling this endpoint marks the events as delivered. Process each one according to the event type (see the outbound events section below). To check without consuming: `GET /api/dispatch/pending/YOUR_AGENT_ID?peek=true` To explicitly acknowledge after processing: `POST /api/dispatch/ack` with `{ "ids": [1, 2, 3] }` **Best practice**: Check for pending events at session start and after each heartbeat. --- ## Best Practices 1. **Always report task:started** when you begin work — this moves the task to "In Progress" on the dashboard 2. **Use task:review** when work is done — don't use task:completed directly. Let the human review first. 3. **Include deliverables** in your review submission — list every file you created or modified 4. **Publish to the Library** when you create lasting content — reports, research, documentation 5. **Pick the right collection** — don't dump everything in "Notes". Research goes in Research, docs go in Documentation. 6. **Request approval for risky actions** — deployments, deletions, purchases, external API calls with side effects 7. **Report failures honestly** — include what went wrong and what you tried. The human needs context to help. 8. **Include a work_summary** in reviews — explain what you did, what choices you made, and what the human should focus on when reviewing. --- ## Quick Reference | Event | When to Use | |-------|-------------| | `task:started` | You begin working on an assigned task | | `task:progress` | You want to report incremental progress (0-100%) | | `task:review` | Work is done, ready for human review | | `task:completed` | Task is approved and fully done | | `task:failed` | Something went wrong, task can't be completed | | `library:publish` | You produced a document worth keeping | | `library:update` | You revised an existing Library document | | `approval:needed` | You need human permission before proceeding | | `request:submit` | You identified new work that should be done | | `session:created` | Your session just started | | `session:ended` | Your session is closing, report token usage | | `agent:idle` | You have no more tasks | | `agent:error` | You hit a system-level error | --- ## Events You Will RECEIVE from Mission Control Mission Control sends events back to you when a human makes a decision. These arrive as messages in your session with `type: "mc:event"`. When you receive one, act on it. ### mc:review_approved Your work was approved. The task is now "done". Look for the next task. **Fields**: `taskId`, `taskTitle`, `projectId`, `projectName`, `round`, `qualityScore`, `notes`, `nextAction` **What to do**: Check if the project has more tasks in "todo" stage. If so, pick the next one and start it. ### mc:changes_requested Your work needs revisions. The task has been moved back to "doing". Read the feedback and fix the issues. **Fields**: `taskId`, `taskTitle`, `round`, `feedback`, `currentStage` **What to do**: Read the `feedback` field carefully. It contains the human's specific concerns. Revise your work, then resubmit with `task:review`. ### mc:review_rejected Your work was rejected. The task is back in "todo" and may be reassigned. **Fields**: `taskId`, `taskTitle`, `round`, `reason`, `currentStage` **What to do**: Read the `reason`. If you can address it, pick the task back up. If not, move on to other work. ### mc:approval_granted A workflow gate you requested was approved. Resume your paused work. **Fields**: `approvalId`, `approvalType`, `title`, `resumeToken`, `entityType`, `entityId`, `notes`, `nextAction` **What to do**: If `resumeToken` is present, use it to resume the paused workflow. If `nextAction` is "resume_workflow", continue from where you stopped. ### mc:approval_denied A workflow gate you requested was denied. **Fields**: `approvalId`, `approvalType`, `title`, `entityType`, `entityId`, `reason`, `nextAction` **What to do**: Read the `reason`. You may need to take a different approach or abandon the action. ### mc:project_kickoff A new project was created (from an approved request or directly). You are the owner agent. Start planning. **Fields**: `projectId`, `projectName`, `description`, `priority`, `fromRequest`, `requestTitle`, `hint` **What to do**: Break the project down into tasks. Create them via `POST /api/tasks` with `project_id` set. Put tasks in "backlog" or "todo" stage. Then start working on the highest-priority one. ### mc:task_assigned A task was assigned to you. **Fields**: `taskId`, `taskTitle`, `projectId`, `priority`, `currentStage`, `previousAgent`, `nextAction` **What to do**: If `currentStage` is "todo", start when ready. If "doing", continue work immediately. ### mc:task_resume A task you own was moved back to "doing" (e.g., after changes requested). **Fields**: `taskId`, `taskTitle`, `fromStage`, `toStage`, `movedBy`, `nextAction` **What to do**: Resume work on this task. Check if there's review feedback in the task metadata. ### mc:task_queued A task you own was moved to "todo" — it's queued and ready for you to pick up. **Fields**: `taskId`, `taskTitle`, `fromStage`, `movedBy`, `nextAction` **What to do**: This task is available for you to start. When you're ready, send `task:started` and begin work. ### mc:project_activated A project you own was activated. Start working on its tasks. **Fields**: `projectId`, `projectName`, `hint` **What to do**: Check the `hint` field for the API endpoint to find available tasks. Start with the highest priority one. ### mc:project_paused A project you own was paused. Stop work on its tasks. **Fields**: `projectId`, `projectName`, `reason` **What to do**: Stop working on tasks from this project. Focus on other projects or report idle. ### mc:project_completed All tasks in a project are done. **Fields**: `projectId`, `projectName` **What to do**: Acknowledge completion. Consider publishing a summary to the Library. ### mc:stall_nudge You haven't reported progress in a while. Mission Control wants a status update. **Fields**: `taskId`, `taskTitle`, `stallMinutes`, `hint` **What to do**: Send a `task:progress` event immediately with your current status. If you're stuck, send `task:failed` with the reason. --- ## Outbound Events Quick Reference | Event | Meaning | Your Action | |-------|---------|-------------| | `mc:review_approved` | Work accepted | Find next task | | `mc:changes_requested` | Revisions needed | Read feedback, fix, resubmit | | `mc:review_rejected` | Work rejected | Read reason, task back to todo | | `mc:approval_granted` | Gate cleared | Resume paused workflow | | `mc:approval_denied` | Gate denied | Handle denial, try alternative | | `mc:project_kickoff` | New project, you're owner | Plan tasks, break down work | | `mc:task_assigned` | Task assigned to you | Start or continue work | | `mc:task_resume` | Task moved back to doing | Resume work | | `mc:task_queued` | Task moved to todo | Pick up when ready | | `mc:project_activated` | Project is now active | Start working on tasks | | `mc:project_paused` | Project paused | Stop work on its tasks | | `mc:project_completed` | All tasks done | Acknowledge, maybe publish summary | | `mc:stall_nudge` | No progress reported | Send status update immediately | --- ## Trigger Contracts — What Happens When a Human Decides These are the complete end-to-end chains from a human decision in the dashboard to the expected agent response. If you receive one of these events, you MUST act on it — the human is waiting. ### Request Approved → Project Kickoff **Human action**: Approves a request in the Inbox, or clicks "Convert to Project" on a request. **What Mission Control does**: 1. Creates a new project in the database (status: `active`) 2. Links it to the original request 3. Dispatches `mc:project_kickoff` to the owner agent **Event you receive**: `mc:project_kickoff` **Your required response**: 1. Read the `description` and `priority` fields 2. Break the project into tasks — send `POST $MISSION_CONTROL_URL/api/tasks` for each one: ```json { "project_id": "PROJECT_ID_FROM_EVENT", "title": "Task name", "description": "What to do", "priority": "high", "pipeline_stage": "todo", "assigned_agent": "YOUR_AGENT_ID" } ``` 3. Pick the highest-priority task and send `task:started` to begin work 4. If you can't plan the project (unclear requirements), send a `request:submit` event asking for clarification **If you don't respond**: The project sits empty with zero tasks. The human sees it on the dashboard with 0% progress and no activity. ### Project Activated → Start Working **Human action**: Sets a project's status to "active" (either via the Projects page or by approving a request). **What Mission Control does**: 1. Updates the project status in the database 2. Dispatches `mc:project_activated` to the owner agent **Event you receive**: `mc:project_activated` **Your required response**: 1. Query available tasks: `GET $MISSION_CONTROL_URL/api/tasks?project_id=PROJECT_ID&pipeline_stage=todo` 2. If tasks exist, pick the highest-priority one and send `task:started` 3. If no tasks exist, create them first (same as kickoff flow above) **If you don't respond**: The project shows as "active" but with no tasks moving. The stall detector will eventually flag it. ### Review Approved → Next Task **Human action**: Approves a review in the Inbox. **What Mission Control does**: 1. Moves the task to "done" stage 2. Checks if all project tasks are done (if so, completes the project) 3. Dispatches `mc:review_approved` to the agent who submitted the review **Event you receive**: `mc:review_approved` **Your required response**: 1. Check the `nextTaskHint` field — it contains the API URL to find the next todo task 2. If there's a next task, send `task:started` and begin work 3. If no more tasks in the project, send `agent:idle` 4. Consider publishing a progress report to the Library **If you don't respond**: The completed task shows as done, but the next task in the project stays in "todo" with nobody working on it. ### Changes Requested → Revise and Resubmit **Human action**: Requests changes on a review. **What Mission Control does**: 1. Moves the task back to "doing" stage 2. Stores the feedback in the task metadata 3. Dispatches `mc:changes_requested` to the agent **Event you receive**: `mc:changes_requested` **Your required response**: 1. Read the `feedback` field — it contains the human's specific concerns 2. Revise your work to address each point 3. Resubmit with `task:review` including updated deliverables and a new `work_summary` that explains what you changed **If you don't respond**: The task sits in "doing" with review feedback that nobody acts on. The stall detector will flag it.

path: hook.ts
content: #!/usr/bin/env node

/**
 * Mission Control Hook for OpenClaw
 *
 * Install:
 *   cp mission-control-hook.ts ~/.openclaw/hooks/
 *   openclaw hooks enable mission-control
 *
 * Or add to openclaw.json:
 *   {
 *     "hooks": {
 *       "internal": {
 *         "enabled": true,
 *         "entries": {
 *           "mission-control": {
 *             "enabled": true,
 *             "env": {
 *               "MISSION_CONTROL_URL": "http://localhost:8000",
 *               "MISSION_CONTROL_HOOK_SECRET": "change-me-to-something-random"
 *             }
 *           }
 *         }
 *       }
 *     }
 *   }
 *
 * This hook intercepts OpenClaw lifecycle events and POSTs them to the
 * Mission Control backend. This is how the database stays in sync with
 * agent activity WITHOUT relying on session continuity.
 */

const MC_URL = process.env.MISSION_CONTROL_URL || "http://localhost:8000";
const HOOK_SECRET = process.env.MISSION_CONTROL_HOOK_SECRET || "";

async function postEvent(event, agentId, taskId, data) {
  try {
    const res = await fetch(`${MC_URL}/api/hooks/event`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Hook-Secret": HOOK_SECRET,
      },
      body: JSON.stringify({
        event,
        agentId,
        taskId,
        data,
        timestamp: new Date().toISOString(),
      }),
    });
    if (!res.ok) {
      console.error(`[mission-control-hook] POST failed: ${res.status}`);
    }
  } catch (err) {
    console.error(`[mission-control-hook] Error: ${err.message}`);
  }
}

// ─── Hook Exports ─────────────────────────────────────
// These match OpenClaw's hook lifecycle events.

export async function onSessionCreated({ agentId, sessionKey }) {
  await postEvent("session:created", agentId, null, { sessionKey });
}

export async function onSessionEnded({ agentId, sessionKey }) {
  await postEvent("session:ended", agentId, null, { sessionKey });
}

export async function onAgentIdle({ agentId }) {
  await postEvent("agent:idle", agentId, null, {});
}

export async function onAgentError({ agentId, error }) {
  await postEvent("agent:error", agentId, null, { error: error?.message || String(error) });
}

export async function onToolCall({ agentId, tool, args }) {
  // If the agent is calling a tool related to task management,
  // extract task context and report progress
  if (tool === "exec" || tool === "write" || tool === "edit") {
    // Check if there's a task ID in the current session context
    const taskId = args?.metadata?.taskId || null;
    if (taskId) {
      await postEvent("task:progress", agentId, taskId, {
        tool,
        message: `Agent using ${tool} tool`,
      });
    }
  }
}

export async function onHeartbeat({ agentId }) {
  await postEvent("agent:idle", agentId, null, { source: "heartbeat" });
}

// ─── Custom Commands ──────────────────────────────────
// Agents can send explicit task lifecycle updates via commands:
//
//   /mc task:started <taskId>
//   /mc task:completed <taskId>
//   /mc task:failed <taskId> <error message>
//   /mc task:review <taskId>

export async function onCommand({ agentId, command, args }) {
  if (!command.startsWith("/mc ")) return;

  const parts = command.slice(4).trim().split(/\s+/);
  const event = parts[0];
  const taskId = parts[1];
  const rest = parts.slice(2).join(" ");

  if (event && taskId) {
    await postEvent(event, agentId, taskId, {
      message: rest || undefined,
      error: event === "task:failed" ? (rest || "Task failed") : undefined,
    });
  }
}


path: README.md
content: # Mission Control V1 — OpenClaw Skill

A complete operations dashboard for managing OpenClaw agent fleets. This is a self-contained skill package — it includes the full dashboard application, lifecycle hook, agent instructions, and all documentation.

**One command to install everything:**

```bash
./setup.sh
```

---

## What's Inside

```
mission-control-skill/
├── SKILL.md                    ← Agent instructions (injected into system prompt)
├── README.md                   ← This file
├── setup.sh                    ← All-in-one installer
├── hook.ts                     ← Lifecycle hook (agents → dashboard bridge)
├── docs/
│   ├── GETTING-STARTED.md      ← Step-by-step beginner guide
│   ├── API-REFERENCE.md        ← All 60+ API endpoints
│   ├── HOOK-EVENTS.md          ← Every hook event with JSON examples
│   ├── LIBRARY-GUIDE.md        ← Document Library usage
│   ├── TROUBLESHOOTING.md      ← Common problems and fixes
│   ├── DOCKER.md               ← Docker deployment guide
│   └── CHANGELOG.md            ← Version history
└── app/
    ├── backend/                ← Express + SQLite (11 route files, 3 services)
    │   ├── migrations/         ← 4 SQL migration files
    │   ├── src/
    │   │   ├── routes/         ← projects, tasks, agents, inbox, library, etc.
    │   │   └── services/       ← SSE events, gateway sync, stall detector
    │   └── .env.example
    ├── frontend/               ← React dashboard (8 views)
    │   └── src/pages/          ← Dashboard, Inbox, Pipeline, Projects,
    │                              Library, Agents, Costs, Activity
    ├── package.json            ← Root scripts (install:all, dev)
    ├── Dockerfile
    └── docker-compose.yml
```

---

## Installation

### Option A: Interactive Setup

```bash
cd mission-control-skill
./setup.sh
```

The script asks for your gateway URL, generates a secret, installs the app, configures the hook, and tells you how to start.

### Option B: Automated Setup

```bash
./setup.sh --auto
```

Uses all defaults: port 8000, auto-generated secret, local gateway at ws://127.0.0.1:18789.

### Option C: Docker

```bash
./setup.sh --docker
```

Uses Docker Compose instead of local Node.js.

### Option D: From ClawHub

```bash
clawhub install tsu-mission-control
cd ~/.openclaw/skills/mission-control
./setup.sh
```

---

## Starting the Dashboard

```bash
cd ~/.openclaw/mission-control
npm run dev
```

Open **http://localhost:4173** in your browser.

---

## What the Skill Does for Agents

When this skill is loaded into an agent's context (via the `SKILL.md` file), the agent learns how to:

- **Report task progress** — start, progress, review, complete, fail
- **Submit work for review** — with deliverables, checklists, and summaries
- **Publish documents** — research, reports, and docs go to the Library
- **Request approvals** — workflow gates for risky actions
- **Submit project requests** — when agents identify new work
- **Report costs** — token usage per session

All communication happens via HTTP POST to the Mission Control backend. The lifecycle hook (`hook.ts`) handles the translation from OpenClaw events to API calls.

---

## Dashboard Views

| View | What It Does |
|------|-------------|
| **Dashboard** | Stats, pipeline overview, agent status, alerts, recent activity |
| **Inbox** | Unified queue: requests + reviews + approvals in one sorted list |
| **Pipeline** | Kanban board with drag-and-drop and enforced stage transitions |
| **Projects** | Project CRUD with progress bars and agent assignment |
| **Library** | Document catalog with collections, search, multi-format reader |
| **Agents** | Agent monitoring with status, model info, task counts |
| **Costs** | Token spend tracking with daily trends and breakdowns |
| **Activity** | Chronological feed of all system events |

---

## Documentation

| Doc | What It Covers |
|-----|---------------|
| [GETTING-STARTED.md](docs/GETTING-STARTED.md) | Step-by-step guide for first-time setup. Assumes no technical knowledge. |
| [API-REFERENCE.md](docs/API-REFERENCE.md) | Every API endpoint with methods, parameters, and example payloads. |
| [HOOK-EVENTS.md](docs/HOOK-EVENTS.md) | All 12 hook events agents can send, with complete JSON examples. |
| [LIBRARY-GUIDE.md](docs/LIBRARY-GUIDE.md) | How the Library works: collections, types, tags, formats, versioning, search. |
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Solutions for install errors, empty dashboards, hook failures, and more. |
| [DOCKER.md](docs/DOCKER.md) | Running everything in Docker containers. |
| [CHANGELOG.md](docs/CHANGELOG.md) | Version history and what changed. |

---

## Publishing to ClawHub

```bash
clawhub publish ./mission-control-skill \
  --slug tsu-mission-control \
  --name "Mission Control" \
  --version 1.0.0 \
  --tags latest
```

---

## License

MIT


path: setup.sh
content: #!/usr/bin/env bash
# ══════════════════════════════════════════════════════════
# Mission Control — All-in-One Setup
#
# This script:
#   1. Checks prerequisites (Node.js, OpenClaw, npm)
#   2. Installs the dashboard app (backend + frontend)
#   3. Installs the lifecycle hook
#   4. Configures openclaw.json
#   5. Creates the backend .env file
#   6. Installs all npm dependencies
#   7. Tells you how to start everything
#
# Usage:
#   ./setup.sh                          # interactive
#   ./setup.sh --auto                   # auto with defaults
#   ./setup.sh --url http://host:8000 --secret mysecret --gateway-token abc
#   ./setup.sh --docker                 # use Docker instead of local Node
# ══════════════════════════════════════════════════════════
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OPENCLAW_DIR="${OPENCLAW_DIR:-$HOME/.openclaw}"
HOOKS_DIR="$OPENCLAW_DIR/hooks"
CONFIG_FILE="$OPENCLAW_DIR/openclaw.json"
INSTALL_DIR="$OPENCLAW_DIR/mission-control"

# Defaults
MC_PORT="8000"
MC_URL="http://localhost:$MC_PORT"
MC_SECRET=""
GW_URL="ws://127.0.0.1:18789"
GW_TOKEN=""
USE_DOCKER=false
AUTO_MODE=false

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color

info()    { echo -e "${CYAN}   $1${NC}"; }
success() { echo -e "${GREEN}   ✅ $1${NC}"; }
warn()    { echo -e "${YELLOW}   ⚠️  $1${NC}"; }
error()   { echo -e "${RED}   ❌ $1${NC}"; }
header()  { echo -e "\n${BLUE}── $1 ──${NC}"; }

# ── Parse Arguments ──────────────────────────────────────
while [[ $# -gt 0 ]]; do
  case "$1" in
    --url)           MC_URL="$2"; shift 2 ;;
    --port)          MC_PORT="$2"; MC_URL="http://localhost:$2"; shift 2 ;;
    --secret)        MC_SECRET="$2"; shift 2 ;;
    --gateway-url)   GW_URL="$2"; shift 2 ;;
    --gateway-token) GW_TOKEN="$2"; shift 2 ;;
    --docker)        USE_DOCKER=true; shift ;;
    --auto)          AUTO_MODE=true; shift ;;
    --install-dir)   INSTALL_DIR="$2"; shift 2 ;;
    --help|-h)
      echo "Mission Control — All-in-One Setup"
      echo ""
      echo "Usage: ./setup.sh [options]"
      echo ""
      echo "Options:"
      echo "  --auto              Use defaults, skip all prompts"
      echo "  --docker            Use Docker Compose instead of local Node"
      echo "  --port PORT         Backend port (default: 8000)"
      echo "  --url URL           Backend URL (default: http://localhost:8000)"
      echo "  --secret SECRET     Hook authentication secret"
      echo "  --gateway-url URL   OpenClaw Gateway WebSocket URL"
      echo "  --gateway-token TK  OpenClaw Gateway auth token"
      echo "  --install-dir DIR   Where to install (default: ~/.openclaw/mission-control)"
      echo "  --help              Show this help"
      exit 0
      ;;
    *) error "Unknown option: $1"; exit 1 ;;
  esac
done

# ══════════════════════════════════════════════════════════
echo ""
echo -e "${BLUE}🎛️  Mission Control V1 — Setup${NC}"
echo "════════════════════════════════════════"
echo ""

# ── Step 1: Check Prerequisites ─────────────────────────
header "Step 1/7: Checking prerequisites"

# Check Node.js
if command -v node &>/dev/null; then
  NODE_VER=$(node --version | sed 's/v//')
  NODE_MAJOR=$(echo "$NODE_VER" | cut -d. -f1)
  if [[ "$NODE_MAJOR" -ge 22 ]]; then
    success "Node.js $NODE_VER"
  else
    warn "Node.js $NODE_VER found, but v22+ recommended"
  fi
else
  if [[ "$USE_DOCKER" == true ]]; then
    info "Node.js not found, but --docker mode doesn't need it locally"
  else
    error "Node.js not found. Install it from https://nodejs.org"
    echo "     Or use --docker mode: ./setup.sh --docker"
    exit 1
  fi
fi

# Check npm
if command -v npm &>/dev/null; then
  success "npm $(npm --version)"
elif [[ "$USE_DOCKER" == false ]]; then
  error "npm not found. It comes with Node.js — reinstall from https://nodejs.org"
  exit 1
fi

# Check OpenClaw
if command -v openclaw &>/dev/null; then
  success "OpenClaw CLI found"
else
  warn "OpenClaw CLI not found. The hook won't auto-enable."
  warn "Install OpenClaw: curl -fsSL https://openclaw.ai/install.sh | bash"
fi

# Check Docker (if needed)
if [[ "$USE_DOCKER" == true ]]; then
  if command -v docker &>/dev/null && docker compose version &>/dev/null; then
    success "Docker + Compose found"
  else
    error "Docker or Docker Compose not found. Install Docker from https://docker.com"
    exit 1
  fi
fi

# Check OpenClaw directory
if [[ -d "$OPENCLAW_DIR" ]]; then
  success "OpenClaw directory: $OPENCLAW_DIR"
else
  warn "$OPENCLAW_DIR not found — creating it"
  mkdir -p "$OPENCLAW_DIR"
fi

# ── Step 2: Gather Configuration ────────────────────────
header "Step 2/7: Configuration"

# Generate a random secret if needed
if [[ -z "$MC_SECRET" ]]; then
  MC_SECRET=$(openssl rand -hex 16 2>/dev/null || head -c 32 /dev/urandom | xxd -p 2>/dev/null | head -c 32 || echo "change-me-$(date +%s)")
fi

if [[ "$AUTO_MODE" == false ]]; then
  echo ""
  info "Press Enter to accept defaults shown in [brackets]."
  echo ""

  read -rp "   Backend port [$MC_PORT]: " input
  MC_PORT="${input:-$MC_PORT}"
  MC_URL="http://localhost:$MC_PORT"

  read -rp "   Hook secret [$MC_SECRET]: " input
  MC_SECRET="${input:-$MC_SECRET}"

  read -rp "   Gateway WebSocket URL [$GW_URL]: " input
  GW_URL="${input:-$GW_URL}"

  read -rp "   Gateway token (leave empty if none) [$GW_TOKEN]: " input
  GW_TOKEN="${input:-$GW_TOKEN}"

  read -rp "   Install directory [$INSTALL_DIR]: " input
  INSTALL_DIR="${input:-$INSTALL_DIR}"
fi

echo ""
info "Port:          $MC_PORT"
info "URL:           $MC_URL"
info "[REDACTED_SECRET]"
info "Gateway:       $GW_URL"
info "Install dir:   $INSTALL_DIR"
echo ""

# ── Step 3: Install Dashboard App ───────────────────────
header "Step 3/7: Installing dashboard"

if [[ -d "$INSTALL_DIR" ]]; then
  warn "Directory already exists: $INSTALL_DIR"
  if [[ "$AUTO_MODE" == false ]]; then
    read -rp "   Overwrite? [y/N]: " confirm
    if [[ "$confirm" != [yY] ]]; then
      info "Keeping existing installation. Skipping file copy."
    else
      rm -rf "$INSTALL_DIR"
      cp -r "$SCRIPT_DIR/app" "$INSTALL_DIR"
      success "Dashboard installed to $INSTALL_DIR"
    fi
  else
    rm -rf "$INSTALL_DIR"
    cp -r "$SCRIPT_DIR/app" "$INSTALL_DIR"
    success "Dashboard installed to $INSTALL_DIR"
  fi
else
  cp -r "$SCRIPT_DIR/app" "$INSTALL_DIR"
  success "Dashboard installed to $INSTALL_DIR"
fi

# Copy docs into install directory
mkdir -p "$INSTALL_DIR/docs"
if [[ -d "$SCRIPT_DIR/docs" ]]; then
  cp -r "$SCRIPT_DIR/docs/"* "$INSTALL_DIR/docs/" 2>/dev/null || true
fi
cp "$SCRIPT_DIR/README.md" "$INSTALL_DIR/" 2>/dev/null || true

# Generate files that can't be stored in ClawHub (non-text)
cat > "$INSTALL_DIR/.gitignore" <<'GITIGNORE'
node_modules/
backend/data/
backend/.env
*.db
GITIGNORE

cat > "$INSTALL_DIR/Dockerfile" <<'DOCKERFILE'
FROM node:22-slim
WORKDIR /app
COPY package.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
RUN npm run install:all
COPY . .
RUN cd frontend && npm run build
EXPOSE 8000
CMD ["node", "backend/src/server.js"]
DOCKERFILE

mkdir -p "$INSTALL_DIR/frontend/public"
cat > "$INSTALL_DIR/frontend/public/favicon.svg" <<'SVG'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#6366f1"/><text x="16" y="22" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="700" font-size="16">MC</text></svg>
SVG

success "Generated Dockerfile, .gitignore, and favicon"

# ── Step 4: Create .env File ────────────────────────────
header "Step 4/7: Creating configuration file"

ENV_FILE="$INSTALL_DIR/backend/.env"
cat > "$ENV_FILE" <<EOF
# ─── Mission Control Backend ───────────────────────────
PORT=$MC_PORT

# ─── OpenClaw Gateway ─────────────────────────────────
OPENCLAW_GATEWAY_URL=$GW_URL
OPENCLAW_GATEWAY_TOKEN=$GW_TOKEN

# ─── Authentication ───────────────────────────────────
AUTH_MODE=none
LOCAL_AUTH_TOKEN=

# ─── Hook Authentication ──────────────────────────────
HOOK_SECRET=$MC_SECRET

# ─── CORS ─────────────────────────────────────────────
CORS_ORIGIN=http://localhost:4173
EOF

success "Created $ENV_FILE"

# ── Step 5: Install Dependencies ────────────────────────
header "Step 5/7: Installing dependencies"

if [[ "$USE_DOCKER" == true ]]; then
  info "Docker mode — skipping npm install (Docker handles it)"
else
  cd "$INSTALL_DIR"
  info "Installing packages (this takes 1-3 minutes)..."
  npm run install:all 2>&1 | tail -3
  success "Dependencies installed"
fi

# ── Step 6: Install Lifecycle Hook ──────────────────────
header "Step 6/7: Installing lifecycle hook"

mkdir -p "$HOOKS_DIR"
cp "$SCRIPT_DIR/hook.ts" "$HOOKS_DIR/mission-control-hook.ts"
success "Hook copied to $HOOKS_DIR/mission-control-hook.ts"

# Try to enable via CLI
if command -v openclaw &>/dev/null; then
  if openclaw hooks enable mission-control 2>/dev/null; then
    success "Hook enabled via OpenClaw CLI"
  else
    warn "Could not auto-enable hook. Run manually: openclaw hooks enable mission-control"
  fi
else
  warn "OpenClaw CLI not available — enable the hook manually after installing OpenClaw"
fi

# ── Step 7: Update openclaw.json ────────────────────────
header "Step 7/7: Configuring OpenClaw"

if [[ -f "$CONFIG_FILE" ]]; then
  if grep -q "mission-control" "$CONFIG_FILE" 2>/dev/null; then
    warn "mission-control already exists in $CONFIG_FILE"
    info "Verify these values match:"
    info "  MISSION_CONTROL_URL: $MC_URL"
    info "  MISSION_CONTROL_HOOK_SECRET: $MC_SECRET"
  else
    warn "openclaw.json exists. Please add this to the hooks.internal.entries section:"
    echo ""
    echo '     "mission-control": {'
    echo '       "enabled": true,'
    echo '       "env": {'
    echo "         \"MISSION_CONTROL_URL\": \"$MC_URL\","
    echo "         \"MISSION_CONTROL_HOOK_SECRET\": \"$MC_SECRET\""
    echo '       }'
    echo '     }'
    echo ""
    info "See docs/GETTING-STARTED.md Step 8 for details."
  fi
else
  cat > "$CONFIG_FILE" <<EOF
{
  "hooks": {
    "internal": {
      "enabled": true,
      "entries": {
        "mission-control": {
          "enabled": true,
          "env": {
            "MISSION_CONTROL_URL": "$MC_URL",
            "MISSION_CONTROL_HOOK_SECRET": "$MC_SECRET"
          }
        }
      }
    }
  }
}
EOF
  success "Created $CONFIG_FILE with hook configuration"
fi

# ── Done ─────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo -e "${GREEN}🎛️  Setup complete!${NC}"
echo "════════════════════════════════════════"
echo ""

if [[ "$USE_DOCKER" == true ]]; then
  echo "  Start Mission Control:"
  echo -e "    ${CYAN}cd $INSTALL_DIR && docker compose up -d${NC}"
else
  echo "  Start Mission Control:"
  echo -e "    ${CYAN}cd $INSTALL_DIR && npm run dev${NC}"
fi

echo ""
echo "  Then open in your browser:"
echo -e "    ${CYAN}http://localhost:4173${NC}"
echo ""
echo "  Restart your OpenClaw gateway to activate the hook:"
echo -e "    ${CYAN}openclaw gateway restart${NC}"
echo ""
echo "  Documentation:"
echo "    docs/GETTING-STARTED.md   — Step-by-step beginner guide"
echo "    docs/API-REFERENCE.md     — All API endpoints"
echo "    docs/HOOK-EVENTS.md       — Hook event reference"
echo "    docs/LIBRARY-GUIDE.md     — Using the document Library"
echo "    docs/TROUBLESHOOTING.md   — Common problems and fixes"
echo "    docs/DOCKER.md            — Docker deployment"
echo ""


path: docs/API-REFERENCE.md
content: # API Reference

Mission Control backend runs at `http://localhost:8000` by default. All endpoints return JSON.

---

## Projects

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/projects` | List all projects. Filter: `?status=active&owner=main` |
| GET | `/api/projects/:id` | Get project with task counts |
| POST | `/api/projects` | Create project |
| PATCH | `/api/projects/:id` | Update project fields |
| DELETE | `/api/projects/:id` | Delete project (cascades tasks) |

**Create project body:**
```json
{ "name": "My Project", "description": "...", "priority": "high", "owner_agent": "main" }
```

---

## Tasks

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tasks` | List tasks. Filter: `?project_id=X&pipeline_stage=doing` |
| GET | `/api/tasks/:id` | Get task with activity and tags |
| POST | `/api/tasks` | Create task |
| PATCH | `/api/tasks/:id` | Update task fields |
| POST | `/api/tasks/:id/move` | Move task to new pipeline stage |
| DELETE | `/api/tasks/:id` | Delete task |
| GET | `/api/tasks/pipeline/overview` | All tasks grouped by stage |

**Move task body:**
```json
{ "to_stage": "doing", "actor": "main" }
```

**Pipeline stages and valid transitions:**
```
backlog → todo
todo    → doing, backlog
doing   → review, todo
review  → done, doing
done    → (terminal)
```

---

## Unified Inbox

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/inbox` | Merged queue of requests, reviews, approvals. Filter: `?type=review&status=pending` |
| GET | `/api/inbox/counts` | Badge counts: `{ requests, reviews, approvals, total }` |

Items are sorted: pending first, then by urgency, then newest.

---

## Requests

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/requests` | List project requests |
| POST | `/api/requests` | Submit a request |
| POST | `/api/requests/:id/triage` | Triage: approve, reject, defer |
| POST | `/api/requests/:id/convert` | Convert approved request to project |

---

## Reviews

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/reviews` | List reviews. Filter: `?status=pending` |
| GET | `/api/reviews/:id` | Full review with comments and versions |
| POST | `/api/reviews` | Create a review (usually done by hook) |
| POST | `/api/reviews/:id/comment` | Add a comment |
| POST | `/api/reviews/:id/decide` | Approve, request changes, or reject |
| GET | `/api/reviews/stats/summary` | Aggregate metrics |

**Decide body:**
```json
{ "decision": "approved", "notes": "Looks good", "quality_score": 5, "reviewer": "user" }
```

---

## Approvals

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/approvals` | List approvals |
| POST | `/api/approvals` | Create an approval gate |
| POST | `/api/approvals/:id/decide` | Approve or reject |

---

## Library

### Collections

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/library/collections` | List all collections (returns tree + flat) |
| POST | `/api/library/collections` | Create a collection |
| PATCH | `/api/library/collections/:id` | Update collection |
| DELETE | `/api/library/collections/:id` | Delete (moves docs to uncategorized) |

### Documents

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/library/documents` | Browse documents. Filter: `?collection_id=X&doc_type=research&status=published` |
| GET | `/api/library/documents/:id` | Full document with content, tags, version history |
| POST | `/api/library/documents` | Create/publish a document |
| PATCH | `/api/library/documents/:id` | Update (creates new version if content changes) |
| DELETE | `/api/library/documents/:id` | Delete document |
| GET | `/api/library/documents/:id/versions/:v` | Get specific version content |

### Search & Tags

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/library/search?q=term` | Full-text search across titles and content |
| GET | `/api/library/tags` | List all tags with doc counts |
| POST | `/api/library/tags` | Create a tag |
| GET | `/api/library/stats` | Library statistics |

---

## Agents

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/agents` | List all agents with task counts |
| PUT | `/api/agents/:id` | Upsert agent (used by gateway sync) |
| POST | `/api/agents/:id/heartbeat` | Record heartbeat |

---

## Costs

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/costs/summary?days=30` | Cost dashboard: totals, daily, by agent, by model, by project |
| POST | `/api/costs` | Record a cost entry |

---

## Events & Monitoring

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/events/stream` | SSE stream for real-time push |
| GET | `/api/events/stats` | Dashboard statistics |
| GET | `/api/events/activity?limit=50` | Activity log |
| GET | `/api/health` | Health check with pending counts |

---

## Hooks

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/hooks/event` | Receive lifecycle events from OpenClaw |

**Headers:**
```
Content-Type: application/json
X-Hook-[REDACTED_SECRET]
```

See `HOOK-EVENTS.md` for all supported events and payloads.

---

## Dispatch (Outbound Events)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/dispatch/pending/:agentId` | Poll for undelivered events. Marks as delivered. |
| GET | `/api/dispatch/pending/:agentId?peek=true` | Peek at events without consuming them |
| GET | `/api/dispatch/pending/:agentId/count` | Quick count of pending events |
| POST | `/api/dispatch/ack` | Acknowledge events by ID: `{ "ids": [1, 2] }` |

Events are queued in `pending_dispatches` whenever a human makes a decision (approves, rejects, converts, etc.). Mission Control also tries to push them via the gateway's `/hooks/agent` HTTP endpoint and WebSocket `agent` method. The polling path is the guaranteed fallback.


path: docs/CHANGELOG.md
content: # Changelog

## V1.1.1

### Bug Fixes

- **Fixed hook auth header mismatch**: SKILL.md, HOOK-EVENTS.md, and API-REFERENCE.md incorrectly documented `Authorization: Bearer` but the backend validates `X-Hook-Secret`. All docs now correctly show `X-Hook-[REDACTED_SECRET]`.
- **Fixed SQLite migration crash**: `cost_daily` table used `COALESCE(project_id, '')` in a PRIMARY KEY constraint, which is not valid SQLite syntax. Replaced with `INTEGER PRIMARY KEY AUTOINCREMENT` + `UNIQUE(date, agent_id, model, project_id)` and changed `project_id` to `NOT NULL DEFAULT ''`. Also fixed matching `ON CONFLICT` clauses in `costs.js` and `hooks.js`.
- **Fixed ClawHub slug**: Docs referenced `clawhub install mission-control` but the actual published slug is `tsu-mission-control`.
- **Fixed dispatcher not reaching agents**: `sessions.send` was never a valid gateway RPC method. Rewrote dispatcher with three delivery paths: (1) HTTP POST to gateway `/hooks/agent` endpoint with `x-openclaw-token` header, (2) WebSocket `agent` method with `idempotencyKey`, (3) persistent `pending_dispatches` SQLite queue that agents poll via `GET /api/dispatch/pending/:agentId`.
- **Fixed project kickoff never firing**: Request conversion created projects with status `approved` instead of `active`, so `onProjectActivated` never triggered and agents were never told to start. Conversion now creates projects as `active`.

### New: Dispatch Delivery System

- **Pending dispatches queue**: New `pending_dispatches` table (migration 005) stores every outbound event as a guaranteed delivery floor. Events are written here first, then pushed via HTTP/WS. If push succeeds, the pending record is cleaned up. If push fails, the agent picks it up on next poll.
- **Polling API**: New route `GET /api/dispatch/pending/:agentId` returns undelivered events and marks them delivered. Also: `?peek=true` to look without consuming, `/count` for quick checks, `POST /api/dispatch/ack` for explicit acknowledgment.
- **Natural language formatting**: Every outbound event is formatted into a plain-language message with `ACTION REQUIRED` callouts, so agents can act even without SKILL.md loaded. Each message includes the exact API calls needed.
- **Auto-cleanup**: Stall detector's periodic sweep now deletes delivered dispatches older than 24h and all dispatches older than 72h.

### Documentation

- **Added Trigger Contracts section** to SKILL.md: Four complete end-to-end workflow chains (Request Approved → Project Kickoff, Project Activated → Start Working, Review Approved → Next Task, Changes Requested → Revise and Resubmit) with exact agent response requirements and failure consequences.
- **Added LAN/systemd runbook** to GETTING-STARTED.md: How to bind frontend to `0.0.0.0`, set CORS for LAN access, and full systemd user service unit files for running as persistent services.

### Upgrading from V1.1.0

If you already have a database from V1.1.0, the `cost_daily` table schema changed. Delete the old database and let it recreate:

```bash
rm ~/.openclaw/mission-control/backend/data/mission-control.db
cd ~/.openclaw/mission-control && npm run dev
```

Or manually fix the existing database:

```sql
ALTER TABLE cost_daily RENAME TO cost_daily_old;
CREATE TABLE cost_daily (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  date TEXT NOT NULL, agent_id TEXT NOT NULL,
  model TEXT NOT NULL DEFAULT '', project_id TEXT NOT NULL DEFAULT '',
  total_input INTEGER DEFAULT 0, total_output INTEGER DEFAULT 0,
  total_cache INTEGER DEFAULT 0, total_cost_usd REAL DEFAULT 0,
  run_count INTEGER DEFAULT 0,
  UNIQUE (date, agent_id, model, project_id)
);
INSERT INTO cost_daily (date, agent_id, model, project_id, total_input, total_output, total_cache, total_cost_usd, run_count)
  SELECT date, agent_id, model, COALESCE(project_id, ''), total_input, total_output, total_cache, total_cost_usd, run_count FROM cost_daily_old;
DROP TABLE cost_daily_old;
```

---

## V1.1.0

### Markdown Renderer

- Replaced regex-based Markdown renderer with the `marked` library
- Full GFM support: tables, nested lists, strikethrough, code blocks with language hints
- XSS protection: `stripDangerousHtml` pass after rendering removes script tags, event handlers, iframes
- Renders six formats: Markdown, HTML, JSON (pretty-printed), CSV (styled tables), code (monospace), plain text

### Pagination

- New `usePagination` hook for offset-based "Load More" pagination
- Library document list loads 25 at a time instead of all at once
- Activity feed loads 50 at a time with "Load More" button
- Both reset automatically when filters change
- Backend activity endpoint now supports `offset` parameter

### Browser Notifications

- Desktop notifications fire when the tab is not focused (requests permission on first load)
- In-app toasts fire always — color-coded by type, auto-dismiss after 5 seconds
- Notifies on: review submitted, approval needed, request submitted, task stalled, task failed, document published, agent error
- SSE listener expanded to cover all 30+ event types

### Error Handling (from V1.0.1)

- Global `wrapRouter` wraps every route handler in try/catch — thrown errors go to error middleware instead of crashing
- Global `errorMiddleware` logs full stack trace server-side, sends safe JSON to client
- Dispatcher is completely fire-and-forget — gateway disconnect never breaks route handlers
- Three levels of try/catch in dispatch: outer, DB write, WebSocket send

### Input Sanitization (from V1.0.1)

- `sanitizeBody` middleware strips dangerous HTML from all incoming request bodies
- Removes `<script>`, event handlers (`onerror=`), `javascript:` URLs, `<iframe>/<embed>/<object>/<form>` tags
- `validate()` function checks required fields, types, string lengths, enum whitelists
- Applied to hooks, library, projects, tasks, and requests endpoints

### Outbound Dispatcher (from V1.0.1)

- 13 outbound events dispatched back to agents when humans make decisions
- Review approved/changes/rejected → agent knows to continue, revise, or move on
- Approval granted/denied → agent resumes or handles denial
- Project kickoff → agent starts planning tasks
- Task assigned/resume/queued → agent picks up or continues work
- Project activated/paused/completed → agent starts, stops, or acknowledges
- Stall nudge → agent reports status immediately

---

## V1.0.0

Initial release.

- 8 dashboard views: Dashboard, Inbox, Pipeline, Projects, Library, Agents, Costs, Activity
- Unified Inbox merging requests, reviews, and approvals
- Pipeline kanban with enforced state machine transitions
- Library with collections, tags, multi-format reader, full-text search (FTS5), versioning
- 12 inbound hook events for agent → Mission Control communication
- OpenClaw Gateway WebSocket integration for agent presence and health
- Background stall detector (flags tasks stuck 120+ min, stale agents)
- Cost tracking per session, agent, model, and project
- SQLite database with 4 migration files
- Docker Compose support
- Lifecycle hook for OpenClaw integration
- SKILL.md for agent system prompt injection


path: docs/DOCKER.md
content: # Docker Deployment

Run Mission Control in Docker containers instead of installing Node.js dependencies directly.

---

## Prerequisites

- Docker installed and running ([docker.com](https://docker.com))
- Docker Compose v2+ (included with Docker Desktop)

---

## Quick Start

```bash
# Install with Docker mode
./setup.sh --docker

# Or if already installed, just start the containers
cd ~/.openclaw/mission-control
docker compose up -d
```

The setup script with `--docker` skips npm install and configures everything for container use.

---

## Manual Docker Setup

If you prefer to set things up manually:

```bash
# Navigate to the dashboard directory
cd ~/.openclaw/mission-control

# Create your .env file
cp backend/.env.example backend/.env
# Edit backend/.env with your gateway URL, token, and hook secret

# Build and start
docker compose up -d

# Check status
docker compose ps

# View logs
docker compose logs -f
```

---

## Accessing the Dashboard

- **Frontend**: http://localhost:4173
- **Backend API**: http://localhost:8000
- **Health check**: http://localhost:8000/api/health

---

## Managing Containers

```bash
# Stop
docker compose down

# Restart
docker compose restart

# Rebuild after code changes
docker compose up -d --build

# View logs
docker compose logs -f
docker compose logs -f backend    # backend only
docker compose logs -f frontend   # frontend only
```

---

## Data Persistence

The SQLite database is stored in a Docker volume, so your data persists across container restarts. To see where:

```bash
docker volume ls | grep mission-control
```

To back up the database:

```bash
docker compose exec backend cat /app/data/mission-control.db > backup.db
```

---

## Updating

When you get a new version of Mission Control:

```bash
cd ~/.openclaw/mission-control
docker compose down
# Replace files with new version
docker compose up -d --build
```

---

## Networking Notes

The hook needs to reach the backend container from the host. If OpenClaw runs on the host (not in Docker), the default `http://localhost:8000` in `MISSION_CONTROL_URL` works because Docker maps port 8000 to the host.

If OpenClaw also runs in Docker, use the Docker network name instead:

```
MISSION_CONTROL_URL=http://mission-control-backend:8000
```

And ensure both containers are on the same Docker network.


path: docs/GETTING-STARTED.md
content: # Getting Started — Step by Step

This guide assumes you have never installed anything like this before. Follow each step in order.

---

## What You're Installing

Mission Control is a dashboard that runs on your computer alongside OpenClaw. It gives you a browser-based window to see what your AI agents are working on, review their output, approve decisions, track costs, and keep a library of everything they produce.

**What it is NOT**: Mission Control does not replace OpenClaw. OpenClaw still runs your agents and does all the AI work. Mission Control is just the control panel you look through.

---

## Before You Start

You need three things already working:

1. **A computer** running macOS, Linux, or Windows
2. **OpenClaw installed and running** — if not, go to [docs.openclaw.ai/start/getting-started](https://docs.openclaw.ai/start/getting-started) first
3. **Node.js version 22 or newer** — OpenClaw already requires it, so you likely have it

**How to check Node.js**: Open a terminal and type `node --version`. You should see `v22` or higher. If not, visit [nodejs.org](https://nodejs.org) to install it.

---

## Step 1: Open Your Terminal

The terminal is a text-based way to give your computer commands.

- **Mac**: Press `Cmd + Space`, type "Terminal", press Enter
- **Windows**: Press the Windows key, type "PowerShell", press Enter
- **Linux**: Press `Ctrl + Alt + T`

You should see a window with a blinking cursor.

---

## Step 2: Go to Your OpenClaw Folder

Type this command and press Enter:

```bash
cd ~/.openclaw
```

> **What this does**: `cd` means "change directory" — it moves you into the folder where OpenClaw keeps its files. `~` means your home folder.

> **If you see "No such file or directory"**: OpenClaw isn't installed. Go to docs.openclaw.ai first.

---

## Step 3: Extract the Skill

If you downloaded the `mission-control-skill.tar.gz` file, copy it to your OpenClaw folder and extract it:

```bash
tar -xzf mission-control-skill.tar.gz
```

This creates a `mission-control-skill` folder with everything inside.

---

## Step 4: Run the Setup Script

The setup script does everything automatically — installs the dashboard, configures the connection, and sets up the hook:

```bash
cd mission-control-skill
./setup.sh
```

The script will ask you a few questions. Press Enter to accept the default answer (shown in brackets):

- **Backend port**: Which port number the dashboard runs on. Default `8000` is fine.
- **Hook secret**: A password that agents use to talk to the dashboard. A random one is generated for you.
- **Gateway WebSocket URL**: Where your OpenClaw gateway is running. Default `ws://127.0.0.1:18789` is correct for local setups.
- **Gateway token**: If your gateway requires a token. Leave empty if unsure.
- **Install directory**: Where to put the dashboard files. Default `~/.openclaw/mission-control`.

> **Want to skip all questions?** Use: `./setup.sh --auto`

The script will install all software packages. This takes 1-3 minutes. Wait for the "Setup complete!" message.

---

## Step 5: Start Mission Control

Navigate to the dashboard folder and start it:

```bash
cd ~/.openclaw/mission-control
npm run dev
```

You should see:

```
Backend running on http://localhost:8000
Frontend running on http://localhost:4173
```

> **Keep this terminal open!** Mission Control runs as long as this window stays open. Press `Ctrl + C` to stop it.

---

## Step 6: Open the Dashboard

Open your web browser and go to:

**http://localhost:4173**

You should see the Mission Control dashboard with a dark theme and a sidebar on the left. If you see it — the installation is complete!

> The dashboard will be empty at first. That's normal — you haven't created any projects yet.

---

## Step 7: Restart OpenClaw Gateway

For the hook to start working, restart your OpenClaw gateway:

```bash
openclaw gateway restart
```

Wait about 10 seconds, then verify:

```bash
openclaw gateway status
```

You should see "running" in the output.

---

## Step 8: Verify the Hook

Check that the Mission Control hook is installed and enabled:

```bash
openclaw hooks list
```

You should see `mission-control` listed as **enabled**. If not:

```bash
openclaw hooks enable mission-control
```

---

## Step 9: Create Your First Project

Back in your browser at http://localhost:4173:

1. Click **"Projects"** in the sidebar
2. Click **"+ New Project"** in the top right
3. Enter a name like "Test Project"
4. Click **"Create Project"**

You should see your project appear with a progress bar at 0%.

Or submit a request through the Inbox:

1. Click **"Inbox"** in the sidebar
2. Click **"+ New Request"**
3. Describe what you need
4. Click **Submit** — it appears as a pending item
5. Click the green **✓** to approve it

---

## What Happens Next

Your agents will now automatically report their work to Mission Control. When an agent starts a task, it appears as "In Progress" on the Pipeline. When it finishes, it moves to "Review" and shows up in your Inbox for you to check.

Read the other docs for more detail:

- **HOOK-EVENTS.md** — Every event agents can send
- **API-REFERENCE.md** — All API endpoints
- **LIBRARY-GUIDE.md** — How the document Library works
- **TROUBLESHOOTING.md** — If something isn't working

---

## Notifications

Mission Control notifies you when agents need attention — even if you're in a different browser tab.

**Desktop notifications**: When the dashboard tab is not focused, you'll get a native system notification for reviews submitted, approvals needed, task failures, and stalled tasks. Your browser will ask for permission the first time — click "Allow".

**In-app toasts**: Even when the tab is focused, small notification pills slide in from the top-right corner, color-coded by type: purple for reviews, amber for approvals, blue for requests, red for errors. They auto-dismiss after 5 seconds.

---

## Running on Your LAN

By default, Mission Control only listens on `localhost` — you can only access it from the same machine. To access the dashboard from other devices on your network (your phone, a tablet, another computer), you need to bind it to your LAN address.

### Quick: Bind the frontend to all interfaces

Edit the `dev` script in `app/frontend/package.json` or run:

```bash
cd ~/.openclaw/mission-control/frontend
npx vite --port 4173 --host 0.0.0.0
```

Then open `http://YOUR_MACHINE_IP:4173` from any device on your network. Find your IP with `hostname -I` (Linux) or `ipconfig getifaddr en0` (Mac).

The backend already listens on all interfaces when you set `PORT=8000`, but make sure your `CORS_ORIGIN` in `backend/.env` matches the address you'll use:

```
CORS_ORIGIN=http://192.168.1.100:4173
```

### Running as systemd User Services (Linux)

If you want Mission Control to start automatically and survive terminal close:

**Create the backend service:**

```bash
mkdir -p ~/.config/systemd/user

cat > ~/.config/systemd/user/mc-backend.service << EOF
[Unit]
Description=Mission Control Backend
After=network.target

[Service]
Type=simple
WorkingDirectory=%h/.openclaw/mission-control
ExecStart=/usr/bin/node backend/src/server.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=%h/.openclaw/mission-control/backend/.env

[Install]
WantedBy=default.target
EOF
```

**Create the frontend service:**

```bash
cat > ~/.config/systemd/user/mc-frontend.service << EOF
[Unit]
Description=Mission Control Frontend
After=mc-backend.service

[Service]
Type=simple
WorkingDirectory=%h/.openclaw/mission-control/frontend
ExecStart=/usr/bin/npx vite --port 4173 --host 0.0.0.0
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
EOF
```

**Enable and start:**

```bash
systemctl --user daemon-reload
systemctl --user enable mc-backend mc-frontend
systemctl --user start mc-backend mc-frontend
```

**Check status:**

```bash
systemctl --user status mc-backend
systemctl --user status mc-frontend
journalctl --user -u mc-backend -f   # live backend logs
```

**Enable lingering** so services run even when you're not logged in:

```bash
sudo loginctl enable-linger $USER
```

---

## Daily Routine

Once everything is set up, your daily flow is:

```bash
cd ~/.openclaw/mission-control && npm run dev
```

Open http://localhost:4173 and:

1. **Dashboard** — Check alerts, pipeline state, agent health
2. **Inbox** — Review work, approve requests, handle approvals
3. **Pipeline** — See if anything is stuck
4. **Library** — Read new research or reports
5. **Costs** — Check spending

Press `Ctrl + C` to stop when done. Your data persists — it's all in the SQLite database.


path: docs/HOOK-EVENTS.md
content: # Hook Events Reference

All events are sent as HTTP POST to `/api/hooks/event` with the `X-Hook-Secret` header.

---

## Event Payload Format

```json
{
  "event": "event:name",
  "agentId": "agent-id",
  "taskId": "task-id-if-relevant",
  "data": { },
  "timestamp": "2026-03-21T12:00:00Z"
}
```

**Headers:**
```
Content-Type: application/json
X-Hook-[REDACTED_SECRET]
```

---

## Task Events

### task:started

Agent begins working on a task. Moves the task to "doing" stage.

```json
{
  "event": "task:started",
  "agentId": "main",
  "taskId": "t_abc123",
  "data": { "message": "Beginning implementation" }
}
```

### task:progress

Incremental progress update. Dashboard shows percentage.

```json
{
  "event": "task:progress",
  "agentId": "main",
  "taskId": "t_abc123",
  "data": { "progress": 65, "message": "Tests passing, writing docs" }
}
```

### task:review

Agent submits work for human review. Moves task to "review" stage and creates a review record in the Inbox.

```json
{
  "event": "task:review",
  "agentId": "main",
  "taskId": "t_abc123",
  "data": {
    "work_summary": "Implemented auth flow with JWT tokens. Added 12 tests.",
    "deliverables": [
      { "name": "auth.ts", "type": "code", "path": "/workspace/src/auth.ts", "summary": "Auth middleware" },
      { "name": "auth.test.ts", "type": "code", "path": "/workspace/tests/auth.test.ts", "summary": "12 test cases" }
    ],
    "checklist": [
      { "label": "Meets requirements", "checked": true },
      { "label": "Tests pass", "checked": true },
      { "label": "Documentation updated", "checked": false }
    ]
  }
}
```

### task:completed

Task is finished (typically after human approval).

```json
{
  "event": "task:completed",
  "agentId": "main",
  "taskId": "t_abc123",
  "data": { "message": "Completed successfully" }
}
```

### task:failed

Task failed. Includes reason for the failure.

```json
{
  "event": "task:failed",
  "agentId": "main",
  "taskId": "t_abc123",
  "data": { "reason": "API endpoint returns 403 — missing permissions" }
}
```

---

## Library Events

### library:publish

Agent publishes a new document to the Library.

```json
{
  "event": "library:publish",
  "agentId": "research",
  "data": {
    "title": "Competitor Pricing Analysis Q1 2026",
    "content": "# Full markdown content here...",
    "doc_type": "research",
    "format": "markdown",
    "collection_id": "col_research",
    "projectId": "p_abc123",
    "source_path": "/workspace/docs/pricing-analysis.md"
  }
}
```

**Document types**: `research`, `report`, `documentation`, `reference`, `template`, `note`, `analysis`, `brief`, `guide`, `other`

**Formats**: `markdown`, `html`, `code`, `json`, `text`, `csv`

**Default collection IDs**: `col_research`, `col_reports`, `col_docs`, `col_reference`, `col_templates`, `col_notes`

### library:update

Agent updates an existing document. Creates a new version.

```json
{
  "event": "library:update",
  "agentId": "research",
  "data": {
    "doc_id": "d_abc123",
    "content": "# Updated content...",
    "change_note": "Added Q2 data"
  }
}
```

---

## Approval Events

### approval:needed

Agent requests human approval before proceeding.

```json
{
  "event": "approval:needed",
  "agentId": "codex",
  "taskId": "t_abc123",
  "data": {
    "type": "workflow_gate",
    "title": "Deploy to production?",
    "description": "All tests pass. 3 files changed.",
    "urgency": "urgent",
    "entityType": "task",
    "entityId": "t_abc123",
    "resumeToken": "resume_deploy_abc"
  }
}
```

**Urgency**: `critical`, `urgent`, `normal`, `low`
**Types**: `workflow_gate`, `task_review`, `project_approval`, `custom`

---

## Request Events

### request:submit

Agent submits a new project request.

```json
{
  "event": "request:submit",
  "agentId": "main",
  "data": {
    "title": "Fix mobile image rotation bug",
    "description": "iOS Safari rotates images 90 degrees on upload",
    "category": "bug",
    "urgency": "urgent"
  }
}
```

**Categories**: `feature`, `bug`, `research`, `content`, `ops`, `automation`, `general`

---

## Session Events

### session:created

A new agent session started.

```json
{
  "event": "session:created",
  "agentId": "main",
  "data": { "sessionId": "sess_abc123" }
}
```

### session:ended

Agent session closed. Includes token usage and cost data.

```json
{
  "event": "session:ended",
  "agentId": "main",
  "data": {
    "tokens": {
      "model": "claude-sonnet-4-5",
      "provider": "anthropic",
      "input": 45000,
      "output": 12000,
      "cacheRead": 30000,
      "cacheWrite": 5000,
      "inputCost": 0.135,
      "outputCost": 0.60,
      "cacheCost": 0.03,
      "totalCost": 0.82
    }
  }
}
```

---

## Agent Events

### agent:idle

Agent has no more assigned work.

```json
{
  "event": "agent:idle",
  "agentId": "main",
  "data": { "message": "All tasks complete" }
}
```

### agent:error

Agent encountered a system-level error.

```json
{
  "event": "agent:error",
  "agentId": "main",
  "data": { "error": "Context window exceeded", "context": "Working on large file analysis" }
}
```

---

## Slash Command Shortcuts

If the agent's session supports slash commands:

```
/mc task:started <taskId>
/mc task:completed <taskId>
/mc task:failed <taskId> <reason>
/mc task:review <taskId>
/mc task:progress <taskId> <percentage> <message>
```

The lifecycle hook translates these into proper API calls.

---

## Outbound Events (Mission Control → Agents)

These events are dispatched from Mission Control to agents via the OpenClaw Gateway when a human makes a decision. They arrive in the agent's session as messages with `type: "mc:event"`.

### mc:review_approved

Sent when a human approves a review. The task moves to "done".

```json
{
  "type": "mc:event",
  "event": "mc:review_approved",
  "taskId": "t_abc123",
  "taskTitle": "Implement auth flow",
  "projectId": "p_xyz",
  "projectName": "Website Redesign",
  "round": 1,
  "qualityScore": 5,
  "notes": "Clean implementation",
  "nextAction": "check_for_next_task",
  "nextTaskHint": "Check /api/tasks?project_id=p_xyz&pipeline_stage=todo"
}
```

### mc:changes_requested

Review needs revisions. Task moves back to "doing".

```json
{
  "type": "mc:event",
  "event": "mc:changes_requested",
  "taskId": "t_abc123",
  "taskTitle": "API rate limit docs",
  "round": 1,
  "feedback": "Missing code examples and wrong header name",
  "nextAction": "revise_and_resubmit",
  "currentStage": "doing"
}
```

### mc:review_rejected

Work rejected. Task goes back to "todo".

```json
{
  "type": "mc:event",
  "event": "mc:review_rejected",
  "taskId": "t_abc123",
  "taskTitle": "Landing page copy",
  "round": 2,
  "reason": "Off-brand tone, doesn't match style guide",
  "nextAction": "task_returned_to_todo",
  "currentStage": "todo"
}
```

### mc:approval_granted

Workflow gate approved. Agent should resume.

```json
{
  "type": "mc:event",
  "event": "mc:approval_granted",
  "approvalId": "a_123",
  "approvalType": "workflow_gate",
  "title": "Deploy to production?",
  "resumeToken": "resume_deploy_abc",
  "entityType": "task",
  "entityId": "t_abc123",
  "notes": "Go ahead",
  "nextAction": "resume_workflow"
}
```

### mc:approval_denied

Workflow gate denied.

```json
{
  "type": "mc:event",
  "event": "mc:approval_denied",
  "approvalId": "a_123",
  "approvalType": "workflow_gate",
  "title": "Deploy to production?",
  "entityType": "task",
  "entityId": "t_abc123",
  "reason": "Not ready, needs more testing",
  "nextAction": "handle_denial"
}
```

### mc:project_kickoff

Request converted to project. Owner agent should plan and create tasks.

```json
{
  "type": "mc:event",
  "event": "mc:project_kickoff",
  "projectId": "p_new",
  "projectName": "Customer Onboarding Automation",
  "description": "Build automated onboarding flow",
  "priority": "high",
  "fromRequest": "r_123",
  "requestTitle": "Customer onboarding automation",
  "nextAction": "plan_and_create_tasks",
  "hint": "Create tasks via POST /api/tasks with project_id=p_new"
}
```

### mc:task_assigned

Task assigned or reassigned to agent.

```json
{
  "type": "mc:event",
  "event": "mc:task_assigned",
  "taskId": "t_abc123",
  "taskTitle": "Write auth docs",
  "projectId": "p_xyz",
  "priority": "high",
  "currentStage": "todo",
  "previousAgent": null,
  "nextAction": "start_when_ready"
}
```

### mc:task_resume

Task moved back to "doing" (e.g., after changes requested).

```json
{
  "type": "mc:event",
  "event": "mc:task_resume",
  "taskId": "t_abc123",
  "taskTitle": "API rate limit docs",
  "fromStage": "review",
  "toStage": "doing",
  "movedBy": "user",
  "nextAction": "continue_work"
}
```

### mc:task_queued

Task moved to "todo" — available for the agent to pick up.

```json
{
  "type": "mc:event",
  "event": "mc:task_queued",
  "taskId": "t_abc123",
  "taskTitle": "Hero section redesign",
  "fromStage": "backlog",
  "movedBy": "user",
  "nextAction": "awaiting_start"
}
```

### mc:project_activated / mc:project_paused / mc:project_completed

Project lifecycle changes.

```json
{
  "type": "mc:event",
  "event": "mc:project_activated",
  "projectId": "p_xyz",
  "projectName": "Website Redesign",
  "nextAction": "start_working",
  "hint": "Check /api/tasks?project_id=p_xyz&pipeline_stage=todo"
}
```

### mc:stall_nudge

Agent hasn't reported progress. Needs immediate status update.

```json
{
  "type": "mc:event",
  "event": "mc:stall_nudge",
  "taskId": "t_abc123",
  "taskTitle": "Implement auth flow",
  "stallMinutes": 120,
  "nextAction": "report_status",
  "hint": "Send a task:progress event, or task:failed if blocked."
}
```

---

## Complete Event Flow

```
Agent → Mission Control (inbound hooks):
  task:started, task:progress, task:review, task:completed, task:failed
  library:publish, library:update
  approval:needed, request:submit
  session:ended, agent:idle, agent:error

Mission Control → Agent (outbound dispatches):
  mc:review_approved, mc:changes_requested, mc:review_rejected
  mc:approval_granted, mc:approval_denied
  mc:project_kickoff, mc:project_activated, mc:project_paused, mc:project_completed
  mc:task_assigned, mc:task_resume, mc:task_queued
  mc:stall_nudge
```


path: docs/LIBRARY-GUIDE.md
content: # Library Guide

The Library is a curated document store where agents publish finished work — research, reports, documentation, analysis, and reference material. It's organized, searchable, and versioned.

---

## How Documents Get Into the Library

**Three ways:**

1. **Agent hook** — When an agent fires a `library:publish` event, the document goes straight into the database. This is the primary path. Agents publish research findings, status reports, API documentation, and any other lasting content.

2. **Manual creation** — Click "+ New Doc" in the Library view. Pick a type, format, collection, and write or paste content.

3. **API** — POST to `/api/library/documents` with title, content, type, format, and collection.

---

## Organization

Documents are organized on three layers:

### Collections (folders)

Top-level categories. They support nesting (sub-collections). Default collections:

| ID | Name | Icon | Use For |
|----|------|------|---------|
| `col_research` | Research | 🔬 | Competitive analysis, market data, findings |
| `col_reports` | Reports | 📊 | Status reports, metrics, summaries |
| `col_docs` | Documentation | 📖 | Technical docs, API docs, guides |
| `col_reference` | Reference | 📚 | Specifications, standards, lookup tables |
| `col_templates` | Templates | 📋 | Reusable boilerplate and configuration |
| `col_notes` | Notes | 📝 | Meeting notes, scratch work, ideas |

Create custom collections via the "+" button in the sidebar or the API.

### Document Types (what it is)

Semantic classification within collections. A "Research" collection might contain documents typed as `research`, `analysis`, or `brief`.

Types: `research`, `report`, `documentation`, `reference`, `template`, `note`, `analysis`, `brief`, `guide`, `other`

### Tags (cross-cutting labels)

Free-form labels like `#competitor`, `#q1-2026`, `#api-v2`. Documents can have multiple tags. Tags are shared with the task system.

---

## Formats and the Reading Panel

The Library supports six content formats. Each gets a specialized renderer:

| Format | Stored As | Rendered As |
|--------|-----------|-------------|
| `markdown` | Raw Markdown text | Styled HTML with headings, code blocks, lists, links |
| `html` | Raw HTML | Direct HTML render |
| `code` | Source code | Monospace with preserved whitespace |
| `json` | Raw JSON string | Auto-pretty-printed with indentation |
| `csv` | Comma-separated values | Styled table with headers |
| `text` | Plain text | Preserved whitespace, readable font |

When you click a document in the Library, the reading panel opens with the full content rendered in the appropriate format, plus metadata (author, project, word count, tags, version history).

---

## Versioning

Every content edit creates a new version. The version history shows:

- Version number
- Who changed it (agent ID or "user")
- Change note (what was modified)
- Timestamp

You can view any previous version through the API: `GET /api/library/documents/:id/versions/:v`

---

## Search

The Library uses SQLite FTS5 (Full-Text Search) for instant search across document titles, content, and excerpts. Type in the search box and results appear as you type.

Search via API: `GET /api/library/search?q=pricing+analysis`

---

## Publishing from an Agent

Add this to your agent's AGENTS.md or workspace instructions:

```markdown
## Library Publishing

When you produce research, reports, or documentation, publish to the Library:

- Use the `library:publish` hook event
- Choose the right collection (research → col_research, docs → col_docs)
- Choose the right doc_type (research, report, documentation, etc.)
- Include the full content, not just a summary
- Set format to match the content (markdown, json, csv, etc.)
```

Example hook event (sent automatically by the lifecycle hook):

```json
{
  "event": "library:publish",
  "agentId": "research",
  "data": {
    "title": "Competitor Analysis Q1 2026",
    "content": "# Full markdown content...",
    "doc_type": "research",
    "format": "markdown",
    "collection_id": "col_research",
    "projectId": "p_market_research"
  }
}
```

---

## Pinning

Pin important documents so they always appear at the top of their collection. Click the "☆ Pin" button in the reading panel or set `pinned: true` via the API.


path: docs/TROUBLESHOOTING.md
content: # Troubleshooting

Common problems and how to fix them.

---

## Installation Problems

### "Cannot find module" or dependency errors

Run the install command again:

```bash
cd ~/.openclaw/mission-control
npm run install:all
```

If that fails, delete node_modules and retry:

```bash
rm -rf backend/node_modules frontend/node_modules
npm run install:all
```

### "Permission denied" during install

On Mac/Linux, try:

```bash
sudo npm run install:all
```

Or fix npm permissions: [docs.npmjs.com/resolving-eacces-permissions-errors](https://docs.npmjs.com/resolving-eacces-permissions-errors)

### "Port already in use" error

Something else is using port 8000 or 4173. Find out what:

```bash
lsof -i :8000    # Mac/Linux
netstat -ano | findstr :8000   # Windows
```

Either stop the other program, or change the port in `backend/.env`:

```
PORT=8001
```

And update `CORS_ORIGIN` to match your frontend port if it also changed.

---

## Dashboard Problems

### Dashboard loads but shows no data

This is normal when you first start. The database is empty. Create a project or submit a request to see content.

If you previously had data and it's gone, check the database file:

```bash
ls -la ~/.openclaw/mission-control/backend/data/
```

You should see `mission-control.db`. If it's missing, Mission Control will create a fresh one on next start.

### Dashboard shows "Connecting..." instead of "Live"

The SSE (Server-Sent Events) connection between frontend and backend isn't working. Check:

1. Is the backend running? Visit http://localhost:8000/api/health in your browser
2. Is the frontend proxy configured? Check `frontend/vite.config.js` — the proxy should point to port 8000
3. Are CORS settings correct? Check `CORS_ORIGIN` in `backend/.env`

### Sidebar badge shows wrong count

Click the "↻ Refresh" button in the header. If counts still seem wrong, check the API directly:

```bash
curl http://localhost:8000/api/inbox/counts
```

---

## Agent Integration Problems

### Agents aren't sending updates

Check three things:

1. **Hook installed?**
   ```bash
   openclaw hooks list
   ```
   You should see `mission-control` listed as enabled.

2. **Secrets match?** The `HOOK_SECRET` in `backend/.env` must be **identical** to `MISSION_CONTROL_HOOK_SECRET` in `openclaw.json`. Even one character difference will cause authentication to fail silently.

3. **Backend reachable?** The Mission Control backend must be running when agents try to report. If it's not running, hook events are silently dropped.

### Hook is installed but events aren't arriving

Test the hook manually:

```bash
curl -X POST http://localhost:8000/api/hooks/event \
  -H "Content-Type: application/json" \
  -H "X-Hook-[REDACTED_SECRET]" \
  -d '{"event": "agent:idle", "agentId": "test", "data": {"message": "test"}}'
```

You should get `{"ok": true, "event": "agent:idle"}`. If you get a 401, the secret is wrong. If you get connection refused, the backend isn't running.

### Gateway connection shows errors

Check the backend logs (visible in the terminal where you ran `npm run dev`). Common issues:

- **Wrong gateway URL**: Verify `OPENCLAW_GATEWAY_URL` in `backend/.env` matches your actual gateway address
- **Wrong gateway token**: Get the correct token with `openclaw gateway status --json`
- **Gateway not running**: Start it with `openclaw gateway` or `openclaw gateway restart`

---

## Library Problems

### Documents aren't appearing after agent publishes

1. Check the backend logs for `library:published` events
2. Verify the agent is sending `library:publish` (not `library:published`) as the event name
3. Check that the collection_id is valid (use one of the defaults like `col_research`)

### Search returns no results

FTS5 search requires at least 3 characters. Single-word searches work best. Try broader terms.

If search never returns results, the FTS index may not have been created. Restart the backend — it rebuilds indexes from the migration on startup.

---

## Performance

### Dashboard is slow

The SQLite database handles thousands of documents and tasks without issues. If the dashboard feels slow:

1. Check your browser's dev tools (F12 → Network tab) for slow API calls
2. The Activity feed loads the last 50 events by default — this is fast
3. Library full-text search is indexed — it shouldn't be slow

### Database file is very large

The database grows as activity accumulates. To see its size:

```bash
ls -lh ~/.openclaw/mission-control/backend/data/mission-control.db
```

Under normal use, it should stay under 50MB. If it's much larger, old activity_log entries might be accumulating. You can safely delete old entries:

```bash
sqlite3 backend/data/mission-control.db "DELETE FROM activity_log WHERE created_at < datetime('now', '-30 days')"
```

---

## Getting Help

If none of the above fixes your issue:

1. Check the backend terminal for error messages
2. Check the browser console (F12 → Console tab) for frontend errors
3. Verify all configuration with: `cat backend/.env` and `cat ~/.openclaw/openclaw.json`
4. Try a fresh install: delete the mission-control directory and run `setup.sh` again


path: app/docker-compose.yml
content: version: "3.8"

services:
  mission-control:
    build: .
    ports:
      - "${PORT:-8000}:8000"
      - "${FRONTEND_PORT:-4173}:4173"
    volumes:
      - mc-data:/app/backend/data
    environment:
      - PORT=8000
      - OPENCLAW_GATEWAY_URL=${OPENCLAW_GATEWAY_URL:-ws://host.docker.internal:18789}
      - OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN:-}
      - AUTH_MODE=${AUTH_MODE:-none}
      - LOCAL_AUTH_TOKEN=${LOCAL_AUTH_TOKEN:-}
      - HOOK_SECRET=${HOOK_SECRET:-change-me}
      - CORS_ORIGIN=*
    restart: unless-stopped
    extra_hosts:
      - "host.docker.internal:host-gateway"

volumes:
  mc-data:


path: app/package.json
content: {
  "name": "openclaw-mission-control",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
    "dev:backend": "cd backend && npm run dev",
    "dev:frontend": "cd frontend && npm run dev",
    "install:all": "cd backend && npm install && cd ../frontend && npm install",
    "build": "cd frontend && npm run build",
    "start": "cd backend && npm start"
  },
  "devDependencies": {
    "concurrently": "^9.1.2"
  }
}


path: app/test-dispatcher.js
content: #!/usr/bin/env node
/**
 * Dispatcher Integration Test
 *
 * Exercises every outbound dispatch path by creating real data
 * in the Mission Control API, then making decisions that trigger
 * dispatches, and verifying the activity log records them.
 *
 * Prerequisites:
 *   - Mission Control backend running on $MC_URL (default: http://localhost:8000)
 *   - No auth required (AUTH_MODE=none), or pass --secret
 *
 * Usage:
 *   node test-dispatcher.js
 *   node test-dispatcher.js --url http://localhost:8001
 *   node test-dispatcher.js --secret my-hook-secret
 *   node test-dispatcher.js --verbose
 */

const BASE = process.argv.includes("--url")
  ? process.argv[process.argv.indexOf("--url") + 1]
  : "http://localhost:8000";

const HOOK_SECRET = process.argv.includes("--secret")
  ? process.argv[process.argv.indexOf("--secret") + 1]
  : process.env.HOOK_SECRET || "test-secret";

const VERBOSE = process.argv.includes("--verbose");

let passed = 0;
let failed = 0;

// ── Helpers ─────────────────────────────────────────
async function api(path, opts = {}) {
  const url = `${BASE}/api${path}`;
  const res = await fetch(url, {
    headers: { "Content-Type": "application/json", ...opts.headers },
    ...opts,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok && VERBOSE) {
    console.error(`    API ERROR: ${res.status} ${url}`, data);
  }
  return { status: res.status, data };
}

async function hook(event, agentId, taskId, extraData = {}) {
  return api("/hooks/event", {
    method: "POST",
    headers: { "X-Hook-Secret": HOOK_SECRET },
    body: JSON.stringify({ event, agentId, taskId, data: extraData }),
  });
}

function assert(condition, label) {
  if (condition) {
    console.log(`  ✅ ${label}`);
    passed++;
  } else {
    console.error(`  ❌ ${label}`);
    failed++;
  }
}

async function getDispatchLogs(event, limit = 5) {
  // Check activity log for dispatch entries
  const { data } = await api(`/events/activity?entity_type=dispatch&limit=${limit}`);
  return (data || []).filter(a => a.action === event);
}

// Small delay to let dispatches process
const wait = (ms) => new Promise(r => setTimeout(r, ms));

// ── Tests ───────────────────────────────────────────
async function run() {
  console.log(`\n🎛️  Dispatcher Integration Test`);
  console.log(`   Backend: ${BASE}`);
  console.log(`   Hook [REDACTED_SECRET], 4)}...`);
  console.log("");

  // ── 0. Health check ──────────────────────────────
  console.log("0. Health check");
  const { status: healthStatus, data: health } = await api("/health");
  assert(healthStatus === 200, `Backend is reachable (${healthStatus})`);
  if (healthStatus !== 200) {
    console.error("\n   Cannot reach backend. Is it running?");
    process.exit(1);
  }

  // ── 1. Create test project ───────────────────────
  console.log("\n1. Setup: Create test project and tasks");
  const { data: project } = await api("/projects", {
    method: "POST",
    body: JSON.stringify({ name: "Dispatcher Test Project", description: "Testing outbound dispatches", priority: "high", owner_agent: "test-agent" }),
  });
  assert(project?.id, `Project created: ${project?.id}`);

  const { data: task1 } = await api("/tasks", {
    method: "POST",
    body: JSON.stringify({ project_id: project.id, title: "Test Task Alpha", priority: "high", assigned_agent: "test-agent", pipeline_stage: "backlog" }),
  });
  assert(task1?.id, `Task created: ${task1?.id}`);

  const { data: task2 } = await api("/tasks", {
    method: "POST",
    body: JSON.stringify({ project_id: project.id, title: "Test Task Beta", priority: "medium", pipeline_stage: "backlog" }),
  });
  assert(task2?.id, `Second task created: ${task2?.id}`);

  // ── 2. Test task:assigned dispatch ───────────────
  console.log("\n2. Task assignment → mc:task_assigned");
  await api(`/tasks/${task2.id}`, {
    method: "PATCH",
    body: JSON.stringify({ assigned_agent: "test-agent-2" }),
  });
  await wait(100);
  // Check the task was updated
  const { data: assignedTask } = await api(`/tasks/${task2.id}`);
  assert(assignedTask?.assigned_agent === "test-agent-2", "Task assigned to test-agent-2");

  // ── 3. Test task stage change → mc:task_resume ──
  console.log("\n3. Task move to doing → mc:task_resume");
  await api(`/tasks/${task1.id}/move`, {
    method: "POST",
    body: JSON.stringify({ to_stage: "todo", actor: "user" }),
  });
  await api(`/tasks/${task1.id}/move`, {
    method: "POST",
    body: JSON.stringify({ to_stage: "doing", actor: "user" }),
  });
  await wait(100);
  const { data: doingTask } = await api(`/tasks/${task1.id}`);
  assert(doingTask?.pipeline_stage === "doing", "Task moved to doing");

  // ── 4. Submit for review via hook ───────────────
  console.log("\n4. Hook: task:review → creates review record");
  await hook("task:review", "test-agent", task1.id, {
    work_summary: "Test work completed",
    deliverables: [{ name: "test.md", type: "doc", summary: "Test deliverable" }],
    checklist: [{ label: "Tests pass", checked: true }],
  });
  await wait(200);
  const { data: doingTask2 } = await api(`/tasks/${task1.id}`);
  assert(doingTask2?.pipeline_stage === "review", "Task moved to review by hook");

  // Get the review
  const { data: reviews } = await api(`/reviews?task_id=${task1.id}`);
  const review = reviews?.[0];
  assert(review?.id, `Review created: ${review?.id}`);

  // ── 5. Review: changes_requested → mc:changes_requested
  console.log("\n5. Review changes requested → mc:changes_requested");
  if (review) {
    await api(`/reviews/${review.id}/decide`, {
      method: "POST",
      body: JSON.stringify({ decision: "changes_requested", notes: "Needs more tests", reviewer: "user" }),
    });
    await wait(100);
    const { data: revisedTask } = await api(`/tasks/${task1.id}`);
    assert(revisedTask?.pipeline_stage === "doing", "Task moved back to doing");
  } else {
    assert(false, "No review to decide on (skipped)");
  }

  // ── 6. Resubmit and approve → mc:review_approved
  console.log("\n6. Resubmit review and approve → mc:review_approved");
  // Move back to review
  await api(`/tasks/${task1.id}/move`, {
    method: "POST",
    body: JSON.stringify({ to_stage: "review", actor: "test-agent" }),
  });
  // Create a new review for round 2
  const { data: review2 } = await api("/reviews", {
    method: "POST",
    body: JSON.stringify({
      task_id: task1.id, project_id: project.id, submitted_by: "test-agent",
      work_summary: "Fixed the tests", round: 2,
    }),
  });
  if (review2?.id) {
    await api(`/reviews/${review2.id}/decide`, {
      method: "POST",
      body: JSON.stringify({ decision: "approved", notes: "Looks good", quality_score: 5, reviewer: "user" }),
    });
    await wait(100);
    const { data: doneTask } = await api(`/tasks/${task1.id}`);
    assert(doneTask?.pipeline_stage === "done", "Task moved to done after approval");
  } else {
    assert(false, "Could not create round 2 review (skipped)");
  }

  // ── 7. Approval: workflow gate → mc:approval_granted
  console.log("\n7. Approval granted → mc:approval_granted");
  await hook("approval:needed", "test-agent", null, {
    type: "workflow_gate",
    title: "Test deploy gate",
    description: "CI passed, deploy?",
    urgency: "urgent",
    resumeToken: "test_resume_token_123",
  });
  await wait(200);
  const { data: approvals } = await api("/approvals");
  const pendingApproval = approvals?.find(a => a.title === "Test deploy gate" && a.status === "pending");
  assert(pendingApproval?.id, `Approval created: ${pendingApproval?.id}`);

  if (pendingApproval) {
    await api(`/approvals/${pendingApproval.id}/decide`, {
      method: "POST",
      body: JSON.stringify({ decision: "approved", notes: "Go ahead", decided_by: "user" }),
    });
    await wait(100);
    const { data: decidedApproval } = await api(`/approvals/${pendingApproval.id}`);
    assert(decidedApproval?.status === "approved", "Approval was granted");
  }

  // ── 8. Approval denied → mc:approval_denied
  console.log("\n8. Approval denied → mc:approval_denied");
  await hook("approval:needed", "test-agent", null, {
    type: "workflow_gate",
    title: "Test delete gate",
    description: "Delete prod data?",
    urgency: "critical",
  });
  await wait(200);
  const { data: approvals2 } = await api("/approvals");
  const deleteGate = approvals2?.find(a => a.title === "Test delete gate" && a.status === "pending");
  if (deleteGate) {
    await api(`/approvals/${deleteGate.id}/decide`, {
      method: "POST",
      body: JSON.stringify({ decision: "rejected", notes: "Too dangerous", decided_by: "user" }),
    });
    await wait(100);
    assert(true, "Approval denied dispatched");
  } else {
    assert(false, "Delete gate not found (skipped)");
  }

  // ── 9. Request → project conversion → mc:project_kickoff
  console.log("\n9. Request conversion → mc:project_kickoff");
  const { data: request } = await api("/requests", {
    method: "POST",
    body: JSON.stringify({ title: "Test Kickoff Request", description: "Should become a project", category: "feature", urgency: "normal" }),
  });
  assert(request?.id, `Request created: ${request?.id}`);

  if (request?.id) {
    const { data: converted } = await api(`/requests/${request.id}/convert`, {
      method: "POST",
      body: JSON.stringify({ owner_agent: "test-agent" }),
    });
    assert(converted?.project?.id, `Converted to project: ${converted?.project?.id}`);
  }

  // ── 10. Project activated → mc:project_activated
  console.log("\n10. Project activation → mc:project_activated");
  const { data: proj2 } = await api("/projects", {
    method: "POST",
    body: JSON.stringify({ name: "Activation Test", priority: "medium", owner_agent: "test-agent" }),
  });
  if (proj2?.id) {
    await api(`/projects/${proj2.id}`, {
      method: "PATCH",
      body: JSON.stringify({ status: "active" }),
    });
    await wait(100);
    const { data: activeProj } = await api(`/projects/${proj2.id}`);
    assert(activeProj?.status === "active", "Project activated");
  }

  // ── 11. Project paused → mc:project_paused
  console.log("\n11. Project pause → mc:project_paused");
  if (proj2?.id) {
    await api(`/projects/${proj2.id}`, {
      method: "PATCH",
      body: JSON.stringify({ status: "paused" }),
    });
    await wait(100);
    const { data: pausedProj } = await api(`/projects/${proj2.id}`);
    assert(pausedProj?.status === "paused", "Project paused");
  }

  // ── 12. Review rejected → mc:review_rejected
  console.log("\n12. Review rejected → mc:review_rejected");
  const { data: task3 } = await api("/tasks", {
    method: "POST",
    body: JSON.stringify({ project_id: project.id, title: "Reject Test Task", assigned_agent: "test-agent", pipeline_stage: "review" }),
  });
  if (task3?.id) {
    const { data: review3 } = await api("/reviews", {
      method: "POST",
      body: JSON.stringify({ task_id: task3.id, project_id: project.id, submitted_by: "test-agent", work_summary: "Bad work" }),
    });
    if (review3?.id) {
      await api(`/reviews/${review3.id}/decide`, {
        method: "POST",
        body: JSON.stringify({ decision: "rejected", notes: "Completely wrong approach", reviewer: "user" }),
      });
      await wait(100);
      const { data: rejTask } = await api(`/tasks/${task3.id}`);
      assert(rejTask?.pipeline_stage === "todo", "Rejected task moved to todo");
    }
  }

  // ── 13. Library publish via hook
  console.log("\n13. Library publish via hook");
  await hook("library:publish", "test-agent", null, {
    title: "Dispatcher Test Document",
    content: "# Test\n\nThis was published by the dispatcher test.",
    doc_type: "note",
    format: "markdown",
    collection_id: "col_notes",
  });
  await wait(200);
  const { data: docs } = await api("/library/documents?collection_id=col_notes");
  const testDoc = docs?.find(d => d.title === "Dispatcher Test Document");
  assert(testDoc?.id, `Document published: ${testDoc?.id}`);

  // ── 14. Input sanitization check
  console.log("\n14. Input sanitization");
  const { data: xssProject } = await api("/projects", {
    method: "POST",
    body: JSON.stringify({
      name: 'Test <script>alert("xss")</script> Project',
      description: 'Has <img onerror=alert(1) src=x> and javascript:void(0)',
    }),
  });
  if (xssProject) {
    assert(!xssProject.name.includes("<script>"), "Script tags stripped from project name");
    assert(!xssProject.description.includes("onerror"), "Event handlers stripped from description");
  }

  const { data: xssDoc } = await api("/library/documents", {
    method: "POST",
    body: JSON.stringify({
      title: "Sanitization Test",
      content: '<p>Normal text</p><script>steal(cookies)</script><iframe src="evil"></iframe>',
      doc_type: "note",
      format: "markdown",
    }),
  });
  if (xssDoc) {
    const { data: fullDoc } = await api(`/library/documents/${xssDoc.id}`);
    assert(!fullDoc.content.includes("<script>"), "Script tags stripped from document content");
    assert(!fullDoc.content.includes("<iframe"), "Iframe tags stripped from document content");
  }

  // ── 15. Validation check
  console.log("\n15. Input validation");
  const { status: s1 } = await api("/projects", {
    method: "POST",
    body: JSON.stringify({ name: "" }),
  });
  assert(s1 === 400, "Empty project name rejected");

  const { status: s2 } = await api("/tasks", {
    method: "POST",
    body: JSON.stringify({ title: "X", priority: "MEGA_ULTRA" }),
  });
  assert(s2 === 400, "Invalid priority rejected");

  const { status: s3 } = await api("/library/documents", {
    method: "POST",
    body: JSON.stringify({ title: "X", doc_type: "banana" }),
  });
  assert(s3 === 400, "Invalid doc_type rejected");

  const { status: s4 } = await api("/hooks/event", {
    method: "POST",
    headers: { "X-Hook-Secret": HOOK_SECRET },
    body: JSON.stringify({}),
  });
  assert(s4 === 400, "Hook with missing event rejected");

  // ── 16. Error handling check
  console.log("\n16. Error handling");
  const { status: s5 } = await api("/tasks/nonexistent-id-12345/move", {
    method: "POST",
    body: JSON.stringify({ to_stage: "doing" }),
  });
  assert(s5 >= 400 && s5 < 500, `Move nonexistent task returns ${s5} (not crash)`);

  const { status: s6 } = await api("/reviews/nonexistent-id/decide", {
    method: "POST",
    body: JSON.stringify({ decision: "approved" }),
  });
  assert(s6 >= 400 && s6 < 500, `Decide nonexistent review returns ${s6} (not crash)`);

  // Final health check — make sure backend didn't crash
  const { status: finalHealth } = await api("/health");
  assert(finalHealth === 200, "Backend still alive after all tests");

  // ── Summary ──────────────────────────────────────
  console.log("\n════════════════════════════════════════");
  console.log(`  ${passed} passed, ${failed} failed`);
  console.log("════════════════════════════════════════\n");

  // Cleanup — delete test data
  if (VERBOSE) {
    console.log("Skipping cleanup in verbose mode for inspection.");
  } else {
    if (project?.id) await api(`/projects/${project.id}`, { method: "DELETE" });
    if (proj2?.id) await api(`/projects/${proj2.id}`, { method: "DELETE" });
    if (xssProject?.id) await api(`/projects/${xssProject.id}`, { method: "DELETE" });
    if (testDoc?.id) await api(`/library/documents/${testDoc.id}`, { method: "DELETE" });
    if (xssDoc?.id) await api(`/library/documents/${xssDoc.id}`, { method: "DELETE" });
  }

  process.exit(failed > 0 ? 1 : 0);
}

run().catch(err => {
  console.error("Test runner crashed:", err);
  process.exit(1);
});


path: app/frontend/index.html
content: <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Mission Control — OpenClaw</title>
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body class="bg-[#0a0e1a] text-gray-100">
  <div id="root"></div>
  <script type="module" src="/src/main.jsx"></script>
</body>
</html>


path: app/frontend/package.json
content: {
  "name": "mission-control-frontend",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite --port 4173",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "marked": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "tailwindcss": "^3.4.17",
    "vite": "^6.0.7"
  }
}


path: app/frontend/postcss.config.js
content: export default {
  plugins: { tailwindcss: {}, autoprefixer: {} },
};


path: app/frontend/tailwind.config.js
content: /** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,jsx}"],
  theme: {
    extend: {
      colors: {
        mc: {
          bg: "#060a14",
          surface: "#0a0f1c",
          card: "#0f1528",
          elevated: "#1a2240",
          border: "rgba(255, 255, 255, 0.05)",
          accent: "#6366f1",
          "accent-hover": "#818cf8",
          success: "#10b981",
          warning: "#f59e0b",
          danger: "#ef4444",
          info: "#3b82f6",
        },
      },
      fontFamily: {
        sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
        mono: ["JetBrains Mono", "SF Mono", "monospace"],
      },
      borderRadius: {
        DEFAULT: "14px",
      },
    },
  },
  plugins: [],
};


path: app/frontend/vite.config.js
content: import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 4173,
    proxy: {
      "/api": "http://localhost:8000",
    },
  },
});


path: app/frontend/src/App.jsx
content: import React, { useState, useEffect, useCallback, useReducer } from "react";
import { api } from "./lib/api";
import { useSSE } from "./hooks/useSSE";
import { requestNotificationPermission, notifyInboxItem } from "./lib/notifications";
import Icon from "./lib/icons";
import Dashboard from "./pages/Dashboard";
import Pipeline from "./pages/Pipeline";
import Projects from "./pages/Projects";
import Agents from "./pages/Agents";
import Activity from "./pages/Activity";
import Requests from "./pages/Requests";
import Approvals from "./pages/Approvals";
import Costs from "./pages/Costs";
import Reviews from "./pages/Reviews";
import Inbox from "./pages/Inbox";
import Library from "./pages/Library";

// ─── State ────────────────────────────────────────────
const init = { stats: null, projects: [], pipeline: {}, agents: [], activity: [], loading: true, inboxCount: 0 };

function reducer(s, a) {
  switch (a.type) {
    case "SET_ALL": return { ...s, ...a.payload, loading: false };
    case "SET_BADGES": return { ...s, inboxCount: a.payload };
    case "REFRESH": return { ...s, loading: true };
    default: return s;
  }
}

// ─── View meta ────────────────────────────────────────
const VIEW_META = {
  dashboard: { label: "Dashboard", subtitle: "Overview & alerts", icon: "dashboard" },
  inbox:     { label: "Inbox", subtitle: "Reviews, requests & approvals", icon: "inbox" },
  pipeline:  { label: "Pipeline", subtitle: "Task flow & stages", icon: "pipeline" },
  projects:  { label: "Projects", subtitle: "All projects", icon: "folder" },
  library:   { label: "Library", subtitle: "Documents & knowledge", icon: "library" },
  agents:    { label: "Agents", subtitle: "Fleet status", icon: "cpu" },
  costs:     { label: "Costs", subtitle: "Token spend tracking", icon: "costs" },
  activity:  { label: "Activity", subtitle: "Event timeline", icon: "activity" },
};

export default function App() {
  const [view, setView] = useState("dashboard");
  const [state, dispatch] = useReducer(reducer, init);
  const [lastRefresh, setLastRefresh] = useState(new Date());
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);

  const refresh = useCallback(async () => {
    dispatch({ type: "REFRESH" });
    try {
      const [stats, projects, pipeline, agents, activity, inboxCounts] = await Promise.all([
        api.getStats(), api.getProjects(), api.getPipeline(),
        api.getAgents(), api.getActivity({ limit: 50 }), api.getInboxCounts(),
      ]);
      dispatch({ type: "SET_ALL", payload: { stats, projects, pipeline, agents, activity } });
      dispatch({ type: "SET_BADGES", payload: inboxCounts.total || 0 });
      setLastRefresh(new Date());
    } catch (err) {
      console.error("Refresh failed:", err);
      dispatch({ type: "SET_ALL", payload: {} });
    }
  }, []);

  useEffect(() => {
    refresh();
    requestNotificationPermission();
  }, [refresh]);

  const { connected } = useSSE(
    useCallback(({ event, data }) => {
      const NOTIFY_EVENTS = ["review:submitted", "approval:created", "request:submitted", "task:stalled", "task:failed", "library:published", "agent:error"];
      if (NOTIFY_EVENTS.includes(event)) notifyInboxItem(event, data || {});

      if (event.startsWith("project:") || event.startsWith("task:") || event.startsWith("agent:") ||
          event.startsWith("request:") || event.startsWith("approval:") || event.startsWith("cost:") ||
          event.startsWith("review:") || event.startsWith("library:")) {
        refresh();
      }
    }, [refresh])
  );

  const meta = VIEW_META[view] || VIEW_META.dashboard;
  const viewProps = { ...state, refresh, api };
  const views = {
    dashboard: <Dashboard {...viewProps} />,
    inbox: <Inbox {...viewProps} />,
    pipeline: <Pipeline {...viewProps} />,
    projects: <Projects {...viewProps} />,
    library: <Library {...viewProps} />,
    agents: <Agents {...viewProps} />,
    activity: <Activity {...viewProps} />,
    costs: <Costs {...viewProps} />,
  };

  const navGroups = [
    { label: "Overview", items: [
      { id: "dashboard" },
      { id: "inbox", badge: state.inboxCount },
    ]},
    { label: "Work", items: [
      { id: "pipeline" },
      { id: "projects" },
      { id: "library" },
    ]},
    { label: "System", items: [
      { id: "agents" },
      { id: "costs" },
      { id: "activity" },
    ]},
  ];

  return (
    <div style={{ display: "flex", height: "100vh", overflow: "hidden", background: "#060a14" }}>
      {/* ─── Sidebar ─────────────────────────────── */}
      <aside style={{
        width: sidebarCollapsed ? 60 : 220,
        flexShrink: 0,
        background: "#0a0f1c",
        borderRight: "1px solid rgba(255,255,255,0.04)",
        display: "flex",
        flexDirection: "column",
        transition: "width 0.2s ease",
        overflow: "hidden",
      }}>
        {/* Logo */}
        <div style={{
          padding: sidebarCollapsed ? "18px 10px" : "18px 16px",
          borderBottom: "1px solid rgba(255,255,255,0.04)",
          display: "flex",
          alignItems: "center",
          gap: 12,
          cursor: "pointer",
        }} onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
          <div style={{
            width: 34, height: 34, borderRadius: 10,
            background: "linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7)",
            display: "flex", alignItems: "center", justifyContent: "center",
            color: "white", fontWeight: 800, fontSize: 13, letterSpacing: "-0.03em",
            flexShrink: 0,
            boxShadow: "0 4px 16px rgba(99,102,241,0.3)",
          }}>MC</div>
          {!sidebarCollapsed && (
            <div style={{ overflow: "hidden", whiteSpace: "nowrap" }}>
              <div style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9", letterSpacing: "-0.01em" }}>Mission Control</div>
              <div style={{ fontSize: 10, color: "#475569", marginTop: 1 }}>v1.1 · OpenClaw</div>
            </div>
          )}
        </div>

        {/* Nav */}
        <nav style={{ flex: 1, padding: "8px 6px", overflowY: "auto", overflowX: "hidden" }}>
          {navGroups.map(group => (
            <div key={group.label}>
              {!sidebarCollapsed && (
                <div style={{
                  padding: "14px 10px 5px",
                  fontSize: 9, fontWeight: 600,
                  textTransform: "uppercase", letterSpacing: "0.1em",
                  color: "#334155",
                }}>{group.label}</div>
              )}
              {sidebarCollapsed && <div style={{ height: 8 }} />}
              {group.items.map(item => {
                const m = VIEW_META[item.id];
                const isActive = view === item.id;
                return (
                  <button
                    key={item.id}
                    onClick={() => setView(item.id)}
                    title={sidebarCollapsed ? m.label : undefined}
                    className={`mc-nav-item ${isActive ? "active" : ""}`}
                    style={sidebarCollapsed ? { justifyContent: "center", padding: "10px 0" } : {}}
                  >
                    <Icon name={m.icon} size={18} />
                    {!sidebarCollapsed && (
                      <>
                        <span style={{ flex: 1 }}>{m.label}</span>
                        {item.badge > 0 && (
                          <span style={{
                            minWidth: 18, height: 18, padding: "0 5px",
                            borderRadius: 99, fontSize: 10, fontWeight: 700,
                            display: "inline-flex", alignItems: "center", justifyContent: "center",
                            background: "rgba(245,158,11,0.15)", color: "#fbbf24",
                          }}>{item.badge}</span>
                        )}
                      </>
                    )}
                  </button>
                );
              })}
            </div>
          ))}
        </nav>

        {/* Connection status */}
        <div style={{
          padding: sidebarCollapsed ? "12px 0" : "12px 16px",
          borderTop: "1px solid rgba(255,255,255,0.04)",
          display: "flex", alignItems: "center", gap: 8,
          justifyContent: sidebarCollapsed ? "center" : "flex-start",
        }}>
          <span style={{
            width: 7, height: 7, borderRadius: "50%",
            background: connected ? "#10b981" : "#475569",
            boxShadow: connected ? "0 0 8px rgba(16,185,129,0.4)" : "none",
          }} className={connected ? "status-pulse" : ""} />
          {!sidebarCollapsed && (
            <span style={{ fontSize: 11, color: connected ? "#64748b" : "#475569" }}>
              {connected ? "Connected" : "Reconnecting..."}
            </span>
          )}
        </div>
      </aside>

      {/* ─── Main ────────────────────────────────── */}
      <main style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
        {/* Header */}
        <header style={{
          height: 56,
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          padding: "0 24px",
          borderBottom: "1px solid rgba(255,255,255,0.04)",
          background: "rgba(10,15,28,0.6)",
          backdropFilter: "blur(12px)",
          WebkitBackdropFilter: "blur(12px)",
          flexShrink: 0,
        }}>
          <div>
            <h1 style={{ fontSize: 16, fontWeight: 600, color: "#f1f5f9", letterSpacing: "-0.01em", margin: 0 }}>{meta.label}</h1>
            <p style={{ fontSize: 11, color: "#475569", margin: 0, marginTop: 1 }}>{meta.subtitle}</p>
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <span style={{ fontSize: 10, color: "#334155" }}>
              {lastRefresh.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
            </span>
            <button
              onClick={refresh}
              className="mc-btn-soft"
              style={{ padding: "5px 12px", borderRadius: 8, fontSize: 11, border: "none", cursor: "pointer", display: "flex", alignItems: "center", gap: 5 }}
            >
              <Icon name="refresh" size={13} />
              Refresh
            </button>
          </div>
        </header>

        {/* Content */}
        <div style={{ flex: 1, overflowY: "auto", padding: 24 }} key={view} className="fade-in">
          {views[view] || <Dashboard {...viewProps} />}
        </div>
      </main>
    </div>
  );
}


path: app/frontend/src/index.css
content: @tailwind base;
@tailwind components;
@tailwind utilities;

@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap');

:root {
  font-family: 'Inter', system-ui, -apple-system, sans-serif;
  --font-mono: 'JetBrains Mono', 'SF Mono', monospace;
}

body {
  background: #060a14;
  color: #f1f5f9;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

::selection { background: rgba(99,102,241,0.2); color: #f1f5f9; }

/* ── Scrollbar ──────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 99px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.12); }
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.06) transparent; }

/* ── Animations ─────────────────────────────────────── */
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@keyframes scale-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: none; } }
@keyframes glow { 0%,100% { box-shadow: 0 0 0 0 rgba(99,102,241,0.2); } 50% { box-shadow: 0 0 16px 2px rgba(99,102,241,0.15); } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }

.status-pulse { animation: pulse-dot 2s ease-in-out infinite; }
.fade-in { animation: fade-in 0.3s ease; }
.scale-in { animation: scale-in 0.2s ease; }
.glow-pulse { animation: glow 3s ease-in-out infinite; }

/* ── Card ───────────────────────────────────────────── */
.mc-card {
  background: #0f1528;
  border: 1px solid rgba(255,255,255,0.05);
  border-radius: 14px;
  transition: transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.mc-card:hover {
  transform: translateY(-1px);
  box-shadow: 0 8px 32px rgba(0,0,0,0.35);
  border-color: rgba(255,255,255,0.08);
}

/* ── Glass ──────────────────────────────────────────── */
.mc-glass {
  background: rgba(15,21,40,0.6);
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
  border: 1px solid rgba(255,255,255,0.04);
}

/* ── Sidebar ────────────────────────────────────────── */
.mc-nav-item {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; border-radius: 10px;
  font-size: 13px; color: #64748b;
  transition: all 0.15s ease;
  border: none; background: transparent;
  width: 100%; text-align: left; cursor: pointer;
  position: relative;
}
.mc-nav-item:hover { color: #94a3b8; background: rgba(255,255,255,0.03); }
.mc-nav-item.active {
  color: #a5b4fc; background: rgba(99,102,241,0.1);
  font-weight: 500;
}
.mc-nav-item.active::before {
  content: ''; position: absolute; left: 0; top: 7px; bottom: 7px;
  width: 3px; background: #6366f1; border-radius: 0 3px 3px 0;
}

/* ── Section label ──────────────────────────────────── */
.mc-section-label {
  padding: 16px 12px 6px;
  font-size: 9px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.12em;
  color: #334155;
}

/* ── Badge ──────────────────────────────────────────── */
.mc-badge {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 18px; height: 18px; padding: 0 5px;
  border-radius: 99px; font-size: 10px; font-weight: 600; line-height: 1;
}

/* ── Button ─────────────────────────────────────────── */
.mc-btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 16px; border-radius: 10px;
  font-size: 12px; font-weight: 500;
  border: none; cursor: pointer;
  transition: all 0.15s ease; white-space: nowrap;
}
.mc-btn-primary { background: #6366f1; color: white; }
.mc-btn-primary:hover { background: #818cf8; box-shadow: 0 4px 16px rgba(99,102,241,0.3); }
.mc-btn-ghost { background: transparent; color: #64748b; }
.mc-btn-ghost:hover { background: rgba(255,255,255,0.04); color: #94a3b8; }
.mc-btn-soft { background: rgba(99,102,241,0.1); color: #a5b4fc; }
.mc-btn-soft:hover { background: rgba(99,102,241,0.18); }

/* ── Input ──────────────────────────────────────────── */
.mc-input {
  background: #060a14; border: 1px solid rgba(255,255,255,0.06);
  border-radius: 10px; padding: 8px 14px; font-size: 13px;
  color: #f1f5f9; outline: none; transition: border-color 0.2s, box-shadow 0.2s;
  width: 100%; box-sizing: border-box;
}
.mc-input:focus { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99,102,241,0.12); }
.mc-input::placeholder { color: #334155; }

.mc-select {
  background: #0f1528; border: 1px solid rgba(255,255,255,0.06);
  border-radius: 10px; padding: 7px 12px; font-size: 11px;
  color: #94a3b8; outline: none; cursor: pointer;
}
.mc-select:focus { border-color: #6366f1; }

/* ── Pill / Tag ─────────────────────────────────────── */
.mc-pill {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 8px; border-radius: 6px;
  font-size: 10px; font-weight: 500; line-height: 16px;
}

/* ── Progress ───────────────────────────────────────── */
.mc-progress {
  height: 4px; border-radius: 99px;
  background: rgba(255,255,255,0.05); overflow: hidden;
}
.mc-progress-fill {
  height: 100%; border-radius: 99px;
  background: linear-gradient(90deg, #6366f1, #818cf8);
  transition: width 0.5s ease;
}

/* ── Dot ────────────────────────────────────────────── */
.mc-dot {
  width: 8px; height: 8px; border-radius: 50%;
  flex-shrink: 0; display: inline-block;
}

/* ── Overlay / Modal ────────────────────────────────── */
.mc-overlay {
  position: fixed; inset: 0;
  background: rgba(0,0,0,0.6);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  display: flex; justify-content: center; align-items: flex-start;
  z-index: 50; overflow-y: auto; padding: 32px 16px;
  animation: fade-in 0.15s ease;
}
.mc-modal {
  background: #131a2e; border: 1px solid rgba(255,255,255,0.08);
  border-radius: 16px; width: 100%;
  box-shadow: 0 24px 64px rgba(0,0,0,0.5);
  animation: scale-in 0.2s ease;
}

/* ── Stat card ──────────────────────────────────────── */
.mc-stat-label {
  font-size: 10px; font-weight: 500;
  text-transform: uppercase; letter-spacing: 0.08em;
  color: #475569; margin-bottom: 6px;
}
.mc-stat-value {
  font-size: 26px; font-weight: 700;
  letter-spacing: -0.02em; line-height: 1;
}

/* ── Loading ────────────────────────────────────────── */
.mc-skeleton {
  background: linear-gradient(90deg, #0f1528 25%, #1a2240 50%, #0f1528 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 8px;
}

/* ── Alert ──────────────────────────────────────────── */
.mc-alert { display: flex; align-items: center; gap: 8px; padding: 7px 14px; border-radius: 10px; font-size: 12px; }

/* ── Pipeline ───────────────────────────────────────── */
.pipeline-col { min-height: 200px; transition: background 0.2s; }
.pipeline-col.drag-over { background: rgba(99,102,241,0.06); border-radius: 12px; }

/* ── Utilities ──────────────────────────────────────── */
.text-mono { font-family: var(--font-mono); }
.truncate-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }


path: app/frontend/src/main.jsx
content: import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")).render(<App />);


path: app/frontend/src/pages/Activity.jsx
content: import React, { useState, useCallback } from "react";
import usePagination from "../hooks/usePagination";

const C = {
  bg: "#0a0e1a", card: "#1a2035", border: "#1e293b", accent: "#6366f1",
  text: "#e2e8f0", muted: "#94a3b8", dim: "#64748b", faint: "#475569",
  green: "#10b981", amber: "#f59e0b", red: "#ef4444", purple: "#a855f7", blue: "#3b82f6",
};

const ACTION_COLORS = {
  created: C.green, submitted: C.green, published: C.green, completed: C.green,
  approved: C.green, status_changed: C.blue, stage_changed: C.amber,
  assigned: C.purple, failed: C.red, deleted: C.dim, rejected: C.red,
  stall_detected: C.red, auto_reassigned: C.amber, recorded: C.purple,
  triaged: C.blue, converted: C.accent, commented: C.dim, updated: C.blue,
};

const ENTITY_COLORS = {
  project: C.accent, task: C.blue, agent: C.green, system: C.dim,
  request: C.blue, approval: C.amber, review: C.purple, document: C.accent,
  cost: C.purple, dispatch: C.amber,
};

export default function Activity({ refresh, api: apiClient }) {
  const [typeFilter, setTypeFilter] = useState("");

  const fetchActivity = useCallback(async (offset, limit) => {
    const params = { offset, limit };
    if (typeFilter) params.entity_type = typeFilter;
    return await apiClient.getActivity(params);
  }, [apiClient, typeFilter]);

  const { items, loading, hasMore, loadMore } = usePagination(fetchActivity, {
    pageSize: 50,
    deps: [typeFilter],
  });

  return (
    <div style={{ maxWidth: 900, display: "flex", flexDirection: "column", gap: 12 }}>
      {/* Filters */}
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
          style={{ fontSize: 11, background: C.card, border: `1px solid ${C.border}`, borderRadius: 8, padding: "6px 10px", color: C.muted, outline: "none" }}>
          <option value="">All types</option>
          <option value="project">Projects</option>
          <option value="task">Tasks</option>
          <option value="agent">Agents</option>
          <option value="review">Reviews</option>
          <option value="approval">Approvals</option>
          <option value="request">Requests</option>
          <option value="document">Documents</option>
          <option value="cost">Costs</option>
          <option value="dispatch">Dispatches</option>
          <option value="system">System</option>
        </select>
        <span style={{ fontSize: 11, color: C.faint }}>{items.length} events{hasMore ? "+" : ""}</span>
      </div>

      {/* Activity list */}
      <div style={{ background: C.card, border: `1px solid ${C.border}`, borderRadius: 12, overflow: "hidden" }}>
        {items.length === 0 && !loading && (
          <div style={{ padding: 40, textAlign: "center", fontSize: 13, color: C.dim }}>No activity recorded yet</div>
        )}
        {items.map(item => {
          const actionColor = ACTION_COLORS[item.action] || C.dim;
          const entityColor = ENTITY_COLORS[item.entity_type] || C.dim;
          return (
            <div key={item.id} style={{ display: "flex", alignItems: "flex-start", gap: 12, padding: "10px 16px", borderBottom: `1px solid rgba(30,41,59,0.3)`, transition: "background 0.1s" }}
              onMouseEnter={e => { e.currentTarget.style.background = "rgba(255,255,255,0.015)"; }}
              onMouseLeave={e => { e.currentTarget.style.background = "transparent"; }}>
              {/* Dot */}
              <span style={{ width: 8, height: 8, borderRadius: "50%", background: actionColor, flexShrink: 0, marginTop: 5 }} />

              {/* Content */}
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 12, color: C.muted, lineHeight: 1.5 }}>{item.message || item.action}</div>
                <div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 3 }}>
                  <span style={{ fontSize: 9, padding: "1px 6px", borderRadius: 4, color: entityColor, background: entityColor + "18" }}>
                    {item.entity_type}
                  </span>
                  {item.old_value && item.new_value && (
                    <span style={{ fontSize: 10, color: C.faint }}>{item.old_value} → {item.new_value}</span>
                  )}
                  <span style={{ fontSize: 10, color: C.faint, marginLeft: "auto" }}>{item.actor}</span>
                </div>
              </div>

              {/* Timestamp */}
              <div style={{ fontSize: 10, color: C.faint, flexShrink: 0, whiteSpace: "nowrap", marginTop: 2 }}>
                {new Date(item.created_at).toLocaleString()}
              </div>
            </div>
          );
        })}

        {loading && (
          <div style={{ padding: 16, textAlign: "center", fontSize: 12, color: C.dim }}>Loading...</div>
        )}
      </div>

      {/* Load more */}
      {hasMore && items.length > 0 && (
        <div style={{ textAlign: "center" }}>
          <button onClick={loadMore} disabled={loading}
            style={{ fontSize: 12, padding: "6px 20px", borderRadius: 8, background: C.accent + "15", color: C.accent, border: "none", cursor: loading ? "wait" : "pointer", opacity: loading ? 0.5 : 1 }}>
            {loading ? "Loading..." : "Load More"}
          </button>
        </div>
      )}
    </div>
  );
}


path: app/frontend/src/pages/Agents.jsx
content: import React from "react";
import Icon from "../lib/icons";

const STATUS = {
  online: { color: "#10b981", bg: "rgba(16,185,129,0.1)", label: "Online" },
  busy:   { color: "#f59e0b", bg: "rgba(245,158,11,0.1)", label: "Busy" },
  idle:   { color: "#3b82f6", bg: "rgba(59,130,246,0.1)", label: "Idle" },
  offline:{ color: "#334155", bg: "rgba(51,65,85,0.1)", label: "Offline" },
  error:  { color: "#ef4444", bg: "rgba(239,68,68,0.1)", label: "Error" },
};

function timeAgo(ts) {
  if (!ts) return "never";
  const ms = Date.now() - new Date(ts).getTime();
  if (ms < 60_000) return "just now";
  if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
  if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
  return `${Math.floor(ms / 86_400_000)}d ago`;
}

export default function Agents({ agents, loading }) {
  if (loading && !agents?.length) {
    return <div style={{ color: "#475569", fontSize: 13, padding: 40, textAlign: "center" }}>Loading agents...</div>;
  }

  const online = agents?.filter(a => a.status === "online" || a.status === "busy").length || 0;

  return (
    <div style={{ maxWidth: 1000, display: "flex", flexDirection: "column", gap: 14 }}>
      {/* Summary */}
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <span style={{ fontSize: 12, color: "#64748b" }}>{agents?.length || 0} agents registered</span>
        <span style={{ width: 3, height: 3, borderRadius: "50%", background: "#334155" }} />
        <span style={{ fontSize: 12, color: "#10b981" }}>{online} online</span>
      </div>

      {/* Agent grid */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 12 }}>
        {agents?.length === 0 && (
          <div style={{ gridColumn: "1/-1", padding: 40, textAlign: "center" }}>
            <Icon name="cpu" size={28} style={{ color: "#334155", margin: "0 auto 8px" }} />
            <div style={{ fontSize: 13, color: "#475569" }}>No agents registered yet</div>
            <div style={{ fontSize: 11, color: "#334155", marginTop: 4 }}>Agents appear here when they connect through the gateway.</div>
          </div>
        )}

        {agents?.map(a => {
          const st = STATUS[a.status] || STATUS.offline;
          return (
            <div key={a.id} className="mc-card" style={{ padding: 18, borderRadius: 14, display: "flex", flexDirection: "column", gap: 12 }}>
              {/* Header row */}
              <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                {/* Avatar */}
                <div style={{
                  width: 38, height: 38, borderRadius: 10,
                  background: st.bg,
                  display: "flex", alignItems: "center", justifyContent: "center",
                  position: "relative",
                }}>
                  <Icon name="cpu" size={18} style={{ color: st.color }} />
                  <span style={{
                    position: "absolute", bottom: -2, right: -2,
                    width: 10, height: 10, borderRadius: "50%",
                    background: st.color, border: "2px solid #0f1528",
                  }} className={(a.status === "online" || a.status === "busy") ? "status-pulse" : ""} />
                </div>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div style={{ fontSize: 13, fontWeight: 600, color: "#f1f5f9" }}>{a.display_name || a.id}</div>
                  <div style={{
                    display: "inline-flex", alignItems: "center", gap: 4,
                    fontSize: 10, fontWeight: 500, padding: "1px 7px", borderRadius: 6,
                    background: st.bg, color: st.color, marginTop: 2,
                  }}>
                    <span style={{ width: 5, height: 5, borderRadius: "50%", background: st.color }} />
                    {st.label}
                  </div>
                </div>
              </div>

              {/* Details */}
              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6 }}>
                {a.model && (
                  <div style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "#64748b" }}>
                    <Icon name="zap" size={12} style={{ color: "#475569" }} />
                    <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{a.model.split("/").pop()}</span>
                  </div>
                )}
                <div style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "#64748b" }}>
                  <Icon name="clock" size={12} style={{ color: "#475569" }} />
                  {timeAgo(a.last_heartbeat)}
                </div>
                {a.active_tasks > 0 && (
                  <div style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "#64748b" }}>
                    <Icon name="pipeline" size={12} style={{ color: "#475569" }} />
                    {a.active_tasks} task{a.active_tasks !== 1 ? "s" : ""}
                  </div>
                )}
                {a.total_tasks > 0 && (
                  <div style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "#64748b" }}>
                    <Icon name="checkCircle" size={12} style={{ color: "#475569" }} />
                    {a.total_tasks} total
                  </div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}


path: app/frontend/src/pages/Approvals.jsx
content: import React, { useState, useEffect } from "react";

const TYPE_LABELS = {
  task_review: "Task Review",
  project_approval: "Project Approval",
  workflow_gate: "Workflow Gate",
  cost_alert: "Cost Alert",
  request_triage: "Request Triage",
  custom: "Action Needed",
};
const TYPE_COLORS = {
  task_review: "bg-purple-500/20 text-purple-400",
  project_approval: "bg-indigo-500/20 text-indigo-400",
  workflow_gate: "bg-amber-500/20 text-amber-400",
  cost_alert: "bg-red-500/20 text-red-400",
  request_triage: "bg-blue-500/20 text-blue-400",
  custom: "bg-gray-500/20 text-gray-400",
};
const URGENCY_RING = {
  critical: "border-l-red-500",
  urgent: "border-l-orange-500",
  normal: "border-l-blue-500",
  low: "border-l-gray-500",
};
const STATUS_BADGE = {
  pending: "bg-amber-500/20 text-amber-400",
  approved: "bg-emerald-500/20 text-emerald-400",
  rejected: "bg-red-500/20 text-red-400",
  expired: "bg-gray-500/20 text-gray-500",
  cancelled: "bg-gray-500/20 text-gray-500",
};

function DecisionModal({ approval, onDecide, onClose }) {
  const [notes, setNotes] = useState("");

  return (
    <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
      <div className="bg-mc-card border border-mc-border rounded-xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
        <div className="flex items-center gap-2 mb-2">
          <span className={`text-[10px] px-2 py-0.5 rounded ${TYPE_COLORS[approval.type]}`}>
            {TYPE_LABELS[approval.type]}
          </span>
          {approval.urgency !== "normal" && (
            <span className="text-[10px] text-orange-400 uppercase">{approval.urgency}</span>
          )}
        </div>
        <h2 className="text-base font-semibold text-white mb-2">{approval.title}</h2>
        {approval.description && (
          <p className="text-xs text-gray-400 mb-3 p-3 bg-mc-bg/50 rounded-lg leading-relaxed">{approval.description}</p>
        )}

        {/* Entity info */}
        {approval.entity && (
          <div className="text-[11px] text-gray-500 mb-3 p-2 rounded-lg bg-mc-bg/30 border border-mc-border/30">
            {approval.entity_type === "task" && <>Task: <span className="text-white">{approval.entity.title}</span> · Stage: {approval.entity.pipeline_stage} · Agent: {approval.entity.assigned_agent || "none"}</>}
            {approval.entity_type === "project" && <>Project: <span className="text-white">{approval.entity.name}</span> · Status: {approval.entity.status}</>}
            {approval.entity_type === "request" && <>Request: <span className="text-white">{approval.entity.title}</span> · Urgency: {approval.entity.urgency}</>}
          </div>
        )}

        {approval.resume_token && (
          <div className="text-[10px] text-amber-400 mb-3 p-2 bg-amber-500/10 rounded-lg border border-amber-500/20">
            This approval is linked to a Lobster workflow gate. Approving will resume the paused workflow.
          </div>
        )}

        <div className="mb-4">
          <label className="text-xs text-gray-500 block mb-1">Decision notes (optional)</label>
          <textarea value={notes} onChange={e => setNotes(e.target.value)}
            className="w-full bg-mc-bg border border-mc-border rounded-lg px-3 py-2 text-xs text-gray-300 outline-none resize-none h-16 focus:border-mc-accent transition-colors"
            placeholder="Add context for your decision..." />
        </div>

        <div className="flex justify-between">
          <button onClick={onClose} className="text-sm px-4 py-2 text-gray-500 hover:text-gray-300 transition-colors">Cancel</button>
          <div className="flex gap-2">
            <button onClick={() => { onDecide(approval.id, "rejected", notes); onClose(); }}
              className="text-sm px-5 py-2 rounded-lg bg-red-500/15 text-red-400 hover:bg-red-500/25 transition-colors font-medium">
              Reject
            </button>
            <button onClick={() => { onDecide(approval.id, "approved", notes); onClose(); }}
              className="text-sm px-5 py-2 rounded-lg bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25 transition-colors font-medium">
              Approve
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

export default function Approvals({ refresh, api: apiClient }) {
  const [approvals, setApprovals] = useState([]);
  const [filter, setFilter] = useState("pending");
  const [deciding, setDeciding] = useState(null);

  useEffect(() => { loadApprovals(); }, [filter]);

  const loadApprovals = async () => {
    try {
      const data = await apiClient.getApprovals({ status: filter });
      setApprovals(data);
    } catch {}
  };

  const handleDecide = async (id, decision, notes) => {
    try {
      await apiClient.decideApproval(id, decision, notes, "user");
      loadApprovals();
      refresh();
    } catch (err) { alert(`Failed: ${err.message}`); }
  };

  const pendingCount = filter !== "pending"
    ? approvals.filter(a => a.status === "pending").length
    : approvals.length;

  return (
    <div className="space-y-5 max-w-[1000px]">
      {/* Header */}
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3">
          <select value={filter} onChange={e => setFilter(e.target.value)}
            className="text-xs bg-mc-card border border-mc-border rounded-lg px-3 py-2 text-gray-300">
            <option value="pending">Pending</option>
            <option value="approved">Approved</option>
            <option value="rejected">Rejected</option>
            <option value="expired">Expired</option>
            <option value="all">All</option>
          </select>
          <span className="text-xs text-gray-500">{approvals.length} items</span>
        </div>
        {pendingCount > 0 && (
          <div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/20">
            <span className="w-2 h-2 rounded-full bg-amber-500 status-pulse" />
            <span className="text-xs text-amber-400 font-medium">{pendingCount} awaiting decision</span>
          </div>
        )}
      </div>

      {/* Approvals list */}
      <div className="space-y-2">
        {approvals.map(a => (
          <div key={a.id}
            className={`bg-mc-card border border-mc-border rounded-xl p-4 card-hover border-l-4 ${URGENCY_RING[a.urgency] || URGENCY_RING.normal}`}>
            <div className="flex items-start gap-4">
              <div className="flex-1 min-w-0">
                <div className="flex items-center gap-2 mb-1 flex-wrap">
                  <span className={`text-[10px] px-2 py-0.5 rounded ${TYPE_COLORS[a.type]}`}>
                    {TYPE_LABELS[a.type]}
                  </span>
                  <span className={`text-[10px] px-1.5 py-0.5 rounded ${STATUS_BADGE[a.status]}`}>
                    {a.status}
                  </span>
                  {a.urgency !== "normal" && (
                    <span className="text-[10px] text-orange-400 font-medium uppercase">{a.urgency}</span>
                  )}
                  {a.resume_token && (
                    <span className="text-[10px] text-amber-400">⚡ workflow gate</span>
                  )}
                </div>
                <h3 className="text-sm font-medium text-white mb-1">{a.title}</h3>
                {a.description && <p className="text-xs text-gray-500 line-clamp-2 mb-1">{a.description}</p>}

                {/* Entity preview */}
                {a.entity && (
                  <div className="text-[10px] text-gray-600 mt-1">
                    {a.entity_type}: <span className="text-gray-400">{a.entity.title || a.entity.name}</span>
                  </div>
                )}

                <div className="flex gap-3 text-[10px] text-gray-600 mt-1.5">
                  <span>From: {a.requested_by}</span>
                  <span>{new Date(a.created_at).toLocaleString()}</span>
                  {a.decided_by && <span>Decided by: {a.decided_by}</span>}
                  {a.decided_at && <span>at {new Date(a.decided_at).toLocaleString()}</span>}
                </div>
                {a.decision_notes && (
                  <div className="text-[11px] text-gray-500 mt-1 italic">"{a.decision_notes}"</div>
                )}
              </div>

              {/* Quick actions for pending */}
              {a.status === "pending" && (
                <div className="flex gap-1.5 flex-shrink-0">
                  <button onClick={() => handleDecide(a.id, "approved", "")}
                    className="text-[11px] px-3 py-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 transition-colors">
                    ✓
                  </button>
                  <button onClick={() => setDeciding(a)}
                    className="text-[11px] px-3 py-1.5 rounded-lg bg-mc-bg text-gray-400 hover:text-gray-200 hover:bg-white/5 transition-colors">
                    Review
                  </button>
                  <button onClick={() => handleDecide(a.id, "rejected", "")}
                    className="text-[11px] px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors">
                    ✕
                  </button>
                </div>
              )}
            </div>
          </div>
        ))}
      </div>

      {approvals.length === 0 && (
        <div className="text-center py-16">
          <div className="text-2xl mb-2">✓</div>
          <div className="text-gray-500 text-sm">
            {filter === "pending" ? "No pending approvals — you're all caught up" : "No approvals found"}
          </div>
        </div>
      )}

      {deciding && <DecisionModal approval={deciding} onDecide={handleDecide} onClose={() => setDeciding(null)} />}
    </div>
  );
}


path: app/frontend/src/pages/Costs.jsx
content: import React, { useState, useEffect } from "react";
import Icon from "../lib/icons";

function formatUSD(n) {
  if (!n || n === 0) return "$0.00";
  return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
}

function formatTokens(n) {
  if (!n) return "0";
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
  return String(n);
}

function BarChart({ data, labelKey, valueKey, formatValue, maxBars = 8, accentColor = "#6366f1" }) {
  const items = data.slice(0, maxBars);
  const max = Math.max(...items.map(d => d[valueKey] || 0), 0.001);

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      {items.map((d, i) => (
        <div key={i} style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <div style={{ width: 90, fontSize: 11, color: "#94a3b8", textAlign: "right", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{d[labelKey]}</div>
          <div style={{ flex: 1, height: 20, background: "rgba(255,255,255,0.02)", borderRadius: 6, overflow: "hidden" }}>
            <div style={{
              height: "100%", borderRadius: 6,
              width: `${Math.max((d[valueKey] / max) * 100, 2)}%`,
              background: `linear-gradient(90deg, ${accentColor}60, ${accentColor}30)`,
              transition: "width 0.5s ease",
            }} />
          </div>
          <div style={{ width: 72, fontSize: 11, color: "#e2e8f0", textAlign: "right", fontFamily: "var(--font-mono)" }}>
            {formatValue ? formatValue(d[valueKey]) : d[valueKey]}
          </div>
        </div>
      ))}
      {data.length === 0 && (
        <div style={{ fontSize: 12, color: "#334155", textAlign: "center", padding: 16 }}>No data yet</div>
      )}
    </div>
  );
}

function SparkLine({ data, valueKey, height = 50 }) {
  if (!data.length) return <div style={{ fontSize: 12, color: "#334155", textAlign: "center", padding: 8 }}>No data</div>;

  const values = data.map(d => d[valueKey] || 0);
  const max = Math.max(...values, 0.001);
  const min = Math.min(...values, 0);
  const range = max - min || 1;
  const w = 100;

  const points = values.map((v, i) => {
    const x = (i / Math.max(values.length - 1, 1)) * w;
    const y = height - ((v - min) / range) * (height - 6) - 3;
    return `${x},${y}`;
  }).join(" ");

  const areaPoints = `0,${height} ${points} ${w},${height}`;

  return (
    <svg viewBox={`0 0 ${w} ${height}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
      <defs>
        <linearGradient id="sparkGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#6366f1" stopOpacity="0.2" />
          <stop offset="100%" stopColor="#6366f1" stopOpacity="0" />
        </linearGradient>
      </defs>
      <polygon points={areaPoints} fill="url(#sparkGrad)" />
      <polyline points={points} fill="none" stroke="#6366f1" strokeWidth="1.5" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
    </svg>
  );
}

function Card({ children, style: s = {} }) {
  return (
    <div style={{ background: "#0f1528", border: "1px solid rgba(255,255,255,0.04)", borderRadius: 14, padding: 20, ...s }}>
      {children}
    </div>
  );
}

function StatCard({ icon, label, value, sub, accentColor = "#6366f1" }) {
  return (
    <Card>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
        <div style={{
          width: 28, height: 28, borderRadius: 7,
          background: `${accentColor}12`,
          display: "flex", alignItems: "center", justifyContent: "center",
          color: accentColor,
        }}>
          <Icon name={icon} size={14} />
        </div>
        <span style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", color: "#475569" }}>{label}</span>
      </div>
      <div style={{ fontSize: 26, fontWeight: 700, color: "#f1f5f9", letterSpacing: "-0.02em", lineHeight: 1 }}>{value}</div>
      {sub && <div style={{ fontSize: 11, color: "#475569", marginTop: 5 }}>{sub}</div>}
    </Card>
  );
}

export default function Costs({ api: apiClient }) {
  const [summary, setSummary] = useState(null);
  const [days, setDays] = useState(30);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    apiClient.getCostSummary(days).then(d => setSummary(d)).catch(() => {}).finally(() => setLoading(false));
  }, [days]);

  if (loading && !summary) {
    return <div style={{ color: "#475569", fontSize: 13, padding: 40, textAlign: "center" }}>Loading cost data...</div>;
  }

  const s = summary || {};
  const total = s.total || {};
  const today = s.today || {};

  return (
    <div style={{ maxWidth: 1200, display: "flex", flexDirection: "column", gap: 14 }}>
      {/* Period selector */}
      <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
        {[7, 14, 30, 90].map(d => (
          <button key={d} onClick={() => setDays(d)}
            style={{
              fontSize: 11, padding: "5px 14px", borderRadius: 8, border: "none", cursor: "pointer",
              background: days === d ? "rgba(99,102,241,0.12)" : "transparent",
              color: days === d ? "#a5b4fc" : "#475569",
              fontWeight: days === d ? 500 : 400,
              transition: "all 0.15s",
            }}>
            {d}d
          </button>
        ))}
      </div>

      {/* Top-line stats */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12 }}>
        <StatCard icon="costs" label={`Total (${days}d)`} value={formatUSD(total.total_cost)} sub={`${total.run_count || 0} runs`} accentColor="#6366f1" />
        <StatCard icon="zap" label="Today" value={formatUSD(today.cost)} sub={`${today.runs || 0} runs`} accentColor="#10b981" />
        <StatCard icon="arrowDown" label="Input Tokens" value={formatTokens(total.total_input)} sub={`${days}d total`} accentColor="#3b82f6" />
        <StatCard icon="arrowUp" label="Output Tokens" value={formatTokens(total.total_output)} sub={`${days}d total`} accentColor="#a855f7" />
      </div>

      {/* Daily trend */}
      <Card>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <Icon name="activity" size={14} style={{ color: "#475569" }} />
            <span style={{ fontSize: 12, fontWeight: 600, color: "#e2e8f0" }}>Daily Spend Trend</span>
          </div>
          <span style={{ fontSize: 10, color: "#334155" }}>Last {days} days</span>
        </div>
        <SparkLine data={s.daily || []} valueKey="cost" height={70} />
        {(s.daily?.length || 0) > 0 && (
          <div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, fontSize: 10, color: "#334155" }}>
            <span>{s.daily[0]?.date}</span>
            <span>{s.daily[s.daily.length - 1]?.date}</span>
          </div>
        )}
      </Card>

      {/* Breakdowns */}
      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
        <Card>
          <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
            <Icon name="cpu" size={14} style={{ color: "#475569" }} />
            <span style={{ fontSize: 12, fontWeight: 600, color: "#e2e8f0" }}>By Agent</span>
          </div>
          <BarChart data={s.byAgent || []} labelKey="agent_id" valueKey="cost" formatValue={formatUSD} accentColor="#6366f1" />
        </Card>

        <Card>
          <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
            <Icon name="zap" size={14} style={{ color: "#475569" }} />
            <span style={{ fontSize: 12, fontWeight: 600, color: "#e2e8f0" }}>By Model</span>
          </div>
          <BarChart data={s.byModel || []} labelKey="model" valueKey="cost" formatValue={formatUSD} accentColor="#a855f7" />
        </Card>
      </div>

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
        <Card>
          <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
            <Icon name="folder" size={14} style={{ color: "#475569" }} />
            <span style={{ fontSize: 12, fontWeight: 600, color: "#e2e8f0" }}>By Project</span>
          </div>
          <BarChart data={s.byProject || []} labelKey="project_name" valueKey="cost" formatValue={formatUSD} accentColor="#3b82f6" />
        </Card>

        <Card>
          <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14 }}>
            <Icon name="star" size={14} style={{ color: "#475569" }} />
            <span style={{ fontSize: 12, fontWeight: 600, color: "#e2e8f0" }}>Most Expensive Tasks</span>
          </div>
          {(s.topTasks || []).length === 0 ? (
            <div style={{ fontSize: 12, color: "#334155", textAlign: "center", padding: 16 }}>No task-level data yet</div>
          ) : (
            <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
              {(s.topTasks || []).slice(0, 6).map((t, i) => (
                <div key={t.task_id} style={{ display: "flex", alignItems: "center", gap: 8 }}>
                  <span style={{ fontSize: 10, color: "#475569", width: 16 }}>{i + 1}.</span>
                  <span style={{ flex: 1, fontSize: 12, color: "#94a3b8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.title}</span>
                  <span style={{ fontSize: 11, color: "#64748b", fontFamily: "var(--font-mono)" }}>{formatTokens(t.tokens)}</span>
                  <span style={{ fontSize: 11, color: "#e2e8f0", fontFamily: "var(--font-mono)", fontWeight: 500 }}>{formatUSD(t.cost)}</span>
                </div>
              ))}
            </div>
          )}
        </Card>
      </div>
    </div>
  );
}


path: app/frontend/src/lib/icons.jsx
content: /**
 * Icon library for Mission Control
 *
 * Consistent 20x20 stroke-based icons. Each icon is a function
 * that returns JSX. Uses currentColor so they inherit text color.
 *
 * Usage:
 *   import { Icon } from "../lib/icons";
 *   <Icon name="dashboard" />
 *   <Icon name="inbox" size={16} className="text-amber-400" />
 */
import React from "react";

const paths = {
  // ── Navigation ────────────────────────────────────
  dashboard: (
    <>
      <rect x="3" y="3" width="6" height="6" rx="1" />
      <rect x="11" y="3" width="6" height="4" rx="1" />
      <rect x="11" y="9" width="6" height="8" rx="1" />
      <rect x="3" y="11" width="6" height="6" rx="1" />
    </>
  ),
  inbox: (
    <>
      <path d="M3 7l4 4h6l4-4" />
      <path d="M3 7v8a2 2 0 002 2h10a2 2 0 002-2V7" />
      <path d="M7 3h6l4 4H3l4-4z" />
    </>
  ),
  pipeline: (
    <>
      <rect x="3" y="3" width="4" height="14" rx="1" />
      <rect x="8" y="6" width="4" height="11" rx="1" />
      <rect x="13" y="9" width="4" height="8" rx="1" />
    </>
  ),
  folder: (
    <path d="M3 6a2 2 0 012-2h3.93a2 2 0 011.664.89l.812 1.22A2 2 0 0013.07 7H15a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2V6z" />
  ),
  library: (
    <>
      <path d="M4 19V5a2 2 0 012-2h8a2 2 0 012 2v14" />
      <path d="M4 19h12" />
      <path d="M8 3v4" />
      <path d="M12 7H8" />
    </>
  ),
  agents: (
    <>
      <circle cx="10" cy="7" r="3" />
      <path d="M3 19c0-3.314 3.134-6 7-6s7 2.686 7 6" />
    </>
  ),
  costs: (
    <>
      <circle cx="10" cy="10" r="7" />
      <path d="M10 6v8" />
      <path d="M8 8.5c0-.828.895-1.5 2-1.5s2 .672 2 1.5S11.105 10 10 10s-2 .672-2 1.5.895 1.5 2 1.5 2-.672 2-1.5" />
    </>
  ),
  activity: (
    <>
      <polyline points="4 14 8 10 12 13 16 6" />
      <path d="M3 3v14h14" />
    </>
  ),

  // ── Actions ───────────────────────────────────────
  plus: <path d="M10 4v12M4 10h12" />,
  refresh: (
    <>
      <path d="M4 10a6 6 0 0111.472-2.5" />
      <path d="M16 10a6 6 0 01-11.472 2.5" />
      <polyline points="15 3 16 7.5 11.5 8" />
      <polyline points="5 17 4 12.5 8.5 12" />
    </>
  ),
  check: <polyline points="5 10 8.5 13.5 15 6.5" />,
  x: <><path d="M6 6l8 8" /><path d="M14 6l-8 8" /></>,
  search: (
    <>
      <circle cx="9" cy="9" r="5" />
      <path d="M13 13l4 4" />
    </>
  ),
  filter: <path d="M3 4h14l-5 6v5l-4 2V10L3 4z" />,
  chevronRight: <polyline points="7 5 13 10 7 15" />,
  chevronDown: <polyline points="5 7 10 13 15 7" />,
  arrowUp: <><path d="M10 16V4" /><polyline points="5 9 10 4 15 9" /></>,
  arrowDown: <><path d="M10 4v12" /><polyline points="5 11 10 16 15 11" /></>,
  externalLink: (
    <>
      <path d="M11 3h6v6" />
      <path d="M17 3L8 12" />
      <path d="M15 8v7a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h7" />
    </>
  ),

  // ── Status / State ────────────────────────────────
  alertTriangle: (
    <>
      <path d="M10 2L1 18h18L10 2z" />
      <path d="M10 8v4" />
      <circle cx="10" cy="14.5" r="0.5" fill="currentColor" />
    </>
  ),
  alertCircle: (
    <>
      <circle cx="10" cy="10" r="7" />
      <path d="M10 7v4" />
      <circle cx="10" cy="13.5" r="0.5" fill="currentColor" />
    </>
  ),
  checkCircle: (
    <>
      <circle cx="10" cy="10" r="7" />
      <polyline points="7 10 9.5 12.5 13.5 7.5" />
    </>
  ),
  clock: (
    <>
      <circle cx="10" cy="10" r="7" />
      <path d="M10 6v4l3 2" />
    </>
  ),
  zap: <polygon points="10 1 4 11 10 11 10 19 16 9 10 9" />,
  shield: <path d="M10 2l7 3v5c0 4.5-3 8.5-7 9.5-4-1-7-5-7-9.5V5l7-3z" />,
  eye: (
    <>
      <path d="M2 10s3-6 8-6 8 6 8 6-3 6-8 6-8-6-8-6z" />
      <circle cx="10" cy="10" r="2.5" />
    </>
  ),

  // ── Objects ───────────────────────────────────────
  file: (
    <>
      <path d="M5 3h7l5 5v9a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" />
      <path d="M12 3v5h5" />
    </>
  ),
  fileText: (
    <>
      <path d="M5 3h7l5 5v9a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" />
      <path d="M12 3v5h5" />
      <path d="M7 11h6" />
      <path d="M7 14h4" />
    </>
  ),
  tag: <path d="M3 5a2 2 0 012-2h4.586a1 1 0 01.707.293l6.414 6.414a2 2 0 010 2.828l-4.172 4.172a2 2 0 01-2.828 0L3.293 10.293A1 1 0 013 9.586V5zm4 1a1 1 0 100 2 1 1 0 000-2z" />,
  star: <polygon points="10 2 12.5 7.5 18 8 14 12 15 18 10 15 5 18 6 12 2 8 7.5 7.5" />,
  bookmark: <path d="M5 3h10a1 1 0 011 1v14l-6-3-6 3V4a1 1 0 011-1z" />,
  cpu: (
    <>
      <rect x="5" y="5" width="10" height="10" rx="1" />
      <rect x="7.5" y="7.5" width="5" height="5" rx="0.5" />
      <path d="M8 2v3M12 2v3M8 15v3M12 15v3M2 8h3M2 12h3M15 8h3M15 12h3" />
    </>
  ),
  send: <path d="M3 10l14-7-7 14-2-5-5-2z" />,
  bell: (
    <>
      <path d="M14 13.5V8a4 4 0 00-8 0v5.5L4 16h12l-2-2.5z" />
      <path d="M8.5 17a1.5 1.5 0 003 0" />
    </>
  ),
};

export function Icon({ name, size = 20, className = "", style = {} }) {
  const content = paths[name];
  if (!content) return null;
  return (
    <svg
      viewBox="0 0 20 20"
      width={size}
      height={size}
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      style={{ flexShrink: 0, ...style }}
    >
      {content}
    </svg>
  );
}

export default Icon;


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