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>
250 lines
8.6 KiB
JavaScript
250 lines
8.6 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
}
|