Dashboard

This document covers the MissionCache Dashboard: a FastAPI backend plus a single-file HTML frontend that runs at http://localhost:8787 and visualizes everything MissionCache knows about your projects, your time, and any MissionCache Auto runs in progress.

It assumes you have read architecture.md for the shared vocabulary (dual-DB pattern, heartbeats, sessions, claude_session_cache, full_path, MissionCache file layout). If a term in this doc is not defined here, it is defined there.

If you are just trying to use the dashboard, the short version is: run missioncache-dashboard serve (the pip-installed console entry point, or have launchd/systemd do it for you via missioncache-dashboard install-service), open http://localhost:8787, and it opens on Attention, which is where most of the time goes; Projects and Activity are the other two you will live in. The rest of this doc is for when you want to understand what you are looking at, hit the API directly, or extend the dashboard.

What the dashboard shows#

The frontend's top-level views, all hash-routed:

Hash View What it shows
#attention (default) Attention Where attention is owed, across projects: what you owe, who owes you what, and every project with something outstanding. Detailed below. #today redirects here.
#projects Projects Active and completed projects in two tables (with due dates, action-item badges, and at-risk dots), with click-through to the project page
#project/<name>[?tid=<task id>] Project page Full page per project: Overview (description, Definition of Done, due date, stakeholders, tickets) and Action Items tabs, plus Tasks/Structure/Context/Plan/Updates. tid is optional and only disambiguates duplicate project names (a fork and its parent, or a stale row left by a rename); without it the name resolves to whichever task sorts first, which is the pre-tid behaviour every older link still gets.
#activity Activity Today's time and LOC stats, hourly timeline, and a multi-week activity history with heatmap, trends, and top projects
#auto Auto Live graph of active MissionCache Auto executions (DAG visualization, worker status, per-task state)

All data is fetched lazily on first view switch and cached in-browser for the remainder of the session (a refresh button on each card forces a re-fetch).

Attention view#

The default view, and the answer to "what needs me today". A greeting, a stats strip, then three gadgets sized to one viewport - each scrolls inside itself rather than growing the page, so the whole picture stays on one screen. Below 1150px wide the gadgets stack and the page scrolls normally instead.

The strip carries tracked time, commits, lines added and removed, work on you, how much of it is late, how much is out with other people, how much has had no reply in 7 days, and an hourly sparkline. Its tracked-time half arrives on a second request, so the board renders before it and fills in.

Projects view#

The Projects view is built from two API calls: GET /api/tasks/active and GET /api/tasks/completed. Both return a tree of parent projects with optional subtasks. The active table includes a remaining-tasks summary and a completion percentage parsed live from the project's -tasks.md file, so editing the checklist in another editor and refreshing the dashboard gives you immediate feedback.

A fork whose parent has left the active set (the parent was completed or deleted) surfaces top-level in this view rather than disappearing under its now-absent parent - so an active fork is always visible even after its parent is done. (Note "orphan" is overloaded here: this parent-left-the-set case is distinct from a task whose DB status is active but whose files were moved to completed/.)

