Compare commits

...
2 Commits
Author SHA1 Message Date
iceBear67 6d34a17d9d 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.
2026-08-09 05:27:00 +00:00
iceBear67andClaude Opus 5 5eec1940be Authenticate /hook/*, and make every hook a command hook
/hook/record and /hook/approve accepted anything that reached the port. That is
not "loopback only": `tailscale serve` proxies tailnet traffic to 127.0.0.1, so
anyone who could reach the tunnel could forge timeline events and answer
approval prompts. Both endpoints now require a 32-byte secret from
$GLANCE_HOME/hook.secret (0600, created once, never rotated so nothing in
flight is 403'd mid-session), compared in constant time before the body is
read, as an x-glance-hook header or a ?k= parameter. Requests carrying
x-forwarded-* are refused outright: a local hook process never sends them and a
tunnelled caller always does. The check applies to /hook/* only, so the
dashboard is unaffected.

While wiring that up: the 13 passive `type: "http"` hooks could never have
worked. Grok Build's http runner rejects every scheme but https, then resolves
the host and blocks private/link-local/CGNAT addresses (validate_hook_url +
is_blocked_ip), so neither loopback-over-http nor *.ts.net (100.64/10) can be a
hook target - and it sends no header but Content-Type, so such a hook could not
authenticate anyway. They were failing validation silently on every event. All
of them are now command hooks running bin/glance-record.mjs, which costs a Node
start and can present the secret.

hooks.json is generated from hooks/hooks.template.json by scripts/gen-hooks.mjs
(npm run build, glance sync-hooks). It creates the secret, derives the approval
hook's timeout from approval.timeoutMs instead of hand-copying 125, and refuses
to write a hook that cannot fire: bad type, non-positive timeout, non-https
http URL, missing bin/ script, or a leftover placeholder. A template that
embeds the token makes the output 0600 with a warning.

Fail-open is unchanged: a missing, stale or rejected secret degrades to "no
telemetry", and glance-approve.mjs still allows on every error path. glance
status warns when the on-disk secret no longer matches the daemon's.

Validated with the e2e suite (190 checks, including no-token/wrong-token/
same-length-token 403s, ?k= acceptance, x-forwarded-* refusal, and the
recorder's fail-open paths) and a clean npm run build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 04:52:17 +00:00
27 changed files with 1298 additions and 175 deletions
+108 -41
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 │
└────────────────────────────┘
```
@@ -46,7 +51,10 @@ npm install && npm run build
```
The build produces `dist/server` (the daemon) and `dist/web` (the dashboard). Both are required;
the daemon serves the dashboard itself.
the daemon serves the dashboard itself. It also generates `hooks/hooks.json` from
`hooks/hooks.template.json`, and creates `~/.grok/glance/hook.secret` (mode 0600) if it does not
exist yet — the shared secret the hook scripts authenticate with. Neither the secret nor anything
derived from it ends up in `hooks.json`.
Then register the directory with Grok Build. Plugins are installed from a marketplace catalog, so
for a local checkout the shortest path is a one-entry catalog. Create
@@ -116,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:
@@ -144,12 +184,13 @@ Defaults worth knowing:
|---|---|---|---|
| Only wait when a phone is watching | on | yes | Otherwise a closed browser tab stalls the agent for 90s per tool call. |
| On timeout | allow | yes | Flip to *deny* if you would rather fail closed. |
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is 125s; raising this past that would just make the hook give up first. |
| Timeout | 90s | no — edit `config.json` | The hook's own timeout is derived from this (`+35s` of slack) when `hooks.json` is generated, so re-run `sync-hooks` after changing it. |
| Risky-tool pattern | `^(Bash\|Write\|Edit\|MultiEdit\|NotebookEdit)$` | no — edit `config.json` | Shown on the phone but not editable: a typo'd regex would silently change what gets gated. |
**This is a convenience gate, not a security boundary.** Every failure path is fail-open: daemon
down, hook timeout, malformed response, port mismatch — the tool call proceeds. If you need calls
actually blocked, use Grok Build's own permission settings.
down, hook timeout, malformed response, port mismatch, a hook secret the daemon no longer
recognises — the tool call proceeds. If you need calls actually blocked, use Grok Build's own
permission settings.
## CLI
@@ -167,7 +208,7 @@ actually blocked, use Grok Build's own permission settings.
| `devices` | List enrolled devices |
| `revoke <id-prefix>` | Revoke a device |
| `approval <off\|risky\|all>` | Set the approval policy |
| `sync-hooks` | Rewrite hook URLs after changing the port |
| `sync-hooks` | Regenerate `hooks/hooks.json` from the template (after changing `config.json`) |
## Files and configuration
@@ -180,7 +221,9 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i
| `auth-sessions.json` | Live dashboard sessions, stored as SHA-256 hashes of the cookie tokens |
| `secret.key` | 32-byte HMAC key used to sign session cookies |
| `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
@@ -192,8 +235,10 @@ instance without touching your real one:
| `GLANCE_PORT` | Port to listen on (and, for the CLI and hooks, to talk to) |
| `GLANCE_ORIGIN` | Public origin, as if set with `set-origin` — but not persisted |
To change the port, edit `config.json`, then run `node bin/glance sync-hooks` so the hook URLs in
`hooks/hooks.json` match. Restart the daemon afterwards.
The port is not baked into `hooks/hooks.json` the hook scripts read `config.json` themselves — so
changing it needs nothing but a daemon restart. Changing `approval.timeoutMs` does affect the
generated file: run `node bin/glance sync-hooks` afterwards so the approval hook's own timeout still
outlasts the wait.
## Security notes
@@ -202,13 +247,20 @@ its own. Three classes of caller:
| Path | Caller | Authentication |
|---|---|---|
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | none — loopback only |
| `/hook/record`, `/hook/approve` | Grok Build's hooks, from this machine | shared secret from `hook.secret`, plus a refusal of any proxied request |
| `/api/*`, `/events` | the dashboard | passkey session cookie + CSRF header |
| `/local/*` | the `glance` CLI | rotating admin token from `admin.token` |
`/local/*` is token-gated rather than "is it from localhost", because `tailscale serve` proxies
remote traffic to `127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by
address alone.
None of the three trusts the source address, because `tailscale serve` proxies remote traffic to
`127.0.0.1` — the daemon cannot tell a local caller from a tunnelled one by address alone. Without
the hook secret, anyone who could reach the tunnel could forge timeline events and answer approval
prompts on your behalf; `/hook/*` compares the secret in constant time before it reads a body, and
additionally refuses any request carrying `x-forwarded-for` or `x-forwarded-proto`, which a local
hook process never sends and a tunnelled caller always does.
That refusal costs nothing, because no legitimate hook traffic comes through the tunnel: hooks are
local processes talking to loopback. It applies **only** to `/hook/*` — the dashboard arrives
through `tailscale serve` with those headers on every request and is unaffected.
**Session cookie** is `HttpOnly`, `SameSite=Strict`, HMAC-signed, and `Secure` whenever the request
arrived over https. Only a SHA-256 hash of the token is stored, compared in constant time. Sessions
@@ -256,21 +308,36 @@ stop working and must be enrolled again.
## Hook wiring
`hooks/hooks.json` subscribes to all 14 lifecycle events. Passive events use `type: "http"`: they
POST straight into the daemon with no process spawn, so they cost close to nothing per tool call
and quietly do nothing when the daemon is down.
`hooks/hooks.json` subscribes to all 14 lifecycle events, and **is generated** — from
`hooks/hooks.template.json` by `scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on
`node bin/glance sync-hooks`. Edit the template, not the output.
Two exceptions:
Every entry is a `command` hook. That is not a style choice: an `http` hook cannot reach this daemon
by any route. Grok Build's http runner rejects every scheme but `https`, then **resolves the host**
and refuses the resolved address if it is private, link-local or CGNAT
(`xai-grok-hooks/src/runner/http.rs`, `validate_hook_url` + `is_blocked_ip`). Plain http on loopback
fails the scheme check; the tailnet fails the address check, because `*.ts.net` resolves into
`100.64/10` (and `fd7a::/48`, inside the blocked `fc00::/7`). On top of that the runner sends no
request header but `Content-Type`, with no configuration surface for one, so such a hook could not
authenticate itself even if it could connect. The generator refuses to emit an `http` handler whose
URL is not `https://`, because the alternative is what this plugin shipped for a while: 13 passive
hooks that failed validation silently on every event.
A command hook has none of those problems. It is a local process, so no URL is validated, nothing
traverses the tunnel, and it can present the shared secret — hook traffic goes straight to
`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs
`bin/glance-record.mjs`, which costs a Node start (~40 ms) and POSTs one event. Two entries differ:
- `SessionStart` runs `bin/glance-up.mjs`, which is what boots the daemon.
- `PreToolUse` is wired **twice** — an `http` entry that records every call for the timeline, and a
`command` entry matching only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs
`bin/glance-approve.mjs`. PreToolUse is the only blocking event, and a command hook is the only
documented way to return a deny decision, so the gate has to be a spawned process; keeping the
match narrow means the cost is paid only for calls that could actually need a tap.
- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching
only `^(Bash|Write|Edit|MultiEdit|NotebookEdit)$` that runs `bin/glance-approve.mjs`. PreToolUse
is the only blocking event, and a command hook is the only documented way to return a deny
decision; keeping the match narrow means the gate's cost is paid only for calls that could
actually need a tap. Its `timeout` is derived from `approval.timeoutMs` at generation time rather
than hand-copied, which is the other thing `sync-hooks` refreshes.
The hook scripts use nothing but the Node standard library and always exit 0 unless they are
deliberately denying.
deliberately denying — including when the daemon rejects their token.
## Deliberately omitted
+37 -18
View File
@@ -10,7 +10,7 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import {
PLUGIN_ROOT,
baseUrl,
@@ -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,8 +138,14 @@ 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(
"\n ! hook auth mismatch: $GLANCE_HOME/hook.secret no longer matches what the daemon" +
"\n loaded, so events are being dropped. Restart it: glance stop && glance up",
);
}
if (s.devices === 0) console.log("\nNo device enrolled yet. Run: glance enroll");
break;
}
@@ -200,22 +222,19 @@ switch (cmd) {
}
case "sync-hooks": {
const file = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
const doc = JSON.parse(fs.readFileSync(file, "utf8"));
let changed = 0;
for (const groups of Object.values(doc.hooks ?? {})) {
for (const group of groups) {
for (const h of group.hooks ?? []) {
if (h.type === "http" && typeof h.url === "string") {
const next = h.url.replace(/127\.0\.0\.1:\d+/, `127.0.0.1:${cfg.port}`);
if (next !== h.url) changed++;
h.url = next;
}
}
}
// hooks.json is generated, not edited: regenerate it from hooks/hooks.template.json.
// The port is not baked into it any more — the hook scripts read config.json themselves —
// so the thing this actually refreshes is the approval gate's timeout, plus the hook
// secret if it has gone missing.
const { status } = spawnSync(
process.execPath,
[path.join(PLUGIN_ROOT, "scripts", "gen-hooks.mjs")],
{ stdio: "inherit" },
);
if (status !== 0) process.exit(status ?? 1);
if (await isDaemonUp(cfg)) {
console.log("restart the daemon to pick up config changes: glance stop && glance up");
}
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
console.log(`rewrote ${changed} hook url(s) to port ${cfg.port}`);
break;
}
@@ -241,7 +260,7 @@ switch (cmd) {
glance devices list enrolled devices
glance revoke <id-prefix> revoke a device
glance approval <off|risky|all> remote approval policy
glance sync-hooks rewrite hook urls after a port change
glance sync-hooks regenerate hooks/hooks.json from the template
glance logs tail the daemon log
`);
}
+21 -4
View File
@@ -10,7 +10,15 @@
* call Grok was already about to make. This hook can never introduce a new command.
*/
import { baseUrl, envEnvelope, postJson, readConfig, readStdinJson } from "./glance-lib.mjs";
import {
approvalHookTimeoutSecs,
baseUrl,
envEnvelope,
hookHeaders,
postJson,
readConfig,
readStdinJson,
} from "./glance-lib.mjs";
function allow() {
process.exit(0);
@@ -28,11 +36,20 @@ function deny(reason) {
const payload = envEnvelope(await readStdinJson());
const cfg = readConfig();
// Stay inside the hook timeout declared in hooks/hooks.json (125s).
const waitMs = Math.min(115_000, Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000);
// Finish inside the hook timeout that scripts/gen-hooks.mjs wrote into hooks.json, with room
// to spare: if Grok Build kills us first, the fail-open path below never gets to run.
const waitMs = Math.min(
approvalHookTimeoutSecs(cfg) * 1000 - 10_000,
Number(cfg.approval?.timeoutMs ?? 90_000) + 15_000,
);
try {
const { data } = await postJson(`${baseUrl(cfg)}/hook/approve`, payload, waitMs);
const { data } = await postJson(
`${baseUrl(cfg)}/hook/approve`,
payload,
waitMs,
hookHeaders(),
);
if (data && data.decision === "deny") deny(data.reason);
allow();
} catch {
+59 -2
View File
@@ -8,6 +8,7 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
@@ -43,6 +44,62 @@ export function baseUrl(cfg = readConfig()) {
return `http://127.0.0.1:${cfg.port}`;
}
/** Header the daemon expects on /hook/*; kept in step with server/src/auth.ts. */
export const HOOK_HEADER = "x-glance-hook";
/**
* The shared secret that admits a caller to /hook/*. Read fresh on every invocation so a
* regenerated secret is picked up without touching hooks.json.
*
* `create` is used by the build-time generator; the hook scripts pass false and simply get
* null when there is no secret yet. That is deliberate: a hook must never be the thing that
* creates state, and a missing secret has to degrade to "no telemetry", not "no tool call".
*/
export function hookSecret({ create = false } = {}) {
const file = path.join(glanceHome(), "hook.secret");
for (let attempt = 0; attempt < 2; attempt++) {
try {
const existing = fs.readFileSync(file, "utf8").trim();
if (existing) return existing;
} catch {
/* fall through */
}
if (!create) return null;
fs.mkdirSync(glanceHome(), { recursive: true, mode: 0o700 });
const token = crypto.randomBytes(32).toString("base64url");
try {
// Exclusive: if the daemon created one a millisecond ago, read theirs instead.
fs.writeFileSync(file, token + "\n", { mode: 0o600, flag: "wx" });
return token;
} catch {
/* lost the race; loop re-reads */
}
}
try {
return fs.readFileSync(file, "utf8").trim() || null;
} catch {
return null;
}
}
export function hookHeaders() {
const token = hookSecret();
return token ? { [HOOK_HEADER]: token } : {};
}
/**
* How long the PreToolUse approval hook is allowed to run, in seconds.
*
* One formula, two consumers: scripts/gen-hooks.mjs writes it into hooks.json as the hook's
* `timeout`, and glance-approve.mjs derives its own wait from it. They must agree — if the
* script outlives its hook timeout, Grok Build kills it and the fail-open path never runs.
*/
export function approvalHookTimeoutSecs(cfg = readConfig()) {
const ms = Number(cfg.approval?.timeoutMs ?? 90_000);
const base = Number.isFinite(ms) && ms > 0 ? ms : 90_000;
return Math.ceil(base / 1000) + 35;
}
/** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */
export async function readStdinJson() {
if (process.stdin.isTTY) return {};
@@ -77,10 +134,10 @@ export function envEnvelope(payload) {
};
}
export async function postJson(url, body, timeoutMs) {
export async function postJson(url, body, timeoutMs, extraHeaders = {}) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", ...extraHeaders },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* Passive recorder hook: POST one lifecycle event into the daemon and get out of the way.
*
* Wired to every observed event. This used to be a `type: "http"` hook — no process spawn at
* all — until it turned out that Grok Build's http runner validates the URL against its SSRF
* rules and rejects any scheme that is not https (crates/codegen/xai-grok-hooks/src/runner/
* http.rs, `validate_hook_url`). The daemon speaks plain http on loopback, so an http hook
* could never reach it: those hooks were failing validation, silently, on every event.
*
* A command hook costs a Node start (~40ms) per event, and buys back the ability to send an
* authentication header, which the http runner has no config surface for.
*
* Always exits 0. A dashboard must never be the reason a tool call fails.
*/
import {
baseUrl,
envEnvelope,
hookHeaders,
postJson,
readConfig,
readStdinJson,
} from "./glance-lib.mjs";
try {
const payload = envEnvelope(await readStdinJson());
const cfg = readConfig();
// Short: if the daemon is not listening this must fail fast, not hold up the tool call.
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2000, hookHeaders());
} catch {
// Daemon down, no secret yet, malformed payload — all the same answer: carry on.
}
process.exit(0);
+4 -1
View File
@@ -14,6 +14,7 @@ import {
baseUrl,
envEnvelope,
glanceHome,
hookHeaders,
isDaemonUp,
postJson,
readConfig,
@@ -59,7 +60,9 @@ const cfg = readConfig();
try {
const up = await ensureDaemon(cfg);
if (up) {
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500);
// hookHeaders() is read here, not at import time: on a first-ever run the daemon we just
// spawned is what created hook.secret.
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2500, hookHeaders());
}
} catch {
// Fail open, always.
+73 -24
View File
@@ -1,14 +1,39 @@
{
"_comment": [
"grok-glance hook wiring.",
"Passive events use type=http: they POST straight into the daemon with no process spawn,",
"so they cost ~nothing per tool call and fail open if the daemon is not running.",
"SessionStart uses type=command because it is what boots the daemon.",
"PreToolUse is wired twice on purpose: an http entry records every tool call for the",
"timeline, and a command entry gates only risky tools, because only a command hook has a",
"documented way to return a deny decision.",
"If you change the port in ~/.grok/glance/config.json, run `glance sync-hooks` to rewrite",
"the URLs below, or edit them by hand."
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by",
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
"",
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
"then resolves the host and refuses the resolved address if it is private, link-local or",
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
"into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public",
"https origin therefore fails upstream, before a request is ever sent. The runner also sends",
"no request header but Content-Type, so such a hook could not authenticate itself even if it",
"could connect.",
"",
"A command hook has none of those problems: it is a local process, so there is no URL to",
"validate, no proxy in the path, and it can present the shared secret. It costs one Node",
"start (~40ms) per event.",
"",
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records",
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,",
"because PreToolUse is the only blocking event and only a command hook can return a deny.",
"",
"Placeholders, written in the template as a name wrapped in double braces:",
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.",
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that",
" cannot set a header: the daemon accepts it as a ?k= query",
" parameter too. Nothing below uses it and no http hook can (see",
" above); it stays because the daemon's ?k= path is real. Note the",
" daemon also refuses any /hook/* request carrying x-forwarded-*,",
" so a reverse-proxied transport is out as well. Using this",
" placeholder makes the generated hooks.json secret-bearing, so",
" gen-hooks writes it 0600 and it must not be committed.",
"",
"Do not spell those names with their braces anywhere in this comment block: the comment is",
"copied verbatim into hooks.json, and substitution would happily expand it there too."
],
"hooks": {
"SessionStart": [
@@ -26,9 +51,9 @@
{
"hooks": [
{
"type": "http",
"url": "http://127.0.0.1:8791/hook/record",
"timeout": 3
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
"timeout": 5
}
]
},
@@ -45,62 +70,86 @@
],
"PostToolUse": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PostToolUseFailure": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"UserPromptSubmit": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PermissionDenied": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"Notification": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"Stop": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"StopFailure": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SubagentStart": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SubagentStop": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PreCompact": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PostCompact": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SessionEnd": [
{
"hooks": [{ "type": "http", "url": "http://127.0.0.1:8791/hook/record", "timeout": 3 }]
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
]
}
+156
View File
@@ -0,0 +1,156 @@
{
"_comment": [
"TEMPLATE. Do not edit hooks/hooks.json by hand - it is generated from this file by",
"`scripts/gen-hooks.mjs`, which runs as part of `npm run build` and on `glance sync-hooks`.",
"",
"Everything is a `command` hook, including the 13 passive recorders. That is not a style",
"choice: an `http` hook cannot reach this daemon by any route. Grok Build's http runner",
"(xai-grok-hooks/src/runner/http.rs, `validate_hook_url`) rejects every scheme but https,",
"then resolves the host and refuses the resolved address if it is private, link-local or",
"CGNAT - so plain http on loopback is out, and so is the tailnet, because *.ts.net resolves",
"into 100.64/10 (and fd7a::/48, inside the blocked fc00::/7). Pointing a hook at the public",
"https origin therefore fails upstream, before a request is ever sent. The runner also sends",
"no request header but Content-Type, so such a hook could not authenticate itself even if it",
"could connect.",
"",
"A command hook has none of those problems: it is a local process, so there is no URL to",
"validate, no proxy in the path, and it can present the shared secret. It costs one Node",
"start (~40ms) per event.",
"",
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records",
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,",
"because PreToolUse is the only blocking event and only a command hook can return a deny.",
"",
"Placeholders, written in the template as a name wrapped in double braces:",
" APPROVAL_TIMEOUT_SECS derived from approval.timeoutMs in ~/.grok/glance/config.json.",
" HOOK_TOKEN the secret from ~/.grok/glance/hook.secret, for a caller that",
" cannot set a header: the daemon accepts it as a ?k= query",
" parameter too. Nothing below uses it and no http hook can (see",
" above); it stays because the daemon's ?k= path is real. Note the",
" daemon also refuses any /hook/* request carrying x-forwarded-*,",
" so a reverse-proxied transport is out as well. Using this",
" placeholder makes the generated hooks.json secret-bearing, so",
" gen-hooks writes it 0600 and it must not be committed.",
"",
"Do not spell those names with their braces anywhere in this comment block: the comment is",
"copied verbatim into hooks.json, and substitution would happily expand it there too."
],
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-up.mjs\"",
"timeout": 20
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"",
"timeout": 5
}
]
},
{
"matcher": "^(Bash|Write|Edit|MultiEdit|NotebookEdit)$",
"hooks": [
{
"type": "command",
"command": "node \"$GROK_PLUGIN_ROOT/bin/glance-approve.mjs\"",
"timeout": "{{APPROVAL_TIMEOUT_SECS}}"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PostToolUseFailure": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PermissionDenied": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"Notification": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"StopFailure": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SubagentStart": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PreCompact": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"PostCompact": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
],
"SessionEnd": [
{
"hooks": [
{ "type": "command", "command": "node \"$GROK_PLUGIN_ROOT/bin/glance-record.mjs\"", "timeout": 5 }
]
}
]
}
}
+2 -1
View File
@@ -8,9 +8,10 @@
"node": ">=20"
},
"scripts": {
"build": "npm run build:server && npm run build:web",
"build": "npm run build:server && npm run build:web && npm run build:hooks",
"build:server": "tsc -p tsconfig.server.json",
"build:web": "tsc -p tsconfig.web.json && vite build",
"build:hooks": "node scripts/gen-hooks.mjs",
"dev": "vite",
"start": "node dist/server/index.js",
"glance": "node bin/glance"
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Generate hooks/hooks.json from hooks/hooks.template.json.
*
* Runs as part of `npm run build`, and again on `glance sync-hooks`. Two jobs:
*
* 1. Make sure the /hook/* shared secret exists (mode 0600, in $GLANCE_HOME). The daemon
* requires it; without it the hook scripts are just anonymous POSTs, which is what this
* whole mechanism exists to stop.
* 2. Substitute the placeholders the template declares, so values that are really derived
* from config.json - the approval hook's timeout above all - stop being hand-copied
* constants that drift.
*
* It also refuses to emit a hook that cannot work. An `http` handler pointed at a non-https
* URL is the specific mistake that made every passive hook in this plugin a no-op for a
* while: Grok Build's http runner puts every URL through SSRF validation and rejects any
* other scheme outright (xai-grok-hooks/src/runner/http.rs, `validate_hook_url`).
*/
import fs from "node:fs";
import path from "node:path";
import { PLUGIN_ROOT, approvalHookTimeoutSecs, hookSecret, readConfig } from "../bin/glance-lib.mjs";
const TEMPLATE = path.join(PLUGIN_ROOT, "hooks", "hooks.template.json");
const OUTPUT = path.join(PLUGIN_ROOT, "hooks", "hooks.json");
const cfg = readConfig();
const token = hookSecret({ create: true });
if (!token) {
console.error(`gen-hooks: could not create the hook secret in ${process.env.GLANCE_HOME ?? "~/.grok/glance"}`);
process.exit(1);
}
const timeoutSecs = approvalHookTimeoutSecs(cfg);
let text = fs.readFileSync(TEMPLATE, "utf8");
// The quoted form first, so a JSON-valid template can carry a value that must end up numeric.
text = text
.split(`"{{APPROVAL_TIMEOUT_SECS}}"`)
.join(String(timeoutSecs))
.split("{{APPROVAL_TIMEOUT_SECS}}")
.join(String(timeoutSecs));
const embedsToken = text.includes("{{HOOK_TOKEN}}");
text = text.split("{{HOOK_TOKEN}}").join(token);
// Trust the emitted bytes, not the placeholder: a template that mentions the placeholder in a
// comment would otherwise ship the real secret in a world-readable file. (It did once.)
const tokenIsInOutput = text.includes(token);
if (tokenIsInOutput && !embedsToken) {
console.error("gen-hooks: the hook secret leaked into hooks.json from somewhere unexpected");
process.exit(1);
}
const leftover = text.match(/\{\{[A-Z_]+\}\}/);
if (leftover) {
console.error(`gen-hooks: unknown placeholder ${leftover[0]} in hooks.template.json`);
process.exit(1);
}
/* ------------------------------------------------------------------ validate */
let doc;
try {
doc = JSON.parse(text);
} catch (err) {
console.error(`gen-hooks: template did not produce valid JSON: ${err.message}`);
process.exit(1);
}
const problems = [];
let handlers = 0;
for (const [event, groups] of Object.entries(doc.hooks ?? {})) {
if (!Array.isArray(groups)) {
problems.push(`${event}: expected an array of matcher groups`);
continue;
}
for (const group of groups) {
for (const h of group.hooks ?? []) {
handlers++;
const where = `${event} -> ${h.command ?? h.url ?? "(no target)"}`;
if (h.type !== "command" && h.type !== "http") {
problems.push(`${where}: type must be "command" or "http", got ${JSON.stringify(h.type)}`);
continue;
}
if (typeof h.timeout !== "number" || !Number.isFinite(h.timeout) || h.timeout <= 0) {
problems.push(`${where}: timeout must be a positive number of seconds`);
}
if (h.type === "http") {
// The runner rejects every scheme but https, and treats RFC1918 / CGNAT / link-local
// targets as SSRF. That rules out both loopback-over-http and Tailscale's 100.64/10.
if (!/^https:\/\//.test(h.url ?? "")) {
problems.push(
`${where}: http handlers must use an https:// URL - Grok Build's SSRF check ` +
`rejects anything else, so this hook would never fire`,
);
}
} else {
const script = /bin\/([A-Za-z0-9._-]+)/.exec(h.command ?? "");
if (!script) {
problems.push(`${where}: could not tell which script this command runs`);
} else if (!fs.existsSync(path.join(PLUGIN_ROOT, "bin", script[1]))) {
problems.push(`${where}: bin/${script[1]} does not exist`);
}
}
}
}
}
if (problems.length) {
console.error("gen-hooks: refusing to write hooks.json\n");
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}
/* --------------------------------------------------------------------- write */
const mode = tokenIsInOutput ? 0o600 : 0o644;
const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : null;
if (previous === text) {
// Leave the mtime alone: a no-op build should not look like a change.
fs.chmodSync(OUTPUT, mode);
console.log(`hooks.json already current (${handlers} handlers, approval timeout ${timeoutSecs}s)`);
} else {
fs.writeFileSync(OUTPUT, text, { mode });
fs.chmodSync(OUTPUT, mode);
console.log(
`wrote hooks/hooks.json - ${handlers} handlers, approval timeout ${timeoutSecs}s` +
(previous === null ? " (new file)" : ""),
);
}
if (tokenIsInOutput) {
console.warn(
"gen-hooks: hooks.json now contains the hook secret (via the HOOK_TOKEN placeholder); it is " +
"mode 0600 and must not be committed.",
);
}
+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,
+4
View File
@@ -5,6 +5,10 @@ import type { IncomingMessage } from "node:http";
export const SESSION_COOKIE = "glance_session";
export const CSRF_HEADER = "x-glance-csrf";
/** Shared-secret header presented by the hook scripts on /hook/*. */
export const HOOK_HEADER = "x-glance-hook";
/** Query-string carrier for the same secret, for hooks that cannot set headers. */
export const HOOK_QUERY_PARAM = "k";
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
+6
View File
@@ -43,9 +43,15 @@ export const paths = {
get adminToken() {
return path.join(glanceHome(), "admin.token");
},
get hookSecret() {
return path.join(glanceHome(), "hook.secret");
},
get events() {
return path.join(glanceHome(), "events.jsonl");
},
get sessions() {
return path.join(glanceHome(), "sessions.json");
},
};
const DEFAULTS: Config = {
+47 -6
View File
@@ -3,10 +3,13 @@
*
* One small http server with three kinds of caller:
*
* /hook/* the plugin's hook scripts, on loopback. /hook/approve is the blocking one.
* /hook/* the plugin's hook scripts, gated by the shared secret in hook.secret.
* /api/* the web app, gated by a passkey-backed cookie session.
* /local/* the `glance` CLI, gated by a rotating admin token on disk.
*
* None of the three trusts the source address: `tailscale serve` proxies tailnet traffic to
* 127.0.0.1, so every caller looks local.
*
* Everything the hooks touch is written to fail open: if this process is confused, wedged, or
* gone, Grok Build keeps working.
*/
@@ -28,6 +31,8 @@ import {
import {
CSRF_HEADER,
EnrollmentCodes,
HOOK_HEADER,
HOOK_QUERY_PARAM,
RateLimiter,
SESSION_COOKIE,
buildSessionCookie,
@@ -47,7 +52,9 @@ import { SESSION_TTL_MS, WebAuthnService } from "./webauthn.js";
import {
destroyAuthSession,
deviceList,
hookSecret,
lookupAuthSession,
readHookSecretFromDisk,
revokeCredentials,
rotateAdminToken,
sessionSecret,
@@ -60,6 +67,7 @@ ensureHome();
const cfg = loadConfig();
const secret = sessionSecret();
const adminToken = rotateAdminToken();
const hookToken = hookSecret();
const webauthn = new WebAuthnService(cfg);
const codes = new EnrollmentCodes();
@@ -93,14 +101,36 @@ function currentSession(req: http.IncomingMessage): Session | null {
return { token, credentialId: record.credentialId, label: record.label };
}
function isAdmin(req: http.IncomingMessage): boolean {
const provided = header(req, "x-glance-admin");
function sameSecret(provided: string | undefined | null, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(adminToken);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function isAdmin(req: http.IncomingMessage): boolean {
return sameSecret(header(req, "x-glance-admin"), adminToken);
}
/**
* Is this really one of our hook scripts?
*
* "It came from 127.0.0.1" proves nothing: `tailscale serve` proxies tailnet traffic to
* loopback, so without a check anyone on the tailnet could POST forged events into the
* timeline and answer /hook/approve on your behalf. Two independent barriers:
*
* 1. A shared secret from $GLANCE_HOME/hook.secret (mode 0600), presented as a header or,
* for hook types that cannot set one, as `?k=`. Compared in constant time.
* 2. The request must not have been proxied. Tailscale stamps `x-forwarded-*` on anything
* it tunnels, so their presence means the caller is not a local process — which no
* real hook ever is. This keeps a leaked secret from being usable off-box.
*/
function isHookCaller(req: http.IncomingMessage, url: URL): boolean {
if (header(req, "x-forwarded-for") || header(req, "x-forwarded-proto")) return false;
const provided = header(req, HOOK_HEADER) ?? url.searchParams.get(HOOK_QUERY_PARAM);
return sameSecret(provided, hookToken);
}
/**
* `application/json` is not a CORS-safelisted content type, so requiring it exactly means a
* hostile page cannot post here without a preflight we never answer. The custom header on
@@ -154,6 +184,11 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
out.json(405, { error: "post json" });
return;
}
// Before reading a body: an unauthenticated caller gets to spend nothing here.
if (!isHookCaller(req, url)) {
out.json(403, { error: "hook token required" });
return;
}
const payload = ((await readJson<HookPayload>(req)) ?? {}) as HookPayload;
if (p === "/hook/record") {
@@ -163,8 +198,8 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Prom
}
if (p === "/hook/approve") {
// Note: this deliberately does not ingest an event. The PreToolUse http hook already
// recorded the tool call; recording it here too would double every entry.
// Note: this deliberately does not ingest an event. The PreToolUse recording hook
// already logged the tool call; recording it here too would double every entry.
const decision = await broker.request(payload);
out.json(200, decision);
return;
@@ -191,9 +226,13 @@ 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,
// Would a hook script authenticate right now? The daemon holds the token it read at
// startup; if the file has since changed or gone, recording is silently dropping.
hookAuthOk: sameSecret(readHookSecretFromDisk(), hookToken),
});
return;
}
@@ -504,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,
},
};
}
+62 -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. */
@@ -69,6 +69,47 @@ export function rotateAdminToken(): string {
return token;
}
/**
* Token the hook scripts present on /hook/*. Unlike `admin.token` this is *not* rotated on
* every start: hook scripts are separate short-lived processes that read the file per
* invocation, and a rotation mid-session would 403 whatever was already in flight.
*
* It exists because `tailscale serve` proxies tailnet traffic to 127.0.0.1, so "the request
* came from loopback" says nothing about who sent it. Without this, anyone on the tailnet
* could forge timeline events and answer approval prompts.
*
* Created exclusively (`wx`) so two hooks racing on a fresh home cannot end up with
* different values — the loser re-reads the winner's file.
*/
export function hookSecret(): string {
ensureHome();
for (let attempt = 0; attempt < 2; attempt++) {
try {
const existing = fs.readFileSync(paths.hookSecret, "utf8").trim();
if (existing) return existing;
} catch {
/* create below */
}
const token = crypto.randomBytes(32).toString("base64url");
try {
fs.writeFileSync(paths.hookSecret, token + "\n", { mode: 0o600, flag: "wx" });
return token;
} catch {
// Lost the race (or the file appeared between the read and the write): read it back.
}
}
return fs.readFileSync(paths.hookSecret, "utf8").trim();
}
/** Whatever is on disk right now, for diagnostics. Never creates the file. */
export function readHookSecretFromDisk(): string | null {
try {
return fs.readFileSync(paths.hookSecret, "utf8").trim() || null;
} catch {
return null;
}
}
/* -------------------------------------------------------------- credentials */
export function listCredentials(): StoredCredential[] {
@@ -191,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 {
+35 -6
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`.
@@ -34,9 +37,29 @@ glance set-origin <https-url> # set the public origin and WebAuthn RP ID
glance devices # list enrolled devices
glance revoke <id-prefix> # revoke one
glance approval <off|risky|all> # remote approve/deny policy
glance sync-hooks # rewrite hook URLs after changing the port
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
@@ -63,8 +86,8 @@ and wait for a tap on the phone. Defaults that matter:
- Nothing waits unless a phone is actually watching the dashboard (`requireWatcher`).
- If nobody answers within 90s the call is **allowed**, not denied. Flip that on the phone's
settings panel if you want the opposite.
- Every failure path is fail-open: daemon down, timeout, bad JSON — the tool call proceeds. This
is a convenience gate, not a security boundary.
- Every failure path is fail-open: daemon down, timeout, bad JSON, a rejected hook secret — the tool
call proceeds. This is a convenience gate, not a security boundary.
`glance approval off` (the default) means Grok Build never blocks on the phone.
@@ -73,9 +96,15 @@ and wait for a tap on the phone. Defaults that matter:
- **"not running"** → `glance up`, then `glance logs`.
- **Passkey prompt fails with a security error** → the phone is on a hostname the RP ID does not
cover. Compare `glance status`'s `rp id` with the hostname in the phone's address bar.
- **Dashboard loads but shows nothing** → hooks are not firing. Check that the port in
`hooks/hooks.json` matches `~/.grok/glance/config.json`; `glance sync-hooks` fixes it.
- **Dashboard loads but shows nothing** → hooks are not firing. `glance status` warns if the hook
secret in `$GLANCE_HOME/hook.secret` no longer matches the one the daemon loaded (events are being
dropped with a 403); `glance stop && glance up` fixes that. Otherwise check that
`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
+41 -16
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>
<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>
</Card.Header>
@@ -28,18 +47,24 @@ 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">
<Spinner size="sm" color="current" className="mt-0.5" />
<div className="min-w-0 flex-1">
<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>
{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">
<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>
<p className="mt-1 text-sm leading-snug break-words">{tool.title}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-muted">
+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,
+24 -5
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>
<Card.Title className="text-base">Activity</Card.Title>
<Card.Description className="text-xs">
{rows.length === 0 ? "Nothing yet." : `${rows.length} events`}
<div className="flex items-baseline justify-between gap-2">
<Card.Title className="text-base">Activity</Card.Title>
{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;