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>
53 lines
2.1 KiB
JavaScript
53 lines
2.1 KiB
JavaScript
#!/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.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
import {
|
|
baseUrl,
|
|
ensureDaemon,
|
|
envEnvelope,
|
|
hookHeaders,
|
|
postJson,
|
|
readConfig,
|
|
readStdinJson,
|
|
} 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 {
|
|
const payload = envEnvelope(await readStdinJson());
|
|
const cfg = readConfig();
|
|
// Cheap when the daemon is already up, which is every call but the first of the session.
|
|
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 {
|
|
// Daemon down, no secret yet, malformed payload — all the same answer: carry on.
|
|
}
|
|
|
|
process.exit(0);
|