# PRD 0019: Active agents in the dashboard, agent-scoped edit verbs

- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-05-26

## Summary

The dashboard today is proposal-centric: it lists every pending
supervise tool call across every running bottle and lets the
operator approve / modify / reject from one place. The operator-
initiated `routes edit` (`e`) and `pipelock edit` (`p`) verbs are
*global* — they discover every running sidecar of that kind and
prompt for which bottle to edit if more than one is up.

This PRD adds a first-class "active agents" view to the dashboard
and reshapes the edit verbs to be **agent-scoped**: the operator
picks an agent, then `e` / `p` (and any future per-agent verbs)
target that agent without a separate prompt.

After this PRD the dashboard answers two questions in one screen:

  1. What's queued for me to act on? (existing proposals view)
  2. What's currently running, and what would I act on if I
     wanted to push a config edit without an agent prompt?

## Problem

Two rough edges in the current dashboard:

1. **No visibility into what's actually running.** The dashboard
   shows only pending proposals. If no agent has called a tool,
   the screen reads "no pending proposals" — even when five
   bottles are quietly working. The operator has to `docker
   compose ls` (or `./cli.py cleanup -n` to see the y/N preview)
   to find out what's actually live.

2. **`e` / `p` re-discover-and-disambiguate every invocation.**
   Today each press of `e` runs `discover_egress_slugs()`, finds
   the running egress sidecars, and prompts if there's more than
   one. The prompt interrupts the keyboard flow — and once the
   operator picks a bottle, there's no carry-over to the next
   edit. Editing pipelock for the same bottle right after is
   another prompt.

The proposal-centric design is fine for the "agent triggered a
remediation" case but flips the relationship the wrong way for
the "operator wants to make an unprompted change" case.

## Goals / Success Criteria

1. The dashboard's main screen shows two lists: pending proposals
   (above) and active agents (below) — both visible at once, no
   tab / mode switch.
2. Each active-agent row shows enough for the operator to
   recognize the bottle at a glance: identity (slug),
   agent_name (from metadata.json), started_at, and which
   sidecars are up.
3. The operator can select an agent row with `j` / `k` /
   arrow keys (the same nav keys already in use for proposals),
   with a clear keystroke that swaps the active list (e.g.,
   `Tab` toggles which list `j` / `k` moves through).
4. Pressing `e` (routes edit) or `p` (pipelock edit) with an
   agent selected targets that agent. No disambiguation prompt;
   no global discover.
5. Pressing `e` / `p` with NO agent selected is a no-op (status
   line surfaces "no agent selected"). The global discover-
   and-prompt path comes out — selection in the agents pane is
   now the only way to scope an edit.
6. The active-agents list refreshes on the same ~1s tick as the
   proposals list so an agent starting / stopping is reflected
   without operator action.

## Non-goals

