Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Get mobile notifications + approval for your herdr agents in 60 seconds.

## 1. Install persistent local services

**macOS/Linux:**

```bash
git clone https://github.com/dcolinmorgan/herdr-remote
cd herdr-remote/relay
Expand All @@ -12,6 +14,18 @@ cd herdr-remote/relay

The installer creates restartable user services for the relay and, optionally, Telegram. Choose `none` for the Cloudflare tunnel when you only need Telegram; the bot connects to the relay over localhost.

**Windows PowerShell:**

```powershell
git clone https://github.com/dcolinmorgan/herdr-remote
Set-Location herdr-remote
herdr plugin link .
./relay/start.ps1
```

The Windows launcher binds to `127.0.0.1` with no tunnel by default. Set
`HERDR_RELAY_TOKEN` before enabling a tunnel or binding beyond loopback.

## 2. Configure Telegram

1. Open `@BotFather` in Telegram and send `/newbot`.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ curl -sL https://github.com/dcolinmorgan/herdr-remote/releases/latest/download/H

## Remote monitoring (phone/Telegram)

The Python relay, web dashboard, TUI, and Telegram client run on macOS, Linux, and Windows.

### macOS/Linux

For monitoring agents across machines or from your phone:

```bash
Expand All @@ -44,6 +48,25 @@ cd herdr-remote/relay && ./start.sh

