/** * Shared helpers for the grok-glance hook scripts and CLI. * * Deliberately dependency-free and stdlib-only: these run on the critical path of * every Grok Build tool call, so they must start fast and never wedge a session. */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node: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; /** * State lives in one fixed place so that hooks (which get GROK_PLUGIN_DATA) and the * CLI (which does not) always agree on where config, credentials and events are. */ export function glanceHome() { if (process.env.GLANCE_HOME) return path.resolve(process.env.GLANCE_HOME); return path.join(os.homedir(), ".grok", "glance"); } export function readConfig() { const file = path.join(glanceHome(), "config.json"); let raw = {}; try { raw = JSON.parse(fs.readFileSync(file, "utf8")); } catch { // No config yet, or unreadable: defaults are always usable. } const port = Number(process.env.GLANCE_PORT ?? raw.port ?? DEFAULT_PORT); return { ...raw, port: Number.isFinite(port) ? port : DEFAULT_PORT, host: raw.host ?? "127.0.0.1", }; } 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 restarting anything. * * Only the daemon ever creates it. A hook must never be the thing that creates state, and a * missing secret has to degrade to "no telemetry", not "no tool call" — so this returns null * and the callers carry on. */ export function hookSecret() { try { return fs.readFileSync(path.join(glanceHome(), "hook.secret"), "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 — the `timeout` written * next to glance-approve.mjs in hooks/hooks.json. Change one, change the other. * * It bounds everything downstream: if the script outlives its hook timeout, Grok Build kills * it and the fail-open path never runs. So the daemon caps its own wait well inside it (see * APPROVAL_MAX_WAIT_MS in server/src/config.ts), and the script leaves itself 10s on top of * that to answer. */ export const APPROVAL_HOOK_TIMEOUT_SECS = 125; /** Read the hook payload that Grok Build writes to stdin. Returns {} if there is none. */ export async function readStdinJson() { if (process.stdin.isTTY) return {}; const chunks = []; try { for await (const chunk of process.stdin) chunks.push(chunk); } catch { return {}; } const text = Buffer.concat(chunks).toString("utf8").trim(); if (!text) return {}; try { return JSON.parse(text); } catch { return {}; } } /** * Grok Build also passes the event in the environment. We merge it in so a payload that * is missing fields (or absent entirely) still produces a usable event. */ export function envEnvelope(payload) { return { hookEventName: payload.hookEventName ?? process.env.GROK_HOOK_EVENT ?? "Unknown", sessionId: payload.sessionId ?? process.env.GROK_SESSION_ID ?? "unknown", workspaceRoot: payload.workspaceRoot ?? process.env.GROK_WORKSPACE_ROOT ?? payload.cwd ?? process.cwd(), cwd: payload.cwd ?? process.cwd(), hookName: process.env.GROK_HOOK_NAME ?? undefined, ...payload, }; } export async function postJson(url, body, timeoutMs, extraHeaders = {}) { const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json", ...extraHeaders }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); if (!text) return { status: res.status, data: null }; try { return { status: res.status, data: JSON.parse(text) }; } catch { return { status: res.status, data: null }; } } export async function isDaemonUp(cfg = readConfig(), timeoutMs = 400) { try { const res = await fetch(`${baseUrl(cfg)}/healthz`, { signal: AbortSignal.timeout(timeoutMs), }); return res.ok; } catch { return false; } } 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; } }