Clicking a row opens the project page (#project/<name>). Beyond Overview and Action Items it carries:

Those tabs are driven by GET /api/task/{id}/files (for markdown content) and GET /api/task/{id}/structure (for the graph). Both re-parse the MissionCache files on the server side on every request - there is no caching of file contents, only of the DuckDB row, so edits outside the dashboard show up the next time the page is opened.

The project page header also carries an inline category selector (icon + dropdown next to the repo badge). It shows the STORED category - "uncategorized" for NULL rows, even when the row icon renders a name-heuristic guess - because the selector edits what the database holds, not the guess. The value paints from the client cache first, then refreshes from the /api/task/{id}/files response (which reads it from SQLite), so it stays correct even when the category changed via the MCP tool or CLI since the tables were loaded. Changing it PUTs to /api/tasks/{id}/category; on success the page icon, the table row icons, and the filter-bar chips all update in place without a refetch. A sync warning in the response surfaces as "Saved (list refresh delayed)" in the status text.

Custom categories

The Settings view's "Custom categories" section extends the built-in taxonomy: each custom category is a kebab-case name (built-in names and the none clear sentinel are reserved), an emoji, and a palette color. Custom categories render their emoji (in the chosen color) wherever built-ins render SVG icons - table rows, filter chips, the page selector - and every category write path accepts them, because validation lives in TaskDB against built-ins plus the custom_categories table. The color is validated server-side as strict #RRGGBB (it lands in style attributes); the emoji is content-validated (non-ASCII required, HTML metacharacters rejected) and renders through text nodes and escaping, never as markup. The category map refreshes with the 15-minute auto-refresh, so categories created from another tab or the CLI catch up without a manual reload.

Deletion always succeeds and orphans degrade: projects keeping a deleted value render with default styling, the value stays selectable on those projects (a save from the page cannot wipe it), and re-adding the name restores its look. The API surface is GET /api/categories (built-in names + custom rows), POST /api/categories (400 on validation, 409 on duplicate), and DELETE /api/categories/{name} (404 for unknown names) - all reading and writing SQLite directly, no DuckDB sync involved.

The active table filters out "orphan" tasks where the DB still says status=active but <project>-tasks.md has been moved to ~/.missioncache/completed/<project>/. Orphans appear in the completed table instead. This is handled server-side in parse_missioncache_progress() at missioncache-dashboard/missioncache_dashboard/server.py:833, which flags missioncache_in_completed=True when it finds the files under the completed path, and the /api/tasks/active handler skips those rows.

Project category icons

Each project row carries a colored SVG icon for its category (bug, feature, refactor, test, docs, infra, ui, api, database, security, perf, coding, noncoding). getTaskCategory() in index.html uses the category value stored on the task row first - assigned at creation time by /missioncache:new, which derives it from the project description. Only when the stored value is NULL (projects created before the feature) does it fall back to a name-keyword heuristic, which matches on word boundaries so an embedded substring (e.g. the "cache" inside "missioncache") cannot mislabel a project. The icon and color maps (TASK_ICONS / TASK_ICON_COLORS) must carry an entry per CATEGORIES value; unknown values render with the default coding icon.

Filter bar

A filter bar above the two tables narrows both at once, entirely client-side: a search box matching project name and description (a parent also matches when one of its subtasks does), category chips, and a repo dropdown. The chips are built from the categories actually present in the loaded data - stored values and heuristic fallbacks alike, so a chip always corresponds to an icon you can see in the rows - and multi-select with OR semantics. On the Completed table the filter runs before the newest-10 display cap, so searching can surface completions that are not among the ten shown by default. Filter state is session-only (unlike the sort order, it is not persisted to localStorage): a filter silently restored from a past visit would read as missing data.

Activity view#

The Activity view is the richest part of the dashboard and the most complicated, because it merges two independent sources of time data:

  1. MissionCache heartbeats from ~/.missioncache/tasks.db - these are WakaTime-style per-prompt activity pings that the UserPromptSubmit hook records when the current directory matches a tracked MissionCache project. They are aggregated into sessions rows by TaskDB.process_heartbeats().
  2. Claude Code JSONL transcripts from ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl - these are the full conversation logs Claude Code writes for its own reasons (session history, resume). Every message has a timestamp, so the dashboard can reconstruct active time even when no MissionCache project was loaded.

The Activity view surfaces both, separately where it matters and merged where the user just wants "how much time did I spend today":

Date navigation at the top of the view lets you browse any past day via GET /api/stats/day?date=YYYY-MM-DD, which returns the same shape as /api/stats/today.

Auto view#

The Auto view lists active MissionCache Auto executions across all tracked repos and renders a D3 graph of each one's task DAG. Each task node is color-coded by state (pending, in_progress, completed, failed), dependencies are drawn as arrows, and a side panel shows per-worker status.

This view is driven by GET /api/auto/projects (list with per-project task graphs parsed live from each project's -tasks.md and prompts/ directory) and GET /api/auto/executions / GET /api/auto/executions/{task_id} (historical execution records from the auto_executions SQLite table).

DAG state (task status, worker assignments, progress counts) is fetched on tab open and does not auto-refresh - reopen the tab or click the execution to get fresh state. The per-execution log output panel is different: once you open an execution's detail view, it subscribes to GET /api/auto/output/{execution_id}/stream (SSE) and new log lines appear as they are written.

Time accounting in detail#

This is the one part of the dashboard that trips people up, so it is worth a proper walkthrough.

Two sources, one number#

Every active task is reported with a time_spent_seconds field, computed in /api/tasks/active like this (see missioncache-dashboard/missioncache_dashboard/server.py:1131):

task_ids = [t.id for t in tasks]
times = db.get_batch_task_times(task_ids, period="all")    # heartbeat-based
jsonl_times = _get_jsonl_task_times(task_ids)              # JSONL-based

for task in tasks:
    etime = _effective_time(task.id, times, jsonl_times)
    task_dict["time_spent_seconds"] = etime
    task_dict["time_spent_formatted"] = db.format_duration(etime)

Where:

def _effective_time(task_id, heartbeat_times, jsonl_times) -> int:
    return max(heartbeat_times.get(task_id, 0), jsonl_times.get(task_id, 0))

The dashboard picks the larger of the two estimates. The rationale is that heartbeat time undercounts when the user works in a repo but outside a MissionCache project directory (heartbeats only fire when find_task_for_cwd matches), and JSONL time undercounts when multiple projects share a repo (because the join scopes by repo path, not project path). Taking the max gives you a conservative upper bound on "time you spent that could plausibly be attributed to this task."

This matters most during debugging. If you look at a task, see "5h 30m" in the dashboard, and then check the MissionCache heartbeat-based CLI report and see "2h 15m," the dashboard is not lying - it is telling you that Claude JSONL transcripts say you spent 5h 30m in that repo since the task was created, and 2h 15m of that was attributed to the task by MissionCache's heartbeats.

Heartbeat time#

Heartbeat time is the pure MissionCache-native answer: how much activity did activity_tracker.py attribute to this task. It is computed by TaskDB.get_batch_task_times(), which groups the sessions table by task_id and sums duration_seconds. It is accurate when the user loaded the project via /missioncache:new, /missioncache:load, or a SessionStart hook resolution, because the projects/<session-id>.json pointer then routes every subsequent heartbeat to the right task.

It is under-counting-prone when:

JSONL time#

JSONL time is the reconstruction from Claude Code's transcript files. Every message in a JSONL file has a timestamp; a session's "duration" is the sum of the gaps between consecutive messages, capped at 5 minutes per gap (longer gaps are treated as idle). This gives a realistic "time spent typing and waiting for Claude" figure rather than a wall-clock-span figure.

The gap-capping logic lives in SessionMetrics.active_seconds_for_date() at missioncache-dashboard/missioncache_dashboard/lib/jsonl_parser.py:77:

max_gap_seconds = 5 * 60  # 5 minutes
active_seconds = 0
for i in range(1, len(sorted_ts)):
    gap = (sorted_ts[i] - sorted_ts[i - 1]).total_seconds()
    if gap <= max_gap_seconds:
        active_seconds += gap
return int(active_seconds)

JSONL sessions are parsed lazily and cached in the claude_session_cache table in ~/.missioncache/tasks.db (not in DuckDB). The cache key is (session_id, file_mtime), so when a new message is appended to an existing JSONL file the mtime changes and the dashboard reparses; unchanged files hit the cache and cost nothing.

Joining JSONL time to tasks#

The tricky bit is attributing cached JSONL sessions back to MissionCache tasks, because JSONL files are keyed by cwd, not by task. The join is at _get_jsonl_task_times() at missioncache-dashboard/missioncache_dashboard/server.py:1099:

SELECT t.id, SUM(c.duration_seconds) as total
FROM tasks t
JOIN repositories r ON t.repo_id = r.id
JOIN claude_session_cache c ON c.cwd = r.path
WHERE t.id IN (?, ?, ...)
  AND c.duration_seconds > 0
  AND c.date >= DATE(t.created_at)
GROUP BY t.id

This does two things worth noting:

  1. Join by repo path, not project path. A task's JSONL time pool is every session whose cwd equals the repo path. This is the broadest possible attribution, which is intentional for tasks without MissionCache-specific directories, but it has a known cost (below).
  2. Lower-bounded by task creation date. c.date >= DATE(t.created_at) means sessions from before the task was created are not counted. This is the only disambiguator when multiple tasks share a repo.

Known limitation: overlapping tasks on the same repo will double-count each other's JSONL time. If you create task A on Monday, finish it Tuesday, create task B on Wednesday, then both tasks will report every Wednesday-or-later JSONL session as their own time. This is mentioned in architecture.md as one of the invariants/gotchas to know about; the fix requires per-task cwd disambiguation (which would require the MissionCache project to have its own directory that JSONL sessions run out of), and that is a larger change than the dashboard currently takes on. If you are debugging suspicious double-counting, this is almost always why.

Caps and overrides#

One more subtlety: the /api/stats/today and /api/stats/day endpoints cap the reported claude_seconds at wall-clock elapsed time for the day (see missioncache-dashboard/missioncache_dashboard/server.py:1569-1580). This handles the case where multiple Claude sessions run in parallel and the naive sum of their per-session durations exceeds the actual elapsed time. For the current day, the cap is (now - midnight).total_seconds(); for past days, it is a hard 24 hours.

The /api/stats/history endpoint does a similar merged-time override at missioncache-dashboard/missioncache_dashboard/server.py:1862-1863: the trends.time.current field is bumped to max(trends, merged_total) so the history trend number never undercounts when JSONL time exceeds MissionCache-tracked time.

API reference#

Every dashboard endpoint lives in missioncache-dashboard/missioncache_dashboard/server.py. Request bodies are JSON for all POST endpoints, and responses are JSON unless otherwise noted. There is no authentication - the server binds to 127.0.0.1:8787 only.

Projects and tasks#

Method Path Purpose
GET /api/tasks/active Active tasks with task progress, effective time, and subtasks. Filters out orphans.
GET /api/tasks/completed Completed tasks plus orphans. Accepts days query param (default 30).
GET /api/task/{id}/files Parsed markdown content of -plan.md, -context.md, -tasks.md.
GET /api/today Cross-project attention, split by who owes the work: on_me (bucketed overdue / due-soon / other), on_others (commitments + Waiting-on rows, grouped by project, each row carrying mine and a normalized who_primary), counts, and per-project blocks with a derived at-risk flag, last_worked_on / days_since_worked, checklist progress (completed_count / total_count / completion_pct / next_up), left_off (the newest Recent Changes bullet, shown when a project has no next step), category, and a ticket reference. Also user_name, the first token of git's global user.name, for the greeting; null when git has no identity configured, which the view renders as its nameless wording.
POST /api/tasks/{id}/waiting-on/resolve Resolve one Waiting-on row from the UI. Body {row_index, what, outcome}; 409 when the table shifted since the view loaded.
GET /api/tasks/{id}/pm One-call PM bundle: due date, action items (with overdue flags), stakeholders, tickets.
POST /api/tasks/{id}/action-items Create an action item (what, requested_by, assignee, due_date, source, notes).
PUT /api/action-items/{id} Partial update: status/what/owner/notes, due_date (or clear_due_date: true).
POST / DELETE /api/tasks/{id}/stakeholders[/{name}] Upsert / remove a stakeholder.
POST / DELETE /api/tasks/{id}/tickets[/{label}] Upsert / remove a ticket reference (label + url contract).
PUT /api/tasks/{id}/due-date Set or clear (null/"none") the project due date.

| GET | /api/task/{id}/structure | Per-task mode assignments (interactive/auto) + dependency adjacency for the Structure tab graph. | | GET | /api/task/{id}/updates | Append-only task_updates rows for non-coding tasks. | | GET | /api/task/{id}/prompt/{subtask_id} | Contents of a specific prompts/task-NN-prompt.md file. | | GET | /api/repos | Tracked repositories with their metadata. | | POST | /api/tasks/{id}/rename | Rename a project. Body {"new_name": "..."}; delegates to missioncache-db's rename_task and resyncs DuckDB. | | PUT | /api/tasks/{id}/category | Set or clear a project's category. Body {"category": "ui"} or {"category": null}; validated server-side against CATEGORIES plus custom categories, resyncs DuckDB. | | GET | /api/categories | Built-in taxonomy names plus custom categories ({name, emoji, color} rows). | | POST | /api/categories | Create a custom category. Body {"name", "emoji", "color"}; 400 on validation, 409 on duplicate. | | DELETE | /api/categories/{name} | Delete a custom category (404 for unknown names). Projects keeping the value render with default styling. |

All PM writes go through missioncache_db.pm_items - the same path as the MCP tools and CLI - so a dashboard click also re-renders the project's context-file mirror sections. PM tables live in SQLite only (not mirrored to DuckDB).

The two records behind "On other people"#

Two different things mean "someone else owes something", and they stay separate at rest because their shapes and lifecycles differ. An action item assigned to a colleague is a commitment: it has an owner, a due date, and a done/dropped end state, and it lives in SQLite with a stable id. A Waiting-on row is a dependency: it has an age and a Gates cell naming what it blocks, and it lives in the context file's markdown table, maintained by the save flow.

/api/today joins them for reading only. Each row carries kind (commitment or blocker) and a shared days_past_line score so one list can sort honestly across the seam: a commitment's line is its due date, a blocker's line is the 7-day staleness threshold, and rows that have not crossed a line sort below, oldest first.

Rows come grouped by project, because a flat cross-project list runs to dozens of entries and stops being readable. Groups sort on newest_age_days ascending, which is the age of the project's freshest ask, so a project someone was asked about yesterday reads as live and one whose every ask has sat for two months reads as archaeology; projects where nothing has come back at all carry None and sort last rather than reading as age zero. Ties on that age break on stale_count descending, then volume, then name, which makes the order total: without those, two projects sharing a freshest-ask age fell back on dict insertion order, deterministic within one process but liable to shift whenever a rename or a new task re-orders the rows underneath. Within a group, most-past-the-line first, so each project's most urgent row sits at its top.

The split is by who owes the work, not by which table stored the row, so a Waiting-on row whose who cell names you (Me (...), or your own name) carries mine: true and counts on your side: into open_count and counts.on_me, and out of on_others_count, stale_on_others_count and counts.stale. It stays inside its project's rows list, because the resolve path addresses it by task_id + row_index either way. Group count and stale_count describe the other-people side only, so your own overdue row cannot fill a bar that reads as "someone else is slow"; mine_count carries yours.

"Late" spans both kinds. counts.overdue and each project's overdue_count count everything of yours that has crossed its line, on the same scale the endpoint uses for sorting: an action item past its due date, and equally a Waiting-on row naming you that has passed the 7-day threshold. Both counts reach across the two lists your late work lives in (action items in on_me, waiting rows in on_others) without double-counting either. A colleague sitting on an ask remains THEIR latency and counts into stale, never into overdue, because red is reserved for "you are late".

counts is what the board's strip shows. The four attention numbers on screen - on you, late, out, no reply over 7d - render counts directly rather than re-counting the rows in the browser. That was not always true: the client used to derive them itself, and because it deduplicated rows the server had already emitted twice, the strip and the endpoint reported different totals for the same state. Duplicates are now removed at the source (see below), so there is one count and one owner.

Duplicate task rows are dropped, not deduplicated downstream. Two task rows can point at one project - a fork and its parent, or a stale row whose full_path no longer resolves and which therefore falls back to the name and finds its twin's context file. Both would parse the same Waiting-on table, counting every ask twice. get_today iterates freshest-first and skips a task whose context file another task already consumed, so the duplicate never reaches the payload and the surviving twin is the one worked most recently.

Which names mean you comes from git's global user.name, first token, plus the literal words "me" and "myself" - the same source the greeting uses, so the two cannot disagree. A machine with no git identity still matches "me" and "myself". This used to be a hardcoded set containing one person's first name, which filed everyone else's own work under "waiting on other people".

who is hand-typed prose, so who_primary normalizes it into a grouping key: parentheticals and any trailing note after - are dropped, multi-owner cells split on / + ,, and the first name wins. Measured over 63 live rows, 47 distinct exact strings collapse to 34 owners with one holding 12 - counting exact strings measures string equality, not owners. The raw who is always kept alongside, because a first-name key can fuse two people who share one and the raw cells are the only evidence of a merge.

Per-project days_since_worked (from tasks.last_worked_on) separates a project whose asks rot while it is being worked from one whose asks rot because the project stopped. The Projects gadget shows it as the last column, and it is also what orders the list. Tracked minutes cannot answer that question: on a measured day only 9% of tracked time attributed to a project at all, and the most attention-owed project read 0m on a day it was worked.

Each project block also carries completed_count / total_count / completion_pct and next_up (the first unchecked checklist item), because who-owes-what alone reads identically on a project at 86% and one at 0%. next_up is picked server-side and the full parsed checklist is never in the payload - it runs to several hundred items across the active projects and a row needs one line of it. ticket_label / ticket_url come from the project's first tickets row, falling back to the legacy tasks.jira_key column with a URL derived from the configured jira_urls prefix map; both are None when a project has no reference, and the label renders as plain text when no URL resolves (the mapping is empty until set in Settings).

Both kinds are resolvable from the UI, by different mechanisms. A commitment has a stable id, so it completes through PUT /api/action-items/{id}. A Waiting-on row has no per-row id - it is a hand-editable markdown table - so POST /api/tasks/{id}/waiting-on/resolve identifies it positionally via row_index and verifies the row's what text before removing it, answering 409 rather than resolving the wrong row when the table has shifted. The resolution lands in Recent Changes exactly as the save flow's waiting_on_resolve does. That resolve is identity-keyed by design; the save flow's substring match is the right shape for "the egress one came back" in conversation and the wrong one for a button press.

Which one to file into at write time is unchanged: if it blocks your next step, it is a Waiting-on row; otherwise it is an action item.

Both write endpoints return {"success": true, ...} with a warnings list; a warning means the SQLite write succeeded but the DuckDB read-path refresh failed or was incomplete, so the list view may lag until the sync issue clears.

The /api/tasks/active response shape is the largest; the relevant fields are:

{
  "tasks": [
    {
      "id": 1,
      "name": "missioncache-release",
      "status": "active",
      "repo_name": "missioncache",
      "repo_path": "/Users/alice/projects/missioncache",
      "jira_key": null,
      "time_spent_seconds": 480,
      "time_spent_formatted": "8m",
      "last_worked_ago": "1m ago",
      "description": "Prepare MissionCache for public open-source release",
      "remaining_summary": "...",
      "completion_pct": 48,
      "completed_count": 19,
      "total_count": 39,
      "project_mode": "interactive",
      "task_modes": [...],
      "auto_count": 0,
      "inter_count": 39,
      "subtasks": [],
      "subtask_count": 0,
      "combined_time_seconds": 480,
      "combined_time_formatted": "8m"
    }
  ],
  "count": 1,
  "total_with_subtasks": 1,
  "timestamp": "2026-04-13T01:52:00"
}

completion_pct, completed_count, total_count, remaining_summary, project_mode, and task_modes all come from parsing <project>-tasks.md live on the server side. The other fields come from DuckDB.

Activity and stats#

Method Path Purpose
GET /api/stats/today Header stats, hourly chart, timeline, repo breakdown, LOC, tasks for current day.
GET /api/stats/day?date=YYYY-MM-DD Same shape as /api/stats/today but for an arbitrary day.
GET /api/stats/history?days=N 7×24 heatmap, day-of-week totals, repo breakdown, top tasks, trend comparison. Cached 5 min.

/api/stats/today is the main data source for the Activity view. Its response merges:

_merge_untracked_sessions is what makes "Claude Code activity with no MissionCache project loaded" visible in the dashboard. The anti-join query lives in ClaudeSessionCache.get_untracked_sessions(date) at missioncache-dashboard/missioncache_dashboard/lib/analytics_db.py:3164 and returns JSONL sessions that do not appear in any MissionCache sessions row for the same session_id. Because the anti-join relies on both tables living in the same SQLite file, the invariant in architecture.md about not moving claude_session_cache to DuckDB matters here.

MissionCache Auto execution tracking#

Method Path Purpose
GET /api/auto/projects Active MissionCache projects with their task graphs, parsed live from each project's -tasks.md and prompts/ directory. Drives the Auto view overview.
GET /api/auto/executions All auto_executions rows across all projects, with optional running_only and limit filters.
GET /api/auto/executions/{task_id} Execution history for a specific task.
GET /api/auto/output/{execution_id} Full output log for an execution, read from auto_execution_logs in SQLite.
GET /api/auto/output/{execution_id}/stream SSE stream of log lines for a running execution. Sends log/status/heartbeat events.

The MissionCache Auto endpoints read from the auto_executions and auto_execution_logs tables in SQLite (via TaskDB, the SQLite-only wrapper), which contain the running and historical execution records. The live DAG graph rendered in the Auto view comes from parsing each project's markdown (/api/auto/projects) rather than from a worker-maintained state file, so there is no separate "live state" source besides what the worker writes back into auto_execution_logs.

Hook receivers#

MissionCache hooks can talk to the dashboard via /api/hooks/* endpoints. Three callers use these:

heartbeat is exposed but not wired by the plugin - power users can optionally POST to it from their own UserPromptSubmit HTTP hook if they want a second redundant heartbeat path; the plugin already records heartbeats via its in-process subprocess hook, so this is a duplicate and most users should skip it.

Method Path Purpose
POST /api/hooks/heartbeat Record an activity heartbeat. Optional - power-user UserPromptSubmit HTTP hook wiring only; the plugin already records heartbeats via its subprocess hook.
POST /api/hooks/edit-count Increment per-session edit count. Wired by missioncache-install as a PostToolUse HTTP hook (matcher Edit|Write|NotebookEdit) when the dashboard is installed. Feeds the statusline edit counter.
POST /api/hooks/task-created Trigger an immediate SQLite → DuckDB sync. Called internally by the MissionCache MCP server after create_task and create_missioncache_files.
POST /api/hooks/action Record current tool action for tab title display.
POST /api/hooks/project Set the active project for a session. Called by /missioncache:new, /missioncache:load, /missioncache:save.
POST /api/hooks/qa-review Mark QA review as suggested for a session.
GET /api/hooks/term-session/{term_session_id} Resolve a terminal-emulator session ID to a Claude session ID.
GET /api/hooks/session/{session_id} Read session state row (edit count, action, last prompt time, etc.).

All of these write to (or read from) ~/.claude/hooks-state.db, a separate SQLite file from tasks.db. That file holds ephemeral per-session state that has a shorter lifetime than task tracking: session_state, project_state, term_sessions, validation_state, guard_warned. The statusline reads from it, the hooks write to it, and neither interacts with the main MissionCache task database.

The fallback flow for writing project state is instructive: /missioncache:load first tries to POST to /api/hooks/project (which handles JSON escaping correctly), and if the dashboard is down it falls back to a direct SQLite write against hooks-state.db. See the missioncache.md rule file in rules/ for the exact bash snippet. This is the only case where MissionCache writes to hooks-state.db from outside the dashboard process.

Sync and health#

Method Path Purpose
GET /health Health check. Returns the running dashboard version, the DuckDB path, and whether it exists.
POST /api/sync Trigger SQLite → DuckDB sync and return result counts.
GET /api/stream SSE stream of today's stats, updated every 30 seconds.

The SSE stream is not heavily used by the frontend - the UI uses lazy per-view fetches and manual refresh buttons - but it is useful for third-party integrations that want a "what is happening now" feed without polling.

The dual-database sync#

The sync from SQLite to DuckDB is the mechanism that lets the dashboard read fast without locking out writes. The sync itself is simple: it reads all rows from the missioncache-db tables in ~/.missioncache/tasks.db and upserts them into ~/.missioncache/tasks.duckdb. There is no incremental log; every sync is a full-table copy, though only changed rows end up actually writing.

Sync runs in four places:

  1. On startup - lifespan() at missioncache-dashboard/missioncache_dashboard/server.py:158 calls db.sync_from_sqlite() before taking traffic. This ensures the dashboard shows current data immediately, even after a long downtime.
  2. Every 60 seconds - the background_sync() async task runs on a SYNC_INTERVAL_SECONDS=60 loop. This is why the dashboard always lags heartbeats by at most a minute.
  3. On demand via POST /api/sync - useful for the "I just committed, show me the latest" case where you do not want to wait for the background loop.
  4. After task creation - the MissionCache MCP server POSTs to /api/hooks/task-created after create_task and create_missioncache_files so a newly created task shows up in the active table right away instead of waiting up to 60s for the next background sync.

The migrate_to_duckdb.py script is a standalone version of the same logic; you run it manually after a DuckDB corruption or when you want to rebuild the analytics DB from scratch.

Why DuckDB is not the source of truth#

A reasonable question: if DuckDB is 10-100x faster than SQLite for aggregates, why not write directly to DuckDB?

Three reasons:

  1. Write concurrency. Multiple MissionCache components (hooks, MCP server, MissionCache Auto) write to the task database concurrently. SQLite handles this well with its standard locking. DuckDB is much more restrictive about concurrent writers and does not cope well with MissionCache's write pattern.
  2. Crash safety. SQLite with WAL mode survives process crashes cleanly and has been battle-tested for decades. A corrupted DuckDB file means losing recent data; a corrupted SQLite file is rare and recoverable.
  3. Schema flexibility. Triggers, WAL pragmas, and the claude_session_cache table are all SQLite features. Moving them to DuckDB would require rewriting the missioncache-db layer.

The trade-off is the ~60-second staleness window on the dashboard. For analytics, that is fine. For live trading or safety-critical applications, you would pick differently.

Deep linking and statusline integration#

The dashboard supports hash-based deep links so external tools (the statusline, other dashboards, notes) can point at specific views and task modals:

The routing is handled by handleHashChange() at missioncache-dashboard/missioncache_dashboard/index.html:5292. Deep links resolve against both /api/tasks/active and /api/tasks/completed?days=90, so you can link to completed projects too. After opening the modal, the query string is stripped from the hash so that a page refresh does not re-open the modal unexpectedly.

The statusline uses this for its clickable project name and progress fraction. When missioncache-statusline renders a line like Project: missioncache-release (19/39), the project name is wrapped in an OSC 8 hyperlink to #{MISSIONCACHE_DASHBOARD_URL}/#projects and the progress fraction is wrapped in a link to #{MISSIONCACHE_DASHBOARD_URL}/#projects?task=missioncache-release&tab=tasks. Terminals that support OSC 8 (iTerm2, Ghostty, cmux, modern Windows Terminal) render these as clickable; terminals that do not, just see plain text with the same content.

The MISSIONCACHE_DASHBOARD_URL environment variable is read at missioncache-dashboard/missioncache_dashboard/statusline.py:1041 and defaults to http://localhost:8787. If you move the dashboard to a different host or port, set this in your shell init so the statusline builds the right links.

Customization#

The dashboard is deliberately minimal about configuration - there are very few knobs because most things can be overridden by editing the 4,000-line server.py directly. The knobs that do exist:

What Where How to change
Listen port missioncache_dashboard/server.py:2761 (uvicorn.run) missioncache-dashboard serve --port <n> or set MISSIONCACHE_DASHBOARD_PORT
SQLite path missioncache_dashboard/lib/analytics_db.py:29 (SQLITE_PATH) Edit the constant
DuckDB path missioncache_dashboard/lib/analytics_db.py:28 (DUCKDB_PATH) Edit the constant
Sync interval missioncache_dashboard/server.py:124 (SYNC_INTERVAL_SECONDS) Default 60s
History cache TTL missioncache_dashboard/server.py:128 (HISTORY_CACHE_TTL_SECONDS) Default 300s
SSE refresh rate missioncache_dashboard/server.py:205 (REFRESH_INTERVAL) Default 30s
JIRA URL mapping missioncache_dashboard/server.py:224 (get_jira_url()) + runtime settings Use the dashboard's Settings screen, or edit ~/.claude/missioncache-dashboard-config.json jira_urls map
Statusline link base MISSIONCACHE_DASHBOARD_URL env var Set in shell init
Dark/light theme Frontend only Toggle in the top-right of the UI (persisted in localStorage)

The JIRA URL mapping is empty by default. The dashboard turns any jira_key field into a clickable link only when the prefix matches a user-configured entry, so until you populate it your task badges will show the JIRA key as plain text. Open the dashboard's Settings screen (or edit ~/.claude/missioncache-dashboard-config.json directly) and add an entry per prefix you use, e.g. "PROJ-": "https://your-jira.example.com/browse/".

launchd deployment (macOS)#

The common way to run the dashboard in the background on macOS is via launchd. The plist lives at ~/Library/LaunchAgents/com.missioncache.dashboard.plist and missioncache-install generates one for you (via missioncache-dashboard install-service, which you can also run standalone). The minimum you need is:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.missioncache.dashboard</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/missioncache-dashboard</string>
        <string>serve</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/missioncache-dashboard.stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/missioncache-dashboard.stderr.log</string>
</dict>
</plist>

Load it with launchctl load ~/Library/LaunchAgents/com.missioncache.dashboard.plist; unload with the matching unload command. The KeepAlive key ensures the dashboard restarts automatically after a crash.

Use the missioncache-dashboard console script, not a raw python3 server.py command. missioncache-install writes the plist for you via missioncache-dashboard install-service - use that rather than hand-rolling. If you do need to roll your own, point ProgramArguments at the absolute path from which missioncache-dashboard. DuckDB is distributed as a compiled wheel that is Python-version-specific; installing missioncache-dashboard into the wrong Python and then pointing launchd at a different one will give you ModuleNotFoundError: No module named 'duckdb' at startup. pipx install missioncache-dashboard avoids this by pinning the package to its own venv.

Running without launchd#

missioncache-dashboard serve works fine as a foreground process. You just lose auto-restart on crash, and you have to remember to start it again after rebooting. If you use tmux or a similar session manager, a dedicated pane for the dashboard is a reasonable middle ground.

Extending the dashboard#

Adding a new endpoint#

The rule is simple: reads go to DuckDB via analytics_db.py, writes go to SQLite via missioncache-db. If you are adding a new aggregate query to the Activity view, you add a method to the AnalyticsDB class and call it from a new @app.get() handler. If you are adding a new mutation, you call into TaskDB (imported at missioncache_dashboard/server.py:50) and then trigger a sync.

The pattern for a new read endpoint:

@app.get("/api/my-new-view")
async def api_my_new_view(days: int = 7):
    db = get_db()  # AnalyticsDB singleton
    result = db.my_new_aggregate_method(days=days)
    return {
        "result": result,
        "timestamp": datetime.now().isoformat(),
    }

The pattern for a new write endpoint:

@app.post("/api/my-new-mutation")
async def api_my_new_mutation(body: dict):
    sqlite_db = get_sqlite_db()  # TaskDB singleton
    sqlite_db.my_new_write_method(body)
    # Trigger sync so the read-side reflects the change
    get_db().sync_from_sqlite()
    return {"success": True}

Do not open either database directly from inside a route handler. The singletons exist for a reason - they manage connections, WAL mode, and thread safety.

Adding a new frontend view#

The frontend is a single HTML file (index.html) with embedded CSS and JavaScript. There is no build tool, no framework, and no bundler. To add a view:

  1. Add a nav item in the <nav> block (around missioncache-dashboard/missioncache_dashboard/index.html:4410).
  2. Add a <div class="view" id="myNewView">...</div> section below the existing views.
  3. Add a lazy loader function (loadMyNewData) and hook it into switchView() at around missioncache-dashboard/missioncache_dashboard/index.html:5359.
  4. Write any render functions your view needs (convention: renderMyNewThing(data)).

The CSS variable system at the top of index.html drives theming. Use var(--bg), var(--fg), var(--accent), etc. and both the light and dark modes will Just Work.

For anything more complex than a table or a bar chart, you may want to pull in D3.js the same way the Structure tab and Auto view do - it is already loaded from a CDN at the top of the file.

Touching path resolution#

If you are changing how MissionCache files are located on disk, you need to update two places: parse_missioncache_progress() in missioncache-dashboard/missioncache_dashboard/server.py:833 (dashboard read path) and helpers.py in the MCP server (MCP write path). They are independent implementations of the same logic, so keep them consistent or the dashboard will render stale state.

Troubleshooting#

"Dashboard empty after Mac restart"#

Symptom: All tables are empty, no sessions, no stats, but missioncache-db reports normal data via the CLI.

Cause: DuckDB analytics tables are missing or corrupt. This happens occasionally when macOS force-kills processes during shutdown.

Fix:

launchctl unload ~/Library/LaunchAgents/com.missioncache.dashboard.plist
/opt/homebrew/bin/python3.11 missioncache-dashboard/migrate_to_duckdb.py
launchctl load ~/Library/LaunchAgents/com.missioncache.dashboard.plist

The migration script rebuilds tasks.duckdb from tasks.db. It is idempotent, safe to run anytime, and takes under a second on a typical install.

"ModuleNotFoundError: No module named 'fastapi' / 'duckdb'"#

Cause: launchd is using a different Python than the one you installed dependencies into.

Fix: Edit ~/Library/LaunchAgents/com.missioncache.dashboard.plist to point ProgramArguments at /opt/homebrew/bin/python3.11 explicitly, and install the dashboard requirements under that exact Python: /opt/homebrew/bin/python3.11 -m pip install -r missioncache-dashboard/requirements.txt. which python3.11 should agree with the plist path.

"DuckDB locked" errors in the logs#

Cause: Two processes are holding the DuckDB file open at the same time, usually because you ran migrate_to_duckdb.py without stopping the server first, or you have two dashboard processes running.

Fix: launchctl unload the dashboard, verify ps aux | grep missioncache-dashboard shows nothing, and then run the migration or restart.

"Task time on the dashboard does not match the CLI"#

This is almost always the heartbeat-vs-JSONL merge at work. See Time accounting in detail. In summary: the dashboard shows max(heartbeat_time, jsonl_time), so the dashboard number will be equal to or larger than the CLI number. If it is much larger, the likely cause is overlapping tasks on the same repo double-counting JSONL sessions - check whether another task was created around the same time in the same repo.

"Untracked sessions not showing up"#

Symptom: You worked in Claude Code outside any MissionCache project, but the Activity view does not show any untracked sessions.

Cause: Most likely the claude_session_cache is missing or got moved to a different database file. The anti-join that identifies untracked sessions requires claude_session_cache and sessions to live in the same SQLite file.

Fix: Check sqlite3 ~/.missioncache/tasks.db ".tables" - you should see both claude_session_cache and sessions. If the cache is missing, restart the dashboard (it is created on first access by ClaudeSessionCache._ensure_table() at missioncache-dashboard/missioncache_dashboard/lib/analytics_db.py:2946). If it is present but queries return nothing, run POST /api/sync to force a rebuild.

"Dashboard shows stale data"#

Cause: The 60-second background sync has not run yet, or the History API is serving a cached response (5-minute TTL).

Fix: Hit POST /api/sync to force an immediate sync. For the History card specifically, the cache is keyed by the days parameter, so clicking a different preset (7/14/30) fetches fresh data.

Cause: The task name in the URL does not exactly match a task in /api/tasks/active or /api/tasks/completed?days=90. Task-name matching is exact, case-sensitive, and does not fuzzy-match.

Fix: Use GET /api/tasks/active to verify the exact task name, and reconstruct the URL as #projects?task=<exact-name>&tab=tasks. For completed projects older than 90 days, you can bump the window by calling /api/tasks/completed?days=365 and linking to that name - the deep-link resolver will find it there too.

Where to go from here#