hookherald 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shoof
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,17 +1,15 @@
1
1
  # HookHerald
2
2
 
3
- A webhook relay that pushes notifications into running [Claude Code](https://claude.ai/code) sessions. Any system that can fire an HTTP POST — CI pipelines, monitoring alerts, deployment hooks, chat bots, cron jobs — can send messages directly into your Claude conversation.
3
+ A webhook relay and watcher system that pushes notifications into running [Claude Code](https://claude.ai/code) sessions. Any system that can fire an HTTP POST — or any script that can print to stdout — can send messages directly into your Claude conversation.
4
4
 
5
5
  ## How It Works
6
6
 
7
7
  ```
8
- Webhook POST ──> Router (auth + route) ──> Channel ──> Claude Code session
9
- :9000 (MCP) <channel> notification
8
+ Webhooks: HTTP POST ──> Router ──> Channel ──> Claude Code
9
+ Watchers: Script stdout ──> Channel ──> Router ──> Channel ──> Claude Code
10
10
  ```
11
11
 
12
- The **router** is a central HTTP server that receives webhooks and forwards them by `project_slug` to the right session. Each Claude Code session runs a **channel** (MCP server) that auto-registers with the router. The **CLI** (`hh`) sets everything up.
13
-
14
- Payloads are forwarded as raw JSON — send whatever you want, the agent figures it out.
12
+ The **router** is a local HTTP server that receives webhooks and forwards them by `project_slug`. Each Claude Code session runs a **channel** (MCP server) that auto-registers with the router. **Watchers** are scripts that run on an interval — their stdout becomes notifications. The **CLI** (`hh`) sets everything up.
15
13
 
16
14
  ## Install
17
15
 
@@ -19,138 +17,192 @@ Payloads are forwarded as raw JSON — send whatever you want, the agent figures
19
17
  npm install -g hookherald
20
18
  ```
21
19
 
22
- That's it. Requires Node.js >= 18.
23
-
24
- ## Usage
20
+ Requires Node.js >= 18.
25
21
 
26
- ### 1. Start the router
22
+ ## Quick Start
27
23
 
28
24
  ```bash
29
- hh router # foreground (see logs, ctrl+c to stop)
30
- hh router --bg # background (detach, write PID file)
31
- hh router stop # stop background router
25
+ # 1. Start the router
26
+ hh router --bg
27
+
28
+ # 2. Set up a project
29
+ cd ~/my-project
30
+ hh init
31
+
32
+ # 3. Start Claude Code (from the same directory — it needs .mcp.json and .hookherald.json)
33
+ claude --dangerously-load-development-channels server:webhook-channel
34
+
35
+ # 4. Send a webhook
36
+ curl -X POST http://127.0.0.1:9000/ \
37
+ -H "Content-Type: application/json" \
38
+ -d '{"project_slug":"my-group/my-project","status":"deployed","version":"1.2.3"}'
32
39
  ```
33
40
 
34
- Dashboard at `http://127.0.0.1:9000/`.
41
+ No auth by default — localhost is trusted. See [Auth](#auth) to enable it.
35
42
 
36
- ### 2. Set up a project
43
+ ## Watchers
37
44
 
38
- ```bash
39
- cd ~/my-project
40
- hh init
45
+ Watchers poll external systems and push notifications into Claude Code. Configure them in `.hookherald.json` (created by `hh init`):
46
+
47
+ ```json
48
+ {
49
+ "slug": "mygroup/myapp",
50
+ "router_url": "http://127.0.0.1:9000",
51
+ "watchers": [
52
+ { "command": "./check-pipeline.sh", "interval": 30 },
53
+ { "command": "kubectl get pods -n default -o json", "interval": 60 }
54
+ ]
55
+ }
41
56
  ```
42
57
 
43
- Auto-detects the project slug from `git remote origin` and writes `.mcp.json`. Merges with existing config if present.
58
+ The contract is simple HookHerald runs your command and forwards whatever it prints:
59
+ - **stdout = send** — any non-empty stdout gets forwarded to Claude Code as a notification
60
+ - **no stdout = skip** — nothing happens, no notification
61
+ - **exit code doesn't matter** — only stdout counts, output is captured even on non-zero exit
62
+ - **JSON stdout is parsed** — valid JSON stays structured, plain text stays as a string
63
+ - **No diffing** — HookHerald doesn't compare outputs between runs. If your script prints something, it gets sent. The script decides when to fire and handles its own state/dedup.
44
64
 
45
- ### 3. Start Claude Code
65
+ ### Example: Watch Kubernetes Pods
46
66
 
47
67
  ```bash
48
- claude --dangerously-load-development-channels server:webhook-channel
68
+ #!/bin/bash
69
+ # watch-pods.sh — notify when pod states change
70
+ STATE_FILE="/tmp/hh-pods-state"
71
+
72
+ CURRENT=$(kubectl get pods -n default -o json 2>/dev/null | jq -c \
73
+ '[.items[] | {name: .metadata.name, phase: .status.phase, ready: (.status.containerStatuses // [] | map(.ready) | all), restarts: (.status.containerStatuses // [] | map(.restartCount) | add // 0)}] | sort_by(.name)')
74
+
75
+ if [ -z "$CURRENT" ] || [ "$CURRENT" = "[]" ]; then exit 0; fi
76
+
77
+ LAST=$(cat "$STATE_FILE" 2>/dev/null)
78
+ if [ "$CURRENT" = "$LAST" ]; then exit 0; fi
79
+
80
+ echo "$CURRENT" > "$STATE_FILE"
81
+ echo "$CURRENT" | jq '{
82
+ pods: .,
83
+ summary: {
84
+ total: (. | length),
85
+ running: ([.[] | select(.phase == "Running")] | length),
86
+ not_ready: ([.[] | select(.ready == false)] | length),
87
+ crashing: ([.[] | select(.restarts > 3)] | length)
88
+ }
89
+ }'
49
90
  ```
50
91
 
51
- The channel starts, registers with the router, and maintains a heartbeat. If the router restarts, the channel reconnects automatically. When the session ends, the channel cleans up after itself.
52
-
53
- ### 4. Send webhooks
92
+ ### Example: Watch GitLab Pipeline
54
93
 
55
94
  ```bash
56
- curl -X POST http://127.0.0.1:9000/ \
57
- -H "Content-Type: application/json" \
58
- -H "X-Webhook-Token: dev-secret" \
59
- -d '{"project_slug":"my-group/my-project","status":"deployed","version":"1.2.3"}'
95
+ #!/bin/bash
96
+ # check-pipeline.sh notify on pipeline completion
97
+ STATE_FILE="/tmp/hh-pipeline-last"
98
+
99
+ PIPELINE=$(curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
100
+ "https://gitlab.com/api/v4/projects/mygroup%2Fmyapp/pipelines/latest")
101
+
102
+ ID=$(echo "$PIPELINE" | jq -r '.id')
103
+ STATUS=$(echo "$PIPELINE" | jq -r '.status')
104
+ LAST=$(cat "$STATE_FILE" 2>/dev/null)
105
+
106
+ case "$STATUS" in
107
+ failed|success|canceled)
108
+ [ "$ID" = "$LAST" ] && exit 0
109
+ echo "$ID" > "$STATE_FILE"
110
+ echo "$PIPELINE" | jq '{
111
+ pipeline_id: .id,
112
+ status: .status,
113
+ ref: .ref,
114
+ url: .web_url
115
+ }'
116
+ ;;
117
+ esac
60
118
  ```
61
119
 
62
- The only required field is `project_slug` for routing — everything else passes through as raw JSON.
120
+ ### Hot Reload
121
+
122
+ Edit `.hookherald.json` while Claude Code is running — watchers are added/removed automatically. No restart needed. The dashboard updates within 30 seconds.
63
123
 
64
- The `X-Webhook-Token` header authenticates the request against `WEBHOOK_SECRET`. GitLab sends this natively as `X-Gitlab-Token` when you configure a webhook secret — the router accepts both headers.
124
+ See [examples/README.md](examples/README.md) for detailed walkthroughs, more scripts, writing your own watchers, and troubleshooting.
65
125
 
66
- ## CLI Reference
126
+ ## CLI
67
127
 
68
128
  ```
