From ba8d471b0e7d98326cdd5cba6af01462a69e23b5 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 10 Jul 2026 22:05:09 +0300 Subject: [PATCH 1/3] feat(hooks): add MONITOR_IGNORE_CWD ingest filter + Russian README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #182, contributes to #197. MONITOR_IGNORE_CWD (server/routes/hooks.js): - Parse comma-separated cwd patterns from env at startup into compiled matcher functions — zero runtime overhead per hook event beyond a single .some() call. - Three pattern forms supported, no external dependencies: /exact/path — strict equality after slash-normalisation /prefix/* — direct children only (no deeper nesting) /prefix/** — /prefix and all descendants - Early-return in router.post('/event') before processEvent(): ignored sessions are never written to the DB, never broadcast, and never appear in the UI, analytics, or exports. - Returns 200 { ok: true, ignored: true } so Claude Code does not retry — the event is intentionally discarded, not an error. - .env.example: document the variable with examples and caveats (pre-existing sessions are unaffected; use DELETE /api/sessions/:id). README-RU.md: - Full Russian translation of the project README (~376 lines). - Covers: overview, features, quick-start, hooks setup, config (including MONITOR_IGNORE_CWD section), API table, event types, deployment, project structure, and troubleshooting. - Language switcher footer linking all existing translations. - README.md: added README-RU.md to the localized-docs tip. --- .env.example | 19 +++ README-RU.md | 376 +++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- server/routes/hooks.js | 48 ++++++ 4 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 README-RU.md diff --git a/.env.example b/.env.example index db8923d8..1e699285 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,22 @@ # fs.watch watcher fires near-instantly regardless; this poll is the safety net. # Set to 0 to disable the poll while leaving the watcher running (default: 30000). # DASHBOARD_SESSION_SYNC_MS=30000 + +# ── Ingest filters ──────────────────────────────────────────────────────────── +# Comma-separated list of working-directory patterns whose hook events should be +# silently discarded before they are written to the database. Useful when you +# run Claude Code in private or scratch directories you never want to appear in +# the dashboard, analytics, or exports. +# +# Supported pattern forms (forward slashes, case-sensitive): +# /home/user/private exact match +# /home/user/work/* direct children only (no deeper nesting) +# /home/user/work/** /work and all descendants +# +# The hook handler responds 200 so Claude Code does not retry — the event is +# intentionally dropped rather than errored. Sessions whose first hook is ignored +# are never created, so they will not appear in the sessions list at all. For +# sessions already in the database (created before the filter was added), see the +# DELETE /api/sessions/:id endpoint. +# +# MONITOR_IGNORE_CWD=/home/user/private,/home/user/scratch/** diff --git a/README-RU.md b/README-RU.md new file mode 100644 index 00000000..25e77aae --- /dev/null +++ b/README-RU.md @@ -0,0 +1,376 @@ +# Agent Dashboard для Claude Code + +### Платформа мониторинга активности Claude Code агентов в режиме реального времени 🚀 + +Профессиональный дашборд для отслеживания и визуализации сессий Claude Code агентов, использования инструментов и оркестрации субагентов в реальном времени. Построен на Node.js, Express, React и SQLite, интегрируется напрямую с Claude Code через нативную систему хуков для бесшовного отслеживания сессий и аналитики. + +![Claude Code](https://img.shields.io/badge/Claude_Code-orange?style=flat-square&logo=claude&logoColor=white) +![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-339933?style=flat-square&logo=node.js&logoColor=white) +![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?style=flat-square&logo=typescript&logoColor=white) +![SQLite](https://img.shields.io/badge/SQLite-3-003B57?style=flat-square&logo=sqlite&logoColor=white) +![WebSocket](https://img.shields.io/badge/WebSocket-RFC_6455-010101?style=flat-square&logo=socketdotio&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white) +![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square) + +**Языки / Language Support**: [English](./README.md) · [中文](./README-CN.md) · [Tiếng Việt](./README-VN.md) · [한국어](./README-KO.md) · **Русский** + +--- + +## Содержание + +- [Обзор](#обзор) +- [Возможности](#возможности) +- [Быстрый старт](#быстрый-старт) +- [Как это работает](#как-это-работает) +- [Конфигурация](#конфигурация) +- [npm Скрипты](#npm-скрипты) +- [MCP Интеграция](#mcp-интеграция) +- [API](#api) +- [Хук-события](#хук-события) +- [Уведомления браузера](#уведомления-браузера) +- [VS Code Расширение](#vs-code-расширение) +- [Десктопное приложение](#десктопное-приложение-macos-и-windows) +- [Хранение данных](#хранение-данных) +- [Развёртывание](#развёртывание) +- [Структура проекта](#структура-проекта) +- [Устранение неполадок](#устранение-неполадок) +- [Участие в разработке](#участие-в-разработке) +- [Лицензия](#лицензия) + +--- + +## Обзор + +Отслеживайте сессии, мониторьте агентов в реальном времени, визуализируйте использование инструментов и наблюдайте за оркестрацией субагентов через профессиональный веб-интерфейс с тёмной темой. Интегрируется напрямую с Claude Code через нативную систему хуков. + +--- + +## Возможности + +- **Мониторинг сессий в реальном времени** — отслеживание активных, ожидающих, завершённых и заброшенных сессий +- **Аналитика агентов** — иерархия агентов, статусы, использование инструментов и затраты +- **Kanban-доска** — агенты и сессии, сгруппированные по статусу (работает / ожидает / завершён / ошибка) +- **Просмотр диалогов** — живой просмотр транскриптов с рендерингом Markdown и подсветкой кода +- **Временная шкала событий** — хронологическая лента событий с многомерной фильтрацией +- **Аналитика токенов** — статистика входных/выходных токенов, кэша и стоимости по моделям +- **WebSocket трансляции** — обновления UI в реальном времени без перезагрузки страницы +- **MCP Сервер** — каталог инструментов для интроспекции дашборда прямо из Claude Code +- **VS Code Расширение** — просмотр активных сессий прямо в редакторе +- **Десктопное приложение** — нативные приложения для macOS (Universal DMG) и Windows (NSIS) +- **Docker** — контейнерное развёртывание через docker-compose +- **OpenAPI / Swagger** — полная документация REST API +- **Фильтр директорий** — исключение приватных путей через `MONITOR_IGNORE_CWD` +- **Tabby** — симпатичный кот-компаньон в интерфейсе 🐱 + +--- + +## Быстрый старт + +### Требования + +- Node.js ≥ 18 (рекомендуется 22+ для нативного SQLite) +- npm ≥ 9 +- Claude Code установлен и настроен + +### Установка + +```bash +git clone https://github.com/hoangsonww/Claude-Code-Agent-Monitor.git +cd Claude-Code-Agent-Monitor +npm install +npm run build +npm start +``` + +Дашборд доступен по адресу **http://localhost:4820**. + +### Docker + +```bash +docker-compose up -d +``` + +### Настройка хуков + +Добавьте в `~/.claude/settings.json`: + +```json +{ + "hooks": { + "PreToolUse": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }], + "PostToolUse": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }], + "Stop": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }], + "SessionStart": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }], + "SessionEnd": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }], + "Notification": [{ "command": "node /path/to/Claude-Code-Agent-Monitor/bin/hook.js" }] + } +} +``` + +--- + +## Как это работает + +1. **Claude Code** запускает хук при каждом событии (использование инструмента, остановка, начало сессии и т.д.) +2. **Скрипт хука** (`bin/hook.js`) отправляет HTTP POST на сервер дашборда +3. **Express сервер** сохраняет данные в SQLite и транслирует обновления через WebSocket +4. **React UI** обновляется мгновенно через WebSocket соединение + +--- + +## Конфигурация + +Скопируйте `.env.example` в `.env`: + +```bash +cp .env.example .env +``` + +### Основные переменные + +| Переменная | По умолчанию | Описание | +|---|---|---| +| `DASHBOARD_PORT` | `4820` | Порт HTTP сервера | +| `DASHBOARD_HOST` | `127.0.0.1` | Сетевой интерфейс | +| `DASHBOARD_TOKEN` | — | Bearer-токен для аутентификации API | +| `CLAUDE_HOME` | `~/.claude` | Путь к данным Claude Code | +| `DASHBOARD_STALE_MINUTES` | `180` | Порог заброшенности сессии (мин) | +| `MONITOR_IGNORE_CWD` | — | Паттерны директорий для исключения | + +### Фильтрация директорий (MONITOR_IGNORE_CWD) + +Если вы работаете в приватных директориях, укажите их в `MONITOR_IGNORE_CWD`: + +```bash +# Точное совпадение +MONITOR_IGNORE_CWD=/home/user/private + +# Прямые дочерние директории +MONITOR_IGNORE_CWD=/home/user/work/* + +# Рекурсивно (все вложенные) +MONITOR_IGNORE_CWD=/home/user/scratch/** + +# Несколько паттернов через запятую +MONITOR_IGNORE_CWD=/home/user/private,/home/user/scratch/**,/tmp/* +``` + +Хук-события из игнорируемых директорий отбрасываются **до записи в БД** — они никогда не появятся в интерфейсе или аналитике. Сервер отвечает `200 OK` с `{ "ok": true, "ignored": true }`, чтобы Claude Code не делал повторных попыток. + +> **Важно:** по умолчанию сервер привязан к `127.0.0.1`. При открытии сетевого доступа обязательно установите `DASHBOARD_TOKEN`. + +--- + +## npm Скрипты + +| Скрипт | Описание | +|---|---| +| `npm start` | Запустить продакшн сервер | +| `npm run dev` | Dev режим с горячей перезагрузкой | +| `npm run build` | Собрать клиент | +| `npm test` | Запустить тесты (Vitest) | +| `npm run lint` | Проверить код ESLint | +| `npm run format` | Форматировать Prettier | + +--- + +## MCP Интеграция + +Дашборд включает MCP сервер в директории `mcp/`: + +```json +{ + "mcpServers": { + "agent-dashboard": { + "command": "node", + "args": ["/path/to/Claude-Code-Agent-Monitor/mcp/server.js"] + } + } +} +``` + +--- + +## API + +Swagger UI: **http://localhost:4820/api-docs** + +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/api/sessions` | Список сессий с пагинацией | +| `GET` | `/api/sessions/:id` | Детали сессии | +| `GET` | `/api/analytics` | Агрегированная статистика | +| `GET` | `/api/stats` | Сводная статистика | +| `POST` | `/api/hooks/event` | Приём хук-событий | +| `WebSocket` | `ws://localhost:4820` | Real-time трансляция | + +--- + +## Хук-события + +| Тип | Описание | +|---|---| +| `PreToolUse` | Начало использования инструмента | +| `PostToolUse` | Завершение вызова инструмента | +| `Stop` | Агент завершил ответ | +| `SubagentStop` | Субагент завершил работу | +| `SessionStart` | Начало сессии | +| `SessionEnd` | Завершение сессии | +| `Notification` | Системное уведомление | +| `UserPromptSubmit` | Пользователь отправил промпт | + +--- + +## Уведомления браузера + +Поддерживается Web Push API (VAPID): + +```bash +npx web-push generate-vapid-keys +``` + +Добавьте ключи в `.env` и разрешите уведомления в браузере через кнопку в интерфейсе. + +--- + +## VS Code Расширение + +```bash +cd vscode-extension +npm install +npm run package +# Установить .vsix: Extensions → Install from VSIX +``` + +--- + +## Десктопное приложение (macOS и Windows) + +```bash +cd desktop +npm install + +npm run build:mac # Universal DMG (arm64 + x64) +npm run build:win # NSIS installer + portable +``` + +Готовые релизы: [GitHub Releases](https://github.com/hoangsonww/Claude-Code-Agent-Monitor/releases) + +--- + +## Хранение данных + +| | Описание | +|---|---| +| По умолчанию | `~/.claude-monitor/data/dashboard.db` | +| `DASHBOARD_DATA_DIR` | Переопределение директории | +| `DASHBOARD_DB_PATH` | Полный путь к файлу БД | + +--- + +## Развёртывание + +```bash +# Docker Compose +docker-compose up -d + +# Helm +helm install agent-monitor deployments/helm/ + +# Kustomize +kubectl apply -k deployments/kustomize/ +``` + +Подробнее: [DEPLOYMENT.md](./DEPLOYMENT.md) + +--- + +## Структура проекта + +``` +Claude-Code-Agent-Monitor/ +├── bin/ # Скрипты хуков и CLI +├── client/ # React + TypeScript + Vite +│ └── src/ +│ ├── components/ # React компоненты +│ ├── pages/ # Страницы +│ └── locales/ # Переводы (i18n) +├── server/ # Express + SQLite +│ ├── routes/ # REST API +│ └── lib/ # Вспомогательные модули +├── mcp/ # MCP сервер +├── desktop/ # Electron приложение +├── vscode-extension/ # VS Code расширение +├── scripts/ # Утилиты +├── deployments/ # Docker, Kubernetes, Helm +└── docs/ # Документация +``` + +--- + +## Устранение неполадок + +### Хуки не отправляют данные + +```bash +curl http://localhost:4820/api/health +``` + +Проверьте пути в `~/.claude/settings.json` и логи сервера. + +### Ошибка SQLite при запуске + +``` +Error: better-sqlite3 could not be loaded +``` + +- **Вариант 1:** обновите Node.js до 22+ +- **Вариант 2:** `npm rebuild better-sqlite3` + +### Порт занят + +```bash +lsof -i :4820 # macOS/Linux +DASHBOARD_PORT=5000 npm start +``` + +### Данные не появляются + +Проверьте, не попадает ли рабочая директория под `MONITOR_IGNORE_CWD` в `.env`. + +--- + +## Участие в разработке + +```bash +gh repo fork hoangsonww/Claude-Code-Agent-Monitor +git checkout -b feat/ваша-фича +npm install && npm run dev +npm test && npm run lint +gh pr create +``` + +Хорошие задачи для начала: +- Переводы UI (`client/src/locales/`) +- Новые фильтры и параметры API +- Улучшение документации + +[Открытые issues](https://github.com/hoangsonww/Claude-Code-Agent-Monitor/issues) + +--- + +## Лицензия + +[MIT License](./LICENSE) + +--- + +

