Files
AgentHub/scripts/install-hooks.js
T
nntrivi2001 c946b73d11 chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
2026-11-04 15:16:49 +07:00

288 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
}
// The one hook that answers Claude: after a Claude Docs doc or a typed
// artifact (Slides, Design…) is published, it asks for a copy AgentHub can
// show to the people it is shared with. Synchronous (its answer is read), but
// only for these tools, so no other tool call waits on it.
const EXPORT_HINT = path.join(__dirname, "artifact-export-hint.js");
const EXPORT_HINT_MATCHER = "Artifact|mcp__claude_ai_.*__batch";
function makeExportHintEntry() {
return {
matcher: EXPORT_HINT_MATCHER,
hooks: [{ type: "command", command: `node "${EXPORT_HINT}"`, timeout: 5 }],
};
}
const OUR_SCRIPTS = ["hook-handler.js", "artifact-export-hint.js"];
const isOurCommand = (c) => typeof c === "string" && OUR_SCRIPTS.some((n) => c.includes(n));
function isOurEntry(entry) {
// Matches old format (entry.command) and new format (entry.hooks[].command)
if (isOurCommand(entry.command)) return true;
if (Array.isArray(entry.hooks)) return entry.hooks.some((h) => isOurCommand(h.command));
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(
(e) => isOurEntry(e) && !JSON.stringify(e).includes("artifact-export-hint.js")
);
const entry = makeHookEntry(hookType);
if (existing >= 0) {
settings.hooks[hookType][existing] = entry;
updated++;
} else {
settings.hooks[hookType].push(entry);
installed++;
}
}
// The export hint is a second PostToolUse entry of ours, kept once.
const post = settings.hooks.PostToolUse;
const hint = post.findIndex((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
if (hint >= 0) post[hint] = makeExportHintEntry();
else post.push(makeExportHintEntry());
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,
};