- **Per-agent proposal filtering.** The proposals list stays
  global across bottles. Filtering ("show me only this agent's
  proposals") might be a follow-up but isn't this PRD.
- **Agent lifecycle from the dashboard.** Starting / stopping
  agents stays in `./cli.py start` / `./cli.py cleanup`. The
  dashboard reads state; it doesn't change it.
- **Preserved-but-not-running bottles.** The active-agents list
  is strictly "what's running now" (cross-referenced from
  `docker compose ls`). Preserved state dirs without a live
  project don't appear — `./cli.py resume <identity>` is the
  path for those.
- **A separate per-agent detail view.** The agent rows are
  one-line summaries. Pressing Enter on a proposal still drops
  into proposal-detail; we don't add an analogous agent-detail
  screen in v1.
- **Replacing the existing `--once` mode.** `dashboard --once`
  stays a proposal-only listing. No active-agents output there
  (different consumers — `--once` is for scripts; the agents
  view is for the interactive TUI).

## Scope

### In scope

- A new "active agents" pane in the curses TUI, rendered below
  the proposals pane.
- A discovery helper that returns `(slug, agent_name,
  started_at, services_up)` per active compose project. Reads
  agent_name + started_at from each project's `metadata.json`,
  cross-references `docker compose ls` for the live list.
- Tab-toggle selection state: which pane the cursor is in. `j`
  / `k` / arrow keys move within that pane.
- Rewire `_operator_edit_routes_flow` and
  `_operator_edit_allowlist_flow` to require a slug from the
  caller. The discover-and-prompt scaffolding (no-arg
  discover + single-bottle shortcut + multi-bottle prompt)
  comes out. The dashboard's key handlers pass the agents-pane
  selection in directly, or no-op if nothing is selected.
- Status-line indicator showing which agent is selected (or
  "no agent selected" when in the proposals pane).
- Tests for the new discovery helper.

### Out of scope

- Changes to proposal handling (`a` / `m` / `r` / Enter all
  unchanged).
- Changes to the queue-dir / supervise sidecar protocol.
- New CLI surface beyond what's in `./cli.py dashboard`.
- Touching the manifest, compose renderer, launch lifecycle.

## Proposed design

### Layout

```
claude-bottle dashboard  (3 pending, 2 active)
─────────────────────────────────────────────────────────
proposals:
  03:14:22  [implementer-cy7a6]  egress-block         abc123…
  03:13:55  [researcher-9xqs1]   pipelock-block       def456…
  03:13:10  [implementer-cy7a6]  capability-block     ghi789…

active agents:
> implementer-cy7a6  implementer   started 02:55:01  [pipelock,egress,git-gate,supervise]
  researcher-9xqs1   researcher    started 02:58:14  [pipelock,supervise]

[selected: implementer-cy7a6]  q quit  Tab switch  j/k nav  e routes  p pipelock  a/m/r/Enter
```

- One screen, two lists. Header counts both totals.
- A `>` cursor and reverse-video highlight mark the currently
  selected row in the active pane.
- Status footer carries `[selected: <slug>]` (or `[no agent
  selected]`) so it's always clear what `e` / `p` will target.

### Selection model

- `Tab` (or Shift-Tab) toggles which pane `j` / `k` /
  arrow keys move through.
- Each pane keeps its own selection index. Switching panes
  doesn't lose the position in the other.
- `e` / `p`:
  - An agent is selected (cursor in the agents pane on a row)
    → use that agent's slug.
  - Otherwise → no-op with a status-line "no agent selected".
    The pre-PRD global discover-and-prompt code paths come out
    of `_operator_edit_routes_flow` and
    `_operator_edit_allowlist_flow`.

### Active-agent discovery

A new helper `discover_active_agents()` in dashboard.py
returns a list of `ActiveAgent(slug, agent_name, started_at,
services)`:

  1. `list_active_slugs()` (already in
     `backend/docker/compose.py`) → list of slugs.
  2. For each slug: read `state/<slug>/metadata.json` →
     `agent_name`, `started_at`.
  3. For each slug: `docker compose -p <project> ps --format
     json` → set of running service names.

Step 3 is the part that's per-bottle and could be slow on
hosts with many bottles. Open question below.

### Implementation chunks

Sized small.

1. **Discovery helper + dataclass.** Pure-ish: takes
   `list_active_slugs()` as injected, reads metadata + queries
   compose ps. Unit-test with mocked subprocess. No UI yet.
2. **Render the agents pane.** Wire `discover_active_agents`
   into `_main_loop`'s tick, render below proposals, no
   selection model yet (cursor stays in proposals).
3. **Selection state + Tab toggle.** Add the `which_pane`
   variable, route `j/k/arrow` based on it, status footer.
4. **Agent-scoped `e` / `p`.** Pass selected slug into the
   edit flows when the agents pane is focused; keep today's
   global behavior when the proposals pane is focused.

## Open questions

1. **`compose ps` per bottle: too slow?** On a host with
   10+ active bottles, calling `docker compose -p <X> ps` per
   project on every 1s tick is 10+ subprocess calls per
   second. Options: (a) cache the services list and refresh
   on a slower cadence (e.g., every 5s); (b) skip the
   per-bottle services column and just show the slug + agent
   name; (c) one `docker ps --filter label=...` call that
   buckets containers by `com.docker.compose.project` label.
   Probably (c) — one call, no per-bottle fanout.

2. **What if `metadata.json` is missing or stale?** For a
   bottle started by pre-chunk-3 code (no `compose_project`
   field), or a state dir written by a tool we don't know
   about, the metadata read can fail. Render with
   `agent_name = ?` rather than dropping the row.

3. **Selection persistence across refresh ticks.** If the
   currently-selected agent is no longer running (it exited
   between ticks), the selection should fall back to the
   previous row, not jump to the top. Mirrors the existing
   proposals-list behavior.

4. **Color / highlight for the selected agent.** The proposals
   pane uses green for newly-arrived. Agents could use a
   different attribute (e.g., reverse video for selection,
   no color for the row itself). Aesthetic decision; pick
   something readable in the standard 8-color palette.

5. **Selecting a proposal cross-selects its agent?** Possible
   UX: highlighting a proposal in the proposals pane could
   auto-move the agents-pane cursor to that proposal's
   bottle. Cute, but probably confusing — the explicit Tab
   toggle is clearer. Out of v1.

## References

- PRD 0013 — supervise sidecar (proposals + queue)
- PRD 0014 / 0015 / 0016 — the apply flows the edit verbs
  drive
- PRD 0018 — compose-per-instance; `list_active_slugs` +
  metadata.json source-of-truth
