Supervise several agents at once

One daemon already saw every session; the dashboard only ever showed one of
them well. Under four parallel agents it failed in specific ways, each fixed
here:

- Sessions were identified by workspace basename, so two agents in one repo
  were indistinguishable. The daemon now hands out a small ordinal badge in
  arrival order and the web UI colours each agent by it — rows, timeline
  lines, and approval cards, which previously asked you to approve `rm -rf`
  "in remote-grok" without saying which one.
- `currentTool` held a single call, so parallel tools overwrote each other.
  It is now a list; durations are matched FIFO per tool name, since hook
  payloads carry no call id.
- One 400-event ring, evicted oldest-first, let a chatty agent blank
  everyone else's history. Eviction now takes from whichever session holds
  the most of the ring.
- Sessions sorted by recency jumped under a moving thumb. They are ordered
  waiting -> error -> working -> idle -> ended, ties on badge, so an agent
  keeps its slot.
- Events carry no workspace root, so a restart came back with a full
  timeline and an empty roster. The session map is persisted to
  sessions.json (debounced, flushed on shutdown, 12h cutoff on load).
  In-flight tools are dropped on the way out: they belonged to a process
  that no longer exists.
- Snapshot pushes now back off to 1s once a snapshot exceeds 24KB, since
  full-snapshot SSE cost scales with agent count.
- Pending approvals are ordered by which expires first, not by arrival.

