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>
76 lines
2.2 KiB
Python
76 lines
2.2 KiB
Python
"""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())
|