1c5521cf2e
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
(pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
cell by cell (reusing one cell) only when a row could be a menu cursor row.
Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
"yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.
Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
plugin.json, S3 managed settings): Claude Code no longer waits a Node start
before and after every tool call.
Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
10 KiB
JavaScript
266 lines
10 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];
|
||
// Fired around EVERY tool call, and Claude Code waits for a synchronous hook
|
||
// before it goes on — each one a Node start (0.1–0.2 s), so ~0.4 s added to
|
||
// every tool call. The handler only reports (it prints nothing, decides
|
||
// nothing), so these run in the background (`async`). The rarer events stay
|
||
// synchronous so a turn's start and end still reach the dashboard in order.
|
||
const ASYNC_HOOKS = new Set(["PreToolUse", "PostToolUse"]);
|
||
|
||
function makeHookEntry(hookType) {
|
||
const entry = {
|
||
hooks: [
|
||
{
|
||
type: "command",
|
||
command: `node "${HOOK_HANDLER}" ${hookType}`,
|
||
...(ASYNC_HOOKS.has(hookType) && { async: true }),
|
||
},
|
||
],
|
||
};
|
||
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 = {
|
||
ASYNC_HOOKS,
|
||
HOOK_TYPES,
|
||
installHooks,
|
||
isInsideContainer,
|
||
isOurEntry,
|
||
makeHookEntry,
|
||
pluginProvidesHooks,
|
||
};
|