`glance status` and /local/status report the roster by state. e2e suite:
220 passed, 0 failed.
This commit is contained in:
iceBear67
2026-08-09 05:27:00 +00:00
parent 5eec1940be
commit 6d34a17d9d
18 changed files with 637 additions and 94 deletions
+9 -2
View File
@@ -44,10 +44,15 @@ export class ApprovalBroker {
}
}
/**
* Soonest to expire first. With one agent that is the same as oldest-first; with four it is
* the difference between answering the call that is about to time out and answering the one
* that happened to ask first.
*/
pending(): PendingApproval[] {
return [...this.waiters.values()]
.map((w) => w.approval)
.sort((a, b) => a.createdAt - b.createdAt);
.sort((a, b) => a.expiresAt - b.expiresAt || a.createdAt - b.createdAt);
}
async request(payload: HookPayload): Promise<Decision> {
@@ -59,12 +64,14 @@ export class ApprovalBroker {
}
const sessionId = payload.sessionId ?? "unknown";
const session = this.state.ensureSession(sessionId, payload);
const summary = summarizeTool(tool, payload.toolInput);
const now = Date.now();
const approval: PendingApproval = {
id: crypto.randomBytes(9).toString("base64url"),
sessionId,
sessionLabel: this.state.sessionLabel(sessionId),
sessionLabel: session.label,
sessionBadge: session.badge,
tool,
title: summary.title,
detail: summary.detail,
+3
View File
@@ -49,6 +49,9 @@ export const paths = {
get events() {
return path.join(glanceHome(), "events.jsonl");
},
get sessions() {
return path.join(glanceHome(), "sessions.json");
},
};
const DEFAULTS: Config = {
+3
View File
@@ -226,6 +226,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
approval: cfg.approval,
watchers: sse.count,
sessions: state.sessionCount,
sessionStates: state.stateSummary(broker.pending()),
events: state.eventCount,
webBuilt: webBuildExists(),
home: paths.home,
@@ -542,6 +543,8 @@ function shutdown(why: string): void {
console.log(`[glance] shutting down (${why})`);
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
broker.drain();
// Keep the agents on screen across the restart instead of blanking every one of them.
state.flush();
sse.closeAll();
server.close(() => process.exit(0));
// Don't let a lingering keep-alive socket hold the process forever.
+16 -1
View File
@@ -41,16 +41,29 @@ export interface GlanceEvent {
durationMs?: number;
}
export interface RunningTool {
name: string;
title: string;
startedAt: number;
}
export interface SessionView {
id: string;
/** Basename of the workspace root — what you actually recognise on a phone. */
label: string;
/**
* Small ordinal handed out in arrival order and kept across daemon restarts. Labels are
* basenames, so two agents in the same repo look identical; this is what tells them apart,
* and the dashboard colours each agent by it.
*/
badge: number;
cwd: string;
state: SessionState;
startedAt: number;
lastActivity: number;
lastPrompt?: string;
currentTool?: { name: string; title: string; startedAt: number };
/** Tool calls in flight, oldest first — an agent can run several at once. */
running: RunningTool[];
counts: { tools: number; failures: number; denials: number };
}
@@ -58,6 +71,8 @@ export interface PendingApproval {
id: string;
sessionId: string;
sessionLabel: string;
/** Matches SessionView.badge, so a card says which agent is asking when two share a label. */
sessionBadge: number;
tool: string;
title: string;
detail?: string;
+20 -4
View File
@@ -4,6 +4,13 @@ import type { Snapshot } from "./protocol.js";
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
const THROTTLE_MS = 250;
/**
* Snapshots are whole state, so they grow with the number of agents being watched, while the
* push rate grows with it too. Past this size, slow down rather than push a phone the same
* 60 KB four times a second: nobody reads a dashboard at 4 Hz.
*/
const LARGE_SNAPSHOT_BYTES = 24 * 1024;
const SLOW_THROTTLE_MS = 1_000;
/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */
const HEARTBEAT_MS = 25_000;
@@ -17,6 +24,7 @@ export class SseHub {
private nextId = 1;
private pending = false;
private lastSentAt = 0;
private throttleMs = THROTTLE_MS;
private timer: NodeJS.Timeout | null = null;
private heartbeat: NodeJS.Timeout | null = null;
@@ -77,8 +85,13 @@ export class SseHub {
}
private send(client: Client, event: string, data: unknown): void {
this.write(client, event, JSON.stringify(data));
}
/** Serialise once, write to every client — the payload is identical for all of them. */
private write(client: Client, event: string, json: string): void {
try {
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
client.res.write(`event: ${event}\ndata: ${json}\n\n`);
} catch {
this.clients.delete(client.id);
}
@@ -91,14 +104,17 @@ export class SseHub {
publish(): void {
if (this.clients.size === 0) return;
if (this.pending) return;
const wait = Math.max(0, THROTTLE_MS - (Date.now() - this.lastSentAt));
const wait = Math.max(0, this.throttleMs - (Date.now() - this.lastSentAt));
this.pending = true;
this.timer = setTimeout(() => {
this.pending = false;
this.lastSentAt = Date.now();
const snap = this.snapshot();
const json = JSON.stringify(this.snapshot());
// Judge the cadence on what was actually just sent, so a quiet single-agent dashboard
// stays at 250ms and only a crowded one backs off.
this.throttleMs = json.length > LARGE_SNAPSHOT_BYTES ? SLOW_THROTTLE_MS : THROTTLE_MS;
for (const client of [...this.clients.values()]) {
this.send(client, "snapshot", snap);
this.write(client, "snapshot", json);
}
}, wait);
this.timer.unref?.();
+196 -20
View File
@@ -1,5 +1,5 @@
import { VERSION, type Config } from "./config.js";
import { appendEventLog, readRecentEvents } from "./store.js";
import { appendEventLog, readRecentEvents, readSessions, writeSessions } from "./store.js";
import {
labelForWorkspace,
summarizeNotification,
@@ -20,6 +20,30 @@ import type {
/** A session that has said nothing for this long is treated as idle, not working. */
const STALE_WORKING_MS = 10 * 60_000;
/** Sessions quieter than this are not restored on start — they are last week's agents. */
const RESTORE_MAX_AGE_MS = 12 * 60 * 60_000;
/**
* A PreToolUse whose PostToolUse never arrives (crash, kill, timeout) would otherwise sit in
* the running list forever, so the list is bounded and the oldest entry falls off.
*/
const MAX_RUNNING_PER_SESSION = 8;
/** Persisting the session map on every hook would mean a file write per tool call. */
const PERSIST_DEBOUNCE_MS = 2_000;
/**
* Which agent you want to look at first. Sorting purely by recency — the obvious choice with
* one session — makes every row jump under your thumb once four agents are working at once.
*/
const ATTENTION_RANK: Record<SessionState, number> = {
waiting: 0,
error: 1,
working: 2,
idle: 3,
ended: 4,
};
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
SessionStart: "session_start",
SessionEnd: "session_end",
@@ -50,9 +74,16 @@ export interface HookPayload {
export class GlanceState {
private events: GlanceEvent[] = [];
private sessions = new Map<string, SessionView>();
/** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */
private toolStarts = new Map<string, number>();
/**
* sessionId|toolName -> start timestamps, oldest first, so PostToolUse can report a
* duration. An array rather than a single stamp because an agent runs tools in parallel
* and the payload carries no call id: matching FIFO within a tool name is the closest
* thing to one we have.
*/
private toolStarts = new Map<string, number[]>();
private nextId = 1;
private nextBadge = 1;
private persistTimer: NodeJS.Timeout | null = null;
private readonly listeners = new Set<() => void>();
constructor(private readonly cfg: Config) {
@@ -60,6 +91,15 @@ export class GlanceState {
const recent = readRecentEvents(cfg.retainEvents);
this.events = recent;
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
// …and the agents themselves, so a restart mid-supervision does not blank the overview.
const cutoff = Date.now() - RESTORE_MAX_AGE_MS;
for (const stored of readSessions()) {
const session = restoreSession(stored);
if (!session || session.lastActivity < cutoff) continue;
this.sessions.set(session.id, session);
this.nextBadge = Math.max(this.nextBadge, session.badge + 1);
}
}
onChange(listener: () => void): () => void {
@@ -83,10 +123,12 @@ export class GlanceState {
existing = {
id,
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
badge: this.nextBadge++,
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
state: "idle",
startedAt: Date.now(),
lastActivity: Date.now(),
running: [],
counts: { tools: 0, failures: 0, denials: 0 },
};
this.sessions.set(id, existing);
@@ -100,12 +142,81 @@ export class GlanceState {
private push(event: GlanceEvent): void {
this.events.push(event);
if (this.events.length > this.cfg.retainEvents) {
this.events.splice(0, this.events.length - this.cfg.retainEvents);
}
this.trim();
appendEventLog(event);
}
/**
* Evict from whichever session is using most of the ring rather than simply dropping the
* oldest event. A single agent grinding through a build would otherwise push every other
* agent's history out, and the timeline would silently become a one-agent timeline.
*/
private trim(): void {
while (this.events.length > this.cfg.retainEvents) {
const perSession = new Map<string, number>();
for (const event of this.events) {
perSession.set(event.sessionId, (perSession.get(event.sessionId) ?? 0) + 1);
}
let greediest = this.events[0].sessionId;
let most = 0;
for (const [sessionId, count] of perSession) {
if (count > most) {
most = count;
greediest = sessionId;
}
}
const oldest = this.events.findIndex((e) => e.sessionId === greediest);
this.events.splice(oldest < 0 ? 0 : oldest, 1);
}
}
/** Forget what a session had in flight — nothing survives a turn ending or a crash. */
private clearRunning(sessionId: string, session: SessionView): void {
session.running = [];
for (const key of this.toolStarts.keys()) {
if (key.startsWith(`${sessionId}|`)) this.toolStarts.delete(key);
}
}
/**
* Write the session map out. Debounced, because the alternative is a file write per hook —
* and with several agents running that is several writes a second.
*/
private schedulePersist(): void {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
this.persist();
}, PERSIST_DEBOUNCE_MS);
this.persistTimer.unref?.();
}
/** Flush that write now — called on shutdown so the last few seconds are not lost. */
flush(): void {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
this.persist();
}
private persist(): void {
// `running` is dropped on the way out: those tool calls belong to a process that is about
// to stop existing, and a restored session claiming three live tools would be a lie the
// dashboard has no way to disprove.
writeSessions([...this.sessions.values()].map((s) => ({ ...s, running: [] })));
}
/**
* Make sure a session is known without recording anything for it. The approval gate and the
* recorder are two separate hooks on the same PreToolUse, so the gate can easily be the
* first to hear about an agent — and an approval card that cannot say which agent is asking
* is worthless when four of them are running.
*/
ensureSession(sessionId: string, payload: HookPayload = {}): SessionView {
return this.session(sessionId, payload);
}
/** Record a raw hook payload. Returns the event it produced, if any. */
ingest(payload: HookPayload): GlanceEvent | null {
const hookName = payload.hookEventName ?? "";
@@ -131,7 +242,7 @@ export class GlanceState {
case "session_end":
session.state = "ended";
session.currentTool = undefined;
this.clearRunning(sessionId, session);
title = "Session ended";
break;
@@ -142,10 +253,15 @@ export class GlanceState {
break;
case "tool_start": {
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
const name = tool ?? "tool";
const summary = summarizeTool(name, payload.toolInput);
session.state = "working";
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, now);
session.running.push({ name, title: summary.title, startedAt: now });
if (session.running.length > MAX_RUNNING_PER_SESSION) session.running.shift();
const starts = this.toolStarts.get(`${sessionId}|${name}`) ?? [];
starts.push(now);
if (starts.length > MAX_RUNNING_PER_SESSION) starts.shift();
this.toolStarts.set(`${sessionId}|${name}`, starts);
title = summary.title;
detail = summary.detail;
break;
@@ -153,14 +269,16 @@ export class GlanceState {
case "tool_end":
case "tool_fail": {
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
const key = `${sessionId}|${tool ?? "tool"}`;
const startedAt = this.toolStarts.get(key);
if (startedAt) {
durationMs = now - startedAt;
this.toolStarts.delete(key);
const name = tool ?? "tool";
const summary = summarizeTool(name, payload.toolInput);
const key = `${sessionId}|${name}`;
const starts = this.toolStarts.get(key);
if (starts?.length) {
durationMs = now - starts.shift()!;
if (!starts.length) this.toolStarts.delete(key);
}
if (session.currentTool?.name === tool) session.currentTool = undefined;
const running = session.running.findIndex((t) => t.name === name);
if (running >= 0) session.running.splice(running, 1);
session.state = "working";
title = summary.title;
detail = summary.detail;
@@ -181,13 +299,13 @@ export class GlanceState {
case "turn_end":
session.state = "idle";
session.currentTool = undefined;
this.clearRunning(sessionId, session);
title = "Turn finished";
break;
case "turn_error":
session.state = "error";
session.currentTool = undefined;
this.clearRunning(sessionId, session);
title = "Turn failed";
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
break;
@@ -224,6 +342,7 @@ export class GlanceState {
durationMs,
};
this.push(event);
this.schedulePersist();
this.notify();
return event;
}
@@ -252,6 +371,7 @@ export class GlanceState {
detail: opts.detail,
};
this.push(event);
this.schedulePersist();
this.notify();
return event;
}
@@ -270,8 +390,11 @@ export class GlanceState {
.map((s) => ({
...s,
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
// A tool that has been "running" for ten minutes lost its PostToolUse somewhere.
running: s.running.filter((t) => now - t.startedAt < STALE_WORKING_MS),
}))
.sort((a, b) => b.lastActivity - a.lastActivity);
// Whatever needs you first, then a fixed slot per agent so rows stay where you left them.
.sort((a, b) => ATTENTION_RANK[a.state] - ATTENTION_RANK[b.state] || a.badge - b.badge);
return {
now,
@@ -287,6 +410,28 @@ export class GlanceState {
return this.sessions.get(sessionId)?.label ?? "workspace";
}
sessionBadge(sessionId: string): number {
return this.sessions.get(sessionId)?.badge ?? 0;
}
/** How many agents are in each state — what `glance status` prints from the terminal. */
stateSummary(pending: PendingApproval[] = []): Record<SessionState, number> {
const now = Date.now();
const waiting = new Set(pending.map((p) => p.sessionId));
const counts: Record<SessionState, number> = {
working: 0,
idle: 0,
waiting: 0,
error: 0,
ended: 0,
};
for (const session of this.sessions.values()) {
const state = waiting.has(session.id) ? "waiting" : this.effectiveState(session, now);
counts[state] += 1;
}
return counts;
}
get sessionCount(): number {
return this.sessions.size;
}
@@ -295,3 +440,34 @@ export class GlanceState {
return this.events.length;
}
}
/**
* Accept a session read back from disk, or reject it. Written by a previous version, edited
* by hand, truncated by a full disk — none of that may take the daemon down, and a session
* with a broken shape is better dropped than rendered as `undefined` on a phone.
*/
function restoreSession(raw: unknown): SessionView | null {
if (typeof raw !== "object" || raw === null) return null;
const s = raw as Partial<SessionView>;
if (typeof s.id !== "string" || !s.id) return null;
if (typeof s.badge !== "number" || !Number.isFinite(s.badge)) return null;
const counts = s.counts ?? { tools: 0, failures: 0, denials: 0 };
return {
id: s.id,
label: typeof s.label === "string" && s.label ? s.label : "workspace",
badge: Math.max(1, Math.floor(s.badge)),
cwd: typeof s.cwd === "string" ? s.cwd : "",
state: s.state && s.state in ATTENTION_RANK ? s.state : "idle",
startedAt: typeof s.startedAt === "number" ? s.startedAt : Date.now(),
lastActivity: typeof s.lastActivity === "number" ? s.lastActivity : 0,
lastPrompt: typeof s.lastPrompt === "string" ? s.lastPrompt : undefined,
// Nothing survives the restart: whatever reports the end of a tool call was talking to
// the process that just died.
running: [],
counts: {
tools: Number(counts.tools) || 0,
failures: Number(counts.failures) || 0,
denials: Number(counts.denials) || 0,
},
};
}
+21 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import crypto from "node:crypto";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import { ensureHome, paths } from "./config.js";
import type { DeviceInfo, GlanceEvent } from "./protocol.js";
import type { DeviceInfo, GlanceEvent, SessionView } from "./protocol.js";
export interface StoredCredential {
/** Base64URL credential ID. */
@@ -232,6 +232,26 @@ export function appendEventLog(event: GlanceEvent): void {
}
}
/* --------------------------------------------------------------- session map */
/**
* The overview itself, so restarting the daemon does not blank every agent you were
* watching until each one happens to fire its next hook. Replaying the event log is not
* enough: events carry no workspace root, and a truncated ring would under-count tools.
*/
export function readSessions(): SessionView[] {
const raw = readJsonFile<unknown>(paths.sessions, []);
return Array.isArray(raw) ? (raw as SessionView[]) : [];
}
export function writeSessions(sessions: SessionView[]): void {
try {
writeJsonFile(paths.sessions, sessions);
} catch {
// Same rule as the event log: the dashboard is not worth crashing over.
}
}
/** Read back the tail of the log so a restarted daemon still has recent history. */
export function readRecentEvents(limit: number): GlanceEvent[] {
try {