+ Сделано с ❤️ сообществом Claude Code
+ English · + 中文 · + Tiếng Việt · + 한국어 · + Русский +

diff --git a/README.md b/README.md index 62274805..58c526f0 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ A professional dashboard to track and visualize your Claude Code agent sessions, ![MIT License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square) > [!TIP] -> See also: [README-CN.md](./README-CN.md) (中文版本), [README-VN.md](./README-VN.md) (Phiên bản tiếng Việt), and [README-KO.md](./README-KO.md) (한국어 버전) for localized documentation with region-specific tips and best practices. +> See also: [README-CN.md](./README-CN.md) (中文版本), [README-VN.md](./README-VN.md) (Phiên bản tiếng Việt), and [README-KO.md](./README-KO.md) (한국어 버전), and [README-RU.md](./README-RU.md) (Русская версия) for localized documentation with region-specific tips and best practices. --- diff --git a/server/routes/hooks.js b/server/routes/hooks.js index 097c48e4..c6783bc8 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -29,6 +29,48 @@ const STALE_MINUTES = (() => { return Number.isFinite(raw) && raw > 0 ? raw : 180; })(); +// ── CWD ignore filter (MONITOR_IGNORE_CWD) ────────────────────────────────── +// Comma-separated list of path prefixes / patterns to exclude from ingestion. +// Each entry is matched against the session's cwd after normalization: +// exact match /home/user/private +// prefix + wildcard /home/user/work/* (any direct child) +// recursive wildcard /home/user/work/** (any descendant) +// Hook events whose cwd matches are silently dropped — they are never stored in +// the database, so they never appear in the UI, analytics, or exports. +const IGNORE_CWD_PATTERNS = (() => { + const raw = process.env.MONITOR_IGNORE_CWD || ""; + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + // Normalise to forward slashes for cross-platform matching + const norm = p.replace(/\/g, "/").replace(/\/+$/, ""); + if (norm.endsWith("/**")) { + // Any descendant: /a/b/** matches /a/b/c and /a/b/c/d + const prefix = norm.slice(0, -3); + return (cwd) => cwd === prefix || cwd.startsWith(prefix + "/"); + } + if (norm.endsWith("/*")) { + // Direct children only: /a/b/* matches /a/b/c but not /a/b/c/d + const prefix = norm.slice(0, -2); + return (cwd) => { + if (!cwd.startsWith(prefix + "/")) return false; + return !cwd.slice(prefix.length + 1).includes("/"); + }; + } + // Exact match (case-sensitive, same as the filesystem) + return (cwd) => cwd === norm; + }); +})(); + +/** Returns true when the given working directory should be excluded from ingest. */ +function isCwdIgnored(cwd) { + if (!cwd || IGNORE_CWD_PATTERNS.length === 0) return false; + const norm = cwd.replace(/\/g, "/").replace(/\/+$/, ""); + return IGNORE_CWD_PATTERNS.some((test) => test(norm)); +} + // Detect Notification messages that indicate Claude Code is blocked waiting // for the user (permission prompt or "waiting for your input" notice). Idle // notifications such as "Claude has finished responding" intentionally do @@ -940,6 +982,12 @@ router.post("/event", (req, res) => { }); } + // Drop hooks from ignored working directories (MONITOR_IGNORE_CWD). + // Return 200 so Claude Code does not retry — we intentionally discard the event. + if (isCwdIgnored(data.cwd)) { + return res.json({ ok: true, ignored: true }); + } + const result = processEvent(hook_type, data); if (!result) { return res.status(400).json({ From 90ac3ed5a59a1ee3b6e54ce11cbca6071a4c7308 Mon Sep 17 00:00:00 2001 From: Anton Date: Sat, 11 Jul 2026 01:17:32 +0300 Subject: [PATCH 2/3] fix(hooks): correct MONITOR_IGNORE_CWD backslash-normalisation regex The backslash replacement used /\/g which is a broken regex literal (matches the string '/g, "' instead of a backslash). Changed to /\/g, the correct form for matching a literal backslash globally. Also tightened the isCwdIgnored guard from !cwd to typeof cwd !== "string" || !cwd so non-string payloads (null, objects) short-circuit safely before the string methods are called. --- server/routes/hooks.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/routes/hooks.js b/server/routes/hooks.js index c6783bc8..63fa21d2 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -45,7 +45,7 @@ const IGNORE_CWD_PATTERNS = (() => { .filter(Boolean) .map((p) => { // Normalise to forward slashes for cross-platform matching - const norm = p.replace(/\/g, "/").replace(/\/+$/, ""); + const norm = p.replace(/\\/g, "/").replace(/\/+$/, ""); if (norm.endsWith("/**")) { // Any descendant: /a/b/** matches /a/b/c and /a/b/c/d const prefix = norm.slice(0, -3); @@ -66,8 +66,8 @@ const IGNORE_CWD_PATTERNS = (() => { /** Returns true when the given working directory should be excluded from ingest. */ function isCwdIgnored(cwd) { - if (!cwd || IGNORE_CWD_PATTERNS.length === 0) return false; - const norm = cwd.replace(/\/g, "/").replace(/\/+$/, ""); + if (typeof cwd !== "string" || !cwd || IGNORE_CWD_PATTERNS.length === 0) return false; + const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); return IGNORE_CWD_PATTERNS.some((test) => test(norm)); } From e6fc944b90c6f6eea7166d5b8a4ad664a0bd69ae Mon Sep 17 00:00:00 2001 From: Anton Date: Sat, 11 Jul 2026 01:20:00 +0300 Subject: [PATCH 3/3] refactor(hooks): extract cwd-filter into lib module + add 29 unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the MONITOR_IGNORE_CWD logic from an inline IIFE in hooks.js into a dedicated server/lib/cwd-filter.js module so it can be tested in isolation without standing up the full Express server. Changes: - server/lib/cwd-filter.js (new) • buildPatterns(raw): compiles a comma-separated pattern string into an array of matcher functions; exported so tests call it directly • isCwdIgnored(cwd): thin wrapper over the module-level PATTERNS array that reads from process.env.MONITOR_IGNORE_CWD at startup • Supports exact, /*, and /** forms; normalises Windows backslashes - server/routes/hooks.js • Remove the inline IGNORE_CWD_PATTERNS block and isCwdIgnored function • require('../lib/cwd-filter') instead — behaviour is unchanged - server/__tests__/cwd-filter.test.js (new, 29 tests) • empty / missing patterns → fast-path • exact-match: match, child, sibling, parent, trailing-slash cases • /* direct-children: child, grandchild, prefix-itself, sibling-prefix • /** recursive: prefix, child, deep descendant, sibling-prefix, other • multiple patterns: all three forms; whitespace around commas • non-string cwd values: null, number, object, empty string • Windows backslash normalisation in both cwd and pattern --- server/__tests__/cwd-filter.test.js | 152 ++++++++++++++++++++++++++++ server/lib/cwd-filter.js | 65 ++++++++++++ server/routes/hooks.js | 43 +------- 3 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 server/__tests__/cwd-filter.test.js create mode 100644 server/lib/cwd-filter.js diff --git a/server/__tests__/cwd-filter.test.js b/server/__tests__/cwd-filter.test.js new file mode 100644 index 00000000..86e6cbed --- /dev/null +++ b/server/__tests__/cwd-filter.test.js @@ -0,0 +1,152 @@ +/** + * @file Unit tests for server/lib/cwd-filter.js — the MONITOR_IGNORE_CWD + * ingest-time ignore filter. Tests cover all three pattern forms (exact, /*, + * /**), edge cases (empty env, bad types, backslash normalisation, trailing + * slashes), and the fast-path when no patterns are configured. + */ + +"use strict"; + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const { buildPatterns } = require("../lib/cwd-filter"); + +// ─── helpers ─────────────────────────────────────────────────────────────── + +/** Run isCwdIgnored against a freshly-built pattern list without touching process.env */ +function match(raw, cwd) { + const patterns = buildPatterns(raw); + if (typeof cwd !== "string" || !cwd || patterns.length === 0) return false; + const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + return patterns.some((test) => test(norm)); +} + +// ─── no patterns ─────────────────────────────────────────────────────────── + +describe("buildPatterns — empty / missing input", () => { + it("returns an empty array for undefined", () => { + assert.deepEqual(buildPatterns(undefined), []); + }); + it("returns an empty array for empty string", () => { + assert.deepEqual(buildPatterns(""), []); + }); + it("returns an empty array for whitespace-only", () => { + assert.deepEqual(buildPatterns(" , , "), []); + }); +}); + +// ─── exact match ─────────────────────────────────────────────────────────── + +describe("exact path match", () => { + const RAW = "/home/user/private"; + + it("matches the exact path", () => { + assert.ok(match(RAW, "/home/user/private")); + }); + it("does not match a child directory", () => { + assert.ok(!match(RAW, "/home/user/private/sub")); + }); + it("does not match a sibling directory", () => { + assert.ok(!match(RAW, "/home/user/other")); + }); + it("does not match a parent directory", () => { + assert.ok(!match(RAW, "/home/user")); + }); + it("strips a trailing slash from the cwd before comparing", () => { + assert.ok(match(RAW, "/home/user/private/")); + }); +}); + +// ─── /* direct children ──────────────────────────────────────────────────── + +describe("/* direct-children pattern", () => { + const RAW = "/home/user/work/*"; + + it("matches a direct child", () => { + assert.ok(match(RAW, "/home/user/work/projectA")); + }); + it("does NOT match a grandchild", () => { + assert.ok(!match(RAW, "/home/user/work/projectA/src")); + }); + it("does NOT match the prefix itself", () => { + assert.ok(!match(RAW, "/home/user/work")); + }); + it("does NOT match a sibling prefix", () => { + assert.ok(!match(RAW, "/home/user/works/projectA")); + }); + it("strips trailing slash from cwd before matching", () => { + assert.ok(match(RAW, "/home/user/work/projectA/")); + }); +}); + +// ─── /** recursive ───────────────────────────────────────────────────────── + +describe("/** recursive-descendant pattern", () => { + const RAW = "/home/user/scratch/**"; + + it("matches the prefix directory itself", () => { + assert.ok(match(RAW, "/home/user/scratch")); + }); + it("matches a direct child", () => { + assert.ok(match(RAW, "/home/user/scratch/a")); + }); + it("matches a deeply nested descendant", () => { + assert.ok(match(RAW, "/home/user/scratch/a/b/c/d")); + }); + it("does NOT match a sibling that starts with the same string", () => { + assert.ok(!match(RAW, "/home/user/scratch-backup")); + }); + it("does NOT match an unrelated path", () => { + assert.ok(!match(RAW, "/home/user/projects")); + }); +}); + +// ─── multiple patterns ───────────────────────────────────────────────────── + +describe("multiple comma-separated patterns", () => { + const RAW = "/home/user/private,/tmp/*,/home/user/scratch/**"; + + it("matches the exact-match pattern", () => { + assert.ok(match(RAW, "/home/user/private")); + }); + it("matches the /* pattern", () => { + assert.ok(match(RAW, "/tmp/session-abc")); + }); + it("matches the /** pattern", () => { + assert.ok(match(RAW, "/home/user/scratch/deep/path")); + }); + it("does NOT match an unrelated path", () => { + assert.ok(!match(RAW, "/home/user/public")); + }); + it("ignores extra whitespace around commas", () => { + assert.ok(match(" /home/user/private , /tmp/* ", "/home/user/private")); + }); +}); + +// ─── edge cases ──────────────────────────────────────────────────────────── + +describe("edge cases — non-string cwd values", () => { + it("returns false for null cwd", () => { + assert.ok(!match("/home/user/private", null)); + }); + it("returns false for numeric cwd", () => { + assert.ok(!match("/home/user/private", 42)); + }); + it("returns false for object cwd", () => { + assert.ok(!match("/home/user/private", {})); + }); + it("returns false for empty string cwd", () => { + assert.ok(!match("/home/user/private", "")); + }); +}); + +describe("Windows backslash normalisation", () => { + const RAW = "C:/Users/user/private"; + + it("matches when cwd uses Windows backslashes", () => { + assert.ok(match(RAW, "C:\\Users\\user\\private")); + }); + it("matches when pattern uses Windows backslashes", () => { + assert.ok(match("C:\\Users\\user\\private", "C:/Users/user/private")); + }); +}); diff --git a/server/lib/cwd-filter.js b/server/lib/cwd-filter.js new file mode 100644 index 00000000..d5eb77b8 --- /dev/null +++ b/server/lib/cwd-filter.js @@ -0,0 +1,65 @@ +/** + * @file Ingest-time CWD ignore filter. + * + * Reads MONITOR_IGNORE_CWD (comma-separated path patterns) at module-load + * time and exposes a single predicate that hooks.js calls before writing any + * event to the database. + * + * Supported pattern forms (forward-slash normalised, case-sensitive): + * /exact/path — strict equality + * /prefix/* — direct children only (no deeper nesting) + * /prefix/** — /prefix itself and all descendants + * + * See also: .env.example → MONITOR_IGNORE_CWD + */ + +"use strict"; + +/** + * Build an array of matcher functions from a raw MONITOR_IGNORE_CWD string. + * Exported so tests can call it with arbitrary input without touching process.env. + * + * @param {string} raw - comma-separated pattern string (may be empty / undefined) + * @returns {Array<(cwd: string) => boolean>} + */ +function buildPatterns(raw) { + if (!raw) return []; + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => { + // Normalise Windows backslashes and strip trailing slashes + const norm = p.replace(/\\/g, "/").replace(/\/+$/, ""); + if (norm.endsWith("/**")) { + const prefix = norm.slice(0, -3); + return (cwd) => cwd === prefix || cwd.startsWith(prefix + "/"); + } + if (norm.endsWith("/*")) { + const prefix = norm.slice(0, -2); + return (cwd) => { + if (!cwd.startsWith(prefix + "/")) return false; + return !cwd.slice(prefix.length + 1).includes("/"); + }; + } + return (cwd) => cwd === norm; + }); +} + +// Patterns compiled once at startup from the live env variable. +const PATTERNS = buildPatterns(process.env.MONITOR_IGNORE_CWD); + +/** + * Returns true when the given working directory matches any configured + * ignore pattern and the hook event should be silently discarded. + * + * @param {unknown} cwd - the cwd field from the hook payload (may be any type) + * @returns {boolean} + */ +function isCwdIgnored(cwd) { + if (typeof cwd !== "string" || !cwd || PATTERNS.length === 0) return false; + const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); + return PATTERNS.some((test) => test(norm)); +} + +module.exports = { isCwdIgnored, buildPatterns }; diff --git a/server/routes/hooks.js b/server/routes/hooks.js index 63fa21d2..37cde4ca 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -15,6 +15,7 @@ const { ingestWorkflowsForSession } = require("../lib/workflow-ingest"); // Required as a module object (not destructured) so tests can swap // `liveness.probeLiveCwds` and the watchdog picks the stub up at call time. const liveness = require("../lib/session-liveness"); +const { isCwdIgnored } = require("../lib/cwd-filter"); const router = Router(); @@ -29,48 +30,6 @@ const STALE_MINUTES = (() => { return Number.isFinite(raw) && raw > 0 ? raw : 180; })(); -// ── CWD ignore filter (MONITOR_IGNORE_CWD) ────────────────────────────────── -// Comma-separated list of path prefixes / patterns to exclude from ingestion. -// Each entry is matched against the session's cwd after normalization: -// exact match /home/user/private -// prefix + wildcard /home/user/work/* (any direct child) -// recursive wildcard /home/user/work/** (any descendant) -// Hook events whose cwd matches are silently dropped — they are never stored in -// the database, so they never appear in the UI, analytics, or exports. -const IGNORE_CWD_PATTERNS = (() => { - const raw = process.env.MONITOR_IGNORE_CWD || ""; - return raw - .split(",") - .map((p) => p.trim()) - .filter(Boolean) - .map((p) => { - // Normalise to forward slashes for cross-platform matching - const norm = p.replace(/\\/g, "/").replace(/\/+$/, ""); - if (norm.endsWith("/**")) { - // Any descendant: /a/b/** matches /a/b/c and /a/b/c/d - const prefix = norm.slice(0, -3); - return (cwd) => cwd === prefix || cwd.startsWith(prefix + "/"); - } - if (norm.endsWith("/*")) { - // Direct children only: /a/b/* matches /a/b/c but not /a/b/c/d - const prefix = norm.slice(0, -2); - return (cwd) => { - if (!cwd.startsWith(prefix + "/")) return false; - return !cwd.slice(prefix.length + 1).includes("/"); - }; - } - // Exact match (case-sensitive, same as the filesystem) - return (cwd) => cwd === norm; - }); -})(); - -/** Returns true when the given working directory should be excluded from ingest. */ -function isCwdIgnored(cwd) { - if (typeof cwd !== "string" || !cwd || IGNORE_CWD_PATTERNS.length === 0) return false; - const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); - return IGNORE_CWD_PATTERNS.some((test) => test(norm)); -} - // Detect Notification messages that indicate Claude Code is blocked waiting // for the user (permission prompt or "waiting for your input" notice). Idle // notifications such as "Claude has finished responding" intentionally do