69
- hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json in current directory
129
+ hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json + .hookherald.json
70
130
  hh status [--router-url <url>] Show active sessions
71
- hh kill <slug> [--router-url <url>] Bounce a session (Claude Code respawns it)
131
+ hh kill <slug> [--router-url <url>] Bounce a session
72
132
  hh router [--port <port>] [--secret <secret>] Start the webhook router
73
133
  [--bg] Run in background
74
134
  hh router stop Stop background router
75
135
  ```
76
136
 
137
+ `hh init` auto-detects the project slug from `git remote origin`. Creates `.hookherald.json` with an empty watchers array. Merges with existing `.mcp.json` if present. Won't overwrite an existing `.hookherald.json`.
138
+
139
+ `hh kill` signals the channel to shut down. Claude Code will respawn it.
140
+
141
+ ## Auth
142
+
143
+ Auth is opt-in. By default, no secret is needed — everything runs on localhost.
144
+
145
+ ```bash
146
+ # No auth (default)
147
+ hh router
148
+
149
+ # Enable auth on webhook ingestion
150
+ hh router --secret my-secret
151
+ ```
152
+
153
+ When a secret is set, `POST /` requires `X-Webhook-Token` (or `X-Gitlab-Token`). Internal endpoints (`/register`, `/unregister`, `/api/kill`) never require auth.
154
+
155
+ For external sources (GitLab CI, GitHub Actions), start the router with `--secret` and configure the same secret in the webhook settings.
156
+
77
157
  ## Docker
78
158
 
79
- The router can also run via Docker. Requires `--network host` to reach channel processes on localhost:
159
+ The router can also run via Docker (requires `--network host` on Linux):
80
160
 
81
161
  ```bash
162
+ docker run -d --network host shoofio/hookherald
163
+
164
+ # With auth
82
165
  docker run -d --network host -e WEBHOOK_SECRET=my-secret shoofio/hookherald
83
166
  ```
84
167
 
85
- > **Note:** `--network host` requires Linux with standard Docker. For rootless Docker or Docker Desktop (Mac/Windows), use `hh router` instead.
168
+ > For rootless Docker or Docker Desktop (Mac/Windows), use `hh router` instead.
169
+
170
+ ## Dashboard
171
+
172
+ Live session management UI at `http://127.0.0.1:9000/`:
173
+
174
+ - **Sessions** — status, events, errors, latency, kill button
175
+ - **Watchers** — shown per session with command and interval, click to filter events by source
176
+ - **Events** — click sessions to filter, ctrl/shift for multi-select, expand for trace waterfall and payload
177
+ - **Live** — SSE-powered, no refresh needed
86
178
 
