· 8 min read
Autonomous does not mean opaque. While a Darkmoon campaign runs, you can watch it think: findings appear as they are proven, the infrastructure graph fills in as ports and technologies are mapped, and the risk level of the target moves in real time. This article explains how that live view is built, from the moment the agent discovers something to the moment it shows up on the dashboard.
The agent writes, immediately
The platform exposes a small set of MCP tools that the model calls as it works. Four of them drive the live view, and each one writes JSON to disk the instant it is called, so the API always serves fresh data:
| MCP tool | When the agent calls it | Effect |
|---|---|---|
| dashboard_init_campaign | Once, at the start | Creates the project, target and campaign skeleton |
| dashboard_push_finding | Each time a vulnerability is proven | Appends the finding and recomputes stats |
| dashboard_push_infra_node | Each time a node is mapped | Adds a host, service, OS or technology to the graph |
| dashboard_finalize_campaign | Once, at the end | Writes the terminal status and the report |
There is no batch export at the end that the live view has to wait for. A finding pushed at 11:26:35 is on disk at 11:26:35. This is what makes the dashboard live rather than a post-run summary.
Every finding recomputes the picture
Pushing a finding does more than append it. The platform recounts the severity and status distributions from the full findings list, derives the campaign’s overall risk from the highest severity present, and updates the target: a target with an exploited finding becomes compromised, one with unexploited findings becomes vulnerable, and its risk level tracks the campaign’s.
if stats.get("critical", 0) > 0:
campaign["overall_risk"] = "critical"
elif stats.get("high", 0) > 0:
campaign["overall_risk"] = "high"
# ... down to "none"
target["status"] = "compromised" if stats["exploited"] else "vulnerable"
target["risk_level"] = campaign["overall_risk"]The dispatch log is derived the same way, from evidence rather than from a declaration: an agent that pushed a finding demonstrably ran, so the list of agents that participated is rebuilt from the findings themselves. That detail exists because an earlier design lost the entire dispatch log whenever a campaign never reached its finalize step; deriving it from the findings makes it robust to a run that is stopped or crashes partway.
The dashboard overview
The main dashboard view is a single aggregation endpoint that reads all projects, targets, campaigns and vulnerabilities and returns the counts a security lead wants first: totals, the severity distribution, the category and status distributions, the ten most recent campaigns, and the targets currently at risk sorted with the worst first. Because it reads the same JSON the agent is writing, it reflects the live state of an in-flight campaign, not a snapshot.
Two polling speeds, and a live stream
On the client, a global refresh service keeps the view current. It polls in the background every 15 seconds when nothing is running, and switches to a fast 5-second poll the moment a campaign starts, then drops back to the idle cadence when it ends.
const ACTIVE_POLL_MS = 5_000; // fast poll while a campaign is running
const IDLE_POLL_MS = 15_000; // background poll to keep the dashboard freshAlongside the polled lists, the run itself is streamed. The orchestrator’s stdout is written to a per-run JSONL log as it is produced, and the API serves that as a live stream, so you can read the agent’s steps as they happen. The log is also the history: replaying a finished run reads the same file.
Seeing inside the sub-agents
Darkmoon’s orchestrator dispatches specialist sub-agents, and by default a sub-agent’s internal steps do not appear in the parent’s stream, only the single tool call that launched it. To make the live view complete, the API reads the child sessions’ activity directly out of opencode’s own SQLite database and appends it to the same run log as subagent_text and subagent_tool events. The result is that the sub-agent’s work shows up in both the live stream and the history replay, so a parallel cascade of agents is legible rather than a black box.
When a run dies without saying goodbye
A campaign only reaches completed when the agent explicitly finalizes it. A run that is stopped or crashes first would otherwise leave its campaign stuck at running forever, a perpetual spinner. The platform handles this in two places: stopping a run reconciles any of its still-running campaigns to a terminal status, and on API startup every campaign still marked running is treated as an orphan of a dead process and marked stopped. The dashboard never shows a spinner that will never resolve.
Honest limits
The live view is powered by fast polling plus a streamed log, not by push notifications; updates arrive within the poll interval, not instantaneously. Sub-agent to parent finding aggregation is robust but not perfect: findings can occasionally be attributed under a sub-agent session id. And the store is file-based JSON, which is simple and auditable but is single-node state. These are deliberate trade-offs for a self-hosted tool, and we would rather name them than imply a distributed real-time platform we did not build.
Where this fits
The dashboard is where a run becomes visible; the report is where it becomes durable. How that report is assembled, deterministically and server-side, is covered in why Darkmoon builds its reports server-side. To have runs appear on this dashboard on a cadence, see the scheduler.
Darkmoon is our open source project (GPL-3.0): github.com/ASCIT31/Dark-Moon, docs.