Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changelog/unreleased/changed-622-activity-from-presence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- **`tps dashboard` and `tps office status` read per-agent activity from Flair's Presence roster and label the column for what it shows.**

Both views read the office-wide `GET /Presence` roster — the per-agent state
each agent pushes — instead of the viewer's OrgEvent catch-up feed. The
dashboard's last column is `Last Seen`, the presence status and the age of the
agent's last heartbeat; office status shows the same in `LAST SEEN` and the
agent's current task or activity in `ACTIVITY`. An agent with no presence
record reads `never seen`; a read that fails reads `unavailable`, not an empty
column. Office status prints no blocker line.
83 changes: 38 additions & 45 deletions packages/cli/src/commands/office-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* office-status.ts — `tps office status`
*
* Full office state at a glance:
* - All agents: role, model, status, last heartbeat
* - Current task (from recent OrgEvents)
* - All agents: role, model, status, and when Flair Presence last saw them
* - Current activity (Presence's currentTask, else its activity)
* - Open PRs (via gh-as)
* - Task loop cursor age per agent
* - Git branch + dirty status for known workspaces
Expand All @@ -16,7 +16,7 @@ import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { cursorFilePosition, positionTimestamp, taskLoopCursorPath } from "../utils/flair-task-loop.js";
import { requireLocalAgentId } from "../utils/local-agent.js";
import { activityState, orgEventReadNotice, readOrgEvents, type OrgEventRecord } from "../utils/org-event-read.js";
import { presenceReadNotice, presenceState, readPresence, relativeAgo, type PresenceRecord } from "../utils/presence-read.js";

interface FlairAgent {
id?: string;
Expand Down Expand Up @@ -54,7 +54,6 @@ const green = (t: string, nc: boolean) => esc(32, t, nc);
const yellow = (t: string, nc: boolean) => esc(33, t, nc);
const cyan = (t: string, nc: boolean) => esc(36, t, nc);
const dim = (t: string, nc: boolean) => esc(2, t, nc);
const red = (t: string, nc: boolean) => esc(31, t, nc);

// ── Auth helper ───────────────────────────────────────────────────────────────
function makeAuth(viewerId: string, keyPath: string, method: string, urlPath: string): string | undefined {
Expand Down Expand Up @@ -140,26 +139,31 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise<void> {
else { console.error(`Flair unreachable at ${flairUrl} (HTTP ${res.status})`); process.exit(1); }
} catch { console.error(`Flair unreachable at ${flairUrl}. Is it running?`); process.exit(1); }

// Recent OrgEvents for task + heartbeat status: the viewer's catch-up feed
// over the last 6h (see utils/org-event-read.ts). A read that does not return
// the events is reported, and the task column says "unavailable", not "idle".
const read = await readOrgEvents({ flairUrl, viewerId, since: new Date(Date.now() - 6 * 3_600_000), sign: a });
const notice = orgEventReadNotice(flairUrl, read);
// Per-agent presence for the last-seen and activity columns: Flair's
// office-wide Presence roster (see utils/presence-read.ts), each agent's own
// heartbeat. A read that does not return the roster is reported, and the
// columns say "unavailable", not "never seen" / "idle".
const read = await readPresence({ flairUrl, sign: a });
const notice = presenceReadNotice(flairUrl, read);
if (notice) console.error(notice);
const activity = activityState(read);
const events = read.status === "ok" ? read.events : [];

// Per-agent newest task event of the kinds task.assigned, task.started and
// task.completed (other task kinds, such as task.failed, are not considered),
// and newest heartbeat event. The feed is oldest first, so a later event
// replaces an earlier one.
const lastTask = new Map<string, OrgEventRecord>();
const lastHbTime = new Map<string, string>();
for (const ev of events) {
const id = ev.authorId;
if (["task.assigned","task.completed","task.started"].includes(ev.kind)) lastTask.set(id, ev);
if (ev.kind === "agent.heartbeat") lastHbTime.set(id, ev.createdAt);
}
const presenceByAgent = new Map<string, PresenceRecord>();
for (const rec of read.status === "ok" ? read.agents : []) presenceByAgent.set(rec.id, rec);
const state = presenceState(read);

const lastSeenText = (id: string): string => {
if (state === "unavailable") return "unavailable";
const rec = presenceByAgent.get(id);
return rec ? `${rec.presenceStatus} (${relativeAgo(rec.lastHeartbeatAt)})` : "never seen";
};
// What the agent is doing: its last asserted current task, else its activity
// (labelled "was: …" once the Presence record has gone stale).
const doingText = (id: string): string => {
if (state === "unavailable") return "unavailable";
const rec = presenceByAgent.get(id);
if (!rec) return "never seen";
if (rec.currentTask) return rec.currentTask;
return rec.activityFresh ? rec.activity : `was: ${rec.lastActivity ?? rec.activity}`;
};

// Fetch open PRs
const openPrs: PrInfo[] = repo ? fetchOpenPrs(ghAgent, repo) : [];
Expand All @@ -170,9 +174,10 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise<void> {
const id = ag.id ?? ag.agentId ?? "?";
return {
id, name: ag.name, role: ag.role, model: ag.model, status: ag.status,
lastHeartbeat: ag.lastHeartbeat ?? lastHbTime.get(id),
lastTask: lastTask.get(id),
activity,
presence: presenceByAgent.get(id) ?? null,
lastSeen: lastSeenText(id),
doing: doingText(id),
presenceState: state,
cursorSince: loadCursorIso(id),
git: gitBranchStatus(ag.workspace),
};
Expand All @@ -187,7 +192,7 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise<void> {
console.log(`${bold("⚒️ TPS Office", nc)} ${dim(now, nc)}`);
console.log(dim("─".repeat(74), nc));
console.log(
dim(` ${"AGENT".padEnd(12)} ${"ROLE".padEnd(26)} ${"MODEL".padEnd(18)} ${"HEARTBEAT".padEnd(12)} TASK`, nc)
dim(` ${"AGENT".padEnd(12)} ${"ROLE".padEnd(26)} ${"MODEL".padEnd(18)} ${"LAST SEEN".padEnd(20)} ACTIVITY`, nc)
);
console.log(dim("─".repeat(74), nc));

Expand All @@ -196,20 +201,13 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise<void> {
const name = (ag.name ?? id).padEnd(12);
const role = (ag.role ?? "?").padEnd(26);
const model= ((ag.model ?? "?").split("/").pop() ?? "?").padEnd(18);
const hbIso= ag.lastHeartbeat ?? lastHbTime.get(id);
const hb = (!hbIso && activity === "unavailable" ? "unavailable" : relTime(hbIso)).padEnd(12);
const rec = presenceByAgent.get(id);
const lastSeen = lastSeenText(id);
const doing = doingText(id);
const dot = ag.status === "active" ? green("●", nc) : yellow("○", nc);
const task = lastTask.get(id);
const partial = activity === "incomplete" ? dim(" (incomplete)", nc) : "";
const taskStr = activity === "unavailable"
? dim("unavailable", nc)
: task
? (task.kind === "task.completed"
? green("✓ " + (task.summary.length > 30 ? task.summary.slice(0,30)+"…" : task.summary), nc)
: yellow("→ " + (task.summary.length > 30 ? task.summary.slice(0,30)+"…" : task.summary), nc)) + partial
: dim(activity === "incomplete" ? "incomplete" : "idle", nc);

console.log(`${dot} ${cyan(name, nc)} ${role} ${dim(model, nc)} ${hb} ${taskStr}`);
const doingColored = state !== "unavailable" && rec?.currentTask ? cyan(doing, nc) : dim(doing, nc);

console.log(`${dot} ${cyan(name, nc)} ${role} ${dim(model, nc)} ${lastSeen.padEnd(20)} ${doingColored}`);

// Sub-lines: git + cursor
const git = gitBranchStatus(ag.workspace);
Expand All @@ -235,10 +233,5 @@ export async function runOfficeStatus(opts: OfficeStatusOpts): Promise<void> {
}
}

// Blockers: the newest one read.
let blocker: OrgEventRecord | undefined;
for (const e of events) if (e.kind === "blocker") blocker = e;
if (blocker) console.log(` ${red(`⚠ BLOCKER: ${blocker.summary}`, nc)}`);

console.log();
}
72 changes: 27 additions & 45 deletions packages/cli/src/commands/roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createFlairClient, defaultFlairKeyPath } from "../utils/flair-client.js
import { sendSignedMail } from "../utils/mail-producer.js";
import { resolveCliSenderId } from "../utils/sender-id.js";
import { requireLocalAgentId } from "../utils/local-agent.js";
import { activityState, orgEventReadNotice, readOrgEvents, type OrgEventRecord } from "../utils/org-event-read.js";
import { presenceReadNotice, presenceState, readPresence, relativeAgo, type PresenceRecord } from "../utils/presence-read.js";

interface RosterArgs {
action: "list" | "show" | "find" | "invite";
Expand Down Expand Up @@ -260,64 +260,46 @@ export async function runDashboard(opts: { flairUrl?: string; json?: boolean; ke
return;
}

// Recent OrgEvents for the activity column: the viewer's catch-up feed over
// the last 24h (see utils/org-event-read.ts). A read that does not return the
// events is reported, and the column says "unavailable", not "no activity".
const read = await readOrgEvents({
flairUrl,
viewerId,
since: new Date(Date.now() - 24 * 60 * 60 * 1000),
sign: makeAuth,
});
const notice = orgEventReadNotice(flairUrl, read);
// Per-agent presence for the last-seen column: Flair's office-wide Presence
// roster (see utils/presence-read.ts). It holds each agent's own heartbeat —
// not the viewer's catch-up feed. A read that does not return the roster is
// reported, and the column says "unavailable", not "never seen".
const read = await readPresence({ flairUrl, sign: makeAuth });
const notice = presenceReadNotice(flairUrl, read);
if (notice) console.error(notice);
const activity = activityState(read);
const presenceByAgent = new Map<string, PresenceRecord>();
for (const rec of read.status === "ok" ? read.agents : []) presenceByAgent.set(rec.id, rec);
const state = presenceState(read);

// Per-agent newest event of any kind, and newest task event of the kinds
// task.assigned and task.completed (other task kinds, such as task.failed,
// are not considered). The feed is oldest first, so a later event replaces an
// earlier one.
const lastEvent = new Map<string, OrgEventRecord>();
const lastTask = new Map<string, OrgEventRecord>();
for (const ev of read.status === "ok" ? read.events : []) {
lastEvent.set(ev.authorId, ev);
if (ev.kind === "task.assigned" || ev.kind === "task.completed") lastTask.set(ev.authorId, ev);
}
const lastSeenText = (agId: string): string => {
if (state === "unavailable") return "unavailable";
const rec = presenceByAgent.get(agId);
return rec ? `${rec.presenceStatus} (${relativeAgo(rec.lastHeartbeatAt)})` : "never seen";
};

if (opts.json) {
console.log(JSON.stringify(agents.map(a => ({
...a,
lastEvent: lastEvent.get(a.id ?? a.agentId ?? ""),
lastTask: lastTask.get(a.id ?? a.agentId ?? ""),
activity,
})), null, 2));
console.log(JSON.stringify(agents.map(a => {
const agId = a.id ?? a.agentId ?? "";
return {
...a,
presence: presenceByAgent.get(agId) ?? null,
lastSeen: lastSeenText(agId),
presenceState: state,
};
}), null, 2));
return;
}

// Pretty print
const now = Date.now();
console.log(`\n⚒️ TPS Office Dashboard — ${agents.length} agents\n`);
console.log(`${"Agent".padEnd(16)} ${"Role".padEnd(12)} ${"Status".padEnd(10)} ${"Last Activity"}`);
console.log(`${"Agent".padEnd(16)} ${"Role".padEnd(12)} ${"Status".padEnd(10)} ${"Last Seen"}`);
console.log("─".repeat(72));

for (const agent of agents) {
const agId = agent.id ?? agent.agentId ?? "?"; const last = lastEvent.get(agId);
const task = lastTask.get(agId);
const ago = activity === "unavailable"
? "unavailable"
: last
? (() => {
const ms = now - new Date(last.createdAt).getTime();
const when = ms < 60000 ? `${Math.round(ms / 1000)}s ago`
: ms < 3600000 ? `${Math.round(ms / 60000)}m ago`
: `${Math.round(ms / 3600000)}h ago`;
return activity === "incomplete" ? `${when} (incomplete)` : when;
})()
: activity === "incomplete" ? "incomplete" : "no activity";
const taskInfo = task ? ` [${task.kind}: ${(task.refId ?? task.summary).slice(0, 30)}]` : "";
const agId = agent.id ?? agent.agentId ?? "?";
const status = agent.status ?? "unknown";
console.log(
`${agId.padEnd(16)} ${(agent.role ?? "agent").padEnd(12)} ${status.padEnd(10)} ${ago}${taskInfo}`
`${agId.padEnd(16)} ${(agent.role ?? "agent").padEnd(12)} ${status.padEnd(10)} ${lastSeenText(agId)}`
);
}
console.log();
Expand Down
31 changes: 6 additions & 25 deletions packages/cli/src/utils/org-event-read.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/**
* org-event-read.ts — the recent-OrgEvent read behind `tps roster dashboard`
* and `tps office status` (cli#606, cli#620).
* org-event-read.ts — the validated OrgEvent catch-up read behind
* `FlairClient.getEventsSince`: the task loop, `tps agent logs`, `tps status`
* and the agent boot context read through it (cli#606, cli#620, cli#624). The
* dashboard and office status read Presence instead (presence-read.ts, cli#622).
*
* `GET /OrgEventCatchup/<viewer>` is the viewer's catch-up feed in Flair: the
* events addressed to the viewer or broadcast, after the viewer's acknowledged
Expand Down Expand Up @@ -104,9 +106,8 @@ const optionalString = (v: unknown) => v === undefined || v === null || typeof v
* `parsePage` checks its value); and the optional `targetIds`, which may be
* absent or null and is otherwise an array of strings, and `refId`, `detail`,
* `scope` and `expiresAt`, which may be absent or null and are otherwise
* strings. These are the fields the dashboard, office status, `tps agent logs`,
* the task loop and its handlers read. Any other field is passed through
* unchecked.
* strings. These are the fields `tps agent logs`, the task loop and its
* handlers read. Any other field is passed through unchecked.
*/
function isEvent(value: unknown): value is OrgEventRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
Expand Down Expand Up @@ -225,23 +226,3 @@ function ok(events: OrgEventRecord[], complete: boolean, nextAfter: string, serv
? { status: "ok", events, complete, nextAfter }
: { status: "ok", events, complete, nextAfter, serverTime };
}

/** The per-agent activity state both commands print and emit as JSON. */
export type ActivityState = "ok" | "incomplete" | "unavailable";

export function activityState(read: OrgEventRead): ActivityState {
if (read.status === "unavailable") return "unavailable";
return read.complete ? "ok" : "incomplete";
}

/**
* The one line a command prints about the read, or null when the read was
* complete. It names the Flair URL and, for an unavailable read, the bounded
* reason.
*/
export function orgEventReadNotice(flairUrl: string, read: OrgEventRead, maxPages = ORG_EVENT_MAX_PAGES): string | null {
if (read.status === "unavailable") return `Cannot read OrgEvents from ${flairUrl} (${read.reason}); activity is unavailable.`;
if (!read.complete)
return `OrgEvents from ${flairUrl} exceed the ${maxPages}-page read bound; newer events were not read and activity is incomplete.`;
return null;
}
Loading
Loading