feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* @file Webhook provider registry. Each provider is described declaratively —
|
||||
* its display label, "family" (which determines optional HMAC/custom-header
|
||||
* support), the credential fields it needs, how its outbound URL is resolved,
|
||||
* any auth headers, and a payload formatter that turns a fired alert into that
|
||||
* provider's native request body. server/lib/webhooks.js consumes this registry
|
||||
* to build and deliver requests; routes/webhooks.js uses it for validation and
|
||||
* for the redacted provider metadata exposed to the UI.
|
||||
*
|
||||
* Adding a provider = one entry here (+ a formatter). No delivery/route changes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function truncate(value, max) {
|
||||
const s = String(value == null ? "" : value);
|
||||
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
||||
}
|
||||
|
||||
function escHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function parseDetails(alert) {
|
||||
if (alert.details == null) return null;
|
||||
if (typeof alert.details === "object") return alert.details;
|
||||
try {
|
||||
return JSON.parse(alert.details);
|
||||
} catch {
|
||||
return alert.details;
|
||||
}
|
||||
}
|
||||
|
||||
// [{ title, value, short }] for the chat platforms that use Slack-style
|
||||
// attachment fields (Slack legacy, Mattermost, Rocket.Chat).
|
||||
function attachmentFields(alert) {
|
||||
const fields = [{ title: "Type", value: alert.rule_type, short: true }];
|
||||
if (alert.session_id)
|
||||
fields.push({ title: "Session", value: truncate(alert.session_id, 120), short: true });
|
||||
if (alert.agent_id)
|
||||
fields.push({ title: "Agent", value: truncate(alert.agent_id, 120), short: true });
|
||||
return fields;
|
||||
}
|
||||
|
||||
const ACCENT_HEX = "#EF4444";
|
||||
const ACCENT_INT = 0xef4444;
|
||||
|
||||
// ── Formatters ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Slack incoming webhook — Block Kit. `text` is the required fallback string.
|
||||
function formatSlack(alert) {
|
||||
const ctx = [`Type: \`${alert.rule_type}\``];
|
||||
if (alert.session_id) ctx.push(`Session: \`${truncate(alert.session_id, 64)}\``);
|
||||
if (alert.agent_id) ctx.push(`Agent: \`${truncate(alert.agent_id, 64)}\``);
|
||||
ctx.push(alert.triggered_at);
|
||||
return {
|
||||
text: truncate(`🔔 ${alert.rule_name}: ${alert.message}`, 3000),
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: { type: "plain_text", text: truncate(`🔔 ${alert.rule_name}`, 150), emoji: true },
|
||||
},
|
||||
{ type: "section", text: { type: "mrkdwn", text: truncate(alert.message, 2900) } },
|
||||
{ type: "context", elements: [{ type: "mrkdwn", text: truncate(ctx.join(" • "), 1900) }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Discord webhook — a single rich embed.
|
||||
function formatDiscord(alert) {
|
||||
const fields = [{ name: "Type", value: truncate(alert.rule_type, 1024), inline: true }];
|
||||
if (alert.session_id)
|
||||
fields.push({ name: "Session", value: truncate(alert.session_id, 1024), inline: true });
|
||||
if (alert.agent_id)
|
||||
fields.push({ name: "Agent", value: truncate(alert.agent_id, 1024), inline: true });
|
||||
return {
|
||||
username: "Claude Code Monitor",
|
||||
embeds: [
|
||||
{
|
||||
title: truncate(`🔔 ${alert.rule_name}`, 256),
|
||||
description: truncate(alert.message, 4000),
|
||||
color: ACCENT_INT,
|
||||
fields,
|
||||
footer: { text: "Claude Code Agent Monitor" },
|
||||
timestamp: alert.triggered_at,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Microsoft Teams — Adaptive Card delivered via a Power Automate "Workflows"
|
||||
// webhook. The legacy O365 Connector + MessageCard transport was retired
|
||||
// (connectors progressively disabled May 18–22 2026), so the target URL is a
|
||||
// Workflows "When a Teams webhook request is received" URL and the body is the
|
||||
// {type:"message", attachments:[adaptive card]} envelope that flow expects.
|
||||
function formatTeams(alert) {
|
||||
const facts = [{ title: "Type", value: alert.rule_type }];
|
||||
if (alert.session_id) facts.push({ title: "Session", value: truncate(alert.session_id, 256) });
|
||||
if (alert.agent_id) facts.push({ title: "Agent", value: truncate(alert.agent_id, 256) });
|
||||
facts.push({ title: "Triggered", value: alert.triggered_at });
|
||||
return {
|
||||
type: "message",
|
||||
attachments: [
|
||||
{
|
||||
contentType: "application/vnd.microsoft.card.adaptive",
|
||||
contentUrl: null,
|
||||
content: {
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
type: "AdaptiveCard",
|
||||
version: "1.4",
|
||||
body: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
size: "Large",
|
||||
weight: "Bolder",
|
||||
color: "Attention",
|
||||
text: truncate(`🔔 ${alert.rule_name}`, 500),
|
||||
wrap: true,
|
||||
},
|
||||
{ type: "TextBlock", text: truncate(alert.message, 4000), wrap: true },
|
||||
{ type: "FactSet", facts },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Google Chat incoming webhook — simple text message with basic markdown
|
||||
// (*bold*, `code`). Reliable across spaces without card-schema pitfalls.
|
||||
function formatGoogleChat(alert) {
|
||||
const lines = [`🔔 *${alert.rule_name}*`, alert.message, ""];
|
||||
const meta = [`\`${alert.rule_type}\``];
|
||||
if (alert.session_id) meta.push(`session \`${truncate(alert.session_id, 64)}\``);
|
||||
if (alert.agent_id) meta.push(`agent \`${truncate(alert.agent_id, 64)}\``);
|
||||
lines.push(meta.join(" · "));
|
||||
return { text: truncate(lines.join("\n"), 4000) };
|
||||
}
|
||||
|
||||
// Mattermost incoming webhook — Slack-compatible (legacy attachments).
|
||||
function formatMattermost(alert) {
|
||||
return {
|
||||
username: "Claude Code Monitor",
|
||||
text: `🔔 **${alert.rule_name}**`,
|
||||
attachments: [
|
||||
{
|
||||
fallback: truncate(`${alert.rule_name}: ${alert.message}`, 1000),
|
||||
color: ACCENT_HEX,
|
||||
text: truncate(alert.message, 3000),
|
||||
fields: attachmentFields(alert),
|
||||
footer: "Claude Code Agent Monitor",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Rocket.Chat incoming webhook — text + Slack-style attachments.
|
||||
function formatRocketChat(alert) {
|
||||
return {
|
||||
alias: "Claude Code Monitor",
|
||||
text: `🔔 *${alert.rule_name}*`,
|
||||
attachments: [
|
||||
{
|
||||
title: truncate(alert.rule_name, 256),
|
||||
text: truncate(alert.message, 3000),
|
||||
color: ACCENT_HEX,
|
||||
fields: attachmentFields(alert),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Telegram Bot API sendMessage. chat_id comes from config; the bot token is in
|
||||
// the resolved URL. HTML parse mode, so message text is HTML-escaped.
|
||||
function formatTelegram(alert, config) {
|
||||
const lines = [`🔔 <b>${escHtml(alert.rule_name)}</b>`, escHtml(alert.message)];
|
||||
const meta = [`<code>${escHtml(alert.rule_type)}</code>`];
|
||||
if (alert.session_id)
|
||||
meta.push(`session <code>${escHtml(truncate(alert.session_id, 64))}</code>`);
|
||||
lines.push("", meta.join(" · "));
|
||||
return {
|
||||
chat_id: config.chat_id,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
text: truncate(lines.join("\n"), 4096),
|
||||
};
|
||||
}
|
||||
|
||||
// PagerDuty Events API v2 (trigger). routing_key + severity from config.
|
||||
// dedup_key groups repeat firings of the same rule+session into one incident.
|
||||
function formatPagerDuty(alert, config) {
|
||||
return {
|
||||
routing_key: config.routing_key,
|
||||
event_action: "trigger",
|
||||
dedup_key: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
|
||||
payload: {
|
||||
summary: truncate(`${alert.rule_name}: ${alert.message}`, 1024),
|
||||
source: alert.session_id || "claude-code-agent-monitor",
|
||||
severity: config.severity || "warning",
|
||||
custom_details: {
|
||||
rule_name: alert.rule_name,
|
||||
rule_type: alert.rule_type,
|
||||
session_id: alert.session_id || null,
|
||||
agent_id: alert.agent_id || null,
|
||||
message: alert.message,
|
||||
details: parseDetails(alert),
|
||||
triggered_at: alert.triggered_at,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Opsgenie Alert API. api_key is sent as the GenieKey auth header (see
|
||||
// authFrom), not in the body. alias dedups; region selects the host.
|
||||
function formatOpsgenie(alert) {
|
||||
return {
|
||||
message: truncate(`${alert.rule_name}: ${alert.message}`, 130),
|
||||
alias: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
|
||||
description: truncate(alert.message, 15000),
|
||||
source: "claude-code-agent-monitor",
|
||||
tags: ["claude-code", alert.rule_type].filter(Boolean),
|
||||
details: {
|
||||
rule_name: String(alert.rule_name),
|
||||
rule_type: String(alert.rule_type),
|
||||
session_id: alert.session_id ? String(alert.session_id) : "",
|
||||
agent_id: alert.agent_id ? String(alert.agent_id) : "",
|
||||
triggered_at: String(alert.triggered_at),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Splunk On-Call (VictorOps) generic REST endpoint. The API + routing key live
|
||||
// in the user-pasted URL; severity maps to message_type.
|
||||
function formatSplunkOnCall(alert, config) {
|
||||
return {
|
||||
message_type: config.severity || "WARNING",
|
||||
entity_id: `ccam:${alert.rule_id || "test"}:${alert.session_id || ""}`,
|
||||
entity_display_name: truncate(alert.rule_name, 256),
|
||||
state_message: truncate(
|
||||
`${alert.message}\n\ntype: ${alert.rule_type}${alert.session_id ? `\nsession: ${alert.session_id}` : ""}`,
|
||||
20000
|
||||
),
|
||||
monitoring_tool: "claude-code-agent-monitor",
|
||||
};
|
||||
}
|
||||
|
||||
// Generic / automation platforms (Zapier, Make, n8n, Pipedream) — a clean,
|
||||
// stable JSON envelope. Optional HMAC signing + custom headers handled by the
|
||||
// caller (server/lib/webhooks.js) for the whole generic family.
|
||||
function formatGeneric(alert) {
|
||||
return {
|
||||
event: "alert.triggered",
|
||||
source: "claude-code-agent-monitor",
|
||||
sent_at: new Date().toISOString(),
|
||||
alert: {
|
||||
id: alert.id ?? null,
|
||||
rule_id: alert.rule_id ?? null,
|
||||
rule_name: alert.rule_name,
|
||||
rule_type: alert.rule_type,
|
||||
session_id: alert.session_id ?? null,
|
||||
agent_id: alert.agent_id ?? null,
|
||||
message: alert.message,
|
||||
details: parseDetails(alert),
|
||||
triggered_at: alert.triggered_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Registry ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// family:
|
||||
// "chat" — incoming-webhook chat platforms (no extra auth, https URL)
|
||||
// "api" — alert/event APIs with credentials and/or derived URLs
|
||||
// "generic" — arbitrary-JSON endpoints; support optional HMAC + custom headers
|
||||
//
|
||||
// needsUrl — the user must supply the outbound URL
|
||||
// https — enforce https on a user-supplied URL (false allows http for local)
|
||||
// defaultUrl — fallback URL when the user supplies none
|
||||
// urlFrom(cfg)— derive the URL from config (user supplies no URL)
|
||||
// authFrom(cfg)— derive auth request headers from config
|
||||
// fields — provider config fields (rendered by the UI, validated server-side)
|
||||
|
||||
const PROVIDERS = {
|
||||
slack: { label: "Slack", family: "chat", needsUrl: true, https: true, format: formatSlack },
|
||||
discord: { label: "Discord", family: "chat", needsUrl: true, https: true, format: formatDiscord },
|
||||
teams: {
|
||||
label: "Microsoft Teams",
|
||||
family: "chat",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
urlHint:
|
||||
"Power Automate Workflows URL (Teams → Workflows → 'Post to a channel when a webhook request is received')",
|
||||
format: formatTeams,
|
||||
},
|
||||
google_chat: {
|
||||
label: "Google Chat",
|
||||
family: "chat",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
format: formatGoogleChat,
|
||||
},
|
||||
mattermost: {
|
||||
label: "Mattermost",
|
||||
family: "chat",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
format: formatMattermost,
|
||||
},
|
||||
rocketchat: {
|
||||
label: "Rocket.Chat",
|
||||
family: "chat",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
format: formatRocketChat,
|
||||
},
|
||||
|
||||
telegram: {
|
||||
label: "Telegram",
|
||||
family: "api",
|
||||
https: true,
|
||||
fields: [
|
||||
{ key: "bot_token", label: "Bot token", secret: true, required: true },
|
||||
{ key: "chat_id", label: "Chat ID", required: true },
|
||||
],
|
||||
urlFrom: (c) => (c.bot_token ? `https://api.telegram.org/bot${c.bot_token}/sendMessage` : null),
|
||||
format: formatTelegram,
|
||||
},
|
||||
|
||||
pagerduty: {
|
||||
label: "PagerDuty",
|
||||
family: "api",
|
||||
https: true,
|
||||
defaultUrl: "https://events.pagerduty.com/v2/enqueue",
|
||||
fields: [
|
||||
{ key: "routing_key", label: "Integration (routing) key", secret: true, required: true },
|
||||
{
|
||||
key: "severity",
|
||||
label: "Severity",
|
||||
type: "enum",
|
||||
options: ["info", "warning", "error", "critical"],
|
||||
default: "warning",
|
||||
},
|
||||
],
|
||||
format: formatPagerDuty,
|
||||
},
|
||||
|
||||
opsgenie: {
|
||||
label: "Opsgenie",
|
||||
family: "api",
|
||||
https: true,
|
||||
fields: [
|
||||
{ key: "api_key", label: "API key", secret: true, required: true },
|
||||
{ key: "region", label: "Region", type: "enum", options: ["us", "eu"], default: "us" },
|
||||
],
|
||||
urlFrom: (c) =>
|
||||
c.region === "eu"
|
||||
? "https://api.eu.opsgenie.com/v2/alerts"
|
||||
: "https://api.opsgenie.com/v2/alerts",
|
||||
authFrom: (c) => (c.api_key ? { Authorization: `GenieKey ${c.api_key}` } : {}),
|
||||
format: formatOpsgenie,
|
||||
},
|
||||
|
||||
splunk_oncall: {
|
||||
label: "Splunk On-Call",
|
||||
family: "api",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
urlHint: "VictorOps REST endpoint URL (contains your API + routing key)",
|
||||
fields: [
|
||||
{
|
||||
key: "severity",
|
||||
label: "Message type",
|
||||
type: "enum",
|
||||
options: ["CRITICAL", "WARNING", "INFO"],
|
||||
default: "WARNING",
|
||||
},
|
||||
],
|
||||
format: formatSplunkOnCall,
|
||||
// VictorOps returns HTTP 200 even when it rejects the event — the real
|
||||
// outcome is in the body ({ result: "success" | "failure" }). Inspect it so
|
||||
// a logical failure isn't silently recorded as delivered.
|
||||
verifyResponse: (text) => {
|
||||
if (!text) return { ok: true };
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
if (j && typeof j.result === "string" && j.result.toLowerCase() === "failure") {
|
||||
return { ok: false, error: j.message || "Splunk On-Call reported failure" };
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON 200 body — trust the status */
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
|
||||
zapier: {
|
||||
label: "Zapier",
|
||||
family: "generic",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
format: formatGeneric,
|
||||
},
|
||||
make: { label: "Make", family: "generic", needsUrl: true, https: true, format: formatGeneric },
|
||||
n8n: { label: "n8n", family: "generic", needsUrl: true, https: false, format: formatGeneric },
|
||||
pipedream: {
|
||||
label: "Pipedream",
|
||||
family: "generic",
|
||||
needsUrl: true,
|
||||
https: true,
|
||||
format: formatGeneric,
|
||||
},
|
||||
generic: {
|
||||
label: "Generic (custom JSON)",
|
||||
family: "generic",
|
||||
needsUrl: true,
|
||||
https: false,
|
||||
format: formatGeneric,
|
||||
},
|
||||
};
|
||||
|
||||
const WEBHOOK_TYPES = Object.keys(PROVIDERS);
|
||||
|
||||
function isGenericFamily(type) {
|
||||
return PROVIDERS[type]?.family === "generic";
|
||||
}
|
||||
|
||||
/** Resolve the outbound URL for a target: derived → user-supplied → default. */
|
||||
function resolveUrl(target) {
|
||||
const p = PROVIDERS[target.type];
|
||||
if (!p) return target.url || null;
|
||||
if (p.urlFrom) {
|
||||
const derived = p.urlFrom(target.config || {});
|
||||
if (derived) return derived;
|
||||
}
|
||||
if (target.url) return target.url;
|
||||
return p.defaultUrl || null;
|
||||
}
|
||||
|
||||
/** Provider-derived auth headers (e.g. Opsgenie GenieKey). */
|
||||
function resolveAuthHeaders(target) {
|
||||
const p = PROVIDERS[target.type];
|
||||
if (p?.authFrom) return p.authFrom(target.config || {}) || {};
|
||||
return {};
|
||||
}
|
||||
|
||||
function formatPayload(type, alert, config = {}) {
|
||||
const p = PROVIDERS[type] || PROVIDERS.generic;
|
||||
return p.format(alert, config);
|
||||
}
|
||||
|
||||
/** Whether a user-supplied URL is required for this provider type. */
|
||||
function urlRequired(type) {
|
||||
const p = PROVIDERS[type];
|
||||
if (!p) return true;
|
||||
if (p.urlFrom || p.defaultUrl) return false;
|
||||
return !!p.needsUrl;
|
||||
}
|
||||
|
||||
/** Redacted, serializable provider metadata for the UI/API. */
|
||||
function publicProviders() {
|
||||
return WEBHOOK_TYPES.map((type) => {
|
||||
const p = PROVIDERS[type];
|
||||
return {
|
||||
type,
|
||||
label: p.label,
|
||||
family: p.family,
|
||||
url_required: urlRequired(type),
|
||||
has_default_url: !!p.defaultUrl,
|
||||
derives_url: !!p.urlFrom,
|
||||
allow_http: p.https === false,
|
||||
url_hint: p.urlHint || null,
|
||||
supports_secret: p.family === "generic",
|
||||
supports_headers: p.family === "generic",
|
||||
fields: (p.fields || []).map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
secret: !!f.secret,
|
||||
required: !!f.required,
|
||||
type: f.type || "string",
|
||||
options: f.options || null,
|
||||
default: f.default ?? null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS,
|
||||
WEBHOOK_TYPES,
|
||||
isGenericFamily,
|
||||
resolveUrl,
|
||||
resolveAuthHeaders,
|
||||
formatPayload,
|
||||
urlRequired,
|
||||
publicProviders,
|
||||
truncate,
|
||||
};
|
||||
Reference in New Issue
Block a user