Skip to content

Latest commit

 

History

History
183 lines (147 loc) · 9.32 KB

File metadata and controls

183 lines (147 loc) · 9.32 KB

CLAUDE.md

Guidance for Claude Code (claude.ai/code) working in this repository.

What this is

Caucus is a supervised message hub letting multiple agents talk to each other (direct, broadcast, or in private channels) while a human operator watches live and can pause/stop the exchange. Agents never use a third-party chat platform — they connect to a local hub over a small HTTP API, and the operator drives everything from a browser console over WebSocket.

The hub is the common denominator — its HTTP API plus the versioned operating protocol it serves. Each agent plugs in the connector that fits its runtime; all connectors speak the same hub:

  • Bridge connector (mcp_bridge.py + watch.py) — for passive, turn-based MCP hosts (interactive Claude Code / Codex / Gemini). The host can't push an inbound message into a running turn, so the bridge relies on an out-of-band watcher process to wake the agent. A constraint adapter, not the ideal shape.
  • Native connector (hub_connector.py + a runtime agent like claude_agent.py) — for an autonomous agent that owns its event loop. It listens and speaks in one process and injects inbound messages straight into the live conversation: no watcher, no wake-by-exit. The clean path for bots.

For the per-module breakdown, data flow, routing, and the state machine, see docs/ARCHITECTURE.md. For what calls what / where is X, prefer the codegraph index over prose — it never drifts.

Commands

# Install (editable, with dev tools)
uv venv && source .venv/bin/activate
uv pip install -e ".[dev]"

# Run the hub server (serves UI at http://127.0.0.1:8765/)
caucus-hub --host 127.0.0.1 --port 8765   # or: python -m caucus.hub

# Run the MCP bridge (normally launched by the MCP client via .mcp.json, not by hand)
CAUCUS_PROJECT=<name> CAUCUS_HUB_URL=http://127.0.0.1:8765 caucus-bridge

# MCP clients can connect straight to the hub over Streamable HTTP (no caucus-bridge
# subprocess): the hub serves /mcp by default on localhost (--no-mcp-http to disable).
caucus-hub --host 127.0.0.1 --port 8765

# Run the native autonomous Claude connector (needs the `claude` extra +
# Claude Agent SDK auth in the environment). Joins, listens, and replies on
# its own loop — no bridge, no watcher.
uv pip install -e ".[claude]"
CAUCUS_PROJECT=<name> caucus-claude-agent --mission "Negotiate the API with peer-x"

# Lint + types
ruff check src/
mypy src/        # configured strict

# Test: pytest suite under tests/ (unit + integration; asyncio auto mode)
pytest                   # models, ratelimit, state, hub API, bridge, connector, claude agent

# Legacy standalone smoke test (boots the hub in-process and drives the full
# HTTP flow end to end):
python smoke_test.py     # prints "ALL CHECKS PASSED" on success

The tests/ suite mirrors smoke_test.py's coverage but split into focused, isolated cases. Each API test swaps a fresh HubState onto caucus.hub.state via the state/client fixtures (the endpoints resolve that global at call time); the bridge tests run against a real in-thread hub server (live_hub fixture) because the bridge uses a synchronous httpx.Client.

Architecture at a glance

Four executables and a shared connector library, one package (src/caucus/), wired by [project.scripts] in pyproject.toml:

  • hub.py (caucus-hub) — FastAPI app, the only stateful process; HTTP endpoints + /control + /ui WebSocket, serves the operator console at /. Single source of truth for the protocol; a background reaper drops idle peers. Also hosts operator forms (/ask + /forms) — one agent pushes a questionnaire, the operator answers in a console wizard, and the reply routes back to the asker's audience as an answer message (see ARCHITECTURE.md).
  • mcp_bridge.py (caucus-bridge) — FastMCP stdio server, one per agent session. Passive until join; tools arm lazily on first use (no setup).
  • watch.py (caucus-watch) — the no-LLM long-poll listener the bridge launches in the background to wake the passive host on inbound.
  • hub_connector.py — no script; the shared async client library for native connectors. Transport only.
  • claude_agent.py (caucus-claude-agent) — native autonomous Claude connector on the Claude Agent SDK; owns its event loop.
  • mcp_http.py (no script): an in-process Streamable HTTP MCP server the hub mounts at /mcp, on by default for a loopback bind (--no-mcp-http disables it), so an MCP client can connect straight to the hub with no caucus-bridge subprocess. Same tool surface as the stdio bridge, but the tools are backed by HubConnector over an in-process httpx.ASGITransport, so every operator brake is reused, and the per-session token is keyed on Mcp-Session-Id.

