198fbb7980
- GET /api/workflows: one LEAD() window pass in a read worker (read-pool.js, workflow-stats.js); idx_events_session restored, idx_events_tool added - compat-sqlite gains function/pluck/readonly so a plugin install without a better-sqlite3 build boots again; antigravity-usage uses db.js's Database - install-hooks adds no checkout hooks while the agenthub plugin is enabled (events were doubled) - terminal attach runs the live tmux server's own binary; the pane scrollbar gets a visible 2-column style, and claude panes start with CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 so tmux keeps history to scroll - Codex sessions' conversation in GET /api/sessions/:id/transcript - Codex/Antigravity sessions claimed by their workspace without hooks - UI-audit follow-ups, docs and OpenAPI
251 lines
9.5 KiB
JavaScript
251 lines
9.5 KiB
JavaScript
#!/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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 = { installHooks, isInsideContainer, isOurEntry, pluginProvidesHooks };
|