diff --git a/CHANGELOG.md b/CHANGELOG.md
index f39ee9ea..5f1594a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,35 @@ new version heading in the same commit.
## [Unreleased]
+## [0.329.0] — 2026-08-08
+### Changed
+- **The status grammar reaches the last four surfaces** (Inbox, Approvals, Agents, Cockpit), finishing the
+ sweep started in v0.328.0.
+ - **Decision cards resolved in the card TYPE's colour, so "applied" and "rejected" looked identical** —
+ every proposal (policy, agent edit, goal edit, automation, skill, secret) rendered its outcome as the
+ same violet/indigo/amber outlined badge whatever the human decided. The per-type verb stays (it says
+ more than "approved": `installed`, `granted`, `applied`), but the ROLE behind it is now shared
+ (`RESOLUTION_ROLE` / `ResolutionChip`), so approved is green, rejected is red and `cancelled` — the
+ session ended before anyone decided — is neutral, on every card type alike.
+ - **A THIRD private outcome map** (`OUTCOME_STYLE`, after `runVerdict` and `OUTCOME_TONE` in v0.328.0)
+ carried the same synonym gap: a run that reported `completed`/`progressed`/`blocked` read `ended`, as
+ if it had never reported. Worse, the card's own icon keyed off a bare `=== 'failure'`, so **a run that
+ reported the synonym `blocked` announced its failure with a green tick**. Both go through
+ `verdictOf` / `VERDICT_META` now.
+ - A **question** card is amber (`needsHuman`) rather than sky — it is blocked on a person, exactly like
+ every other such signal. Sky stays what it always meant elsewhere: chat/unread.
+ - The last "in progress = sky" in the app (a task-event card that is neither blocked nor done) is
+ emerald.
+
+### Added
+- **The Agents roster says which agents are busy right now** — the first thing you want when deciding who
+ to give work to, and something it never showed. Newest live run per agent, off the feed the console
+ already polls, rendered through the same `SessionStatus` (so an agent whose run needs a human rings the
+ same bell here as in the sidebar). Both the roster rail and the grid card.
+- **Cockpit candidates show whether that agent is already running**, so you can see before dispatching
+ that your best-fit agent is mid-run. The Cockpit has no status vocabulary of its own — it is a
+ dispatcher — so this is the only status-shaped thing on the page.
+
## [0.328.0] — 2026-08-08
### Changed
- **One status grammar across the whole console.** Session status was unified in v0.320–0.327; everything
diff --git a/package-lock.json b/package-lock.json
index 0faf3b2b..ce8ee92e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "agent-os",
- "version": "0.328.0",
+ "version": "0.329.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-os",
- "version": "0.328.0",
+ "version": "0.329.0",
"license": "MIT",
"bin": {
"agent-os": "bin/agent-os"
diff --git a/package.json b/package.json
index ec154289..4ce52cdc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "agent-os",
- "version": "0.328.0",
+ "version": "0.329.0",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ea850cf0..77170295 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1693,12 +1693,12 @@ function Console({ me }: { me: Member }) {
- {route === 'agents' && nav('agents', id)} run={runAgent} onEdit={openAgent} onNew={() => nav('new-agent')} onDelete={deleteAgent} onDuplicate={duplicateAgent} onRescan={rescanAgents} onImport={importAgent} onRefresh={refreshState} nav={nav} />}
+ {route === 'agents' && nav('agents', id)} run={runAgent} onEdit={openAgent} onNew={() => nav('new-agent')} onDelete={deleteAgent} onDuplicate={duplicateAgent} onRescan={rescanAgents} onImport={importAgent} onRefresh={refreshState} nav={nav} />}
{route === 'new-agent' && { await refreshState(); nav('agents', id) }} />}
{route === 'sessions' && nav('agents')} onStop={stopSession} onDelete={deleteSession} onRate={rateSession} onRename={renameSession} onTransfer={transferSession} onBulkStop={stopSessions} onBulkDelete={deleteSessions} urlQuery={urlQuery} onFiltersChange={setUrlQuery} />}
{route === 'overview' && me.role === 'owner' && }
{route === 'inbox' && nav('tasks', id)} onOpenGoal={(id) => nav('goals', id)} />}
- {route === 'cockpit' && nav('chat', id)} onOpenTerminal={openTerminal} nav={nav} />}
+ {route === 'cockpit' && nav('chat', id)} onOpenTerminal={openTerminal} nav={nav} />}
{route === 'chat' && nav('chat', id)} onOpenTerminal={openTerminal} />}
{route === 'connectors' && nav('connectors', t)} />}
{route === 'team' && }
@@ -2200,10 +2200,11 @@ function ShareAgentDialog({ me, agent, open, onOpenChange }: { me: Member; agent
}
function AgentsPage({
- me, agents, selected, onSelect, run, onEdit, onNew, onDelete, onDuplicate, onRescan, onImport, onRefresh, nav,
+ me, agents, sessions, selected, onSelect, run, onEdit, onNew, onDelete, onDuplicate, onRescan, onImport, onRefresh, nav,
}: {
me: Member
agents: AgentInfo[]
+ sessions: Session[]
selected: string
onSelect: (id: string) => void
run: (agentId: string, task: string) => Promise
@@ -2327,6 +2328,11 @@ function AgentsPage({
// Filter the fleet by the search box (id / description / category), then group for display. The
// selected agent is resolved over the FULL list, so searching never deselects what you picked.
+ // Which agents are busy RIGHT NOW — the thing you want when deciding who to give work to, and the one
+ // thing the roster never said. Newest live run per agent, off the feed the console already polls.
+ const liveRunOf = (agentId: string): Session | undefined => sessions
+ .filter((s) => s.agent === agentId && isLive(s))
+ .sort((x, y) => y.createdAt - x.createdAt)[0]
const q = query.trim().toLowerCase()
const filtered = q
? agents.filter((a) => a.id.toLowerCase().includes(q) || (a.description ?? '').toLowerCase().includes(q) || (a.category ?? '').toLowerCase().includes(q))
@@ -2461,6 +2467,7 @@ function AgentsPage({
{a.id}
+ {liveRunOf(a.id) && }
@@ -2493,6 +2500,7 @@ function AgentsPage({
{a.id}
+ {liveRunOf(a.id) && }
{a.builtIn && }
@@ -4564,7 +4572,8 @@ function prettyAgent(id: string): string {
* - ask → a question about the workspace is answered **inline** (no session).
* - action → "schedule…/create a task…" deep-links into that primitive's surface.
* Every "work" dispatch still runs through the governed chat/terminal spawn (run-as you, gate hook). */
-function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
+function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: {
+ sessions: Session[]
onOpenChat: (id: string) => void
onOpenTerminal: (tmux: string, title?: string) => void
nav: (r: Route, detail?: string) => void
@@ -4653,6 +4662,9 @@ function CockpitPage({ onOpenChat, onOpenTerminal, nav }: {
const isWork = !preview?.intent || preview.intent === 'work'
+ const liveRunOf = (agentId: string): Session | undefined => sessions
+ .filter((s) => s.agent === agentId && isLive(s))
+ .sort((x, y) => y.createdAt - x.createdAt)[0]
const card = (c: RouterCard, opts?: { primary?: boolean }) => (