feat: relay daemon skeleton — queue, dispatch, conversation, ntfy (#1)
First-PR scope from #1. Single-process Python daemon that relays between Claude Code instances and chat-Claude (Anthropic API). Components: * relay.config — .env + config.yaml loader. Auto-generates ntfy topic on first run and persists it back to .env. * relay.state — atomic file I/O via tempfile + rename, advisory flock at state/.lock to enforce single-instance. * relay.conversation — append-only history with summarization. Triggers a summarize call when total chars exceed HISTORY_CHAR_CAP (default 400k); replaces history with the summary plus the most recent 10 turns. * relay.anthropic_client — SDK wrapper. Marks the system prompt cacheable (5-min ephemeral cache); concatenates text blocks; estimates per-call cost from the Anthropic price table with cache-write/read accounted for. * relay.queue — JSON envelope intake; oldest-by-mtime; malformed envelopes moved to queue/.rejected/. * relay.dispatch — one-input-at-a-time per session (dispatch/<session_id>/input.txt). Won't overwrite a pending dispatch; queues internally and waits for CC to delete. * relay.ntfy — best-effort POST to https://ntfy.sh/<topic>; failures logged but never block the main loop. * relay.daemon — main loop. Polls jc_input.txt (priority) then queue/. Detects [NEEDS-JC] in the first 200 chars of any response and pauses dispatch until JC writes jc_input.txt. JC override supports @session-N: prefix for direct dispatch without an API call. * relay.__main__ — CLI: relay run / relay status / relay topic. Tests: 57 unit tests pass (config, state, conversation, queue, dispatch, anthropic_client, ntfy, full daemon loop with a fake client). One real-API smoke test marked real_api, opt-in via pytest -m real_api; skips cleanly on credit-balance errors. Out of scope for this PR (deferred to follow-ups): Flask status endpoint, multi-session config in production, exponential backoff, systemd unit, cost-tracking aggregation. Closes #1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
relay/__main__.py
Normal file
75
relay/__main__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""CLI entry point: ``relay <command>``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
|
||||
from relay.config import load_settings
|
||||
from relay.daemon import Daemon
|
||||
from relay.logs import configure as configure_logs
|
||||
from relay.ntfy import topic_url
|
||||
from relay.state import read_json
|
||||
|
||||
|
||||
def _cmd_run(args: argparse.Namespace) -> int:
|
||||
settings = load_settings()
|
||||
configure_logs(settings.logs_dir, level=logging.DEBUG if args.verbose else logging.INFO)
|
||||
daemon = Daemon(settings)
|
||||
|
||||
def _stop(signum: int, _frame: object) -> None:
|
||||
logging.getLogger(__name__).info("received signal %s; shutting down", signum)
|
||||
daemon.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
|
||||
try:
|
||||
daemon.run()
|
||||
except RuntimeError as exc:
|
||||
# Most commonly the lock file: another daemon is running.
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
settings = load_settings()
|
||||
status_path = settings.state_dir / "status.json"
|
||||
payload = read_json(status_path, default=None)
|
||||
if payload is None:
|
||||
print("no status file yet — has the daemon run?", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(payload, indent=2, sort_keys=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_topic(_args: argparse.Namespace) -> int:
|
||||
settings = load_settings()
|
||||
print(topic_url(settings.ntfy_topic))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="relay", description="risv3-relay daemon")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
run = sub.add_parser("run", help="Run the daemon in the foreground")
|
||||
run.add_argument("-v", "--verbose", action="store_true")
|
||||
run.set_defaults(func=_cmd_run)
|
||||
|
||||
status = sub.add_parser("status", help="Print the current daemon status")
|
||||
status.set_defaults(func=_cmd_status)
|
||||
|
||||
topic = sub.add_parser("topic", help="Print the ntfy subscription URL")
|
||||
topic.set_defaults(func=_cmd_topic)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user