Open [herdr-demo.pages.dev](https://herdr-demo.pages.dev) on your phone, paste the tunnel URL.

### Windows

With Git, [uv](https://docs.astral.sh/uv/), and `herdr` installed:

```powershell
git clone https://github.com/dcolinmorgan/herdr-remote
Set-Location herdr-remote

herdr plugin link .
herdr plugin list

./relay/start.ps1
```

The launcher starts a local-only relay on `127.0.0.1:8375` by default. Set
`HERDR_RELAY_TOKEN` before enabling a tunnel or binding beyond loopback. Use
`HERDR_REMOTES` for a comma-separated list of SSH targets and `HERDR_BIN` only
when `herdr` is not available on `PATH`.

## Telegram Bot

For an automatically restarting relay and Telegram bot:
Expand Down Expand Up @@ -128,9 +151,17 @@ export HERDR_RELAY_TOKEN="$(openssl rand -hex 32)"
uv run relay/herdr_relay.py
```

On Windows PowerShell:

```powershell
$env:HERDR_RELAY_TOKEN = [guid]::NewGuid().ToString("N")
uv run relay/herdr_relay.py
```

## Requirements

- macOS 14+ (menu bar app)
- Windows 10+ (relay/web/TUI/Telegram; no tray app)
- Python 3.10+ with [uv](https://docs.astral.sh/uv/) (relay/TUI/bot)
- `cloudflared` (for remote access)
- herdr 0.7+
Expand Down
4 changes: 2 additions & 2 deletions herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name = "Herdr Remote"
version = "0.5.0"
min_herdr_version = "0.7.0"
description = "Monitor and approve herdr agents from your phone, menu bar, or Telegram"
platforms = ["macos", "linux"]
platforms = ["macos", "linux", "windows"]

[[events]]
on = "pane.agent_status_changed"
command = ["python3", "relay/on_event.py"]
command = ["uv", "run", "--script", "relay/on_event.py"]
4 changes: 2 additions & 2 deletions relay/herdr-plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ name = "Herdr Remote Relay"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Push agent events to the local herdr-remote relay via UDP"
platforms = ["macos", "linux"]
platforms = ["macos", "linux", "windows"]

[[events]]
on = "pane.agent_status_changed"
command = ["python3", "on_event.py"]
command = ["uv", "run", "--script", "on_event.py"]
175 changes: 131 additions & 44 deletions relay/herdr_relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# dependencies = ["websockets>=14.0", "zeroconf>=0.80.0", "pywebpush>=2.0.0", "py-vapid>=1.9.0"]
# ///
"""herdr-remote relay — polls herdr, accepts push events (HTTP POST + WebSocket + UDP), broadcasts to clients."""
import asyncio, json, logging, os, re, shutil, signal, socket, subprocess, time
import asyncio, json, logging, os, re, shutil, signal, socket, subprocess, threading, time

from agent_state import complete_agent_update_message

Expand All @@ -20,6 +20,9 @@
def _get_log_dir():
if sys.platform == "darwin":
return os.path.expanduser("~/Library/Logs/herdr-remote")
if sys.platform == "win32":
base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local"))
return os.path.join(base, "herdr-remote", "logs")
if os.path.isdir("/var/log") and os.access("/var/log", os.W_OK):
return "/var/log/herdr-remote"
return os.path.expanduser("~/.local/state/herdr-remote/log")
Expand All @@ -41,8 +44,14 @@ def _get_log_dir():
log.addHandler(_console_handler)
logging.getLogger("websockets").setLevel(logging.WARNING)

HERDR = os.environ.get("HERDR_BIN") or shutil.which("herdr") or "/opt/homebrew/bin/herdr"
HERDR = (
os.environ.get("HERDR_BIN")
or shutil.which("herdr")
or ("herdr" if sys.platform == "win32" else "/opt/homebrew/bin/herdr")
)
REMOTE_HERDR = os.environ.get("HERDR_REMOTE_BIN", "herdr")
WS_PORT = int(os.environ.get("HERDR_RELAY_PORT", "8375"))
RELAY_HOST = os.environ.get("HERDR_RELAY_HOST", "127.0.0.1")
POLL_INTERVAL = 2
AUTH_TOKEN = os.environ.get("HERDR_RELAY_TOKEN", "") # Optional: shared secret for relay auth

Expand All @@ -53,6 +62,9 @@ def _get_log_dir():
push_subscriptions = [] # list of PushSubscription dicts
PUSH_SUBS_FILE = os.path.join(LOG_DIR, "push_subs.json")

if RELAY_HOST not in {"127.0.0.1", "localhost", "::1"} and not AUTH_TOKEN:
raise SystemExit("HERDR_RELAY_TOKEN is required when HERDR_RELAY_HOST binds beyond loopback")

# Remote hosts: comma-separated SSH targets
REMOTES = [r.strip() for r in os.environ.get("HERDR_REMOTES", "").split(",") if r.strip()]

Expand All @@ -72,6 +84,8 @@ def _get_log_dir():
pane_remote_map = {}
known_panes = set()
agent_cache = {}
_remote_locks = {}
_remote_locks_guard = threading.Lock()

SAFE_RESPONSES = {"y", "n", "a", "yes", "no", "trust", "yes, single permission", "trust, always allow", "no (tab to edit)", "approve all pending", "configure individually", "exit (cancel subagents)"}
SAFE_KEYS = {"y", "n", "a", "Enter", "Tab", "Escape", "C-c", "Up", "Down", "Left", "Right", "BSpace"} | {
Expand Down Expand Up @@ -160,10 +174,39 @@ async def send_web_push(title: str, body: str, url: str = "/", clear: bool = Fal

def run_herdr_result(*args, remote=None):
if remote:
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", remote, HERDR, *args]
else:
cmd = [HERDR, *args]
return subprocess.run(cmd, capture_output=True, text=True, timeout=15)
cmd = [
"ssh",
"-o",
"ConnectTimeout=5",
"-o",
"BatchMode=yes",
remote,
REMOTE_HERDR,
*args,
]
with _remote_locks_guard:
remote_lock = _remote_locks.get(remote)
if remote_lock is None:
remote_lock = threading.Lock()
_remote_locks[remote] = remote_lock
with remote_lock:
return subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
)
cmd = [HERDR, *args]
return subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
)


def run_herdr(*args, remote=None):
Expand All @@ -173,6 +216,13 @@ def run_herdr(*args, remote=None):
return ""


def _mutate_herdr(*args, remote=None):
try:
return run_herdr_result(*args, remote=remote).returncode == 0
except Exception:
return False


def get_agents_from_host(remote=None):
raw = run_herdr("pane", "list", remote=remote)
host_label = remote or "local"
Expand Down Expand Up @@ -205,6 +255,23 @@ def get_all_agents():
return agents


def update_pane_maps(agents):
current_pane_ids = {agent["pane_id"] for agent in agents}
for agent in agents:
pane_id = agent["pane_id"]
pane_remote_map[pane_id] = agent.get("remote")
known_panes.add(pane_id)
agent_cache[pane_id] = agent

stale = known_panes - current_pane_ids
if stale:
known_panes.difference_update(stale)
for pane_id in stale:
pane_remote_map.pop(pane_id, None)
last_statuses.pop(pane_id, None)
agent_cache.pop(pane_id, None)


def read_pane(pane_id, remote=None):
raw = run_herdr("pane", "read", pane_id, "--lines", "50", "--source", "recent", remote=remote)
lines = [l for l in raw.splitlines() if l.strip() and not CHROME_RE.search(l)]
Expand Down Expand Up @@ -246,11 +313,7 @@ async def poll_loop():

async def _poll_once():
agents = get_all_agents()
# Always broadcast (even empty list) so clients stay in sync
for a in agents:
pane_remote_map[a["pane_id"]] = a.get("remote")
known_panes.add(a["pane_id"])
agent_cache[a["pane_id"]] = a
update_pane_maps(agents)
await broadcast({"type": "agents", "agents": agents})
for a in agents:
pid, status = a["pane_id"], a["status"]
Expand All @@ -274,17 +337,6 @@ async def _poll_once():
if status != "blocked" and last_statuses.get(pid) == "blocked":
await send_web_push("", "", clear=True)
last_statuses[pid] = status
# Clean up panes that are no longer reported
current_pane_ids = {a["pane_id"] for a in agents}
stale = known_panes - current_pane_ids
if stale:
known_panes.difference_update(stale)
for pid in stale:
pane_remote_map.pop(pid, None)
last_statuses.pop(pid, None)
agent_cache.pop(pid, None)


async def event_push():
while True:
event = await event_queue.get()
Expand Down Expand Up @@ -464,14 +516,15 @@ async def handle_client(ws):
if pane_id not in known_panes:
await ws.send(json.dumps({"type": "error", "message": "unknown pane_id"}))
continue
text = msg.get("text", "")
if text.strip().lower() not in SAFE_RESPONSES:
text = msg.get("text", "").strip()
if text.lower() not in SAFE_RESPONSES:
await ws.send(json.dumps({"type": "error", "message": "response not in allowlist"}))
continue
remote = pane_remote_map.get(pane_id)
log.info("Response from %s (%s): pane=%s text=%r", ip, device, pane_id, text)
audit("respond", ip, device, pane_id, f"text={text!r}")
run_herdr("pane", "send-text", pane_id, text + "\n", remote=remote)
if _mutate_herdr("pane", "send-text", pane_id, text, remote=remote):
_mutate_herdr("pane", "send-keys", pane_id, "Enter", remote=remote)
elif msg_type == "agent_event":
event_queue.put_nowait(msg)
elif msg_type == "read_pane":
Expand Down Expand Up @@ -561,7 +614,6 @@ def start_mdns():
try:
from zeroconf import Zeroconf, ServiceInfo
import socket as sock_mod
import threading
ip = sock_mod.gethostbyname(sock_mod.gethostname())
info = ServiceInfo(
"_herdr-remote._tcp.local.", "herdr-remote._herdr-remote._tcp.local.",
Expand All @@ -577,26 +629,61 @@ def start_mdns():


async def main():
zc, info = start_mdns()
loop = asyncio.get_running_loop()
try:
await loop.create_datagram_endpoint(UDPPlugin, local_addr=("127.0.0.1", 8376))
except OSError:
log.warning("UDP 8376 in use, plugin push disabled")
asyncio.create_task(poll_loop())
asyncio.create_task(event_push())
server = await serve(handle_client, "0.0.0.0", WS_PORT, process_request=process_request)
hosts = ["local"] + REMOTES
log.info("herdr-remote relay on :%d (WebSocket + HTTP POST)", WS_PORT)
log.info("Polling: %s", ", ".join(hosts))
stop = loop.create_future()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set_result, None)
await stop
server.close()
if zc and info:
zc.unregister_service(info)
zc.close()
zc = info = udp_transport = server = None
tasks = []
loop_signal_handlers = []
fallback_signal_handlers = {}

def resolve_stop():
if not stop.done():
stop.set_result(None)

def request_stop(*_):
loop.call_soon_threadsafe(resolve_stop)

try:
zc, info = start_mdns()
try:
udp_transport, _ = await loop.create_datagram_endpoint(
UDPPlugin, local_addr=("127.0.0.1", 8376)
)
except OSError:
log.warning("UDP 8376 in use, plugin push disabled")
tasks = [asyncio.create_task(poll_loop()), asyncio.create_task(event_push())]
server = await serve(handle_client, RELAY_HOST, WS_PORT, process_request=process_request)
hosts = ["local"] + REMOTES
log.info("herdr-remote relay on %s:%d (WebSocket + HTTP POST)", RELAY_HOST, WS_PORT)
log.info("Polling: %s", ", ".join(hosts))
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, request_stop)
loop_signal_handlers.append(sig)
except NotImplementedError:
fallback_signal_handlers[sig] = signal.getsignal(sig)
signal.signal(sig, request_stop)
await stop
finally:
for sig in loop_signal_handlers:
loop.remove_signal_handler(sig)
for sig, handler in fallback_signal_handlers.items():
signal.signal(sig, handler)
if server is not None:
server.close()
await server.wait_closed()
if udp_transport is not None:
udp_transport.close()
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
if zc is not None:
try:
if info is not None:
zc.unregister_service(info)
finally:
zc.close()


if __name__ == "__main__":
Expand Down
Loading