From 6d34a17d9d66e4d006dc0da642f2cc4798bd3bd3 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sun, 9 Aug 2026 05:27:00 +0000 Subject: [PATCH] Supervise several agents at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 78 +++++++--- bin/glance | 18 ++- server/src/approvals.ts | 11 +- server/src/config.ts | 3 + server/src/index.ts | 3 + server/src/protocol.ts | 17 ++- server/src/sse.ts | 24 +++- server/src/state.ts | 216 +++++++++++++++++++++++++--- server/src/store.ts | 22 ++- skills/glance/SKILL.md | 28 +++- web/src/App.tsx | 50 ++++++- web/src/components/NowCard.tsx | 57 +++++--- web/src/components/PendingCard.tsx | 10 +- web/src/components/SessionBadge.tsx | 26 ++++ web/src/components/SessionsCard.tsx | 85 +++++++++-- web/src/components/Timeline.tsx | 29 +++- web/src/lib/sessionColor.ts | 31 ++++ web/src/protocol.ts | 23 ++- 18 files changed, 637 insertions(+), 94 deletions(-) create mode 100644 web/src/components/SessionBadge.tsx create mode 100644 web/src/lib/sessionColor.ts diff --git a/README.md b/README.md index e70a72d..61d92d7 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,37 @@ # grok-glance A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what -an agent is doing from your phone — and tap **approve** or **deny** when it wants to run something -risky. +your agents are doing from your phone — and tap **approve** or **deny** when one wants to run +something risky. Several agents at once is the normal case, not an edge case. It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files, or drive a session. ``` ┌────────────────────────────┐ -│ ● grok-glance 2 sessions│ +│ ● grok-glance 2 working ·│ +│ 1 waiting │ ├────────────────────────────┤ │ Waiting on you 62s │ -│ Bash · in remote-grok │ +│ Bash · in ●2 remote-grok │ │ rm -rf ./dist │ │ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │ │ [ Deny ] [ Approve ] │ ├────────────────────────────┤ -│ remote-grok working │ -│ ~/src/remote-grok │ -│ Last asked: fix the flaky │ -│ ⟳ Read 4s │ -│ server/src/state.ts │ -│ 12 tools 0 failed 0 ✗ │ +│ Agents 3 live │ +│ All agents 3 │ +│ ●1 remote-grok working │ +│ Read 4s state.ts +2 │ +│ 31 tools now │ +│ ●2 remote-grok waiting │ +│ Bash 1m rm -rf ./dist │ +│ 12 tools 1 failed 4s │ +│ ●3 docs-site error │ +│ build failed 2m │ ├────────────────────────────┤ │ Activity │ -│ ● Read state.ts 14:22 │ -│ ● Bash npm test 14:21 │ +│ ● Read state.ts ●1 14:22 │ +│ ● Bash npm test ●3 14:21 │ └────────────────────────────┘ ``` @@ -119,18 +124,50 @@ Repeat for each device you want. `node bin/glance devices` lists them; `node bin ## What the dashboard shows -- **Now** — the workspace, its state (working / waiting on you / idle / error / ended), the last - thing you asked, the tool currently running with a live elapsed timer, and running counts of - tools, failures and denials. -- **Sessions** — one row per live session when there is more than one; tap to filter. -- **Activity** — a timeline of prompts, tool calls with durations, failures, permission denials, - notifications, subagents, compactions, session start/end. -- **Pending approvals** — a card per waiting tool call, with the command, a countdown, and two - large buttons. +- **Agents** — one row per agent whenever there is more than one: badge, workspace, state, what it + is running right now, and how long ago it last did anything. Tap one to focus it; tap **All + agents** to come back. Ended sessions are folded away behind a toggle. +- **Now** — the focused agent: its workspace, state (working / waiting on you / idle / error / + ended), the last thing you asked, **every** tool it currently has in flight with a live elapsed + timer each, and running counts of tools, failures and denials. +- **Activity** — a merged timeline of prompts, tool calls with durations, failures, permission + denials, notifications, subagents, compactions, session start/end. Each row is stamped with the + badge of the agent it came from; focusing an agent filters it down to that one. +- **Pending approvals** — a card per waiting tool call, with the command, which agent is asking, a + countdown, and two large buttons. Updates arrive over Server-Sent Events. The server sends whole snapshots rather than deltas, so a phone that slept through twenty events still wakes up showing the truth. +## Several agents at once + +Watching four agents on a phone is a different problem from watching one, so a few things are not +what you might assume: + +- **Badges, not names.** Labels are workspace basenames, so two agents in the same repo are both + "remote-grok". The daemon hands each session a small ordinal in arrival order — `●1`, `●2` — and + the dashboard colours everything belonging to that agent with it: its row, its timeline lines, its + approval cards. The ordinal survives a daemon restart. +- **Sorted by who needs you, then fixed.** Rows are ordered *waiting → error → working → idle → + ended*, and ties break on badge. Within a state an agent never changes position, because a list + that re-sorts on every event moves the row out from under a thumb already heading for it. +- **A chatty agent cannot bury the others.** The event ring is global, but eviction always takes + from whichever session currently holds the most of it. One agent in a tight loop trims itself + rather than blanking everyone else's history. +- **Parallel tool calls are all shown.** An agent runs several tools at once; the focused card lists + them and the overview row shows the first with a `+2`. Durations are matched oldest-first per tool + name, since hook payloads carry no call id. +- **A restart does not lose the roster.** Events carry no workspace root, so the session map is + persisted separately (`sessions.json`) and reloaded on boot — otherwise every agent would come + back nameless until it happened to speak again. In-flight tools are deliberately *not* restored: + they belonged to a process that no longer exists. + +`glance status` shows the same breakdown from a terminal: + +``` + sessions : 4 (1 waiting on you, 1 error, 2 working) +``` + ## Remote approve / deny Off by default. Turn it on from the phone's settings panel, or: @@ -186,6 +223,7 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i | `admin.token` | Rotated every daemon start; authenticates the CLI | | `hook.secret` | Shared secret the hook scripts present on `/hook/*`. Created once, mode 0600, never rotated — a rotation mid-session would 403 whatever was already in flight. Delete it and the daemon mints a new one on its next start; hooks then need that restart to agree again, which `glance status` will tell you about. | | `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB | +| `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. | | `daemon.log` | Daemon stdout/stderr | Three environment variables override `config.json`, which is mostly useful for testing a second diff --git a/bin/glance b/bin/glance index f205b75..b13e7cb 100755 --- a/bin/glance +++ b/bin/glance @@ -59,6 +59,22 @@ async function api(pathname, { method = "GET", body, admin = false } = {}) { return data; } +/** "3 (2 working, 1 waiting on you)" — a raw count says nothing when you watch several agents. */ +function sessionBreakdown(states) { + if (!states || typeof states !== "object") return ""; + const order = [ + ["waiting", "waiting on you"], + ["error", "error"], + ["working", "working"], + ["idle", "idle"], + ["ended", "ended"], + ]; + const parts = order + .filter(([key]) => Number(states[key]) > 0) + .map(([key, label]) => `${states[key]} ${label}`); + return parts.length ? ` (${parts.join(", ")})` : ""; +} + function requireBuild() { if (!fs.existsSync(SERVER_ENTRY)) { console.error(`grok-glance is not built yet.\n\n cd ${PLUGIN_ROOT}\n npm install && npm run build\n`); @@ -122,7 +138,7 @@ switch (cmd) { console.log(` devices : ${s.devices}`); console.log(` approval mode : ${s.approval.mode}`); console.log(` watchers : ${s.watchers}`); - console.log(` sessions : ${s.sessions}`); + console.log(` sessions : ${s.sessions}${sessionBreakdown(s.sessionStates)}`); console.log(` events kept : ${s.events}`); if (s.hookAuthOk === false) { console.log( diff --git a/server/src/approvals.ts b/server/src/approvals.ts index b80e80d..31ba0c7 100644 --- a/server/src/approvals.ts +++ b/server/src/approvals.ts @@ -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 { @@ -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, diff --git a/server/src/config.ts b/server/src/config.ts index e437665..b63196f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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 = { diff --git a/server/src/index.ts b/server/src/index.ts index bfbe533..e514617 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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. diff --git a/server/src/protocol.ts b/server/src/protocol.ts index 1ac7123..4495e72 100644 --- a/server/src/protocol.ts +++ b/server/src/protocol.ts @@ -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; diff --git a/server/src/sse.ts b/server/src/sse.ts index b74c6cb..2377154 100644 --- a/server/src/sse.ts +++ b/server/src/sse.ts @@ -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?.(); diff --git a/server/src/state.ts b/server/src/state.ts index 7885e0a..0a3191b 100644 --- a/server/src/state.ts +++ b/server/src/state.ts @@ -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 = { + waiting: 0, + error: 1, + working: 2, + idle: 3, + ended: 4, +}; + const EVENT_KIND_BY_HOOK: Record = { SessionStart: "session_start", SessionEnd: "session_end", @@ -50,9 +74,16 @@ export interface HookPayload { export class GlanceState { private events: GlanceEvent[] = []; private sessions = new Map(); - /** sessionId|toolName -> start timestamp, so PostToolUse can report a duration. */ - private toolStarts = new Map(); + /** + * 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(); 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(); + 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 { + const now = Date.now(); + const waiting = new Set(pending.map((p) => p.sessionId)); + const counts: Record = { + 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; + 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, + }, + }; +} diff --git a/server/src/store.ts b/server/src/store.ts index 8777c85..597a873 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -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(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 { diff --git a/skills/glance/SKILL.md b/skills/glance/SKILL.md index 58ed0ab..93675ae 100644 --- a/skills/glance/SKILL.md +++ b/skills/glance/SKILL.md @@ -1,6 +1,6 @@ --- name: glance -description: Set up, inspect, or control grok-glance — the passkey-guarded phone dashboard for this Grok Build session. Use when the user asks to watch a session from their phone, enrol a device, expose the dashboard over Tailscale, or turn remote approve/deny on or off. +description: Set up, inspect, or control grok-glance — the passkey-guarded phone dashboard for this Grok Build session. Use when the user asks to watch one or several sessions from their phone, enrol a device, expose the dashboard over Tailscale, or turn remote approve/deny on or off. --- # grok-glance @@ -8,6 +8,9 @@ description: Set up, inspect, or control grok-glance — the passkey-guarded pho A local daemon plus web dashboard that shows what Grok Build is doing, readable from a phone behind a WebAuthn passkey. It can also pause risky tool calls until someone taps approve. +One daemon covers every session on the machine, so several agents running at once all appear on the +same dashboard — no per-session setup. + The daemon is started automatically by the `SessionStart` hook. Everything below is done through the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`. @@ -37,6 +40,26 @@ glance approval # remote approve/deny policy glance sync-hooks # regenerate hooks/hooks.json from hooks/hooks.template.json ``` +## Watching several agents + +Nothing to configure — every session that runs the hooks shows up. `glance status` reports the +roster and what each agent is doing: + +``` + sessions : 4 (1 waiting on you, 1 error, 2 working) +``` + +Points worth passing on to the user: + +- Agents are identified by a coloured badge (`●1`, `●2`) as well as the workspace name, because two + agents in the same repo carry the same label. The badge is stable across daemon restarts. +- The list is ordered by who needs attention (waiting → error → working → idle → ended) and never + re-sorts underneath a tap. +- Approval cards say which agent is asking; with `approval risky` on and several agents, expect + several cards. +- One noisy agent will not push the others out of the timeline — the event ring is trimmed from + whichever session is using the most of it. + ## Getting it onto a phone The dashboard listens on `127.0.0.1` only. Passkeys need a real hostname with valid TLS — a bare @@ -79,6 +102,9 @@ and wait for a tap on the phone. Defaults that matter: `hooks/hooks.json` exists and is registered — it is generated, so `glance sync-hooks` rebuilds it from the template. - **Page says "run npm install && npm run build"** → the web bundle is missing; build it. +- **Only one agent shows up** → the others were started before the plugin was installed, or in an + environment where the hooks are not registered. A session appears on its next hook event; nothing + can be back-filled for one that already ran. ## What it deliberately does not do diff --git a/web/src/App.tsx b/web/src/App.tsx index 300f46b..b04e56d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,7 @@ import { SessionsCard } from "@/components/SessionsCard"; import { SettingsPanel } from "@/components/SettingsPanel"; import { Timeline } from "@/components/Timeline"; import { GearIcon } from "@/components/icons"; -import type { GateInfo } from "@/protocol"; +import type { GateInfo, SessionState, SessionView } from "@/protocol"; export default function App() { const [gate, setGate] = useState(null); @@ -87,8 +87,12 @@ export default function App() { } const sessions = snapshot?.sessions ?? []; - const focus = sessions.find((s) => s.id === selected) ?? sessions[0]; const pending = snapshot?.pending ?? []; + // With one agent the detail card *is* the dashboard, so focus it and skip the list. With + // several, the overview leads and the detail appears only for the one you tapped. + const focus = + sessions.find((s) => s.id === selected) ?? (sessions.length === 1 ? sessions[0] : undefined); + const many = sessions.length > 1; return (
@@ -108,7 +112,7 @@ export default function App() {

grok-glance

{connection === "live" - ? `${sessions.length} session${sessions.length === 1 ? "" : "s"}` + ? stateSummary(sessions) : connection === "connecting" ? "connecting…" : "offline — retrying"} @@ -163,8 +167,14 @@ export default function App() { ) : ( <> - {focus && } - {sessions.length > 1 && ( + {focus && ( + setSelected(null) : undefined} + /> + )} + {many && ( )} - + setSelected(null)} + /> )} @@ -180,6 +195,29 @@ export default function App() { ); } +const SUMMARY_ORDER: Array<[SessionState, string]> = [ + ["waiting", "waiting"], + ["error", "error"], + ["working", "working"], + ["idle", "idle"], + ["ended", "ended"], +]; + +/** + * "2 working · 1 waiting" rather than "3 sessions". When you are supervising several agents, + * the count you actually want from the top of the screen is how many of them need you. + */ +function stateSummary(sessions: SessionView[]): string { + if (sessions.length === 0) return "no sessions yet"; + const counts = new Map(); + for (const session of sessions) { + counts.set(session.state, (counts.get(session.state) ?? 0) + 1); + } + return SUMMARY_ORDER.filter(([state]) => counts.get(state)) + .map(([state, label]) => `${counts.get(state)} ${label}`) + .join(" · "); +} + function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) { return (

void; +}) { + const running = session.running; return (
- {session.label} + + + {session.cwd}
- +
+ + {onBack && ( + + )} +
@@ -28,18 +47,24 @@ export function NowCard({ session, now }: { session: SessionView; now: number })
)} - {tool ? ( -
- -
-
- - - {duration(Math.max(0, now - tool.startedAt))} - + {running.length > 0 ? ( + /* An agent runs tools in parallel, so this is a list — showing only the newest one + would keep redrawing the same card with a different tool in it. */ +
+ {running.map((tool) => ( +
+ +
+
+ + + {duration(Math.max(0, now - tool.startedAt))} + +
+

{tool.title}

+
-

{tool.title}

-
+ ))}
) : (

diff --git a/web/src/components/PendingCard.tsx b/web/src/components/PendingCard.tsx index 935018e..1d6188f 100644 --- a/web/src/components/PendingCard.tsx +++ b/web/src/components/PendingCard.tsx @@ -1,5 +1,6 @@ import { Button, Card } from "@heroui/react"; import { BanIcon, CheckIcon } from "@/components/icons"; +import { SessionBadge } from "@/components/SessionBadge"; import { ToolChip } from "@/components/StatusChip"; import { secondsLeft } from "@/lib/format"; import type { PendingApproval } from "@/protocol"; @@ -28,7 +29,14 @@ export function PendingCard({

- in {approval.sessionLabel} + {/* Which agent is asking. Two of them in one repo would otherwise both read + "in remote-grok", and you would be approving a command blind. */} + in + diff --git a/web/src/components/SessionBadge.tsx b/web/src/components/SessionBadge.tsx new file mode 100644 index 0000000..224a15f --- /dev/null +++ b/web/src/components/SessionBadge.tsx @@ -0,0 +1,26 @@ +import { sessionColor } from "@/lib/sessionColor"; + +/** + * Colour dot plus #N: the only thing on screen guaranteed to be unique per agent, which + * matters the moment two of them are running in the same repo. + */ +export function SessionBadge({ + badge, + label, + className = "", +}: { + badge: number; + label?: string; + className?: string; +}) { + const color = sessionColor(badge); + return ( + + + ); +} diff --git a/web/src/components/SessionsCard.tsx b/web/src/components/SessionsCard.tsx index c46affe..2bb57d4 100644 --- a/web/src/components/SessionsCard.tsx +++ b/web/src/components/SessionsCard.tsx @@ -1,12 +1,18 @@ +import { useState } from "react"; import type { ReactNode } from "react"; import { Card } from "@heroui/react"; +import { SessionBadge } from "@/components/SessionBadge"; import { StateChip } from "@/components/StatusChip"; -import { relTime } from "@/lib/format"; +import { duration, relTime } from "@/lib/format"; import type { SessionView } from "@/protocol"; /** - * Only rendered when more than one session is live. With a single workspace the Now card - * already says everything, and an extra list is just noise on a small screen. + * Every agent at once: what each one is doing right now, not just the one you last tapped. + * Rendered whenever more than one session is live — with a single workspace the Now card + * already says all of this and a list is just noise on a small screen. + * + * The server sorts these: whatever needs you first, then a fixed slot per agent. Rows must + * not reorder themselves under a thumb that is already moving toward one. */ export function SessionsCard({ sessions, @@ -19,42 +25,97 @@ export function SessionsCard({ onSelect: (id: string | null) => void; now: number; }) { + const [showEnded, setShowEnded] = useState(false); + const live = sessions.filter((s) => s.state !== "ended"); + const ended = sessions.filter((s) => s.state === "ended"); + const rows = showEnded ? [...live, ...ended] : live; + return ( - Sessions +
+ Agents + {live.length} live +
- Tap one to filter the activity list. + Tap one for its detail and its own activity.
+
  • onSelect(null)}> - All sessions + All agents {sessions.length}
  • - {sessions.map((session) => ( + {rows.map((session) => (
  • onSelect(session.id === selectedId ? null : session.id)} > - {session.label} - - {relTime(session.lastActivity, now)} - - +
  • ))}
+ + {ended.length > 0 && ( + + + + )}
); } +function AgentRow({ session, now }: { session: SessionView; now: number }) { + const [head, ...rest] = session.running; + const { tools, failures, denials } = session.counts; + + return ( + + + + + + + {head ? ( + + {head.name} + + {duration(Math.max(0, now - head.startedAt))} + + {head.title} + {rest.length > 0 && +{rest.length}} + + ) : ( + session.lastPrompt && ( + {session.lastPrompt} + ) + )} + + + {tools} tools + {failures > 0 && {failures} failed} + {denials > 0 && {denials} denied} + {relTime(session.lastActivity, now)} + + + ); +} + function Row({ active, onPress, diff --git a/web/src/components/Timeline.tsx b/web/src/components/Timeline.tsx index 45495ed..87072a3 100644 --- a/web/src/components/Timeline.tsx +++ b/web/src/components/Timeline.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { Button, Card } from "@heroui/react"; +import { SessionBadge } from "@/components/SessionBadge"; import { clockTime, duration } from "@/lib/format"; -import type { EventKind, GlanceEvent } from "@/protocol"; +import type { EventKind, GlanceEvent, SessionView } from "@/protocol"; const DOT: Record = { session_start: "bg-muted", @@ -39,20 +40,35 @@ function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[] export function Timeline({ events, sessionId, + sessions, + onClearFilter, }: { events: GlanceEvent[]; sessionId: string | null; + sessions: SessionView[]; + onClearFilter?: () => void; }) { const [limit, setLimit] = useState(PAGE); const rows = visible(events, sessionId); const shown = rows.slice(0, limit); + const focused = sessionId ? sessions.find((s) => s.id === sessionId) : undefined; + // Interleaved lines from four agents are unreadable without saying whose each one is. + const badges = sessions.length > 1 && !sessionId ? new Map(sessions.map((s) => [s.id, s])) : null; return ( - Activity - - {rows.length === 0 ? "Nothing yet." : `${rows.length} events`} +
+ Activity + {sessionId && onClearFilter && ( + + )} +
+ + {focused && } + {rows.length === 0 ? "Nothing yet." : `${rows.length} events`}
@@ -70,7 +86,10 @@ export function Timeline({

{event.title}

- + + {badges?.get(event.sessionId) && ( + + )} {clockTime(event.ts)}
diff --git a/web/src/lib/sessionColor.ts b/web/src/lib/sessionColor.ts new file mode 100644 index 0000000..7338369 --- /dev/null +++ b/web/src/lib/sessionColor.ts @@ -0,0 +1,31 @@ +/** + * A fixed colour per agent, keyed on the badge the daemon hands out. + * + * Labels are workspace basenames, so two agents working in the same repo read identically. + * Colour plus #N is what makes a row in the overview, a line in the timeline and an approval + * card recognisably the same agent without reading anything. + * + * Written as whole class names on purpose: Tailwind scans the source text, so a class + * assembled from a template string at runtime would not survive the build. + */ + +export interface SessionColor { + dot: string; + text: string; +} + +const PALETTE: SessionColor[] = [ + { dot: "bg-sky-500", text: "text-sky-600 dark:text-sky-400" }, + { dot: "bg-violet-500", text: "text-violet-600 dark:text-violet-400" }, + { dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400" }, + { dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400" }, + { dot: "bg-rose-500", text: "text-rose-600 dark:text-rose-400" }, + { dot: "bg-cyan-500", text: "text-cyan-600 dark:text-cyan-400" }, + { dot: "bg-fuchsia-500", text: "text-fuchsia-600 dark:text-fuchsia-400" }, + { dot: "bg-lime-500", text: "text-lime-600 dark:text-lime-400" }, +]; + +export function sessionColor(badge: number): SessionColor { + const index = Math.max(0, Math.floor(badge) - 1) % PALETTE.length; + return PALETTE[index]; +} diff --git a/web/src/protocol.ts b/web/src/protocol.ts index d0deab7..4495e72 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -1,9 +1,9 @@ /** * Wire protocol shared between the daemon and the web app. * - * NOTE: this is a copy of server/src/protocol.ts. Keep the two in sync — they are duplicated - * rather than shared because the server compiles under NodeNext while the web app compiles - * under a bundler resolution, and a single rootDir cannot span both. + * NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are + * duplicated rather than shared because the server compiles under NodeNext while the web + * app compiles under a bundler resolution, and a single rootDir cannot span both. */ export type EventKind = @@ -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;