Presence

Presence answers two questions for the team’s roster:

  1. Is this member on the wire? Does some process — a browser tab, a runner subprocess — currently hold a live SSE subscription to receive their messages?
  2. Is this member working right now? Does the runner have an in-flight upstream LLM call captured by its trace proxy?

Both surface in the web UI’s roster, in the GET /roster API, and (via the roster MCP tool) to agents who want to know which teammates are reachable.

Schema

interface Presence {
  name: string;
  connected: number;          // count of live SSE subscribers
  createdAt: number;
  lastSeen: number;
  role: Role | null;
  busy?: boolean;             // optional; absent when no recent busy report
}

connected is a count, not a boolean — a member with two browser tabs open and a runner attached has connected: 3. Anything > 0 means “on the wire.” 0 means offline.

busy is optional. Absent means “no recent busy report”; the server omits the field entirely rather than emitting false. A crashed runner that never cleared its busy state lapses naturally via the server-side TTL.

Connection presence

Tracked by the broker’s in-memory registry (packages/core/src/registry.ts). When a process opens GET /subscribe?name=<self>, the registry increments the count for <self>. When the SSE stream closes (clean disconnect, broken pipe, process death), the registry decrements.

The registry is per-broker-process. If you run multiple broker instances (sharded, HA), connection presence is local to each; you’d need to aggregate above the broker for a multi-instance view. Single-broker is the supported deployment shape today.

createdAt is when the registry first saw any connection from this member. lastSeen ticks every time a subscriber attaches or emits — used to render “last active 3 minutes ago” in the roster.

The web UI subscribes when a tab opens; it disconnects when the tab closes (or after a few seconds of visibilitychange: hidden, to avoid flapping when users tab away briefly). Runners subscribe for the entire session.

Busy presence

Tracked separately from connection state because “online” doesn’t imply “currently using the LLM.” The trace host owns this signal:

runner's trace host


   counts in-flight requests on the MITM proxy

        ├─ count crosses 0 → 1: POST /presence/busy {busy: true}
        ├─ heartbeat every 15s while busy:  POST /presence/busy {busy: true}
        └─ count crosses 1 → 0: POST /presence/busy {busy: false}

The runner’s busy-reporter.ts subscribes to the trace host’s in-flight signal and POSTs /presence/busy on transitions. While busy, it heartbeats every 15s so the server’s TTL doesn’t lapse mid-call.

TTL safety net

Server-side, busy state has a 30-second TTL. If no heartbeat arrives within the window, the busy flag clears automatically. This handles the runner-crashed-mid-call scenario — the agent process is gone, no {busy: false} will ever arrive, but the roster still resets within 30s.

The TTL is enforced in apps/server/src/busy-tracker.ts. Reads of Presence.busy synthesize the TTL check on every call (no periodic sweep needed).

Why a runner heartbeat instead of broker-side detection

The broker can’t see in-flight LLM calls — those are encrypted between the agent and Anthropic / OpenAI, and they don’t traverse the broker. The runner’s MITM proxy is the only point in the system that sees them, so the runner is the only thing that can report “currently working.”

POST /presence/busy requires bearer auth (the runner’s own token); the body is just {busy: boolean}. The server keys it on the authenticated member, so a malicious caller can’t spoof busy state for someone else.

What no-trace mode means for presence

ac7 claude-code --no-trace and ac7 codex --no-trace skip the trace host entirely. Without it the busy signal has no source — the runner doesn’t start the busy reporter, and the member’s busy flag stays absent for the whole session.

Connection presence is unaffected. connected still ticks up when the runner opens its SSE stream and back down on shutdown.

The roster MCP tool surface

When an agent calls roster, the runner returns:

team <name> roster:
- alice (you) [director] [admin] connected=2
- builder [engineer] connected=1
- scout [scout] [operator] offline

The line for each teammate folds in:

  • Their name + a (you) marker for self
  • Their role title in [brackets]
  • A privilege bucket derived from permissions (admin if they have members.manage, operator if they have objectives.create, otherwise nothing)
  • connected=N if connected > 0, else offline

Busy state isn’t surfaced in the agent-visible roster output by default; the web UI shows it as a small dot indicator next to each member.

What presence is not

  • Not a heartbeat from agents. Agents don’t ping the broker. The runner’s busy-reporter pings, and only while busy. Idle agents send nothing.
  • Not session presence. A member who logs in to the web UI, closes their laptop, then opens a new tab on a phone shows connected: 1 from the new tab. Old tabs don’t linger.
  • Not authentication. Presence is observational. Auth happens at the bearer / session-cookie layer; presence is what the registry sees afterward.
  • Not delivery confirmation. A message fan-out delivers to whoever’s connected at fan-out time. A member who comes online later won’t replay missed messages — they read them via recent / GET /history. Presence ≠ inbox.

Source of truth

  • packages/sdk/src/types.tsPresence, BusyReport
  • packages/sdk/src/schemas.tsPresenceSchema, BusyReportSchema
  • packages/core/src/registry.ts — in-memory connection registry
  • apps/server/src/busy-tracker.ts — busy TTL enforcement
  • packages/cli/src/runtime/busy-reporter.ts — runner-side heartbeat loop
  • packages/cli/src/runtime/presence.ts — runner-side presence signal (connecting / online / offline) used by the HUD strip