A plugin's SessionStart hook never runs, so nothing was starting the
daemon: no daemon.log, nothing on :8791, and a manual `glance up`
working perfectly.
Grok Build dispatches SessionStart from inside session creation
(xai-grok-shell, agent_ops.rs -> DispatchSessionStartHook) and resolves
it against the session's hook registry as it stands at that moment.
That registry comes from discover_hooks(), whose sources are the config
layers and the global/project settings files; plugin directories are not
among them. Plugin hooks are appended later, under a plugin/ prefix, by
reload_hooks_impl and reload_plugins_impl - which run in response to a
plugin action, a /hooks reload, or a folder-trust grant. So the entry is
always registered after the event it subscribes to has been dispatched.
The other thirteen events work because they happen later in the session.
There is no boot event to move to, so every recorder boots the daemon
instead and whichever fires first wins. The cost is one loopback request
to /healthz per event once it is up, which is the steady state. A
daemon.lock (O_EXCL, 15s staleness takeover) keeps a burst of concurrent
events from starting five daemons and leaving four to die on EADDRINUSE.
glance-up.mjs stays wired: it costs nothing when it does not fire, and
it is the right hook for the job if that ordering is ever fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Grok Build loads plugins out of ~/.grok/plugins/ and clones marketplace sources
straight from git; it never runs npm install or a build for you. So a plugin that
ships TypeScript is a plugin you have to build by hand before it does anything,
and the SessionStart hook's first act is to print "not built yet".
dist/ is now committed, and the tree is arranged so that a bare clone can run:
- The daemon is bundled to a single ESM file with rolldown, platform node. Its one
runtime dependency (@simplewebauthn/server, plus the asn1/cbor tree under it) is
inlined; the only imports left in the output are node: builtins. Not minified —
a committed blob nobody can read is worse than no committed blob.
- tsc no longer emits for the server, it only typechecks (noEmit). rolldown emits.
- dist/web was already a self-contained static bundle.
- The hook scripts under bin/ were stdlib-only from the start.
.grok-plugin/marketplace.json makes the repo its own one-entry catalog with a local
source of "./", so `grok plugin marketplace add <git-url>` followed by
`grok plugin install grok-glance` works without pinning a SHA of itself.
`npm run check:dist` rebuilds and fails if the committed output is stale — the one
real hazard of checking in build output.
Also drops the daemon's "non-default port, run `glance sync-hooks`" startup note,
which the previous commit should have taken with the rest of that scheme; the hook
scripts read config.json themselves, so a non-default port needs nothing.
The e2e suite now takes GLANCE_ROOT and was run twice: once against the repo, once
against a copy containing only tracked files plus dist/ and no node_modules — which
is what actually demonstrates the claim, passkey registration and assertion
included. 233 checks, both runs green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hooks/hooks.json was generated from a template by scripts/gen-hooks.mjs. Once every
hook became a command hook that reads hook.secret from $GLANCE_HOME itself, the
template had exactly two placeholders left: {{HOOK_TOKEN}}, which nothing had ever
substituted into anything, and {{APPROVAL_TIMEOUT_SECS}}. Generating a whole file to
compute one number is not a good trade, so the number is now fixed at 125s in the
committed hooks.json and the coupling is enforced in code instead: the daemon clamps
approval.timeoutMs to APPROVAL_MAX_WAIT_MS (90s), which keeps the script inside its
own hook timeout no matter what a hand-edited config.json says. Losing that clamp is
what would actually hurt — a killed script never runs its fail-open path.
Also removed:
- `glance sync-hooks`, `npm run build:hooks`, and hookSecret({create}). The daemon is
the only thing that should ever mint the secret.
- The ?k= query-string carrier for the hook secret. It existed for hooks that cannot
set headers; there are none, and a secret in a URL lands in logs and shell history.
- Snapshot.now and SessionView.startedAt, which were written on every snapshot and
every persist and read by nobody.
- An unused crypto import.
Docs and the e2e suite follow. The suite's ~12 sync-hooks assertions become static
checks on the committed file, plus new ones that hooks.json, APPROVAL_HOOK_TIMEOUT_SECS
and APPROVAL_MAX_WAIT_MS still agree, and that ?k= is refused. 220 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
/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>