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:
@@ -1,32 +1,37 @@
|
|||||||
# grok-glance
|
# grok-glance
|
||||||
|
|
||||||
A Grok Build plugin that puts a small web dashboard behind a passkey, so you can glance at what
|
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
|
your agents are doing from your phone — and tap **approve** or **deny** when one wants to run
|
||||||
risky.
|
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,
|
It is deliberately small: read-only, plus remote approve/deny. It cannot send prompts, edit files,
|
||||||
or drive a session.
|
or drive a session.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────┐
|
┌────────────────────────────┐
|
||||||
│ ● grok-glance 2 sessions│
|
│ ● grok-glance 2 working ·│
|
||||||
|
│ 1 waiting │
|
||||||
├────────────────────────────┤
|
├────────────────────────────┤
|
||||||
│ Waiting on you 62s │
|
│ Waiting on you 62s │
|
||||||
│ Bash · in remote-grok │
|
│ Bash · in ●2 remote-grok │
|
||||||
│ rm -rf ./dist │
|
│ rm -rf ./dist │
|
||||||
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
|
│ ▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░ │
|
||||||
│ [ Deny ] [ Approve ] │
|
│ [ Deny ] [ Approve ] │
|
||||||
├────────────────────────────┤
|
├────────────────────────────┤
|
||||||
│ remote-grok working │
|
│ Agents 3 live │
|
||||||
│ ~/src/remote-grok │
|
│ All agents 3 │
|
||||||
│ Last asked: fix the flaky │
|
│ ●1 remote-grok working │
|
||||||
│ ⟳ Read 4s │
|
│ Read 4s state.ts +2 │
|
||||||
│ server/src/state.ts │
|
│ 31 tools now │
|
||||||
│ 12 tools 0 failed 0 ✗ │
|
│ ●2 remote-grok waiting │
|
||||||
|
│ Bash 1m rm -rf ./dist │
|
||||||
|
│ 12 tools 1 failed 4s │
|
||||||
|
│ ●3 docs-site error │
|
||||||
|
│ build failed 2m │
|
||||||
├────────────────────────────┤
|
├────────────────────────────┤
|
||||||
│ Activity │
|
│ Activity │
|
||||||
│ ● Read state.ts 14:22 │
|
│ ● Read state.ts ●1 14:22 │
|
||||||
│ ● Bash npm test 14:21 │
|
│ ● 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
|
## What the dashboard shows
|
||||||
|
|
||||||
- **Now** — the workspace, its state (working / waiting on you / idle / error / ended), the last
|
- **Agents** — one row per agent whenever there is more than one: badge, workspace, state, what it
|
||||||
thing you asked, the tool currently running with a live elapsed timer, and running counts of
|
is running right now, and how long ago it last did anything. Tap one to focus it; tap **All
|
||||||
tools, failures and denials.
|
agents** to come back. Ended sessions are folded away behind a toggle.
|
||||||
- **Sessions** — one row per live session when there is more than one; tap to filter.
|
- **Now** — the focused agent: its workspace, state (working / waiting on you / idle / error /
|
||||||
- **Activity** — a timeline of prompts, tool calls with durations, failures, permission denials,
|
ended), the last thing you asked, **every** tool it currently has in flight with a live elapsed
|
||||||
notifications, subagents, compactions, session start/end.
|
timer each, and running counts of tools, failures and denials.
|
||||||
- **Pending approvals** — a card per waiting tool call, with the command, a countdown, and two
|
- **Activity** — a merged timeline of prompts, tool calls with durations, failures, permission
|
||||||
large buttons.
|
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
|
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.
|
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
|
## Remote approve / deny
|
||||||
|
|
||||||
Off by default. Turn it on from the phone's settings panel, or:
|
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 |
|
| `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. |
|
| `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 |
|
| `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 |
|
| `daemon.log` | Daemon stdout/stderr |
|
||||||
|
|
||||||
Three environment variables override `config.json`, which is mostly useful for testing a second
|
Three environment variables override `config.json`, which is mostly useful for testing a second
|
||||||
|
|||||||
+17
-1
@@ -59,6 +59,22 @@ async function api(pathname, { method = "GET", body, admin = false } = {}) {
|
|||||||
return data;
|
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() {
|
function requireBuild() {
|
||||||
if (!fs.existsSync(SERVER_ENTRY)) {
|
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`);
|
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(` devices : ${s.devices}`);
|
||||||
console.log(` approval mode : ${s.approval.mode}`);
|
console.log(` approval mode : ${s.approval.mode}`);
|
||||||
console.log(` watchers : ${s.watchers}`);
|
console.log(` watchers : ${s.watchers}`);
|
||||||
console.log(` sessions : ${s.sessions}`);
|
console.log(` sessions : ${s.sessions}${sessionBreakdown(s.sessionStates)}`);
|
||||||
console.log(` events kept : ${s.events}`);
|
console.log(` events kept : ${s.events}`);
|
||||||
if (s.hookAuthOk === false) {
|
if (s.hookAuthOk === false) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -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[] {
|
pending(): PendingApproval[] {
|
||||||
return [...this.waiters.values()]
|
return [...this.waiters.values()]
|
||||||
.map((w) => w.approval)
|
.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> {
|
async request(payload: HookPayload): Promise<Decision> {
|
||||||
@@ -59,12 +64,14 @@ export class ApprovalBroker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = payload.sessionId ?? "unknown";
|
const sessionId = payload.sessionId ?? "unknown";
|
||||||
|
const session = this.state.ensureSession(sessionId, payload);
|
||||||
const summary = summarizeTool(tool, payload.toolInput);
|
const summary = summarizeTool(tool, payload.toolInput);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const approval: PendingApproval = {
|
const approval: PendingApproval = {
|
||||||
id: crypto.randomBytes(9).toString("base64url"),
|
id: crypto.randomBytes(9).toString("base64url"),
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionLabel: this.state.sessionLabel(sessionId),
|
sessionLabel: session.label,
|
||||||
|
sessionBadge: session.badge,
|
||||||
tool,
|
tool,
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
detail: summary.detail,
|
detail: summary.detail,
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const paths = {
|
|||||||
get events() {
|
get events() {
|
||||||
return path.join(glanceHome(), "events.jsonl");
|
return path.join(glanceHome(), "events.jsonl");
|
||||||
},
|
},
|
||||||
|
get sessions() {
|
||||||
|
return path.join(glanceHome(), "sessions.json");
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS: Config = {
|
const DEFAULTS: Config = {
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
|
|||||||
approval: cfg.approval,
|
approval: cfg.approval,
|
||||||
watchers: sse.count,
|
watchers: sse.count,
|
||||||
sessions: state.sessionCount,
|
sessions: state.sessionCount,
|
||||||
|
sessionStates: state.stateSummary(broker.pending()),
|
||||||
events: state.eventCount,
|
events: state.eventCount,
|
||||||
webBuilt: webBuildExists(),
|
webBuilt: webBuildExists(),
|
||||||
home: paths.home,
|
home: paths.home,
|
||||||
@@ -542,6 +543,8 @@ function shutdown(why: string): void {
|
|||||||
console.log(`[glance] shutting down (${why})`);
|
console.log(`[glance] shutting down (${why})`);
|
||||||
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
|
// Anything still waiting on a decision gets allowed, so no hook is left hanging.
|
||||||
broker.drain();
|
broker.drain();
|
||||||
|
// Keep the agents on screen across the restart instead of blanking every one of them.
|
||||||
|
state.flush();
|
||||||
sse.closeAll();
|
sse.closeAll();
|
||||||
server.close(() => process.exit(0));
|
server.close(() => process.exit(0));
|
||||||
// Don't let a lingering keep-alive socket hold the process forever.
|
// Don't let a lingering keep-alive socket hold the process forever.
|
||||||
|
|||||||
+16
-1
@@ -41,16 +41,29 @@ export interface GlanceEvent {
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunningTool {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionView {
|
export interface SessionView {
|
||||||
id: string;
|
id: string;
|
||||||
/** Basename of the workspace root — what you actually recognise on a phone. */
|
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||||
label: string;
|
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;
|
cwd: string;
|
||||||
state: SessionState;
|
state: SessionState;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
lastPrompt?: string;
|
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 };
|
counts: { tools: number; failures: number; denials: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +71,8 @@ export interface PendingApproval {
|
|||||||
id: string;
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionLabel: string;
|
sessionLabel: string;
|
||||||
|
/** Matches SessionView.badge, so a card says which agent is asking when two share a label. */
|
||||||
|
sessionBadge: number;
|
||||||
tool: string;
|
tool: string;
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
|||||||
+20
-4
@@ -4,6 +4,13 @@ import type { Snapshot } from "./protocol.js";
|
|||||||
|
|
||||||
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
|
/** Coalesce bursts — a single tool call can fire several hooks in a few milliseconds. */
|
||||||
const THROTTLE_MS = 250;
|
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. */
|
/** Proxies and phone radios drop idle connections; a comment frame keeps them honest. */
|
||||||
const HEARTBEAT_MS = 25_000;
|
const HEARTBEAT_MS = 25_000;
|
||||||
|
|
||||||
@@ -17,6 +24,7 @@ export class SseHub {
|
|||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private pending = false;
|
private pending = false;
|
||||||
private lastSentAt = 0;
|
private lastSentAt = 0;
|
||||||
|
private throttleMs = THROTTLE_MS;
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
private heartbeat: 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 {
|
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 {
|
try {
|
||||||
client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
client.res.write(`event: ${event}\ndata: ${json}\n\n`);
|
||||||
} catch {
|
} catch {
|
||||||
this.clients.delete(client.id);
|
this.clients.delete(client.id);
|
||||||
}
|
}
|
||||||
@@ -91,14 +104,17 @@ export class SseHub {
|
|||||||
publish(): void {
|
publish(): void {
|
||||||
if (this.clients.size === 0) return;
|
if (this.clients.size === 0) return;
|
||||||
if (this.pending) 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.pending = true;
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
this.pending = false;
|
this.pending = false;
|
||||||
this.lastSentAt = Date.now();
|
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()]) {
|
for (const client of [...this.clients.values()]) {
|
||||||
this.send(client, "snapshot", snap);
|
this.write(client, "snapshot", json);
|
||||||
}
|
}
|
||||||
}, wait);
|
}, wait);
|
||||||
this.timer.unref?.();
|
this.timer.unref?.();
|
||||||
|
|||||||
+196
-20
@@ -1,5 +1,5 @@
|
|||||||
import { VERSION, type Config } from "./config.js";
|
import { VERSION, type Config } from "./config.js";
|
||||||
import { appendEventLog, readRecentEvents } from "./store.js";
|
import { appendEventLog, readRecentEvents, readSessions, writeSessions } from "./store.js";
|
||||||
import {
|
import {
|
||||||
labelForWorkspace,
|
labelForWorkspace,
|
||||||
summarizeNotification,
|
summarizeNotification,
|
||||||
@@ -20,6 +20,30 @@ import type {
|
|||||||
/** A session that has said nothing for this long is treated as idle, not working. */
|
/** A session that has said nothing for this long is treated as idle, not working. */
|
||||||
const STALE_WORKING_MS = 10 * 60_000;
|
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> = {
|
const EVENT_KIND_BY_HOOK: Record<string, EventKind> = {
|
||||||
SessionStart: "session_start",
|
SessionStart: "session_start",
|
||||||
SessionEnd: "session_end",
|
SessionEnd: "session_end",
|
||||||
@@ -50,9 +74,16 @@ export interface HookPayload {
|
|||||||
export class GlanceState {
|
export class GlanceState {
|
||||||
private events: GlanceEvent[] = [];
|
private events: GlanceEvent[] = [];
|
||||||
private sessions = new Map<string, SessionView>();
|
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 nextId = 1;
|
||||||
|
private nextBadge = 1;
|
||||||
|
private persistTimer: NodeJS.Timeout | null = null;
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
|
|
||||||
constructor(private readonly cfg: Config) {
|
constructor(private readonly cfg: Config) {
|
||||||
@@ -60,6 +91,15 @@ export class GlanceState {
|
|||||||
const recent = readRecentEvents(cfg.retainEvents);
|
const recent = readRecentEvents(cfg.retainEvents);
|
||||||
this.events = recent;
|
this.events = recent;
|
||||||
this.nextId = recent.reduce((max, e) => Math.max(max, e.id), 0) + 1;
|
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 {
|
onChange(listener: () => void): () => void {
|
||||||
@@ -83,10 +123,12 @@ export class GlanceState {
|
|||||||
existing = {
|
existing = {
|
||||||
id,
|
id,
|
||||||
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
|
label: labelForWorkspace(payload.workspaceRoot, payload.cwd ?? ""),
|
||||||
|
badge: this.nextBadge++,
|
||||||
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
|
cwd: payload.workspaceRoot ?? payload.cwd ?? "",
|
||||||
state: "idle",
|
state: "idle",
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
lastActivity: Date.now(),
|
lastActivity: Date.now(),
|
||||||
|
running: [],
|
||||||
counts: { tools: 0, failures: 0, denials: 0 },
|
counts: { tools: 0, failures: 0, denials: 0 },
|
||||||
};
|
};
|
||||||
this.sessions.set(id, existing);
|
this.sessions.set(id, existing);
|
||||||
@@ -100,12 +142,81 @@ export class GlanceState {
|
|||||||
|
|
||||||
private push(event: GlanceEvent): void {
|
private push(event: GlanceEvent): void {
|
||||||
this.events.push(event);
|
this.events.push(event);
|
||||||
if (this.events.length > this.cfg.retainEvents) {
|
this.trim();
|
||||||
this.events.splice(0, this.events.length - this.cfg.retainEvents);
|
|
||||||
}
|
|
||||||
appendEventLog(event);
|
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. */
|
/** Record a raw hook payload. Returns the event it produced, if any. */
|
||||||
ingest(payload: HookPayload): GlanceEvent | null {
|
ingest(payload: HookPayload): GlanceEvent | null {
|
||||||
const hookName = payload.hookEventName ?? "";
|
const hookName = payload.hookEventName ?? "";
|
||||||
@@ -131,7 +242,7 @@ export class GlanceState {
|
|||||||
|
|
||||||
case "session_end":
|
case "session_end":
|
||||||
session.state = "ended";
|
session.state = "ended";
|
||||||
session.currentTool = undefined;
|
this.clearRunning(sessionId, session);
|
||||||
title = "Session ended";
|
title = "Session ended";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -142,10 +253,15 @@ export class GlanceState {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "tool_start": {
|
case "tool_start": {
|
||||||
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
const name = tool ?? "tool";
|
||||||
|
const summary = summarizeTool(name, payload.toolInput);
|
||||||
session.state = "working";
|
session.state = "working";
|
||||||
session.currentTool = { name: tool ?? "tool", title: summary.title, startedAt: now };
|
session.running.push({ name, title: summary.title, startedAt: now });
|
||||||
this.toolStarts.set(`${sessionId}|${tool ?? "tool"}`, 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;
|
title = summary.title;
|
||||||
detail = summary.detail;
|
detail = summary.detail;
|
||||||
break;
|
break;
|
||||||
@@ -153,14 +269,16 @@ export class GlanceState {
|
|||||||
|
|
||||||
case "tool_end":
|
case "tool_end":
|
||||||
case "tool_fail": {
|
case "tool_fail": {
|
||||||
const summary = summarizeTool(tool ?? "tool", payload.toolInput);
|
const name = tool ?? "tool";
|
||||||
const key = `${sessionId}|${tool ?? "tool"}`;
|
const summary = summarizeTool(name, payload.toolInput);
|
||||||
const startedAt = this.toolStarts.get(key);
|
const key = `${sessionId}|${name}`;
|
||||||
if (startedAt) {
|
const starts = this.toolStarts.get(key);
|
||||||
durationMs = now - startedAt;
|
if (starts?.length) {
|
||||||
this.toolStarts.delete(key);
|
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";
|
session.state = "working";
|
||||||
title = summary.title;
|
title = summary.title;
|
||||||
detail = summary.detail;
|
detail = summary.detail;
|
||||||
@@ -181,13 +299,13 @@ export class GlanceState {
|
|||||||
|
|
||||||
case "turn_end":
|
case "turn_end":
|
||||||
session.state = "idle";
|
session.state = "idle";
|
||||||
session.currentTool = undefined;
|
this.clearRunning(sessionId, session);
|
||||||
title = "Turn finished";
|
title = "Turn finished";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "turn_error":
|
case "turn_error":
|
||||||
session.state = "error";
|
session.state = "error";
|
||||||
session.currentTool = undefined;
|
this.clearRunning(sessionId, session);
|
||||||
title = "Turn failed";
|
title = "Turn failed";
|
||||||
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
|
detail = truncateDetail(String(payload["error"] ?? payload["message"] ?? "")) || undefined;
|
||||||
break;
|
break;
|
||||||
@@ -224,6 +342,7 @@ export class GlanceState {
|
|||||||
durationMs,
|
durationMs,
|
||||||
};
|
};
|
||||||
this.push(event);
|
this.push(event);
|
||||||
|
this.schedulePersist();
|
||||||
this.notify();
|
this.notify();
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
@@ -252,6 +371,7 @@ export class GlanceState {
|
|||||||
detail: opts.detail,
|
detail: opts.detail,
|
||||||
};
|
};
|
||||||
this.push(event);
|
this.push(event);
|
||||||
|
this.schedulePersist();
|
||||||
this.notify();
|
this.notify();
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
@@ -270,8 +390,11 @@ export class GlanceState {
|
|||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
state: waiting.has(s.id) ? ("waiting" as SessionState) : this.effectiveState(s, now),
|
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 {
|
return {
|
||||||
now,
|
now,
|
||||||
@@ -287,6 +410,28 @@ export class GlanceState {
|
|||||||
return this.sessions.get(sessionId)?.label ?? "workspace";
|
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 {
|
get sessionCount(): number {
|
||||||
return this.sessions.size;
|
return this.sessions.size;
|
||||||
}
|
}
|
||||||
@@ -295,3 +440,34 @@ export class GlanceState {
|
|||||||
return this.events.length;
|
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
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
||||||
import { ensureHome, paths } from "./config.js";
|
import { ensureHome, paths } from "./config.js";
|
||||||
import type { DeviceInfo, GlanceEvent } from "./protocol.js";
|
import type { DeviceInfo, GlanceEvent, SessionView } from "./protocol.js";
|
||||||
|
|
||||||
export interface StoredCredential {
|
export interface StoredCredential {
|
||||||
/** Base64URL credential ID. */
|
/** 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. */
|
/** Read back the tail of the log so a restarted daemon still has recent history. */
|
||||||
export function readRecentEvents(limit: number): GlanceEvent[] {
|
export function readRecentEvents(limit: number): GlanceEvent[] {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+27
-1
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: glance
|
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
|
# 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
|
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.
|
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 daemon is started automatically by the `SessionStart` hook. Everything below is done through
|
||||||
the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`.
|
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
|
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
|
## Getting it onto a phone
|
||||||
|
|
||||||
The dashboard listens on `127.0.0.1` only. Passkeys need a real hostname with valid TLS — a bare
|
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
|
`hooks/hooks.json` exists and is registered — it is generated, so `glance sync-hooks` rebuilds it
|
||||||
from the template.
|
from the template.
|
||||||
- **Page says "run npm install && npm run build"** → the web bundle is missing; build it.
|
- **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
|
## What it deliberately does not do
|
||||||
|
|
||||||
|
|||||||
+44
-6
@@ -10,7 +10,7 @@ import { SessionsCard } from "@/components/SessionsCard";
|
|||||||
import { SettingsPanel } from "@/components/SettingsPanel";
|
import { SettingsPanel } from "@/components/SettingsPanel";
|
||||||
import { Timeline } from "@/components/Timeline";
|
import { Timeline } from "@/components/Timeline";
|
||||||
import { GearIcon } from "@/components/icons";
|
import { GearIcon } from "@/components/icons";
|
||||||
import type { GateInfo } from "@/protocol";
|
import type { GateInfo, SessionState, SessionView } from "@/protocol";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [gate, setGate] = useState<GateInfo | null>(null);
|
const [gate, setGate] = useState<GateInfo | null>(null);
|
||||||
@@ -87,8 +87,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessions = snapshot?.sessions ?? [];
|
const sessions = snapshot?.sessions ?? [];
|
||||||
const focus = sessions.find((s) => s.id === selected) ?? sessions[0];
|
|
||||||
const pending = snapshot?.pending ?? [];
|
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 (
|
return (
|
||||||
<div className="min-h-dvh bg-background text-foreground">
|
<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>
|
<h1 className="truncate text-sm font-semibold tracking-tight">grok-glance</h1>
|
||||||
<p className="text-[11px] text-muted">
|
<p className="text-[11px] text-muted">
|
||||||
{connection === "live"
|
{connection === "live"
|
||||||
? `${sessions.length} session${sessions.length === 1 ? "" : "s"}`
|
? stateSummary(sessions)
|
||||||
: connection === "connecting"
|
: connection === "connecting"
|
||||||
? "connecting…"
|
? "connecting…"
|
||||||
: "offline — retrying"}
|
: "offline — retrying"}
|
||||||
@@ -163,8 +167,14 @@ export default function App() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{focus && <NowCard session={focus} now={now} />}
|
{focus && (
|
||||||
{sessions.length > 1 && (
|
<NowCard
|
||||||
|
session={focus}
|
||||||
|
now={now}
|
||||||
|
onBack={many ? () => setSelected(null) : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{many && (
|
||||||
<SessionsCard
|
<SessionsCard
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
selectedId={selected}
|
selectedId={selected}
|
||||||
@@ -172,7 +182,12 @@ export default function App() {
|
|||||||
now={now}
|
now={now}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Timeline events={snapshot.events} sessionId={selected} />
|
<Timeline
|
||||||
|
events={snapshot.events}
|
||||||
|
sessionId={selected}
|
||||||
|
sessions={sessions}
|
||||||
|
onClearFilter={() => setSelected(null)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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 }) {
|
function Centered({ children, inline }: { children: ReactNode; inline?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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 { StateChip, ToolChip } from "@/components/StatusChip";
|
||||||
import { duration, relTime } from "@/lib/format";
|
import { duration, relTime } from "@/lib/format";
|
||||||
import type { SessionView } from "@/protocol";
|
import type { SessionView } from "@/protocol";
|
||||||
|
|
||||||
export function NowCard({ session, now }: { session: SessionView; now: number }) {
|
export function NowCard({
|
||||||
const tool = session.currentTool;
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<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>
|
<Card.Description className="truncate text-xs">{session.cwd}</Card.Description>
|
||||||
</div>
|
</div>
|
||||||
<StateChip state={session.state} />
|
<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>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
@@ -28,18 +47,24 @@ export function NowCard({ session, now }: { session: SessionView; now: number })
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tool ? (
|
{running.length > 0 ? (
|
||||||
<div className="flex items-start gap-2.5 rounded-xl bg-surface-secondary p-3">
|
/* An agent runs tools in parallel, so this is a list — showing only the newest one
|
||||||
<Spinner size="sm" color="current" className="mt-0.5" />
|
would keep redrawing the same card with a different tool in it. */
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex flex-col gap-2.5 rounded-xl bg-surface-secondary p-3">
|
||||||
<div className="flex items-center gap-1.5">
|
{running.map((tool) => (
|
||||||
<ToolChip tool={tool.name} />
|
<div key={`${tool.name}-${tool.startedAt}`} className="flex items-start gap-2.5">
|
||||||
<span className="text-xs tabular-nums text-muted">
|
<Spinner size="sm" color="current" className="mt-0.5" />
|
||||||
{duration(Math.max(0, now - tool.startedAt))}
|
<div className="min-w-0 flex-1">
|
||||||
</span>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ToolChip tool={tool.name} />
|
||||||
|
<span className="text-xs tabular-nums text-muted">
|
||||||
|
{duration(Math.max(0, now - tool.startedAt))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button, Card } from "@heroui/react";
|
import { Button, Card } from "@heroui/react";
|
||||||
import { BanIcon, CheckIcon } from "@/components/icons";
|
import { BanIcon, CheckIcon } from "@/components/icons";
|
||||||
|
import { SessionBadge } from "@/components/SessionBadge";
|
||||||
import { ToolChip } from "@/components/StatusChip";
|
import { ToolChip } from "@/components/StatusChip";
|
||||||
import { secondsLeft } from "@/lib/format";
|
import { secondsLeft } from "@/lib/format";
|
||||||
import type { PendingApproval } from "@/protocol";
|
import type { PendingApproval } from "@/protocol";
|
||||||
@@ -28,7 +29,14 @@ export function PendingCard({
|
|||||||
</div>
|
</div>
|
||||||
<Card.Description className="flex flex-wrap items-center gap-1.5">
|
<Card.Description className="flex flex-wrap items-center gap-1.5">
|
||||||
<ToolChip tool={approval.tool} />
|
<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.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Card } from "@heroui/react";
|
import { Card } from "@heroui/react";
|
||||||
|
import { SessionBadge } from "@/components/SessionBadge";
|
||||||
import { StateChip } from "@/components/StatusChip";
|
import { StateChip } from "@/components/StatusChip";
|
||||||
import { relTime } from "@/lib/format";
|
import { duration, relTime } from "@/lib/format";
|
||||||
import type { SessionView } from "@/protocol";
|
import type { SessionView } from "@/protocol";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only rendered when more than one session is live. With a single workspace the Now card
|
* Every agent at once: what each one is doing right now, not just the one you last tapped.
|
||||||
* already says everything, and an extra list is just noise on a small screen.
|
* 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({
|
export function SessionsCard({
|
||||||
sessions,
|
sessions,
|
||||||
@@ -19,42 +25,97 @@ export function SessionsCard({
|
|||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
now: number;
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<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">
|
<Card.Description className="text-xs">
|
||||||
Tap one to filter the activity list.
|
Tap one for its detail and its own activity.
|
||||||
</Card.Description>
|
</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
<Card.Content className="px-0">
|
<Card.Content className="px-0">
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
<li className="border-b border-separator">
|
<li className="border-b border-separator">
|
||||||
<Row active={selectedId === null} onPress={() => onSelect(null)}>
|
<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>
|
<span className="text-xs text-muted">{sessions.length}</span>
|
||||||
</Row>
|
</Row>
|
||||||
</li>
|
</li>
|
||||||
{sessions.map((session) => (
|
{rows.map((session) => (
|
||||||
<li key={session.id} className="border-b border-separator last:border-b-0">
|
<li key={session.id} className="border-b border-separator last:border-b-0">
|
||||||
<Row
|
<Row
|
||||||
active={selectedId === session.id}
|
active={selectedId === session.id}
|
||||||
onPress={() => onSelect(session.id === selectedId ? null : session.id)}
|
onPress={() => onSelect(session.id === selectedId ? null : session.id)}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{session.label}</span>
|
<AgentRow session={session} now={now} />
|
||||||
<span className="shrink-0 text-[11px] text-muted">
|
|
||||||
{relTime(session.lastActivity, now)}
|
|
||||||
</span>
|
|
||||||
<StateChip state={session.state} />
|
|
||||||
</Row>
|
</Row>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Card.Content>
|
</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>
|
</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({
|
function Row({
|
||||||
active,
|
active,
|
||||||
onPress,
|
onPress,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, Card } from "@heroui/react";
|
import { Button, Card } from "@heroui/react";
|
||||||
|
import { SessionBadge } from "@/components/SessionBadge";
|
||||||
import { clockTime, duration } from "@/lib/format";
|
import { clockTime, duration } from "@/lib/format";
|
||||||
import type { EventKind, GlanceEvent } from "@/protocol";
|
import type { EventKind, GlanceEvent, SessionView } from "@/protocol";
|
||||||
|
|
||||||
const DOT: Record<EventKind, string> = {
|
const DOT: Record<EventKind, string> = {
|
||||||
session_start: "bg-muted",
|
session_start: "bg-muted",
|
||||||
@@ -39,20 +40,35 @@ function visible(events: GlanceEvent[], sessionId: string | null): GlanceEvent[]
|
|||||||
export function Timeline({
|
export function Timeline({
|
||||||
events,
|
events,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
sessions,
|
||||||
|
onClearFilter,
|
||||||
}: {
|
}: {
|
||||||
events: GlanceEvent[];
|
events: GlanceEvent[];
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
|
sessions: SessionView[];
|
||||||
|
onClearFilter?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [limit, setLimit] = useState(PAGE);
|
const [limit, setLimit] = useState(PAGE);
|
||||||
const rows = visible(events, sessionId);
|
const rows = visible(events, sessionId);
|
||||||
const shown = rows.slice(0, limit);
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title className="text-base">Activity</Card.Title>
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<Card.Description className="text-xs">
|
<Card.Title className="text-base">Activity</Card.Title>
|
||||||
{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.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
@@ -70,7 +86,10 @@ export function Timeline({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<p className="min-w-0 text-sm leading-snug break-words">{event.title}</p>
|
<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)}
|
{clockTime(event.ts)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Wire protocol shared between the daemon and the web app.
|
* 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
|
* NOTE: web/src/protocol.ts is a copy of this file. Keep the two in sync — they are
|
||||||
* rather than shared because the server compiles under NodeNext while the web app compiles
|
* duplicated rather than shared because the server compiles under NodeNext while the web
|
||||||
* under a bundler resolution, and a single rootDir cannot span both.
|
* app compiles under a bundler resolution, and a single rootDir cannot span both.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type EventKind =
|
export type EventKind =
|
||||||
@@ -41,16 +41,29 @@ export interface GlanceEvent {
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RunningTool {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionView {
|
export interface SessionView {
|
||||||
id: string;
|
id: string;
|
||||||
/** Basename of the workspace root — what you actually recognise on a phone. */
|
/** Basename of the workspace root — what you actually recognise on a phone. */
|
||||||
label: string;
|
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;
|
cwd: string;
|
||||||
state: SessionState;
|
state: SessionState;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
lastActivity: number;
|
lastActivity: number;
|
||||||
lastPrompt?: string;
|
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 };
|
counts: { tools: number; failures: number; denials: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +71,8 @@ export interface PendingApproval {
|
|||||||
id: string;
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionLabel: string;
|
sessionLabel: string;
|
||||||
|
/** Matches SessionView.badge, so a card says which agent is asking when two share a label. */
|
||||||
|
sessionBadge: number;
|
||||||
tool: string;
|
tool: string;
|
||||||
title: string;
|
title: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user