87
179
  ## Router API
88
180
 
89
181
  | Method | Path | Description |
90
182
  |--------|------|-------------|
91
- | `POST` | `/` | Receive webhook (requires `X-Webhook-Token` or `X-Gitlab-Token` header) |
92
- | `POST` | `/register` | Channel self-registration |
93
- | `POST` | `/unregister` | Channel self-unregistration |
183
+ | `POST` | `/` | Receive webhook (auth required only if `WEBHOOK_SECRET` is set) |
94
184
  | `POST` | `/api/kill` | Remove a session (signals channel to shut down) |
95
- | `GET` | `/` | Session management dashboard |
96
- | `GET` | `/api/health` | Router health check |
97
- | `GET` | `/api/sessions` | List active sessions with metrics |
185
+ | `GET` | `/` | Dashboard |
186
+ | `GET` | `/api/health` | Health check |
187
+ | `GET` | `/api/sessions` | Active sessions with metrics and watchers |
98
188
  | `GET` | `/api/events` | Query events (`?slug=`, `?limit=`, `?offset=`) |
99
189
  | `GET` | `/api/events/:id` | Single event by ID |
100
- | `GET` | `/api/stats` | Aggregated statistics |
101
190
  | `GET` | `/api/stream` | SSE live updates |
102
- | `GET` | `/routes` | Raw routing table |
103
- | `GET` | `/metrics` | Prometheus format metrics |
104
-
105
- ## Dashboard
106
-
107
- The router serves a live session management UI at `http://127.0.0.1:9000/`:
108
-
109
- - **Sessions table** — status, port, event count, errors, latency, kill button
110
- - **Event feed** — click sessions to filter, ctrl/shift+click for multi-select
111
- - **Event detail** — expand inline for summary, trace waterfall, raw payload
112
- - **Live updates** — SSE-powered, no refresh needed
113
-
114
- ## GitLab CI Integration
115
-
116
- Add a webhook in your GitLab project/group settings:
117
- - **URL**: `http://<your-machine>:9000/`
118
- - **Secret token**: your `WEBHOOK_SECRET`
119
- - **Trigger**: Pipeline events (or any events you want)
120
-
121
- Or add a `curl` step in `.gitlab-ci.yml` for custom payloads:
122
-
123
- ```yaml
124
- notify:
125
- stage: .post
126
- script:
127
- - |
128
- curl -s -X POST http://$ROUTER_HOST:9000/ \
129
- -H "Content-Type: application/json" \
130
- -H "X-Webhook-Token: $WEBHOOK_SECRET" \
131
- -d "{\"project_slug\":\"$CI_PROJECT_PATH\",\"status\":\"$CI_PIPELINE_STATUS\",\"branch\":\"$CI_COMMIT_BRANCH\",\"sha\":\"$CI_COMMIT_SHA\",\"pipeline\":\"$CI_PIPELINE_URL\"}"
132
- ```
191
+ | `GET` | `/metrics` | Prometheus format |
133
192
 
134
193
  ## Environment Variables
135
194
 
136
195
  | Variable | Default | Description |
137
196
  |----------|---------|-------------|
138
197
  | `ROUTER_PORT` | `9000` | Router listen port |
139
- | `ROUTER_HOST` | `127.0.0.1` | Router bind address (`0.0.0.0` for Docker) |
140
- | `WEBHOOK_SECRET` | `dev-secret` | Shared secret for webhook auth |
141
- | `PROJECT_SLUG` | `unknown/project` | Channel's project identifier |
142
- | `ROUTER_URL` | `http://127.0.0.1:9000` | Channel's router address |
143
- | `LOG_LEVEL` | `info` | Log level (debug/info/warn/error) |
198
+ | `ROUTER_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for Docker) |
199
+ | `WEBHOOK_SECRET` | *(none)* | Auth secret (opt-in) |
200
+ | `PROJECT_SLUG` | `unknown/project` | Channel's project ID |
201
+ | `ROUTER_URL` | `http://127.0.0.1:9000` | Router address |
202
+ | `LOG_LEVEL` | `info` | debug/info/warn/error |
144
203
  | `HH_HEARTBEAT_MS` | `30000` | Channel heartbeat interval |
204
+ | `HH_CONFIG_PATH` | *(none)* | Path to `.hookherald.json` (set by `hh init`) |
145
205
 
