hookherald 0.3.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/README.md +156 -0
- package/bin/hh +2 -0
- package/package.json +26 -0
- package/src/cli.ts +304 -0
- package/src/dashboard.html +633 -0
- package/src/observability.ts +279 -0
- package/src/webhook-channel.ts +164 -0
- package/src/webhook-router.ts +547 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# HookHerald
|
|
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.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Webhook POST ──> Router (auth + route) ──> Channel ──> Claude Code session
|
|
9
|
+
:9000 (MCP) <channel> notification
|
|
10
|
+
```
|
|
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.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g hookherald
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That's it. Requires Node.js >= 18.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### 1. Start the router
|
|
27
|
+
|
|
28
|
+
```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
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Dashboard at `http://127.0.0.1:9000/`.
|
|
35
|
+
|
|
36
|
+
### 2. Set up a project
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cd ~/my-project
|
|
40
|
+
hh init
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Auto-detects the project slug from `git remote origin` and writes `.mcp.json`. Merges with existing config if present.
|
|
44
|
+
|
|
45
|
+
### 3. Start Claude Code
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
claude --dangerously-load-development-channels server:webhook-channel
|
|
49
|
+
```
|
|
50
|
+
|
|
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
|
|
54
|
+
|
|
55
|
+
```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"}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The only required field is `project_slug` for routing — everything else passes through as raw JSON.
|
|
63
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
## CLI Reference
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json in current directory
|
|
70
|
+
hh status [--router-url <url>] Show active sessions
|
|
71
|
+
hh kill <slug> [--router-url <url>] Bounce a session (Claude Code respawns it)
|
|
72
|
+
hh router [--port <port>] [--secret <secret>] Start the webhook router
|
|
73
|
+
[--bg] Run in background
|
|
74
|
+
hh router stop Stop background router
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Docker
|
|
78
|
+
|
|
79
|
+
The router can also run via Docker. Requires `--network host` to reach channel processes on localhost:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
docker run -d --network host -e WEBHOOK_SECRET=my-secret shoofio/hookherald
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> **Note:** `--network host` requires Linux with standard Docker. For rootless Docker or Docker Desktop (Mac/Windows), use `hh router` instead.
|
|
86
|
+
|
|
87
|
+
## Router API
|
|
88
|
+
|
|
89
|
+
| Method | Path | Description |
|
|
90
|
+
|--------|------|-------------|
|
|
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 |
|
|
94
|
+
| `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 |
|
|
98
|
+
| `GET` | `/api/events` | Query events (`?slug=`, `?limit=`, `?offset=`) |
|
|
99
|
+
| `GET` | `/api/events/:id` | Single event by ID |
|
|
100
|
+
| `GET` | `/api/stats` | Aggregated statistics |
|
|
101
|
+
| `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
|
+
```
|
|
133
|
+
|
|
134
|
+
## Environment Variables
|
|
135
|
+
|
|
136
|
+
| Variable | Default | Description |
|
|
137
|
+
|----------|---------|-------------|
|
|
138
|
+
| `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) |
|
|
144
|
+
| `HH_HEARTBEAT_MS` | `30000` | Channel heartbeat interval |
|
|
145
|
+
|
|
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
|
+
```
|
|
155
|
+
|
|
156
|
+
Tests are integration-heavy — they spawn real processes and make real HTTP requests. Safe to run with a live router.
|
package/bin/hh
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hookherald",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Webhook relay for Claude Code — push notifications from any HTTP POST into running sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hh": "bin/hh"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"bin/"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.0.0"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"router": "npx tsx src/webhook-router.ts",
|
|
18
|
+
"channel": "npx tsx src/webhook-channel.ts",
|
|
19
|
+
"build:go": "go build -ldflags \"-X main.projectRoot=$(pwd)\" -o hh ./cmd/hh/",
|
|
20
|
+
"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
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
24
|
+
"tsx": "^4.19.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { execSync, spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { resolve, dirname, basename } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const CHANNEL_PATH = resolve(__dirname, "webhook-channel.ts");
|
|
9
|
+
const ROUTER_PATH = resolve(__dirname, "webhook-router.ts");
|
|
10
|
+
|
|
11
|
+
// Resolve tsx loader path relative to this package (works from any CWD)
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const TSX_PATH = dirname(require.resolve("tsx/package.json"));
|
|
14
|
+
const PID_DIR = resolve(process.env.HOME || "/tmp", ".hookherald");
|
|
15
|
+
const PID_FILE = resolve(PID_DIR, "router.pid");
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ROUTER_URL = "http://127.0.0.1:9000";
|
|
18
|
+
const DEFAULT_PORT = "9000";
|
|
19
|
+
const DEFAULT_SECRET = "dev-secret";
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const command = args[0];
|
|
23
|
+
|
|
24
|
+
// --- Flag parsing ---
|
|
25
|
+
|
|
26
|
+
function getFlag(name: string): string | undefined {
|
|
27
|
+
const flag = `--${name}`;
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
if (args[i] === flag && i + 1 < args.length) return args[i + 1];
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasFlag(name: string): boolean {
|
|
35
|
+
return args.includes(`--${name}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Slug detection ---
|
|
39
|
+
|
|
40
|
+
function detectSlug(): string {
|
|
41
|
+
try {
|
|
42
|
+
const remote = execSync("git remote get-url origin", {
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
45
|
+
}).trim();
|
|
46
|
+
|
|
47
|
+
// SSH: git@gitlab.com:group/project.git
|
|
48
|
+
const lastColon = remote.lastIndexOf(":");
|
|
49
|
+
if (lastColon !== -1 && !remote.slice(0, lastColon).includes("/")) {
|
|
50
|
+
const slug = remote.slice(lastColon + 1).replace(/\.git$/, "");
|
|
51
|
+
if (slug.includes("/")) return slug;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// HTTPS: https://gitlab.com/group/project.git
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(remote);
|
|
57
|
+
const slug = u.pathname.replace(/^\//, "").replace(/\.git$/, "");
|
|
58
|
+
if (slug.includes("/")) return slug;
|
|
59
|
+
} catch {}
|
|
60
|
+
} catch {}
|
|
61
|
+
|
|
62
|
+
return basename(process.cwd());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Commands ---
|
|
66
|
+
|
|
67
|
+
async function cmdInit() {
|
|
68
|
+
const slug = getFlag("slug") || detectSlug();
|
|
69
|
+
const routerUrl = getFlag("router-url") || DEFAULT_ROUTER_URL;
|
|
70
|
+
const mcpPath = resolve(process.cwd(), ".mcp.json");
|
|
71
|
+
|
|
72
|
+
let config: any = {};
|
|
73
|
+
if (existsSync(mcpPath)) {
|
|
74
|
+
try {
|
|
75
|
+
config = JSON.parse(readFileSync(mcpPath, "utf-8"));
|
|
76
|
+
} catch {
|
|
77
|
+
console.error("Error: existing .mcp.json is not valid JSON");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
83
|
+
|
|
84
|
+
config.mcpServers["webhook-channel"] = {
|
|
85
|
+
command: "node",
|
|
86
|
+
args: ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), CHANNEL_PATH],
|
|
87
|
+
env: {
|
|
88
|
+
PROJECT_SLUG: slug,
|
|
89
|
+
ROUTER_URL: routerUrl,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
|
|
94
|
+
console.log(`Initialized HookHerald for ${slug} in .mcp.json`);
|
|
95
|
+
console.log(` Channel: ${CHANNEL_PATH}`);
|
|
96
|
+
console.log(` Router: ${routerUrl}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function cmdStatus() {
|
|
100
|
+
const routerUrl = getFlag("router-url") || DEFAULT_ROUTER_URL;
|
|
101
|
+
|
|
102
|
+
let resp: Response;
|
|
103
|
+
try {
|
|
104
|
+
resp = await fetch(`${routerUrl}/api/sessions`, {
|
|
105
|
+
signal: AbortSignal.timeout(5000),
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
console.error(`Router not reachable at ${routerUrl}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!resp.ok) {
|
|
113
|
+
console.error(`Router returned ${resp.status}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sessions: any[] = await resp.json();
|
|
118
|
+
|
|
119
|
+
if (sessions.length === 0) {
|
|
120
|
+
console.log("No active sessions");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(
|
|
125
|
+
"SLUG".padEnd(30) +
|
|
126
|
+
"PORT".padEnd(8) +
|
|
127
|
+
"STATUS".padEnd(10) +
|
|
128
|
+
"EVENTS".padEnd(10) +
|
|
129
|
+
"ERRORS".padEnd(10) +
|
|
130
|
+
"LAST EVENT",
|
|
131
|
+
);
|
|
132
|
+
console.log("-".repeat(88));
|
|
133
|
+
|
|
134
|
+
for (const s of sessions) {
|
|
135
|
+
const lastEvent = s.lastEventAt ? timeAgo(s.lastEventAt) : "never";
|
|
136
|
+
console.log(
|
|
137
|
+
String(s.slug).padEnd(30) +
|
|
138
|
+
String(s.port).padEnd(8) +
|
|
139
|
+
String(s.status).padEnd(10) +
|
|
140
|
+
String(s.eventCount).padEnd(10) +
|
|
141
|
+
String(s.errorCount).padEnd(10) +
|
|
142
|
+
lastEvent,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function cmdKill() {
|
|
148
|
+
const slug = args[1];
|
|
149
|
+
if (!slug || slug.startsWith("--")) {
|
|
150
|
+
console.error("Usage: hh kill <slug>");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const routerUrl = getFlag("router-url") || DEFAULT_ROUTER_URL;
|
|
155
|
+
|
|
156
|
+
let resp: Response;
|
|
157
|
+
try {
|
|
158
|
+
resp = await fetch(`${routerUrl}/api/kill`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
body: JSON.stringify({ project_slug: slug }),
|
|
162
|
+
signal: AbortSignal.timeout(5000),
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
console.error(`Router not reachable at ${routerUrl}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const data = await resp.json();
|
|
170
|
+
if (!resp.ok) {
|
|
171
|
+
console.error(`Error: ${data.error}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log(`Killed session: ${data.slug} (port ${data.port}, ${data.eventCount} events)`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function cmdRouter() {
|
|
179
|
+
const subcommand = args[1];
|
|
180
|
+
|
|
181
|
+
if (subcommand === "stop") {
|
|
182
|
+
cmdRouterStop();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const port = getFlag("port") || process.env.ROUTER_PORT || DEFAULT_PORT;
|
|
187
|
+
const secret = getFlag("secret") || process.env.WEBHOOK_SECRET || DEFAULT_SECRET;
|
|
188
|
+
const bg = hasFlag("bg");
|
|
189
|
+
|
|
190
|
+
const child = spawn("node", ["--import", resolve(TSX_PATH, "dist", "esm", "index.mjs"), ROUTER_PATH], {
|
|
191
|
+
env: {
|
|
192
|
+
...process.env,
|
|
193
|
+
ROUTER_PORT: port,
|
|
194
|
+
WEBHOOK_SECRET: secret,
|
|
195
|
+
},
|
|
196
|
+
stdio: bg ? "ignore" : "inherit",
|
|
197
|
+
detached: bg,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (bg) {
|
|
201
|
+
// Write PID file and detach
|
|
202
|
+
mkdirSync(PID_DIR, { recursive: true });
|
|
203
|
+
writeFileSync(PID_FILE, String(child.pid));
|
|
204
|
+
child.unref();
|
|
205
|
+
console.log(`Router started in background (PID ${child.pid}, port ${port})`);
|
|
206
|
+
console.log(` Stop with: hh router stop`);
|
|
207
|
+
} else {
|
|
208
|
+
// Foreground: forward signals, wait for exit
|
|
209
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
210
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
211
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function cmdRouterStop() {
|
|
216
|
+
if (!existsSync(PID_FILE)) {
|
|
217
|
+
console.error("No background router found (no PID file)");
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
process.kill(pid, "SIGTERM");
|
|
225
|
+
console.log(`Stopped router (PID ${pid})`);
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
if (err.code === "ESRCH") {
|
|
228
|
+
console.log(`Router already stopped (PID ${pid} not found)`);
|
|
229
|
+
} else {
|
|
230
|
+
console.error(`Failed to stop router: ${err.message}`);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try { unlinkSync(PID_FILE); } catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- Helpers ---
|
|
239
|
+
|
|
240
|
+
function timeAgo(iso: string): string {
|
|
241
|
+
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
242
|
+
if (diff < 5) return "just now";
|
|
243
|
+
if (diff < 60) return `${diff}s ago`;
|
|
244
|
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
245
|
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
246
|
+
return `${Math.floor(diff / 86400)}d ago`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function usage() {
|
|
250
|
+
console.log(` ...::::::::..
|
|
251
|
+
.=%@@@*+=#@
|
|
252
|
+
-%@*.
|
|
253
|
+
.%@-
|
|
254
|
+
*#@*.
|
|
255
|
+
.=@@*
|
|
256
|
+
:. =@=.
|
|
257
|
+
=@@*. .=@+
|
|
258
|
+
-@.: =@+ .-%@@@@@@@@@
|
|
259
|
+
.@*:+@+ .=#@@@@@@@@@@@@@@@
|
|
260
|
+
.. .%@@@@@%@@@@@@@@@@@@@
|
|
261
|
+
=@@@@@@+ .@@@@@@@@@@@@
|
|
262
|
+
.*@@@@@@@@@@@@@@@@@
|
|
263
|
+
=******@@@@@@@@@@@@@@@@
|
|
264
|
+
.+@@@@@@@@@@@@@@@@@@@@
|
|
265
|
+
:%@@@@@@@@@@@@@@@@
|
|
266
|
+
.-+#@@@@@@@@@@
|
|
267
|
+
|
|
268
|
+
HookHerald — webhook relay for Claude Code
|
|
269
|
+
|
|
270
|
+
Usage: hh <command> [options]
|
|
271
|
+
|
|
272
|
+
Commands:
|
|
273
|
+
init [--slug <slug>] [--router-url <url>] Set up .mcp.json in current directory
|
|
274
|
+
status [--router-url <url>] Show active sessions
|
|
275
|
+
kill <slug> [--router-url <url>] Bounce a session (Claude Code respawns it)
|
|
276
|
+
router [--port <port>] [--secret <secret>] Start the webhook router
|
|
277
|
+
[--bg] Run in background
|
|
278
|
+
router stop Stop background router`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Main ---
|
|
282
|
+
|
|
283
|
+
switch (command) {
|
|
284
|
+
case "init":
|
|
285
|
+
await cmdInit();
|
|
286
|
+
break;
|
|
287
|
+
case "status":
|
|
288
|
+
await cmdStatus();
|
|
289
|
+
break;
|
|
290
|
+
case "kill":
|
|
291
|
+
await cmdKill();
|
|
292
|
+
break;
|
|
293
|
+
case "router":
|
|
294
|
+
cmdRouter();
|
|
295
|
+
break;
|
|
296
|
+
case "--help":
|
|
297
|
+
case "-h":
|
|
298
|
+
case "help":
|
|
299
|
+
usage();
|
|
300
|
+
break;
|
|
301
|
+
default:
|
|
302
|
+
usage();
|
|
303
|
+
if (command) process.exit(1);
|
|
304
|
+
}
|