@edngibson/reporter
v0.1.0
Published
Stateless reporter for the agent-dock Layer 1 rendezvous: Claude Code hooks + deadman heartbeat + client-side encryption. Zero runtime dependencies (Node >= 18 stdlib only).
Readme
agent-dock reporter
The Layer 1 write path: a stateless reporter that tells the agent-dock rendezvous which agents are running on this host, so the dock — on any machine — sees the merged global fleet.
One small Node script (reporter.mjs), zero runtime dependencies — Node ≥ 18
stdlib only. It's a script, not a compiled binary, on purpose: every host that runs
agents already runs Claude Code, which already requires Node, so the reporter adds
no new runtime and no cross-compile. Copy reporter/ to the host and you're done.
What it does
Both triggers run the same path — enumerate this host's agents via
claude agents --json, encrypt the sensitive fields, and POST a full snapshot
to the rendezvous ingest function:
- hook (one-shot) — wired to Claude Code lifecycle hooks. Low-latency: a
transition is reflected almost immediately. Full snapshots mean a finished agent
disappears the instant its
Stophook fires, rather than waiting out the TTL. - heartbeat (long-running) — re-snapshots every 90 s (
ADK_HEARTBEAT_SECONDS). The deadman: it proves the host is alive so the server can TTL-expire a silently-dead host (crash /kill -9/ OOM / power loss), and it self-heals any hook push that got dropped. This is the only periodic traffic.
Zero-knowledge (decision I): label, cwd, and current_action are encrypted
client-side with AES-256-GCM using a key the rendezvous never sees. Status,
kind, and timestamps travel in clear so the server can route and expire without
decrypting. A leaked server row is unreadable without the key.
Onboard a machine — pair (recommended)
Status: the
pair/unpairCLI and the dock's bundle-mint command (mint_pairing_bundle) are implemented; still pending are publishing@edngibson/reporterto npm and wiring the dock's "+ Add a machine" button into the UI. Until then, use the manual setup below — this section describes the target one-paste experience.
One command does everything the manual sections below do by hand — enroll, write the config, install the Claude Code hooks, and start the heartbeat service:
npx -y @edngibson/reporter pair <bundle>Get <bundle> from the dock's "+ Add a machine" button: it mints a single-use,
15-minute pairing code and shows the adk1_… string to paste plus a four-word
fingerprint. pair prints the same fingerprint on this host — eyeball that the
two match (this catches a bundle swapped in transit through Slack/email/SSH), and
you're on the dock within seconds. The bundle carries the rendezvous URL, the
pairing code, and the encryption key out-of-band; the server never sees the key.
pair is idempotent — re-running refreshes in place (one hook per event, one
service). It installs the right per-user service for the OS, no sudo: systemd
--user (+ loginctl enable-linger), a launchd LaunchAgent, or a Task Scheduler
ONLOGON task. The hooks it installs are async (a slow or down rendezvous never
blocks the agent) and cover the lifecycle events — SessionStart, Stop,
SubagentStop, Notification; current_action is refreshed by the heartbeat.
npx -y @edngibson/reporter unpair # reverse it: stop the service, strip the hooks, delete the configThe sections below document the manual equivalents — pair automates all of
them. Reach for them to customize beyond what pair does, or where npx isn't
available.
Configuration
Config comes from env vars (which override an optional file at
~/.agent-dock/reporter.json, or $ADK_CONFIG). The token and key arrive
out-of-band from the dock's pairing bundle — they are never fetched from the
server.
| Env var | File key | Required | Meaning |
|---|---|---|---|
| ADK_SUPABASE_URL | supabaseUrl | one of these | Project URL, e.g. https://<ref>.supabase.co (ingest URL is derived) |
| ADK_INGEST_URL | ingestUrl | one of these | Full ingest URL (overrides the derived one) |
| ADK_REPORTER_TOKEN | token | ✅ | Opaque per-host token (adk_…) from pairing |
| ADK_ENC_KEY | encKey | ✅ | Shared key, base64 of 32 bytes |
| ADK_HEARTBEAT_SECONDS | heartbeatSeconds | — | Snapshot interval, default 90 |
| ADK_CLAUDE_BIN | claudeBin | — | Claude CLI command, default claude |
| ADK_APIKEY | apikey | — | Optional gateway apikey header (not needed for the current verify_jwt=false ingest) |
| ADK_CONFIG | — | — | Path to the config file |
Generate a fresh encryption key (put the same value in the dock and every host you pair):
node reporter.mjs gen-key # prints base64 of 32 random bytesExample ~/.agent-dock/reporter.json:
{
"supabaseUrl": "https://txwhijfclpozpsukmypm.supabase.co",
"token": "adk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"encKey": "base64-of-32-bytes-same-as-the-dock"
}Install — Claude Code hooks (every host)
Add to ~/.claude/settings.json (user-level → covers all projects). The reporter
runs as a type:"command" hook with async:true so a slow or down rendezvous
never blocks or fails the agent (type:"http" hooks block — that's why this is
a command hook). Use an absolute path to reporter.mjs.
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node /opt/agent-dock/reporter/reporter.mjs hook", "async": true }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "node /opt/agent-dock/reporter/reporter.mjs hook", "async": true }] }],
"SubagentStop": [{ "hooks": [{ "type": "command", "command": "node /opt/agent-dock/reporter/reporter.mjs hook", "async": true }] }],
"Notification": [{ "hooks": [{ "type": "command", "command": "node /opt/agent-dock/reporter/reporter.mjs hook", "async": true }] }],
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node /opt/agent-dock/reporter/reporter.mjs hook", "async": true }] }],
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node /opt/agent-dock/reporter/reporter.mjs hook", "async": true }] }]
}
}On Windows use the absolute path, e.g.
node C:\\opt\\agent-dock\\reporter\\reporter.mjs hook.
Coverage notes: SessionStart/Stop/SubagentStop/Pre|PostToolUse fire in
both interactive and background sessions. Notification (blocked-waiting) is
interactive-only; in headless sessions the blocked state is caught by the next
snapshot, since claude agents --json reports state:"blocked". current_action
is best-effort — PreToolUse names the running tool; it reverts to nothing on the
next heartbeat.
Install — heartbeat service (every host)
Linux / VPS (systemd)
/etc/systemd/system/agent-dock-reporter.service:
[Unit]
Description=agent-dock reporter (deadman heartbeat)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/node /opt/agent-dock/reporter/reporter.mjs heartbeat
Environment=ADK_SUPABASE_URL=https://txwhijfclpozpsukmypm.supabase.co
Environment=ADK_REPORTER_TOKEN=adk_xxxxxxxx
Environment=ADK_ENC_KEY=base64-of-32-bytes
Restart=always
RestartSec=10
User=youruser
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now agent-dock-reporter
journalctl -u agent-dock-reporter -f # watch snapshotsWindows (laptop)
Run the heartbeat at logon via Task Scheduler:
$action = New-ScheduledTaskAction -Execute "node" `
-Argument "C:\opt\agent-dock\reporter\reporter.mjs heartbeat"
$trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName "agent-dock-reporter" `
-Action $action -Trigger $trigger -RunLevel LimitedPut the config in %USERPROFILE%\.agent-dock\reporter.json (Task Scheduler doesn't
inherit a shell's env vars).
Develop
node --test # unit tests (status/label mapping, snapshot, crypto round-trip + KAT)
node reporter.mjs gen-key
ADK_CLAUDE_BIN=./test-fixtures/fake-claude node reporter.mjs heartbeat # point at a stubThe AES-256-GCM wire envelope is base64( nonce(12) ‖ ciphertext ‖ tag(16) ). The
dock decrypts the inverse with the Rust aes-gcm crate (Task 3); reporter.test.mjs
pins a known-answer vector so the two stay byte-compatible.
