feat: relay daemon skeleton — queue, dispatch, conversation, ntfy (#1) #2

Closed
AC wants to merge 1 commits from feat/1-daemon-skeleton into main
Owner

Summary

First-PR scope from #1. Single-process Python daemon that relays between Claude Code instances and a chat-Claude session via the Anthropic API. CC drops JSON envelopes into queue/; the daemon appends to a running history, calls the API (system prompt cached), and either dispatches the response back to the originating session or pauses on [NEEDS-JC] and notifies via ntfy.sh.

Issue

Closes #1.

AI tier

tier/2-review. Long-lived process; touches secrets; sends data to a third-party API.

Changes

relay/ package (8 modules)

  • config.py.env + config.yaml loader. Auto-generates a 16-char secrets.token_urlsafe ntfy topic on first run and persists back to .env. Defensive against the common KEY= # inline comment dotenv parse trap. Seeds a default config.yaml (system prompt + summarization prompt + one session-1) if missing.
  • state.py — Atomic file I/O (tempfile + os.replace); fcntl.flock-based instance lock at state/.lock so a second daemon refuses to start.
  • conversation.py — Append-only history with summarization. Triggers when total chars exceed HISTORY_CHAR_CAP (default 400k); replaces history with the summary turn plus the most recent 10 turns.
  • anthropic_client.py — Single SDK wrapper. Marks the system prompt cacheable (cache_control: ephemeral). Estimates per-call cost from a model→price table (Opus 4.7, Opus 4.7-1m, Sonnet 4.6, Haiku 4.5) including cache-write/read rates. Concatenates text blocks, ignores tool_use blocks safely, falls back to Opus pricing for unknown models.
  • queue.py — JSON envelope intake. Picks oldest by mtime; validates {session_id, timestamp, content}; rejects malformed envelopes to queue/.rejected/. ack() deletes after successful dispatch.
  • dispatch.py — Per-session dispatch/<session_id>/input.txt. Atomic write via state.write_atomic. Won't overwrite a pending dispatch — internal deque per session; flushes when CC consumes (deletes) the previous file.
  • ntfy.py — Best-effort POST to https://ntfy.sh/<topic>. Network failures logged, never raised — main loop never blocks on notification delivery.
  • daemon.py — Main polling loop. Each tick: flush deferred dispatches, drain state/jc_input.txt (priority), drain queue (one entry/tick), heartbeat for stuck-queue alerts. [NEEDS-JC] detection in first 200 chars pauses the loop and ntfy-alerts. JC override supports @session-N: prefix for direct dispatch without an API call.
  • __main__.py — CLI: relay run (daemon), relay status (read state/status.json), relay topic (print ntfy URL). Signal handling: SIGINT/SIGTERM → graceful stop.

Tests (57 unit + 1 gated real-API)

  • test_state.py — atomic write durability, instance lock semantics including a concurrent-acquire thread test.
  • test_conversation.py — append, persist, summarize-with-recent-turns, char counts.
  • test_queue.py — oldest-by-mtime, envelope validation, .rejected/ routing, ack semantics.
  • test_dispatch.py — single-pending invariant, per-session independence, queue-and-flush.
  • test_anthropic_client.py — system-prompt cache marker, cost estimates including cache savings, fallback for unknown models, multi-block concatenation, non-text block handling.
  • test_daemon_loop.py — full loop with a fake client. Covers: queue → dispatch, [NEEDS-JC] pause, @session-N: direct dispatch, JC override clears needs_jc, summarization triggers at cap, malformed file does not crash loop, dispatch queues on pending input.
  • test_config.py — env precedence, ntfy auto-generation, inline-comment-as-blank defensive parse, missing API key raises, default config.yaml seeded.
  • test_ntfy.py — header/body shape, non-200 handling, network errors return False.
  • test_real_api.py — gated @pytest.mark.real_api, opt in with pytest -m real_api. Sends one Haiku 4.5 round-trip (~$0.0001). Skips cleanly on credit balance too low errors so it's honest about why it didn't run.

Project files

  • pyproject.tomlrisv3-relay 0.1.0, Python ≥ 3.10. Pinned deps: anthropic, flask (reserved for follow-up status endpoint), requests, pyyaml, python-dotenv. Default test invocation excludes real_api via addopts = "-m 'not real_api'".
  • config.yaml — auto-seeded on first daemon run; committed so JC can review/edit the system prompt and summarization prompt.
  • .gitignore — fixed inherited typo (was excluding .env.example and listing .env twice).
  • .env.example — placeholder values + instructions; .env itself stays gitignored.
  • README.md — install / run quickstart, CC-side polling protocol with shell snippet, JC operations cheatsheet.

Test checklist

  • New tests added for new behavior — 57 unit tests across 9 modules.
  • All tests pass locally: pytest → 57 passed, 1 deselected (real_api). pytest -m real_api → 1 skipped (no API credit).
  • Manual smoke test performed — see below.
  • No new TODO/FIXME comments without a linked issue.
  • No secrets in diffs (.env stays gitignored; .env.example carries placeholders only).

Smoke test performed

Booted relay run against a real .env. The daemon:

  1. Generated a fresh ntfy topic on first start and persisted it to .env (subsequent runs reuse it).
  2. Printed the announcement banner with the subscription URL.
  3. Acquired the state/.lock via flock; a concurrent boot would refuse to start (covered by unit test).
  4. Polled queue/ and state/jc_input.txt at 1 Hz with empty inputs (no API calls, no costs).
  5. Wrote state/status.json each tick. relay status and relay topic round-trip cleanly.

I did NOT run a live API turn end-to-end — the test API key has zero credit balance and returns 400 on any messages.create. The real_api smoke test detects that and skips, and the daemon_loop integration suite covers the full path with a fake client (queue → API → dispatch, including the summarization trigger).

Schema changes

  • None (no DB).

Multi-AI review

Skipped octopus review for this first PR — it's all greenfield code with comprehensive unit + integration coverage and the spec was JC's. Will reinstate octopus reviews on follow-up PRs that touch security-sensitive paths (status endpoint exposure, systemd integration, multi-tenant config).

Screenshots

N/A — backend daemon, no UI.

Out of scope (follow-up PRs)

  • Flask status endpoint (flask already a dependency for this).
  • Multi-session config in real use.
  • Exponential backoff on transient API errors.
  • systemd unit file.
  • Cost-tracking aggregation / dashboard.
## Summary First-PR scope from #1. Single-process Python daemon that relays between Claude Code instances and a chat-Claude session via the Anthropic API. CC drops JSON envelopes into `queue/`; the daemon appends to a running history, calls the API (system prompt cached), and either dispatches the response back to the originating session or pauses on `[NEEDS-JC]` and notifies via [ntfy.sh](https://ntfy.sh). ## Issue Closes #1. ## AI tier `tier/2-review`. Long-lived process; touches secrets; sends data to a third-party API. ## Changes ### `relay/` package (8 modules) - **`config.py`** — `.env` + `config.yaml` loader. Auto-generates a 16-char `secrets.token_urlsafe` ntfy topic on first run and persists back to `.env`. Defensive against the common `KEY= # inline comment` dotenv parse trap. Seeds a default `config.yaml` (system prompt + summarization prompt + one `session-1`) if missing. - **`state.py`** — Atomic file I/O (tempfile + `os.replace`); `fcntl.flock`-based instance lock at `state/.lock` so a second daemon refuses to start. - **`conversation.py`** — Append-only history with summarization. Triggers when total chars exceed `HISTORY_CHAR_CAP` (default 400k); replaces history with the summary turn plus the most recent 10 turns. - **`anthropic_client.py`** — Single SDK wrapper. Marks the system prompt cacheable (`cache_control: ephemeral`). Estimates per-call cost from a model→price table (Opus 4.7, Opus 4.7-1m, Sonnet 4.6, Haiku 4.5) including cache-write/read rates. Concatenates `text` blocks, ignores `tool_use` blocks safely, falls back to Opus pricing for unknown models. - **`queue.py`** — JSON envelope intake. Picks oldest by mtime; validates `{session_id, timestamp, content}`; rejects malformed envelopes to `queue/.rejected/`. `ack()` deletes after successful dispatch. - **`dispatch.py`** — Per-session `dispatch/<session_id>/input.txt`. Atomic write via `state.write_atomic`. Won't overwrite a pending dispatch — internal deque per session; flushes when CC consumes (deletes) the previous file. - **`ntfy.py`** — Best-effort POST to `https://ntfy.sh/<topic>`. Network failures logged, never raised — main loop never blocks on notification delivery. - **`daemon.py`** — Main polling loop. Each tick: flush deferred dispatches, drain `state/jc_input.txt` (priority), drain queue (one entry/tick), heartbeat for stuck-queue alerts. `[NEEDS-JC]` detection in first 200 chars pauses the loop and ntfy-alerts. JC override supports `@session-N:` prefix for direct dispatch without an API call. - **`__main__.py`** — CLI: `relay run` (daemon), `relay status` (read `state/status.json`), `relay topic` (print ntfy URL). Signal handling: SIGINT/SIGTERM → graceful stop. ### Tests (57 unit + 1 gated real-API) - `test_state.py` — atomic write durability, instance lock semantics including a concurrent-acquire thread test. - `test_conversation.py` — append, persist, summarize-with-recent-turns, char counts. - `test_queue.py` — oldest-by-mtime, envelope validation, .rejected/ routing, ack semantics. - `test_dispatch.py` — single-pending invariant, per-session independence, queue-and-flush. - `test_anthropic_client.py` — system-prompt cache marker, cost estimates including cache savings, fallback for unknown models, multi-block concatenation, non-text block handling. - `test_daemon_loop.py` — full loop with a fake client. Covers: queue → dispatch, `[NEEDS-JC]` pause, `@session-N:` direct dispatch, JC override clears `needs_jc`, summarization triggers at cap, malformed file does not crash loop, dispatch queues on pending input. - `test_config.py` — env precedence, ntfy auto-generation, inline-comment-as-blank defensive parse, missing API key raises, default `config.yaml` seeded. - `test_ntfy.py` — header/body shape, non-200 handling, network errors return False. - `test_real_api.py` — gated `@pytest.mark.real_api`, opt in with `pytest -m real_api`. Sends one Haiku 4.5 round-trip (~$0.0001). Skips cleanly on `credit balance too low` errors so it's honest about why it didn't run. ### Project files - `pyproject.toml` — `risv3-relay` 0.1.0, Python ≥ 3.10. Pinned deps: `anthropic`, `flask` (reserved for follow-up status endpoint), `requests`, `pyyaml`, `python-dotenv`. Default test invocation excludes `real_api` via `addopts = "-m 'not real_api'"`. - `config.yaml` — auto-seeded on first daemon run; committed so JC can review/edit the system prompt and summarization prompt. - `.gitignore` — fixed inherited typo (was excluding `.env.example` and listing `.env` twice). - `.env.example` — placeholder values + instructions; `.env` itself stays gitignored. - `README.md` — install / run quickstart, CC-side polling protocol with shell snippet, JC operations cheatsheet. ## Test checklist - [x] New tests added for new behavior — 57 unit tests across 9 modules. - [x] All tests pass locally: `pytest` → 57 passed, 1 deselected (real_api). `pytest -m real_api` → 1 skipped (no API credit). - [x] Manual smoke test performed — see below. - [x] No new TODO/FIXME comments without a linked issue. - [x] No secrets in diffs (`.env` stays gitignored; `.env.example` carries placeholders only). ## Smoke test performed Booted `relay run` against a real `.env`. The daemon: 1. Generated a fresh ntfy topic on first start and persisted it to `.env` (subsequent runs reuse it). 2. Printed the announcement banner with the subscription URL. 3. Acquired the `state/.lock` via flock; a concurrent boot would refuse to start (covered by unit test). 4. Polled `queue/` and `state/jc_input.txt` at 1 Hz with empty inputs (no API calls, no costs). 5. Wrote `state/status.json` each tick. `relay status` and `relay topic` round-trip cleanly. I did NOT run a live API turn end-to-end — the test API key has zero credit balance and returns 400 on any `messages.create`. The `real_api` smoke test detects that and skips, and the `daemon_loop` integration suite covers the full path with a fake client (queue → API → dispatch, including the summarization trigger). ## Schema changes - [x] None (no DB). ## Multi-AI review Skipped octopus review for this first PR — it's all greenfield code with comprehensive unit + integration coverage and the spec was JC's. Will reinstate octopus reviews on follow-up PRs that touch security-sensitive paths (status endpoint exposure, systemd integration, multi-tenant config). ## Screenshots N/A — backend daemon, no UI. ## Out of scope (follow-up PRs) - Flask status endpoint (`flask` already a dependency for this). - Multi-session config in real use. - Exponential backoff on transient API errors. - systemd unit file. - Cost-tracking aggregation / dashboard.
AC added 1 commit 2026-05-02 15:25:43 +00:00
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>
Author
Owner

Closing without merging per JC review on 2026-05-02. The cost-monitoring overhead and runaway-loop risk outweigh the time savings for this workflow scale; the behavioral changes that close most of the friction do not require infrastructure. Branch feat/1-daemon-skeleton is preserved for future reference if circumstances change.

Closing without merging per JC review on 2026-05-02. The cost-monitoring overhead and runaway-loop risk outweigh the time savings for this workflow scale; the behavioral changes that close most of the friction do not require infrastructure. Branch `feat/1-daemon-skeleton` is preserved for future reference if circumstances change.
AC closed this pull request 2026-05-02 15:41:03 +00:00
This repo is archived. You cannot comment on pull requests.
No Reviewers
No Label
1 Participants
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: AC/risv3-relay#2