146
- ## Alternative: Go CLI
147
-
148
- A compiled Go binary is also available for faster startup (~5ms vs ~700ms). See `cmd/hh/` and [releases](https://github.com/Shoofio/HookHerald/releases).
149
-
150
- ## Testing
151
-
152
- ```bash
153
- npm test # 79 tests across 4 suites
154
- ```
206
+ ## License
155
207
 
156
- Tests are integration-heavy — they spawn real processes and make real HTTP requests. Safe to run with a live router.
208
+ MIT
package/bin/hh CHANGED
@@ -1,2 +1,10 @@
1
1
  #!/bin/sh
2
- exec npx tsx "$(dirname "$(readlink -f "$0")")/../src/cli.ts" "$@"
2
+ # Resolve symlinks to find the actual package directory
3
+ SELF="$0"
4
+ while [ -L "$SELF" ]; do
5
+ DIR="$(cd "$(dirname "$SELF")" && pwd)"
6
+ SELF="$(readlink "$SELF")"
7
+ case "$SELF" in /*) ;; *) SELF="$DIR/$SELF" ;; esac
8
+ done
9
+ SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
10
+ exec npx tsx "$SCRIPT_DIR/../src/cli.ts" "$@"
package/package.json CHANGED
@@ -1,8 +1,21 @@
1
1
  {
2
2
  "name": "hookherald",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Webhook relay for Claude Code — push notifications from any HTTP POST into running sessions",
5
5
  "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Shoofio/HookHerald.git"
10
+ },
11
+ "keywords": [
12
+ "webhook",
13
+ "claude-code",
14
+ "mcp",
15
+ "notifications",
16
+ "ci",
17
+ "cli"
18
+ ],
6
19
  "bin": {
7
20
  "hh": "bin/hh"
8
21
  },
@@ -16,7 +29,6 @@
16
29
  "scripts": {
17
30
  "router": "npx tsx src/webhook-router.ts",
18
31
  "channel": "npx tsx src/webhook-channel.ts",
19
- "build:go": "go build -ldflags \"-X main.projectRoot=$(pwd)\" -o hh ./cmd/hh/",
20
32
  "test": "npx tsx --test-force-exit --test tests/observability.test.ts && npx tsx --test-force-exit --test tests/channel.test.ts && npx tsx --test-force-exit --test tests/router.test.ts && npx tsx --test-force-exit --test tests/cli.test.ts"
21
33
  },
22
34
  "dependencies": {
package/src/cli.ts CHANGED
@@ -16,7 +16,6 @@ const PID_FILE = resolve(PID_DIR, "router.pid");
16
16
 
17
17
  const DEFAULT_ROUTER_URL = "http://127.0.0.1:9000";
18
18
  const DEFAULT_PORT = "9000";
19
- const DEFAULT_SECRET = "dev-secret";
20
19
 
21
20
  const args = process.argv.slice(2);
22
21
  const command = args[0];
@@ -45,8 +44,9 @@ function detectSlug(): string {
45
44
  }).trim();
46
45
 
47
46
  // SSH: git@gitlab.com:group/project.git
47
+ // Skip if it looks like a URL scheme (e.g. https:)
48
48
  const lastColon = remote.lastIndexOf(":");
49
- if (lastColon !== -1 && !remote.slice(0, lastColon).includes("/")) {
49
+ if (lastColon !== -1 && !remote.includes("://")) {
50
50
  const slug = remote.slice(lastColon + 1).replace(/\.git$/, "");
51
51
  if (slug.includes("/")) return slug;
52
52
  }
@@ -81,17 +81,33 @@ async function cmdInit() {
81
81
 
82
82
  if (!config.mcpServers) config.mcpServers = {};
83
83
 
84
+ const hhConfigPath = resolve(process.cwd(), ".hookherald.json");
85
+
84
86
  config.mcpServers["webhook-channel"] = {
85
87
  command: "node",
86
88
  args: ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), CHANNEL_PATH],
87
89
  env: {
88
90
  PROJECT_SLUG: slug,
89
91
  ROUTER_URL: routerUrl,
92
+ HH_CONFIG_PATH: hhConfigPath,
90
93
  },
91
94
  };
92
95
 
93
96
  writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
94
- console.log(`Initialized HookHerald for ${slug} in .mcp.json`);
97
+
98
+ // Write .hookherald.json if it doesn't exist
99
+ if (!existsSync(hhConfigPath)) {
100
+ const hhConfig = {
101
+ slug,
102
+ router_url: routerUrl,
103
+ watchers: [],
104
+ };
105
+ writeFileSync(hhConfigPath, JSON.stringify(hhConfig, null, 2) + "\n");
106
+ console.log(`Initialized HookHerald for ${slug}`);
107
+ console.log(` Config: ${hhConfigPath}`);
108
+ } else {
109
+ console.log(`Initialized HookHerald for ${slug} (config already exists)`);
110
+ }
95
111
  console.log(` Channel: ${CHANNEL_PATH}`);
96
112
  console.log(` Router: ${routerUrl}`);
97
113
  }
@@ -184,7 +200,7 @@ function cmdRouter() {
184
200
  }
185
201
 
186
202
  const port = getFlag("port") || process.env.ROUTER_PORT || DEFAULT_PORT;
187
- const secret = getFlag("secret") || process.env.WEBHOOK_SECRET || DEFAULT_SECRET;
203
+ const secret = getFlag("secret") || process.env.WEBHOOK_SECRET || "";
188
204
  const bg = hasFlag("bg");
189
205
 
190
206
  const child = spawn("node", ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), ROUTER_PATH], {
@@ -284,6 +284,28 @@
284
284
  border-radius: 4px;
285
285
  }
286
286
  .filter-bar label { color: var(--text-dim); }
287
+
288
+ /* Watcher rows */
289
+ .watcher-row td { padding: 0 12px 8px !important; border-bottom: 1px solid var(--border); }
290
+ .watcher-row.selected td { background: #1a2636; }
291
+ .watchers-list { display: flex; flex-wrap: wrap; gap: 6px; }
292
+ .watcher-tag {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ gap: 4px;
296
+ font-family: var(--mono);
297
+ font-size: 11px;
298
+ padding: 2px 8px;
299
+ border-radius: 3px;
300
+ background: #1a2a3a;
301
+ color: var(--text-dim);
302
+ border: 1px solid var(--border);
303
+ }
304
+ .watcher-tag { cursor: pointer; }
305
+ .watcher-tag:hover { border-color: var(--blue); }
306
+ .watcher-tag.active { background: #1a3a5f; border-color: var(--blue); color: var(--text); }
307
+ .watcher-icon { font-size: 10px; }
308
+ .watcher-interval { color: var(--blue); font-weight: 600; }
287
309
  </style>
288
310
  </head>
289
311
  <body>
@@ -335,6 +357,7 @@ const state = {
335
357
  expandedEventId: null,
336
358
  selectedSlugs: new Set(),
337
359
  filterType: '',
360
+ filterSource: '',
338
361
  };
339
362
 
340
363
  // --- SSE ---
@@ -431,6 +454,14 @@ function renderSessions() {
431
454
  <td class="last-event-cell" data-ts="${s.lastEventAt || ''}">${s.lastEventAt ? timeAgo(s.lastEventAt) : 'never'}</td>
432
455
  <td><button class="btn-kill" onclick="event.stopPropagation(); killSession('${esc(s.slug)}')">kill</button></td>
433
456
  </tr>`;
457
+ if (s.watchers && s.watchers.length > 0) {
458
+ html += `<tr class="watcher-row${sel}"><td colspan="8"><div class="watchers-list">`;
459
+ for (const w of s.watchers) {
460
+ const active = state.filterSource === w.command ? ' active' : '';
461
+ html += `<span class="watcher-tag${active}" title="${esc(w.command)}" onclick="event.stopPropagation(); filterBySource('${esc(w.command)}')"><span class="watcher-icon">&#9201;</span>${esc(w.command.length > 40 ? w.command.slice(0, 37) + '...' : w.command)} <span class="watcher-interval">${w.interval}s</span></span>`;
462
+ }
463
+ html += '</div></td></tr>';
464
+ }
434
465
  }
435
466
 
436
467
  html += '</tbody></table>';
@@ -472,22 +503,28 @@ function selectSession(slug, ev) {
472
503
  updateFilterHint();
473
504
  }
474
505
 
506
+ function filterBySource(cmd) {
507
+ state.filterSource = state.filterSource === cmd ? '' : cmd;
508
+ renderSessions();
509
+ renderEvents();
510
+ updateFilterHint();
511
+ }
512
+
475
513
  function updateFilterHint() {
476
514
  const el = document.getElementById('filterHint');
515
+ const parts = [];
477
516
  const n = state.selectedSlugs.size;
478
- if (n === 0) {
479
- el.textContent = 'Showing all sessions';
480
- } else if (n === 1) {
481
- el.textContent = 'Filtered: ' + [...state.selectedSlugs][0];
482
- } else {
483
- el.textContent = 'Filtered: ' + n + ' sessions';
484
- }
517
+ if (n === 1) parts.push([...state.selectedSlugs][0]);
518
+ else if (n > 1) parts.push(n + ' sessions');
519
+ if (state.filterSource) parts.push('source: ' + state.filterSource);
520
+ el.textContent = parts.length ? 'Filtered: ' + parts.join(', ') : 'Showing all sessions';
485
521
  }
486
522
 
487
523
  function getFilteredEvents() {
488
524
  return state.events.filter(ev => {
489
525
  if (state.selectedSlugs.size > 0 && !state.selectedSlugs.has(ev.slug)) return false;
490
526
  if (state.filterType && ev.type !== state.filterType) return false;
527
+ if (state.filterSource && (!ev.payload || ev.payload.source !== state.filterSource)) return false;
491
528
  return true;
492
529
  });
493
530
  }
@@ -30,6 +30,11 @@ export interface RouterEvent {
30
30
  error?: string;
31
31
  }
32
32
 
33
+ export interface WatcherConfig {
34
+ command: string;
35
+ interval: number;
36
+ }
37
+
33
38
  export interface RouteInfo {
34
39
  port: number;
35
40
  registeredAt: string;
@@ -37,6 +42,7 @@ export interface RouteInfo {
37
42
  eventCount: number;
38
43
  errorCount: number;
39
44
  status: "up" | "down" | "unknown";
45
+ watchers: WatcherConfig[];
40
46
  }
41
47
 
42
48
  interface RouteMetrics {
@@ -1,16 +1,26 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { createServer } from "node:http";
4
- import { createLogger } from "./observability.js";
4
+ import { readFileSync, watch as fsWatch, type FSWatcher } from "node:fs";
5
+ import { execSync } from "node:child_process";
6
+ import { resolve, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createLogger, type WatcherConfig } from "./observability.js";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8"));
12
+ const VERSION = pkg.version;
5
13
 
6
14
  const PROJECT_SLUG = process.env.PROJECT_SLUG || "unknown/project";
7
15
  const ROUTER_URL = process.env.ROUTER_URL || "http://127.0.0.1:9000";
16
+ const CONFIG_PATH = process.env.HH_CONFIG_PATH || "";
17
+ const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "";
8
18
 
9
19
  const log = createLogger(`channel:${PROJECT_SLUG}`, true); // stderr for MCP
10
20
 
11
21
  // --- MCP Server setup ---
12
22
  const mcp = new Server(
13
- { name: "webhook-channel", version: "0.2.0" },
23
+ { name: "webhook-channel", version: VERSION },
14
24
  {
15
25
  capabilities: {
16
26
  experimental: { "claude/channel": {} },
@@ -95,7 +105,11 @@ async function register(): Promise<boolean> {
95
105
  const resp = await fetch(`${ROUTER_URL}/register`, {
96
106
  method: "POST",
97
107
  headers: { "Content-Type": "application/json" },
98
- body: JSON.stringify({ project_slug: PROJECT_SLUG, port: assignedPort }),
108
+ body: JSON.stringify({
109
+ project_slug: PROJECT_SLUG,
110
+ port: assignedPort,
111
+ watchers: currentWatcherConfigs,
112
+ }),
99
113
  });
100
114
  if (resp.ok) {
101
115
  if (!registered) log.info("registered with router", { router: ROUTER_URL });
@@ -117,20 +131,148 @@ function startHeartbeat() {
117
131
  heartbeatTimer.unref();
118
132
  }
119
133
 
134
+ // --- Watcher system ---
135
+
136
+ let currentWatcherConfigs: WatcherConfig[] = [];
137
+ const watcherIntervals: Map<string, ReturnType<typeof setInterval>> = new Map();
138
+ let configWatcher: FSWatcher | null = null;
139
+
140
+ function readConfig(): { slug: string; router_url: string; watchers: WatcherConfig[] } | null {
141
+ if (!CONFIG_PATH) return null;
142
+ try {
143
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function executeCommand(cmd: string): string {
150
+ try {
151
+ return execSync(cmd, { encoding: "utf-8", shell: true, stdio: ["pipe", "pipe", "pipe"], timeout: 60000 }).trim();
152
+ } catch (err: any) {
153
+ return (err.stdout || "").trim();
154
+ }
155
+ }
156
+
157
+ async function runWatcher(watcher: WatcherConfig) {
158
+ const output = executeCommand(watcher.command);
159
+ if (!output) return;
160
+
161
+ let parsed: any;
162
+ try {
163
+ parsed = JSON.parse(output);
164
+ } catch {
165
+ parsed = output;
166
+ }
167
+
168
+ const envelope = {
169
+ project_slug: PROJECT_SLUG,
170
+ source: watcher.command,
171
+ output: parsed,
172
+ };
173
+
174
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
175
+ if (WEBHOOK_SECRET) headers["X-Webhook-Token"] = WEBHOOK_SECRET;
176
+
177
+ try {
178
+ await fetch(`${ROUTER_URL}/`, {
179
+ method: "POST",
180
+ headers,
181
+ body: JSON.stringify(envelope),
182
+ });
183
+ log.debug("watcher sent", { command: watcher.command });
184
+ } catch (err: any) {
185
+ log.warn("watcher POST failed", { command: watcher.command, error: err.message });
186
+ }
187
+ }
188
+
189
+ function watcherKey(w: WatcherConfig): string {
190
+ return `${w.command}::${w.interval}`;
191
+ }
192
+
193
+ function startWatchers(watchers: WatcherConfig[]) {
194
+ // Stop removed/changed watchers
195
+ const newKeys = new Set(watchers.map(watcherKey));
196
+ for (const [key, timer] of watcherIntervals) {
197
+ if (!newKeys.has(key)) {
198
+ clearInterval(timer);
199
+ watcherIntervals.delete(key);
200
+ log.info("watcher stopped", { key });
201
+ }
202
+ }
203
+
204
+ // Start new watchers
205
+ for (const w of watchers) {
206
+ const key = watcherKey(w);
207
+ if (watcherIntervals.has(key)) continue;
208
+
209
+ // Run immediately, then on interval
210
+ runWatcher(w);
211
+ const timer = setInterval(() => runWatcher(w), w.interval * 1000);
212
+ timer.unref();
213
+ watcherIntervals.set(key, timer);
214
+ log.info("watcher started", { command: w.command, interval: w.interval });
215
+ }
216
+
217
+ currentWatcherConfigs = watchers;
218
+ }
219
+
220
+ function loadAndStartWatchers() {
221
+ const config = readConfig();
222
+ const watchers = config?.watchers || [];
223
+ startWatchers(watchers);
224
+ }
225
+
226
+ function startConfigWatcher() {
227
+ if (!CONFIG_PATH) return;
228
+
229
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
230
+
231
+ function watch() {
232
+ if (configWatcher) { try { configWatcher.close(); } catch {} }
233
+ try {
234
+ configWatcher = fsWatch(CONFIG_PATH, () => {
235
+ if (debounceTimer) clearTimeout(debounceTimer);
236
+ debounceTimer = setTimeout(() => {
237
+ log.info("config changed, reloading watchers");
238
+ loadAndStartWatchers();
239
+ // Re-establish watcher (inode may have changed)
240
+ watch();
241
+ }, 200);
242
+ });
243
+ configWatcher.unref();
244
+ } catch {
245
+ log.debug("could not watch config file, retrying in 5s", { path: CONFIG_PATH });
246
+ setTimeout(watch, 5000);
247
+ }
248
+ }
249
+
250
+ watch();
251
+ }
252
+
120
253
  // Bind to port 0 for auto-assignment
121
254
  httpServer.listen(0, "127.0.0.1", async () => {
122
255
  const addr = httpServer.address();
123
256
  assignedPort = typeof addr === "object" && addr ? addr.port : 0;
124
257
  log.info("HTTP server listening", { host: "127.0.0.1", port: assignedPort });
125
258
 
259
+ loadAndStartWatchers();
260
+ startConfigWatcher();
126
261
  await register();
127
262
  startHeartbeat();
128
263
  });
129
264
 
130
265
  // Graceful shutdown: unregister from router
266
+ let shutdownInProgress = false;
267
+
131
268
  async function shutdown() {
269
+ if (shutdownInProgress) return;
270
+ shutdownInProgress = true;
132
271
  log.info("shutting down");
133
272
  if (heartbeatTimer) clearInterval(heartbeatTimer);
273
+ for (const timer of watcherIntervals.values()) clearInterval(timer);
274
+ watcherIntervals.clear();
275
+ if (configWatcher) { configWatcher.close(); configWatcher = null; }
134
276
  try {
135
277
  await fetch(`${ROUTER_URL}/unregister`, {
136
278
  method: "POST",
@@ -2,6 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
2
2
  import { readFileSync } from "node:fs";
3
3
  import { resolve, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { timingSafeEqual } from "node:crypto";
5
6
  import {
6
7
  createLogger,
7
8
  EventStore,
@@ -15,9 +16,17 @@ import {
15
16
 
16
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
18
 
19
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8"));
20
+ const VERSION = pkg.version;
21
+
18
22
  const PORT = parseInt(process.env.ROUTER_PORT || "9000", 10);
19
23
  const HOST = process.env.ROUTER_HOST || "127.0.0.1";
20
- const SECRET = process.env.WEBHOOK_SECRET || "dev-secret";
24
+ const SECRET = process.env.WEBHOOK_SECRET || "";
25
+
26
+ function safeEqual(a: string, b: string): boolean {
27
+ if (a.length !== b.length) return false;
28
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
29
+ }
21
30
 
22
31
  const log = createLogger("router");
23
32
  const events = new EventStore();
@@ -36,9 +45,16 @@ try {
36
45
 
37
46
  // --- Request body helper ---
38
47
 
48
+ const MAX_BODY = 10 * 1024 * 1024; // 10MB
49
+
39
50
  async function readBody(req: IncomingMessage): Promise<string> {
40
51
  const chunks: Buffer[] = [];
41
- for await (const chunk of req) chunks.push(chunk as Buffer);
52
+ let size = 0;
53
+ for await (const chunk of req) {
54
+ size += (chunk as Buffer).length;
55
+ if (size > MAX_BODY) throw new Error("body too large");
56
+ chunks.push(chunk as Buffer);
57
+ }
42
58
  return Buffer.concat(chunks).toString();
43
59
  }
44
60
 
@@ -51,20 +67,27 @@ function getRoutesSnapshot() {
51
67
  // --- Handlers ---
52
68
 
53
69
  async function handleRegister(req: IncomingMessage, res: ServerResponse) {
54
- const body = JSON.parse(await readBody(req));
55
- const { project_slug, port } = body;
70
+ let body: any;
71
+ try {
72
+ body = JSON.parse(await readBody(req));
73
+ } catch {
74
+ res.writeHead(400, { "Content-Type": "application/json" });
75
+ res.end(JSON.stringify({ error: "invalid JSON" }));
76
+ return;
77
+ }
78
+ const { project_slug, port, watchers } = body;
56
79
  if (!project_slug || !port) {
57
80
  res.writeHead(400, { "Content-Type": "application/json" });
58
81
  res.end(JSON.stringify({ error: "missing project_slug or port" }));
59
82
  return;
60
83
  }
61
84
 
62
- // Heartbeat: if same slug already registered, treat as keepalive
85
+ // Heartbeat: if same slug and same port, treat as keepalive
63
86
  const existing = routes.get(project_slug);
64
- if (existing) {
65
- if (existing.port !== port) {
66
- existing.port = port;
67
- log.info("route updated", { slug: project_slug, port });
87
+ if (existing && existing.port === port) {
88
+ // Update watchers on heartbeat (supports hot reload)
89
+ if (watchers && JSON.stringify(watchers) !== JSON.stringify(existing.watchers)) {
90
+ existing.watchers = watchers;
68
91
  broadcast("session", { sessions: getSessionsData() });
69
92
  }
70
93
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -79,6 +102,7 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
79
102
  eventCount: 0,
80
103
  errorCount: 0,
81
104
  status: "unknown",
105
+ watchers: watchers || [],
82
106
  };
83
107
  routes.set(project_slug, info);
84
108
  metrics.registrations++;
@@ -103,7 +127,14 @@ async function handleRegister(req: IncomingMessage, res: ServerResponse) {
103
127
  }
104
128
 
105
129
  async function handleUnregister(req: IncomingMessage, res: ServerResponse) {
106
- const body = JSON.parse(await readBody(req));
130
+ let body: any;
131
+ try {
132
+ body = JSON.parse(await readBody(req));
133
+ } catch {
134
+ res.writeHead(400, { "Content-Type": "application/json" });
135
+ res.end(JSON.stringify({ error: "invalid JSON" }));
136
+ return;
137
+ }
107
138
  const { project_slug } = body;
108
139
  if (!project_slug) {
109
140
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -141,32 +172,34 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
141
172
  const trace = createTrace();
142
173
  const traceId = newEventId();
143
174
 
144
- // Auth
175
+ // Auth (opt-in: only check if SECRET is configured)
145
176
  const authSpan = trace.span("auth_validate");
146
- const token = req.headers["x-webhook-token"] || req.headers["x-gitlab-token"];
147
- if (token !== SECRET) {
148
- trace.end(authSpan);
149
- log.warn("rejected: invalid token", { traceId });
150
- metrics.recordRequest(401);
151
- metrics.recordWebhook("unauthorized");
152
-
153
- const ev: RouterEvent = {
154
- id: traceId,
155
- timestamp: new Date().toISOString(),
156
- type: "webhook",
157
- slug: "unknown",
158
- routingDecision: "unauthorized",
159
- durationMs: trace.elapsed(),
160
- responseStatus: 401,
161
- traceSpans: trace.spans,
162
- error: "invalid token",
163
- };
164
- events.push(ev);
165
- broadcast("webhook", ev);
166
-
167
- res.writeHead(401, { "Content-Type": "application/json" });
168
- res.end(JSON.stringify({ error: "unauthorized" }));
169
- return;
177
+ if (SECRET) {
178
+ const token = req.headers["x-webhook-token"] || req.headers["x-gitlab-token"];
179
+ if (!safeEqual(String(token ?? ""), SECRET)) {
180
+ trace.end(authSpan);
181
+ log.warn("rejected: invalid token", { traceId });
182
+ metrics.recordRequest(401);
183
+ metrics.recordWebhook("unauthorized");
184
+
185
+ const ev: RouterEvent = {
186
+ id: traceId,
187
+ timestamp: new Date().toISOString(),
188
+ type: "webhook",
189
+ slug: "unknown",
190
+ routingDecision: "unauthorized",
191
+ durationMs: trace.elapsed(),
192
+ responseStatus: 401,
193
+ traceSpans: trace.spans,
194
+ error: "invalid token",
195
+ };
196
+ events.push(ev);
197
+ broadcast("webhook", ev);
198
+
199
+ res.writeHead(401, { "Content-Type": "application/json" });
200
+ res.end(JSON.stringify({ error: "unauthorized" }));
201
+ return;
202
+ }
170
203
  }
171
204
  trace.end(authSpan);
172
205
 
@@ -294,6 +327,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
294
327
  };
295
328
  events.push(ev);
296
329
  broadcast("webhook", ev);
330
+ broadcast("session", { sessions: getSessionsData() });
297
331
 
298
332
  res.writeHead(200, { "Content-Type": "application/json" });
299
333
  res.end(JSON.stringify({ ok: true, forwarded_to: routeInfo.port }));
@@ -323,6 +357,7 @@ async function handleWebhook(req: IncomingMessage, res: ServerResponse) {
323
357
  };
324
358
  events.push(ev);
325
359
  broadcast("webhook", ev);
360
+ broadcast("session", { sessions: getSessionsData() });
326
361
 
327
362
  res.writeHead(502, { "Content-Type": "application/json" });
328
363
  res.end(JSON.stringify({ error: "downstream unreachable" }));
@@ -404,14 +439,21 @@ function handleApiHealth(_req: IncomingMessage, res: ServerResponse) {
404
439
  res.writeHead(200, { "Content-Type": "application/json" });
405
440
  res.end(JSON.stringify({
406
441
  status: "ok",
407
- version: "0.2.0",
442
+ version: VERSION,
408
443
  uptimeSeconds: Math.floor((Date.now() - metrics.startTime) / 1000),
409
444
  routesActive: routes.size,
410
445
  }));
411
446
  }
412
447
 
413
448
  async function handleApiKill(req: IncomingMessage, res: ServerResponse) {
414
- const body = JSON.parse(await readBody(req));
449
+ let body: any;
450
+ try {
451
+ body = JSON.parse(await readBody(req));
452
+ } catch {
453
+ res.writeHead(400, { "Content-Type": "application/json" });
454
+ res.end(JSON.stringify({ error: "invalid JSON" }));
455
+ return;
456
+ }
415
457
  const { project_slug } = body;
416
458
  if (!project_slug) {
417
459
  res.writeHead(400, { "Content-Type": "application/json" });
@@ -468,6 +510,7 @@ function getSessionsData() {
468
510
  avgLatencyMs: rm?.avgLatencyMs ?? 0,
469
511
  successCount: rm?.success ?? 0,
470
512
  failedCount: rm?.failed ?? 0,
513
+ watchers: info.watchers,
471
514
  };
472
515
  });
473
516
  }
@@ -530,6 +573,11 @@ const server = createServer(async (req, res) => {
530
573
  res.writeHead(404, { "Content-Type": "application/json" });
531
574
  res.end(JSON.stringify({ error: "not found" }));
532
575
  } catch (err: any) {
576
+ if (err.message === "body too large") {
577
+ res.writeHead(413, { "Content-Type": "application/json" });
578
+ res.end(JSON.stringify({ error: "payload too large" }));
579
+ return;
580
+ }
533
581
  log.error("unhandled error", { error: err.message, path: url.pathname });
534
582
  metrics.recordRequest(500);
535
583
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -541,7 +589,8 @@ server.listen(PORT, HOST, () => {
541
589
  const addr = server.address();
542
590
  const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
543
591
  log.info("listening", { host: HOST, port: actualPort });
544
- log.info("secret", { preview: SECRET.slice(0, 4) + "..." });
592
+ if (SECRET) log.info("auth enabled", { preview: SECRET.slice(0, 4) + "..." });
593
+ else log.info("auth disabled (no WEBHOOK_SECRET set)");
545
594
  // Machine-readable line for process spawners to discover the port
546
595
  process.stderr.write(`HOOKHERALD_PORT=${actualPort}\n`);
547
596
  });