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
+58 -20
View File
@@ -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
+17 -1
View File
@@ -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(
+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 {
+27 -1
View File
@@ -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 <off|risky|all> # 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
+44 -6
View File
@@ -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<GateInfo | null>(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 (
<div className="min-h-dvh bg-background text-foreground">
@@ -108,7 +112,7 @@ export default function App() {
<h1 className="truncate text-sm font-semibold tracking-tight">grok-glance</h1>
<p className="text-[11px] text-muted">
{connection === "live"
? `${sessions.length} session${sessions.length === 1 ? "" : "s"}`
? stateSummary(sessions)
: connection === "connecting"
? "connecting…"
: "offline — retrying"}
@@ -163,8 +167,14 @@ export default function App() {
</Card>
) : (
<>
{focus && <NowCard session={focus} now={now} />}
{sessions.length > 1 && (
{focus && (
<NowCard
session={focus}
now={now}
onBack={many ? () => setSelected(null) : undefined}
/>
)}
{many && (
<SessionsCard
sessions={sessions}
selectedId={selected}
@@ -172,7 +182,12 @@ export default function App() {
now={now}
/>
)}
<Timeline events={snapshot.events} sessionId={selected} />
<Timeline
events={snapshot.events}
sessionId={selected}
sessions={sessions}
onClearFilter={() => setSelected(null)}
/>
</>
)}
</main>
@@ -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<SessionState, number>();
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 (
<div
+31 -6
View File
@@ -1,20 +1,39 @@
import { Card, Spinner } from "@heroui/react";
import { Button, Card, Spinner } from "@heroui/react";
import { SessionBadge } from "@/components/SessionBadge";
import { StateChip, ToolChip } from "@/components/StatusChip";
import { duration, relTime } from "@/lib/format";
import type { SessionView } from "@/protocol";
export function NowCard({ session, now }: { session: SessionView; now: number }) {
const tool = session.currentTool;
export function NowCard({
session,
now,
onBack,
}: {
session: SessionView;
now: number;
/** Only passed when there is more than one agent — otherwise there is nothing to go back to. */
onBack?: () => void;
}) {
const running = session.running;
return (
<Card>
<Card.Header>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Card.Title className="truncate text-base">{session.label}</Card.Title>
<Card.Title className="flex min-w-0 items-center gap-2 text-base">
<SessionBadge badge={session.badge} label={session.label} />
</Card.Title>
<Card.Description className="truncate text-xs">{session.cwd}</Card.Description>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<StateChip state={session.state} />
{onBack && (
<Button size="sm" variant="ghost" onPress={onBack}>
All agents
</Button>
)}
</div>
</div>
</Card.Header>
@@ -28,8 +47,12 @@ export function NowCard({ session, now }: { session: SessionView; now: number })
</div>
)}
{tool ? (
<div className="flex items-start gap-2.5 rounded-xl bg-surface-secondary p-3">
{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. */
<div className="flex flex-col gap-2.5 rounded-xl bg-surface-secondary p-3">
{running.map((tool) => (
<div key={`${tool.name}-${tool.startedAt}`} className="flex items-start gap-2.5">
<Spinner size="sm" color="current" className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
@@ -41,6 +64,8 @@ export function NowCard({ session, now }: { session: SessionView; now: number })
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">
Nothing running · last activity {relTime(session.lastActivity, now)}
+9 -1
View File
@@ -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({
</div>
<Card.Description className="flex flex-wrap items-center gap-1.5">
<ToolChip tool={approval.tool} />
<span className="text-xs text-muted">in {approval.sessionLabel}</span>
{/* 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. */}
<span className="text-xs text-muted">in</span>
<SessionBadge
badge={approval.sessionBadge}
label={approval.sessionLabel}
className="text-xs text-muted"
/>
</Card.Description>
</Card.Header>
+26
View File
@@ -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 (
<span className={`inline-flex min-w-0 items-center gap-1.5 ${className}`}>
<span className={`h-2 w-2 shrink-0 rounded-full ${color.dot}`} aria-hidden="true" />
<span className={`shrink-0 text-[11px] font-semibold tabular-nums ${color.text}`}>
#{badge}
</span>
{label !== undefined && <span className="min-w-0 truncate">{label}</span>}
</span>
);
}
+73 -12
View File
@@ -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 (
<Card>
<Card.Header>
<Card.Title className="text-base">Sessions</Card.Title>
<div className="flex items-baseline justify-between gap-2">
<Card.Title className="text-base">Agents</Card.Title>
<span className="text-xs text-muted">{live.length} live</span>
</div>
<Card.Description className="text-xs">
Tap one to filter the activity list.
Tap one for its detail and its own activity.
</Card.Description>
</Card.Header>
<Card.Content className="px-0">
<ul className="flex flex-col">
<li className="border-b border-separator">
<Row active={selectedId === null} onPress={() => onSelect(null)}>
<span className="text-sm">All sessions</span>
<span className="flex-1 text-sm">All agents</span>
<span className="text-xs text-muted">{sessions.length}</span>
</Row>
</li>
{sessions.map((session) => (
{rows.map((session) => (
<li key={session.id} className="border-b border-separator last:border-b-0">
<Row
active={selectedId === session.id}
onPress={() => onSelect(session.id === selectedId ? null : session.id)}
>
<span className="min-w-0 flex-1 truncate text-sm">{session.label}</span>
<span className="shrink-0 text-[11px] text-muted">
{relTime(session.lastActivity, now)}
</span>
<StateChip state={session.state} />
<AgentRow session={session} now={now} />
</Row>
</li>
))}
</ul>
</Card.Content>
{ended.length > 0 && (
<Card.Footer>
<button
type="button"
className="w-full text-center text-xs text-muted"
onClick={() => setShowEnded((value) => !value)}
>
{showEnded
? "Hide ended"
: `Show ${ended.length} ended session${ended.length === 1 ? "" : "s"}`}
</button>
</Card.Footer>
)}
</Card>
);
}
function AgentRow({ session, now }: { session: SessionView; now: number }) {
const [head, ...rest] = session.running;
const { tools, failures, denials } = session.counts;
return (
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center gap-2">
<SessionBadge badge={session.badge} label={session.label} className="flex-1 text-sm" />
<StateChip state={session.state} />
</span>
{head ? (
<span className="flex min-w-0 items-baseline gap-1.5 text-xs">
<span className="shrink-0 font-medium">{head.name}</span>
<span className="shrink-0 tabular-nums text-muted">
{duration(Math.max(0, now - head.startedAt))}
</span>
<span className="min-w-0 flex-1 truncate text-muted">{head.title}</span>
{rest.length > 0 && <span className="shrink-0 text-muted">+{rest.length}</span>}
</span>
) : (
session.lastPrompt && (
<span className="min-w-0 truncate text-xs text-muted">{session.lastPrompt}</span>
)
)}
<span className="flex items-baseline gap-2 text-[11px] text-muted">
<span className="tabular-nums">{tools} tools</span>
{failures > 0 && <span className="tabular-nums text-danger">{failures} failed</span>}
{denials > 0 && <span className="tabular-nums text-danger">{denials} denied</span>}
<span className="ml-auto tabular-nums">{relTime(session.lastActivity, now)}</span>
</span>
</span>
);
}
function Row({
active,
onPress,
+23 -4
View File
@@ -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<EventKind, string> = {
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 (
<Card>
<Card.Header>
<div className="flex items-baseline justify-between gap-2">
<Card.Title className="text-base">Activity</Card.Title>
<Card.Description className="text-xs">
{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}
{sessionId && onClearFilter && (
<button type="button" className="text-xs text-accent" onClick={onClearFilter}>
Show all
</button>
)}
</div>
<Card.Description className="flex items-center gap-1.5 text-xs">
{focused && <SessionBadge badge={focused.badge} label={focused.label} />}
<span>{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}</span>
</Card.Description>
</Card.Header>
@@ -70,7 +86,10 @@ export function Timeline({
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 text-sm leading-snug break-words">{event.title}</p>
<span className="shrink-0 text-[11px] tabular-nums text-muted">
<span className="flex shrink-0 items-baseline gap-1.5 text-[11px] tabular-nums text-muted">
{badges?.get(event.sessionId) && (
<SessionBadge badge={badges.get(event.sessionId)!.badge} />
)}
{clockTime(event.ts)}
</span>
</div>
+31
View File
@@ -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];
}
+19 -4
View File
@@ -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;