Start the daemon from whichever hook fires first

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>
This commit is contained in:
iceBear67
2026-08-09 08:04:03 +00:00
co-authored by Claude Opus 5
parent b586323fdb
commit 966133eeda
7 changed files with 201 additions and 70 deletions
+30 -3
View File
@@ -53,8 +53,9 @@ git clone <this repo> ~/.grok/plugins/grok-glance
``` ```
That is the whole thing — no `npm install`, no build. Open `/plugins` in Grok Build and enable That is the whole thing — no `npm install`, no build. Open `/plugins` in Grok Build and enable
**grok-glance**. On the next session start its `SessionStart` hook boots the daemon in the **grok-glance**. The first hook to fire after that — your next prompt, or the first tool call —
background, and the dashboard is on `http://127.0.0.1:8791`. starts the daemon in the background, and the dashboard is on `http://127.0.0.1:8791`. (Not the
`SessionStart` hook, which for a plugin never runs; see [Hook wiring](#hook-wiring).)
### …or from a marketplace, by URL ### …or from a marketplace, by URL
@@ -244,6 +245,7 @@ Everything lives in `~/.grok/glance` (mode 0700), or `$GLANCE_HOME` if you set i
| `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB | | `events.jsonl` | Append-only event log, one JSON object per line, rotated at 5 MB |
| `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. | | `sessions.json` | The agent roster — label, badge, workspace, state, counts — so a restart comes back with the overview intact. Written debounced, flushed on shutdown; sessions older than 12 hours are dropped on load. |
| `daemon.log` | Daemon stdout/stderr | | `daemon.log` | Daemon stdout/stderr |
| `daemon.lock` | Held while a hook script is starting the daemon, so a burst of events starts one and not five. Created with `O_EXCL`, deleted on the way out, and ignored by anyone else once 15s stale. |
Three environment variables override `config.json`, which is mostly useful for testing a second Three environment variables override `config.json`, which is mostly useful for testing a second
instance without touching your real one: instance without touching your real one:
@@ -343,7 +345,8 @@ traverses the tunnel, and it can present the shared secret — hook traffic goes
`http://127.0.0.1:8791` and never leaves the machine. So each observed event runs `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: `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. - `SessionStart` runs `bin/glance-up.mjs`. It is the obvious hook to boot the daemon from, and it
never runs — see below.
- `PreToolUse` is wired **twice** — a recording entry for the timeline, and a second entry matching - `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 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 is the only blocking event, and a command hook is the only documented way to return a deny
@@ -354,6 +357,30 @@ traverses the tunnel, and it can present the shared secret — hook traffic goes
The hook scripts use nothing but the Node standard library and always exit 0 unless they are The hook scripts use nothing but the Node standard library and always exit 0 unless they are
deliberately denying — including when the daemon rejects their token. deliberately denying — including when the daemon rejects their token.
### Why every recorder boots the daemon
A plugin gets no usable boot event, so `bin/glance-record.mjs` starts the daemon itself when it
finds it missing, and whichever event fires first wins.
`SessionStart` looks like the right answer and cannot work. Grok Build dispatches it from inside
session creation (`xai-grok-shell`, `agent_ops.rs``SessionCommand::DispatchSessionStartHook`),
and the dispatch resolves 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 — `~/.grok/settings.json`, `<git_root>/.grok/hooks`, the vendor-compat paths. Plugin
directories are not among them. Plugin hooks are appended separately, under a `plugin/` prefix, by
`reload_hooks_impl` and `reload_plugins_impl` — both of which run later, in response to a plugin
action, a `/hooks reload`, or a folder-trust grant. So a plugin's `SessionStart` entry is always
registered after `SessionStart` has already been dispatched, and is never called. Every other event
this plugin subscribes to happens later in the session, once the plugin registry has landed, which
is why they all work.
The symptom, if you hit this from the other end: no `daemon.log` at all, nothing on `:8791`, and a
manual `glance up` working perfectly.
Asking costs one loopback request to `/healthz` per event, which is the steady state once the
daemon is up. The spawn path is taken once. A `daemon.lock` (`O_EXCL`, 15s staleness) keeps a burst
of concurrent events from starting five daemons and leaving four of them to die on `EADDRINUSE`.
## Deliberately omitted ## Deliberately omitted
Not oversights — decisions: Not oversights — decisions:
+5 -17
View File
@@ -10,17 +10,16 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn } from "node:child_process";
import { import {
PLUGIN_ROOT, PLUGIN_ROOT,
SERVER_ENTRY,
baseUrl, baseUrl,
ensureDaemon,
glanceHome, glanceHome,
isDaemonUp, isDaemonUp,
readConfig, readConfig,
sleep,
} from "./glance-lib.mjs"; } from "./glance-lib.mjs";
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
const cfg = readConfig(); const cfg = readConfig();
const cmd = process.argv[2] ?? "status"; const cmd = process.argv[2] ?? "status";
const args = process.argv.slice(3); const args = process.argv.slice(3);
@@ -87,21 +86,10 @@ function requireBuild() {
} }
async function ensureUp() { async function ensureUp() {
if (await isDaemonUp(cfg)) return true;
requireBuild(); requireBuild();
const home = glanceHome(); // No hook timeout to fit inside here, so wait long enough that a slow cold start still counts.
fs.mkdirSync(home, { recursive: true }); if (await ensureDaemon(cfg, { waitMs: 8000, startedBy: "cli" })) return true;
const logFd = fs.openSync(path.join(home, "daemon.log"), "a"); console.error(`daemon did not come up; see ${path.join(glanceHome(), "daemon.log")}`);
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
});
child.unref();
for (let i = 0; i < 40; i++) {
await sleep(200);
if (await isDaemonUp(cfg, 300)) return true;
}
console.error(`daemon did not come up; see ${path.join(home, "daemon.log")}`);
return false; return false;
} }
+107
View File
@@ -8,10 +8,13 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../.."); export const PLUGIN_ROOT = path.resolve(fileURLToPath(import.meta.url), "../..");
export const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
export const DEFAULT_PORT = 8791; export const DEFAULT_PORT = 8791;
/** /**
@@ -140,3 +143,107 @@ export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) {
} }
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ daemon start */
/**
* How long a spawn lock is believed before another hook takes it over. Long enough to cover a
* cold Node start and a bind, short enough that a script killed mid-spawn cannot wedge startup
* for the rest of the session.
*/
const SPAWN_LOCK_STALE_MS = 15_000;
/** Never hold the lock for less than this, so a fire-and-forget caller still covers the bind. */
const MIN_SPAWN_WAIT_MS = 600;
/**
* Claim the right to spawn the daemon, so a burst of hooks does not start a race in which every
* loser dies on EADDRINUSE and litters daemon.log.
*
* `wx` is the whole mechanism: an atomic create-or-fail. A lock that is already there and still
* fresh means another script is mid-spawn, and we wait for its daemon rather than starting a
* second one. Taking over a *stale* lock is deliberately not atomic — two scripts could both
* decide it is stale and both spawn — because the consequence is only the EADDRINUSE we had
* before, and it takes a 15s-dead lock to get there at all.
*/
function acquireSpawnLock(home) {
const file = path.join(home, "daemon.lock");
try {
fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
return file;
} catch {
try {
if (Date.now() - fs.statSync(file).mtimeMs < SPAWN_LOCK_STALE_MS) return null;
fs.writeFileSync(file, String(process.pid), { mode: 0o600 });
return file;
} catch {
return null;
}
}
}
async function waitUntilUp(cfg, budgetMs) {
const deadline = Date.now() + budgetMs;
while (Date.now() < deadline) {
await sleep(100);
const left = deadline - Date.now();
if (left <= 0) break;
if (await isDaemonUp(cfg, Math.min(300, Math.max(50, left)))) return true;
}
return false;
}
/**
* Make sure the daemon is listening, starting it if it is not. Never throws.
*
* Every recording hook calls this, not just one designated boot hook, because Grok Build gives
* a plugin no usable boot event. `SessionStart` is dispatched from inside session creation
* (xai-grok-shell agent_ops.rs, `DispatchSessionStartHook`) and dispatch reads the session's
* hook registry as it stands at that moment. That registry is built by `discover_hooks()`,
* whose sources are the config layers and the global/project settings files — plugin hooks are
* not among them. They are appended later, with a `plugin/` prefix, only by `reload_hooks_impl`
* and `reload_plugins_impl`. So a plugin's `SessionStart` entry is registered strictly after
* `SessionStart` has already fired, and never runs. Every other event we subscribe to happens
* later in the session, once the plugin registry has landed — so whichever of them fires first
* is the one that has to boot us.
*
* The cost of asking is one loopback request to /healthz once the daemon is up, which is the
* steady state; the spawn path is taken once per machine boot.
*/
export async function ensureDaemon(cfg = readConfig(), options = {}) {
const { waitMs = 8000, startedBy = "hook", onMissingBuild } = options;
try {
if (await isDaemonUp(cfg)) return true;
if (!fs.existsSync(SERVER_ENTRY)) {
onMissingBuild?.(SERVER_ENTRY);
return false;
}
const home = glanceHome();
fs.mkdirSync(home, { recursive: true });
const budget = Math.max(waitMs, MIN_SPAWN_WAIT_MS);
const lock = acquireSpawnLock(home);
// Someone else is already starting it: wait on theirs instead of racing it.
if (!lock) return await waitUntilUp(cfg, budget);
try {
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: { ...process.env, GLANCE_STARTED_BY: startedBy },
});
child.unref();
return await waitUntilUp(cfg, budget);
} finally {
try {
fs.unlinkSync(lock);
} catch {
/* best effort */
}
}
} catch {
// A dashboard that cannot start must still not be the reason a tool call fails.
return false;
}
}
+19 -2
View File
@@ -11,11 +11,16 @@
* A command hook costs a Node start (~40ms) per event, and buys back the ability to send an * 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. * authentication header, which the http runner has no config surface for.
* *
* It also boots the daemon if nothing else has. That is not this hook being greedy: a plugin's
* `SessionStart` entry provably never runs (see `ensureDaemon` in glance-lib.mjs), so there is
* no single boot event to delegate to and whichever recorder fires first has to do it.
*
* Always exits 0. A dashboard must never be the reason a tool call fails. * Always exits 0. A dashboard must never be the reason a tool call fails.
*/ */
import { import {
baseUrl, baseUrl,
ensureDaemon,
envEnvelope, envEnvelope,
hookHeaders, hookHeaders,
postJson, postJson,
@@ -23,11 +28,23 @@ import {
readStdinJson, readStdinJson,
} from "./glance-lib.mjs"; } from "./glance-lib.mjs";
/**
* hooks/hooks.json gives this hook 5s. Everything below has to finish inside that with room to
* spare, because a killed script is a lost event either way — and losing one is fine, the next
* event is 40ms behind it.
*/
const START_BUDGET_MS = 1200;
const POST_BUDGET_MS = 1500;
try { try {
const payload = envEnvelope(await readStdinJson()); const payload = envEnvelope(await readStdinJson());
const cfg = readConfig(); const cfg = readConfig();
// Short: if the daemon is not listening this must fail fast, not hold up the tool call. // Cheap when the daemon is already up, which is every call but the first of the session.
await postJson(`${baseUrl(cfg)}/hook/record`, payload, 2000, hookHeaders()); if (await ensureDaemon(cfg, { waitMs: START_BUDGET_MS, startedBy: "record-hook" })) {
// hookHeaders() is read after the daemon is up: on a first-ever run it is the daemon we
// just started that created hook.secret.
await postJson(`${baseUrl(cfg)}/hook/record`, payload, POST_BUDGET_MS, hookHeaders());
}
} catch { } catch {
// Daemon down, no secret yet, malformed payload — all the same answer: carry on. // Daemon down, no secret yet, malformed payload — all the same answer: carry on.
} }
+23 -43
View File
@@ -2,64 +2,44 @@
/** /**
* SessionStart hook: make sure the glance daemon is running, then record the event. * SessionStart hook: make sure the glance daemon is running, then record the event.
* *
* This is the only hook that spawns anything. It always exits 0 — a monitoring * Note that as of Grok Build's current hook wiring this never actually runs: a plugin's
* dashboard must never be the reason a Grok Build session fails to start. * `SessionStart` entry is registered after `SessionStart` has already been dispatched, so the
* event finds no plugin hooks to call. `ensureDaemon` in glance-lib.mjs has the details, and
* bin/glance-record.mjs is what really boots the daemon.
*
* It stays wired anyway. It costs nothing when it does not fire, it is the correct hook for the
* job on the day that ordering is fixed, and it keeps the answer to "what starts this thing"
* in the obvious place.
*
* It always exits 0 — a monitoring dashboard must never be the reason a session fails to start.
*/ */
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { import {
PLUGIN_ROOT,
baseUrl, baseUrl,
ensureDaemon,
envEnvelope, envEnvelope,
glanceHome,
hookHeaders, hookHeaders,
isDaemonUp,
postJson, postJson,
readConfig, readConfig,
readStdinJson, readStdinJson,
sleep,
} from "./glance-lib.mjs"; } from "./glance-lib.mjs";
const SERVER_ENTRY = path.join(PLUGIN_ROOT, "dist", "server", "index.js");
async function ensureDaemon(cfg) {
if (await isDaemonUp(cfg)) return true;
if (!fs.existsSync(SERVER_ENTRY)) {
// dist/ ships with the plugin, so this means an incomplete checkout. Say so once, on
// stderr, where it is recorded but harmless — a hook must never fail a session.
process.stderr.write(
`[grok-glance] dist/ is missing - run \`npm install && npm run build\` in ${PLUGIN_ROOT}\n`,
);
return false;
}
const home = glanceHome();
fs.mkdirSync(home, { recursive: true });
const logFd = fs.openSync(path.join(home, "daemon.log"), "a");
const child = spawn(process.execPath, [SERVER_ENTRY], {
detached: true,
stdio: ["ignore", logFd, logFd],
env: { ...process.env, GLANCE_STARTED_BY: "hook" },
});
child.unref();
// Give it a moment to bind before the first http hook fires.
for (let i = 0; i < 40; i++) {
await sleep(200);
if (await isDaemonUp(cfg, 300)) return true;
}
return false;
}
const payload = envEnvelope(await readStdinJson()); const payload = envEnvelope(await readStdinJson());
const cfg = readConfig(); const cfg = readConfig();
try { try {
const up = await ensureDaemon(cfg); // hooks/hooks.json allows this hook 20s; leave most of it as headroom.
const up = await ensureDaemon(cfg, {
waitMs: 8000,
startedBy: "session-start-hook",
onMissingBuild: (entry) => {
// dist/ ships with the plugin, so this means an incomplete checkout. Say so once, on
// stderr, where it is recorded but harmless — a hook must never fail a session.
process.stderr.write(
`[grok-glance] ${entry} is missing - run \`npm install && npm run build\` in the plugin root\n`,
);
},
});
if (up) { if (up) {
// hookHeaders() is read here, not at import time: on a first-ever run the daemon we just // hookHeaders() is read here, not at import time: on a first-ever run the daemon we just
// spawned is what created hook.secret. // spawned is what created hook.secret.
+11 -3
View File
@@ -16,9 +16,17 @@
"derived from config.json - the scripts read that themselves - so this file is plain,", "derived from config.json - the scripts read that themselves - so this file is plain,",
"committed, and edited by hand.", "committed, and edited by hand.",
"", "",
"SessionStart boots the daemon. PreToolUse is wired twice on purpose: one entry records", "Every recorder boots the daemon if it is not already up, rather than one designated boot",
"every call for the timeline, and a second, narrowly matched entry runs the approval gate,", "hook doing it. Grok Build gives a plugin no usable boot event: SessionStart is dispatched",
"because PreToolUse is the only blocking event and only a command hook can return a deny.", "from inside session creation, against the hook registry as it stands at that moment, and",
"that registry holds only config-layer and settings-file hooks. Plugin hooks are appended",
"afterwards, under a 'plugin/' prefix, by reload_hooks_impl and reload_plugins_impl - so the",
"SessionStart entry below is registered strictly after SessionStart has already fired and",
"never runs. It is kept because it costs nothing and is the right hook once that is fixed.",
"",
"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.",
"", "",
"The gate's 125s timeout is the ceiling for the whole approval round trip. The daemon caps", "The gate's 125s timeout is the ceiling for the whole approval round trip. The daemon caps",
"approval.timeoutMs at 90s against it, so the script always outlives its own wait and gets", "approval.timeoutMs at 90s against it, so the script always outlives its own wait and gets",
+6 -2
View File
@@ -11,8 +11,12 @@ behind a WebAuthn passkey. It can also pause risky tool calls until someone taps
One daemon covers every session on the machine, so several agents running at once all appear on the One daemon covers every session on the machine, so several agents running at once all appear on the
same dashboard — no per-session setup. same dashboard — no per-session setup.
The daemon is started automatically by the `SessionStart` hook. Everything below is done through The daemon starts itself: every recording hook boots it if it is not already up, so the first
the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`. prompt or tool call of a session brings it back. (Not the `SessionStart` hook — a plugin's
`SessionStart` entry is registered after the event has already fired, so it never runs. If someone
reports an empty `:8791` and no `daemon.log`, that is the reason, and any hook firing will fix it.)
Everything below is done through the `glance` CLI at `$GROK_PLUGIN_ROOT/bin/glance`.
## No build step ## No build step