diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md
index 421636976..69a29debb 100644
--- a/docs/2-handbook/swarm/ui.md
+++ b/docs/2-handbook/swarm/ui.md
@@ -78,7 +78,7 @@ action that asks first.
`/agents` is a column of separate cards: a filter card, then one
card per agent, none of them inside a bigger card. A card's
-header shows its icon, name, the status message, a **terminal**
+header shows its icon, name, the status message with how long ago the agent set it (`5m ago`), a **terminal**
button that opens the agent's terminal tab, the **wanted** menu (up, offline,
paused, destroy), a **paused by hive** badge when the agent's own harness paused
its turn loop (hover for the reason; set the agent **up** to resume it), and a
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
index 9990c1c31..fda669880 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
@@ -19,6 +19,7 @@ import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "../roster/AgentActions.js";
import { AgentClaudeLogin } from "./AgentClaudeLogin.js";
import { AgentInference } from "./AgentInference.js";
+import { StatusMessage } from "./StatusMessage.js";
import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js";
import {
@@ -80,7 +81,9 @@ function HeaderRows({
/>
message
- {row.snapshot?.status_text ?? "—"}
+
+
+
wanted
{/* Full menu (destroy included) — mara: "destroy is already
diff --git a/frontend/packages/swarm-ui/src/agent/detail/StatusMessage.tsx b/frontend/packages/swarm-ui/src/agent/detail/StatusMessage.tsx
new file mode 100644
index 000000000..8b8f65f64
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/agent/detail/StatusMessage.tsx
@@ -0,0 +1,26 @@
+// — an agent's status message with how long ago it set it,
+// `working on #1 (5m ago)`. The age ticks, so a card left open stays true. An
+// agent with no message shows a dash and no age.
+import type { AgentStatusSnapshot } from "../AgentTypes.js";
+import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
+import { statusSetAtMs, statusText } from "./statusMessage.js";
+
+export function StatusMessage({
+ snapshot,
+}: {
+ snapshot: AgentStatusSnapshot | null;
+}) {
+ const text = statusText(snapshot);
+ const at = statusSetAtMs(snapshot);
+ return (
+ <>
+ {text}
+ {text !== "—" && at !== null ? (
+ <>
+ {" "}
+ ()
+ >
+ ) : null}
+ >
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/agent/detail/statusMessage.ts b/frontend/packages/swarm-ui/src/agent/detail/statusMessage.ts
new file mode 100644
index 000000000..c7f59b71e
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/agent/detail/statusMessage.ts
@@ -0,0 +1,20 @@
+// The agent's status message and when it set it, for the card and its detail.
+// Kept out of the component so `npm test` runs it.
+import type { AgentStatusSnapshot } from "../AgentTypes.js";
+
+/** When the message was set, in ms since the epoch, or null when the agent has
+ * set none or the time is not known. The wire carries seconds. */
+export function statusSetAtMs(
+ snapshot: AgentStatusSnapshot | null,
+): number | null {
+ const at = snapshot?.status_set_at;
+ return typeof at === "number" && Number.isFinite(at) && at > 0
+ ? at * 1000
+ : null;
+}
+
+/** The message, or a dash when there is none. */
+export function statusText(snapshot: AgentStatusSnapshot | null): string {
+ const text = snapshot?.status_text;
+ return text === null || text === undefined || text === "" ? "—" : text;
+}
diff --git a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx b/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
index 24d4ea5ca..ab08a64cf 100644
--- a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
+++ b/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx
@@ -21,6 +21,7 @@ import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import { AgentIcon } from "../AgentIcon.js";
import { ClaudeLoginDialog } from "../claude-login/ClaudeLoginDialog.js";
import { AgentDetail } from "../detail/AgentDetail.js";
+import { StatusMessage } from "../detail/StatusMessage.js";
import { termPaths } from "../terminal/term-paths.js";
import {
hivePauseReason,
@@ -126,7 +127,7 @@ export function AgentCard({
) : null}
}
- message={row.snapshot?.status_text ?? "—"}
+ message={}
chevron={}
/>
{open ? (
diff --git a/frontend/packages/swarm-ui/test/status-message.test.mjs b/frontend/packages/swarm-ui/test/status-message.test.mjs
new file mode 100644
index 000000000..ce59e81fc
--- /dev/null
+++ b/frontend/packages/swarm-ui/test/status-message.test.mjs
@@ -0,0 +1,32 @@
+// The agent status message and its age. Run by `npm test`, which Node runs
+// with its built-in test runner and type stripping, so the `.ts` module is
+// imported as it is.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ statusSetAtMs,
+ statusText,
+} from "../src/agent/detail/statusMessage.ts";
+
+const snap = (status_text, status_set_at) => ({
+ status_text,
+ status_set_at,
+ running: true,
+});
+
+test("the set time is read from seconds to ms, or is nothing", () => {
+ assert.equal(
+ statusSetAtMs(snap("working", 1_731_000_000)),
+ 1_731_000_000_000,
+ );
+ assert.equal(statusSetAtMs(snap("working", null)), null);
+ assert.equal(statusSetAtMs(snap("working", 0)), null);
+ assert.equal(statusSetAtMs(null), null);
+});
+
+test("an agent with no message shows a dash", () => {
+ assert.equal(statusText(snap("fixing a bug", 1)), "fixing a bug");
+ assert.equal(statusText(snap(null, 1)), "—");
+ assert.equal(statusText(snap("", 1)), "—");
+ assert.equal(statusText(null), "—");
+});