Full detail (responsibilities, invariants, data flow, state machine, long-poll contract) lives in docs/ARCHITECTURE.md.

Load-bearing invariants (don't break these)

  • Long-poll ordering: server poll (LONG_POLL_SECONDS = 25) < bridge httpx timeout (35s) < client timeout. Invert it and you get spurious disconnects. Keep this in mind when editing /receive.
  • Bridge stdout is sacred: mcp_bridge.py logs to stderr to keep stdout clean for the MCP stdio transport — never print to stdout there.
  • Watcher starts on join, not on first say — a peer may message first; with no watcher running that inbound message is never observed.
  • State is in-memory only — restarting the hub clears peers and log; all mutation goes through HubState so the FastAPI layer stays thin.

Conventions

  • Full NumPy/Google-style docstrings on modules, classes, and functions (match the existing density).
  • from __future__ import annotations at the top of every module; PEP 604 unions (X | None).
  • coloredlogs for logging (the bridge to stderr — see invariants above).
  • Python ≥3.10, line length 88, mypy strict.

Pushing to main

Anything that changes behaviour, the HTTP API, the operating protocol, the CLI surface, or dependencies goes through a PR, so CI and review actually see it.

Micro edits may be pushed straight to main, no branch and no PR: a CHANGELOG.md entry or release cut, a typo, a doc or comment tweak, anything with no effect on what the package does.

If in doubt, open the PR. The cost of a needless PR is a few minutes; the cost of an unreviewed behaviour change on main is a broken release.

Versioning

The package version (SemVer) is derived from git tags by hatch-vcs — it is never written in the source. At build time hatch-vcs ([tool.hatch.version] source = "vcs") reads the latest vX.Y.Z tag and writes src/caucus/_version.py (git-ignored build artifact). caucus.__version__ reads that module, falling back to installed package metadata, and hub.py's FastAPI title uses it. Builds between tags get a dev version like 1.4.1.dev3+g<sha>.

A release is a tag. Merge any number of PRs into main with no version bump — there is no version field to edit and no chore(release) commit. To ship, create a GitHub Release vX.Y.Z; the Release workflow (.github/workflows/release.yml) builds, asserts the built version matches the tag, publishes to PyPI (Trusted Publishing, environment pypi), and attaches the artifacts. A PR template plus a PreToolUse hook (.claude/hooks/pr-version-reminder.sh) remind humans and Claude not to bump the version in a PR.

Update CHANGELOG.md as you work, never after the fact (mandatory). Every PR that changes behaviour, the HTTP API, the operating protocol, the CLI surface, or dependencies MUST add its entry under the [Unreleased] heading in that same PR, in the right Keep a Changelog bucket (Added / Changed / Fixed / Security). This is not optional: a merged change with no [Unreleased] line is an incomplete change, and a reviewer should treat the missing entry as a blocker. There is exactly one [Unreleased] section and it lives at the top of the file, right under the intro, never buried mid-history. Cutting a release is then a mechanical rename: [Unreleased] becomes ## [X.Y.Z](compare/vPREV...vX.Y.Z) (DATE), a fresh empty [Unreleased] is added back on top, the footer [Unreleased] compare link is repointed at the new tag, and the GitHub Release vX.Y.Z is created. Never reconstruct a version's entry from git log at release time, that is how entries get lost (2.1.0 and 2.2.0 shipped undocumented precisely this way).

One-time setup before the first tag: configure PyPI Trusted Publishing for caucus-mcp (workflow release.yml, environment pypi) and create the pypi GitHub environment — otherwise the publish step fails.

Note: PROTOCOL_VERSION in hub.py is not the package version — it is an independent counter for the operating-protocol revision. This bump is mandatory: any edit to PROTOCOL_TEXT, however small, must bump PROTOCOL_VERSION — otherwise connected bridges never learn they are behind and never re-read the protocol on their next join. Conversely, only a PROTOCOL_TEXT change warrants bumping it.

Peer protocol doc

caucus-protocol.md is a generic, copy-into-any-repo operating protocol (when/how a given repo's agent should open the caucus, with <this-project> / <peer-project> placeholders to fill in). It is deployed into peer repos, not part of the caucus package.