#!/usr/bin/env node /** * Installs Claude Code hooks that forward events to the Agent Dashboard. * Modifies ~/.claude/settings.json to add hook entries — or, while the * `agenthub` plugin is enabled (it ships the same hooks inline), removes ours * instead, since two handlers post every event twice. * * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const path = require("path"); const { getSettingsPath } = require("../server/lib/claude-home"); // Read per call, not at load: the dashboard's CLAUDE_HOME can change while it runs. const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/"); /** * True when the plugin bootstrap has run on this machine — i.e. the `agenthub` * plugin is (or was) installed and supplies its own hook entries. * Read-only and never throws; `plugin-bootstrap.js` is not required here to * keep this module free of a circular dependency. * * @returns {boolean} */ function pluginBootstrapRan() { try { const { getDataDir } = require("../server/lib/claude-home"); return fs.existsSync(path.join(getDataDir(), "runtime", "state.json")); } catch { return false; } } /** * True when settings.json enables the `agenthub` plugin, whose `plugin.json` * carries these same eight hooks inline — Claude Code then runs them itself, * and any hook-handler.js entry in settings.json is a second copy of each. * * @param {object} settings parsed settings.json * @returns {boolean} */ function pluginProvidesHooks(settings) { const ep = settings && settings.enabledPlugins; if (!ep || typeof ep !== "object") return false; return Object.entries(ep).some( ([k, v]) => v === true && (k === "agenthub" || k.startsWith("agenthub@")) ); } function envFlag(name) { return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase()); } /** * True when this process is running inside a container (Docker, Podman, or a * Kubernetes pod). Detected via the Docker/Podman marker files, the OCI/systemd * `container` env var, and a Linux cgroup heuristic. `AGENTHUB_FORCE_CONTAINER=1` * forces a positive result and `AGENTHUB_FORCE_HOST=1` forces a negative result * (used by tests / to override misfiring detection). * * Why this matters (GitHub #193): the hook command written into * `~/.claude/settings.json` embeds the absolute handler path resolved here. * Inside a container that path (e.g. `/app/scripts/hook-handler.js`) does not * exist on the host. When `~/.claude` is bind-mounted, installing from the * container poisons the host settings and every host hook fails with * `MODULE_NOT_FOUND`. Claude Code runs on the host, so hooks must be installed * on the host. * * @returns {boolean} */ function isInsideContainer() { if (envFlag("AGENTHUB_FORCE_CONTAINER")) return true; if (envFlag("AGENTHUB_FORCE_HOST")) return false; try { if (fs.existsSync("/.dockerenv")) return true; // Docker if (fs.existsSync("/run/.containerenv")) return true; // Podman } catch { /* fs probe failed — fall through to other signals */ } // systemd-nspawn / Podman (and often Docker) export `container`. if (typeof process.env.container === "string" && process.env.container.length > 0) return true; // Linux cgroup heuristic — covers Docker, containerd, Kubernetes, Podman. try { const cgroup = fs.readFileSync("/proc/self/cgroup", "utf8"); if (/\b(docker|containerd|kubepods|libpod|podman)\b/.test(cgroup)) return true; } catch { /* not Linux / no cgroup file — not a container by this signal */ } return false; } /** Multi-line message explaining why a container install is refused. */ function containerRefusalMessage() { return [ "✖ Refusing to install Claude Code hooks from inside a container.", "", ` The hook command would embed this handler path:`, ` ${HOOK_HANDLER}`, ` written into:`, ` ${getSettingsPath()}`, "", " Claude Code runs on the HOST. When ~/.claude is bind-mounted, a", " container-internal handler path does not exist on the host, so every host", " hook fails with MODULE_NOT_FOUND (e.g. the SessionEnd hook). See issue #193.", "", " → Install hooks ON THE HOST instead:", " npm run install-hooks", " # or: node /path/to/AgentHub/scripts/install-hooks.js", "", " The host handler POSTs to http://localhost:4820, which the container already", " publishes — so a host-installed hook reaches the containerized dashboard.", "", " If you genuinely run Claude Code inside this same container, override with:", " AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks", ].join("\n"); } // Hook types to install. Some support matchers, some don't. const HOOKS_WITH_MATCHER = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification"]; // UserPromptSubmit fires the instant the user hits enter — the only reliable // signal that the user has resumed for *text-only* turns (no PreToolUse will // fire until Claude calls a tool, which never happens for plain-text replies). // Without it the Waiting badge persists through the entire generation of a // text response. SessionStart / SessionEnd / UserPromptSubmit don't take // tool-name matchers, hence the separate list. const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"]; const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER]; function makeHookEntry(hookType) { const entry = { hooks: [ { type: "command", command: `node "${HOOK_HANDLER}" ${hookType}`, }, ], }; if (HOOKS_WITH_MATCHER.includes(hookType)) { entry.matcher = "*"; } return entry; } function isOurEntry(entry) { // Matches old format (entry.command) and new format (entry.hooks[].command) if (entry.command && entry.command.includes("hook-handler.js")) return true; if (Array.isArray(entry.hooks)) { return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js")); } return false; } function installHooks(silent = false) { // Host-only guard (issue #193): never write a container-internal handler path // into a (potentially bind-mounted) host settings file. Honors an explicit // opt-out for the rare case of running Claude Code inside this same container. if (isInsideContainer() && !envFlag("AGENTHUB_ALLOW_CONTAINER_HOOKS")) { if (!silent) console.error(containerRefusalMessage()); return false; } let settings = {}; if (fs.existsSync(getSettingsPath())) { try { const raw = fs.readFileSync(getSettingsPath(), "utf8"); settings = JSON.parse(raw); } catch (err) { if (!silent) console.error(`Failed to parse ${getSettingsPath()}:`, err.message); return false; } } // The enabled plugin already runs these hooks: take our copies out instead // of adding them (every server start used to re-add them and double every // event). Other hooks stay; the file is backed up before it is written. if (pluginProvidesHooks(settings)) { let removed = 0; for (const [type, entries] of Object.entries(settings.hooks || {})) { if (!Array.isArray(entries)) continue; const kept = entries.filter((e) => !isOurEntry(e)); removed += entries.length - kept.length; if (kept.length) settings.hooks[type] = kept; else delete settings.hooks[type]; } if (removed) { fs.copyFileSync(getSettingsPath(), `${getSettingsPath()}.agenthub-bak`); fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf8"); } if (!silent) { console.log( "The agenthub plugin is enabled and provides these hooks — nothing to install." + (removed ? ` Removed ${removed} duplicate entr${removed === 1 ? "y" : "ies"}.` : "") ); } return true; } // Bootstrapped once but not enabled now (disabled or uninstalled): the // plugin's hooks are off, so ours are the only ones — install them. if (!silent && pluginBootstrapRan()) { console.warn( "Note: the agenthub plugin was installed here but is not enabled — installing\n" + " the checkout's hooks. Re-enabling the plugin removes them again." ); } if (!settings.hooks) settings.hooks = {}; let installed = 0; let updated = 0; for (const hookType of HOOK_TYPES) { if (!settings.hooks[hookType]) settings.hooks[hookType] = []; const existing = settings.hooks[hookType].findIndex(isOurEntry); const entry = makeHookEntry(hookType); if (existing >= 0) { settings.hooks[hookType][existing] = entry; updated++; } else { settings.hooks[hookType].push(entry); installed++; } } const dir = path.dirname(getSettingsPath()); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(getSettingsPath(), JSON.stringify(settings, null, 2) + "\n", "utf8"); if (!silent) { console.log(`Hook handler: ${HOOK_HANDLER}`); console.log(`Settings file: ${getSettingsPath()}`); console.log(`Installed: ${installed} new, updated: ${updated} existing`); console.log("Claude Code hooks configured. Start a new Claude Code session to begin tracking."); } return true; } if (require.main === module) { // Non-zero exit on refusal/failure so CI and shell users notice it. if (!installHooks(false)) process.exitCode = 1; } // isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries // a previous checkout install left behind (they would double-count events). module.exports = { HOOK_TYPES, installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };