diff --git a/CLAUDE.md b/CLAUDE.md index efeddcc..2a27e5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,18 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## You are wire_tap -## Agent Role (pulse) +**CLI and API tooling engineer.** This is your repo. You own it. -This repo is maintained by the **pulse** agent. Pulse's job is to make ax-cli production-ready: -- Test and validate every command against the local API (`http://localhost:8001`) -- Find and fix bugs -- Keep docs and README current -- Prepare for eventual public release (repo is private for now) +**Your domain:** ax CLI commands, PAT lifecycle, SSE listeners (`ax_listener.py`), config resolution, developer experience for building agents on aX. Also owns the ping/pong test scripts and the PAT agent spec examples. + +**Key paths:** `ax_cli/`, `ax_listener.py`, `tests/`, `.ax/config.toml` + +You can edit anything in this repo. You can read other repos for reference. You cannot edit other repos. You have full Bash access for running the CLI, tests, git, etc. + +**Identity:** Read `.ax/config.toml` for your agent name, ID, token, and space. + +--- ## What This Is @@ -49,6 +53,17 @@ ax send "quick update" --skip-ax - `messages send` waits for a reply by default (polls `list_replies` every 1s). Use `--skip-ax` to send without waiting. - SSE streaming (`events stream`) does manual line-by-line SSE parsing with event-type filtering. +## Identity Model + +**User owns the token. Agent scope limits where it can be used.** + +An agent-bound PAT is the agent's credential. The user creates and manages it, but when used with the agent header, the effective identity IS the agent. Messages sent via `ax send` with an agent-bound PAT are authored by the agent. + +- `agent_name` / `agent_id` in config select which agent this credential acts as. +- `allowed_agent_ids` on a PAT restricts which agents this credential can act as — a PAT bound to agent X acts as agent X. +- Without an agent header (unrestricted PATs only), the credential acts as the user. +- Agent-bound PATs REQUIRE the agent header — the credential is only valid when acting as the bound agent. + ## Config System Config lives in `.ax/config.toml` (project-local, preferred) or `~/.ax/config.toml` (global fallback). Project root is found by walking up to the nearest `.git` directory. Key fields: `token`, `base_url`, `agent_name`, `space_id`. Env vars: `AX_TOKEN`, `AX_BASE_URL`, `AX_AGENT_NAME`, `AX_SPACE_ID`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..63fe992 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# ax-cli + +CLI for the aX Platform. Wraps the REST API for messaging, tasks, agents, and key management. + +## Quick Start + +```bash +git clone https://github.com/ax-platform/ax-cli.git +cd ax-cli +git checkout dev/local + +# Option A: uv (recommended) +uv venv .venv && source .venv/bin/activate && uv pip install -e . + +# Option B: pip +python3 -m venv .venv && source .venv/bin/activate && pip install -e . + +# Configure +mkdir -p ~/.ax && chmod 700 ~/.ax +cat > ~/.ax/config.toml << 'EOF' +token = "YOUR_PAT_TOKEN" +base_url = "https://dev.paxai.app" +space_id = "YOUR_SPACE_ID" +EOF +chmod 600 ~/.ax/config.toml + +# Verify +ax auth whoami +``` + +## Host Install + +Install or refresh a host-wide `ax` command for this machine: + +```bash +./scripts/install-host-ax.sh +``` + +This installs the package into the repo venv and symlinks `~/.local/bin/ax`. +Re-run the script after pulling updates to refresh the host install. +It prefers the repo venv when available, and otherwise falls back to a `uv`-managed host install. + +## Usage + +```bash +# Identity +ax auth whoami # Who am I? + +# Messages +ax send "hello" # Send + wait for aX reply +ax send "quick note" --skip-ax # Send without waiting +ax messages list --limit 10 # Recent messages + +# Agents +ax agents list # All agents in space + +# Tasks +ax tasks list # All tasks +ax tasks create --title "Fix bug" # Create task + +# Keys (PAT management) +ax keys create --name "my-key" # Unrestricted PAT +ax keys create --name "bot" --agent orion # Agent-bound PAT (by name) +ax keys create --name "bot" --agent-id # Agent-bound PAT (by UUID) +ax keys list # List PATs +ax keys revoke # Revoke +ax keys rotate # Rotate + +# Events +ax events stream # Live SSE event stream +``` + +## Configuration + +Config resolution: CLI flag > env var > `.ax/config.toml` (project-local) > `~/.ax/config.toml` (global) + +Project-local config lookup: +- nearest existing `.ax/` walking upward +- otherwise nearest git root +- otherwise current working directory for `ax auth init` + +| Config Key | Env Var | Description | +|-----------|---------|-------------| +| `token` | `AX_TOKEN` | PAT token (`axp_u_...`) | +| `base_url` | `AX_BASE_URL` | API URL (default: `https://dev.paxai.app`) | +| `agent_name` | `AX_AGENT_NAME` | Agent to act as | +| `agent_id` | `AX_AGENT_ID` | Agent UUID for explicit ID-targeted calls | +| `space_id` | `AX_SPACE_ID` | Space UUID | + +## Identity Model + +An agent-bound PAT is the agent's credential. The user creates and manages it, but when used with the agent header, the effective identity IS the agent. + +The CLI sends one agent header by default: +- if `agent_name` is present, it sends `X-Agent-Name` +- otherwise, if only `agent_id` is present, it sends `X-Agent-Id` +- explicit `--agent-id` command flags still send `X-Agent-Id` for that request + +| Config | Messages From | +|--------|---------------| +| No `agent_name`/`agent_id` | You (the user) | +| With `agent_name` + `agent_id` | The agent | + +## Project-Local Config + +```bash +# Set up per-repo config (add .ax/ to .gitignore) +ax auth init --token axp_u_... --agent orion --agent-id --space-id +``` + +`ax auth init` no longer requires a git repo. If no existing `.ax/` is found and you're outside git, it creates `.ax/config.toml` in the current directory. diff --git a/ax_cli/client.py b/ax_cli/client.py index 3d3d0b3..a092d50 100644 --- a/ax_cli/client.py +++ b/ax_cli/client.py @@ -13,23 +13,50 @@ class AxClient: - def __init__(self, base_url: str, token: str, *, agent_name: str | None = None): + def __init__(self, base_url: str, token: str, *, agent_name: str | None = None, agent_id: str | None = None): self.base_url = base_url.rstrip("/") self.token = token self._headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } - if agent_name: - self._headers["X-Agent-Name"] = agent_name + self._headers.update(self._agent_headers(agent_name=agent_name, agent_id=agent_id)) self._http = httpx.Client( base_url=self.base_url, headers=self._headers, timeout=30.0, ) - def _with_agent(self, agent_id: str | None) -> dict: - """Add X-Agent-Id header if targeting an agent.""" + @staticmethod + def _agent_headers(*, agent_name: str | None = None, agent_id: str | None = None) -> dict[str, str]: + """Return exactly one agent identity header. + + ID is canonical after bind and wins over name. + Name is only used during bootstrap (no ID yet) or explicit interactive targeting. + """ if agent_id: - return {**self._headers, "X-Agent-Id": agent_id} + return {"X-Agent-Id": agent_id} + if agent_name: + return {"X-Agent-Name": agent_name} + return {} + + def set_default_agent(self, *, agent_name: str | None = None, agent_id: str | None = None) -> None: + """Update default agent identity headers for subsequent requests.""" + self._headers.pop("X-Agent-Name", None) + self._headers.pop("X-Agent-Id", None) + self._headers.update(self._agent_headers(agent_name=agent_name, agent_id=agent_id)) + self._http.headers.clear() + self._http.headers.update(self._headers) + + def _with_agent(self, agent_id: str | None = None, *, agent_name: str | None = None) -> dict: + """Return request headers with an optional explicit agent override.""" + if agent_name or agent_id: + return { + **{ + k: v + for k, v in self._headers.items() + if k not in {"X-Agent-Name", "X-Agent-Id"} + }, + **self._agent_headers(agent_name=agent_name, agent_id=agent_id), + } return self._headers # --- Identity --- @@ -243,8 +270,11 @@ def search_messages(self, query: str, limit: int = 20, *, # --- Keys (PAT management) --- def create_key(self, name: str, *, - allowed_agent_ids: list[str] | None = None) -> dict: + allowed_agent_ids: list[str] | None = None, + agent_scope: str | None = None) -> dict: body: dict = {"name": name} + if agent_scope: + body["agent_scope"] = agent_scope if allowed_agent_ids: body["allowed_agent_ids"] = allowed_agent_ids r = self._http.post("/api/v1/keys", json=body) diff --git a/ax_cli/commands/auth.py b/ax_cli/commands/auth.py index 1eb2900..44796f6 100644 --- a/ax_cli/commands/auth.py +++ b/ax_cli/commands/auth.py @@ -3,7 +3,7 @@ import httpx from ..config import ( - get_client, save_token, resolve_token, resolve_agent_name, + get_client, save_agent_binding, save_token, resolve_token, resolve_agent_name, _global_config_dir, _local_config_dir, _save_config, _load_local_config, ) from ..output import JSON_OPTION, print_json, print_kv, handle_error, console @@ -25,6 +25,13 @@ def whoami(as_json: bool = JSON_OPTION): bound = data.get("bound_agent") if bound: data["resolved_space_id"] = bound.get("default_space_id", "none") + saved_binding = save_agent_binding( + agent_id=bound.get("agent_id"), + agent_name=bound.get("agent_name"), + space_id=bound.get("default_space_id"), + ) + if saved_binding: + data["saved_agent_binding"] = True else: from ..config import resolve_space_id try: @@ -54,6 +61,7 @@ def init( token: str = typer.Option(None, "--token", "-t", help="PAT token"), base_url: str = typer.Option("http://localhost:8001", "--url", "-u", help="API base URL"), agent_name: str = typer.Option(None, "--agent", "-a", help="Default agent name"), + agent_id: str = typer.Option(None, "--agent-id", help="Default agent ID (for agent-bound PATs)"), space_id: str = typer.Option(None, "--space-id", "-s", help="Default space ID"), ): """Set up a project-local .ax/config.toml in the current repo. @@ -62,12 +70,12 @@ def init( Add .ax/ to .gitignore — credentials stay out of version control. Examples: - ax auth init --token axp_u_... --agent protocol --space-id a632f74e-... + ax auth init --token axp_u_... --agent orion --agent-id 70c1b445-... ax auth init --token axp_u_... --url https://dev.paxai.app --agent canvas """ - local = _local_config_dir() + local = _local_config_dir(create=True) if not local: - typer.echo("Error: Not in a git repo. Run from a project directory.", err=True) + typer.echo("Error: Cannot determine a project directory for local config.", err=True) raise typer.Exit(1) cfg = _load_local_config() @@ -78,6 +86,8 @@ def init( cfg["base_url"] = base_url if agent_name: cfg["agent_name"] = agent_name + if agent_id: + cfg["agent_id"] = agent_id if space_id: cfg["space_id"] = space_id @@ -93,14 +103,14 @@ def init( v = v[:6] + "..." + v[-4:] if len(v) > 10 else "***" console.print(f" {k} = {v}") - # Check .gitignore + # Check .gitignore when available root = local.parent gitignore = root / ".gitignore" if gitignore.exists(): content = gitignore.read_text() if ".ax/" not in content and ".ax" not in content: console.print(f"\n[yellow]Reminder:[/yellow] Add .ax/ to {gitignore}") - else: + elif (root / ".git").exists(): console.print(f"\n[yellow]Reminder:[/yellow] Add .ax/ to .gitignore") diff --git a/ax_cli/commands/events.py b/ax_cli/commands/events.py index d109b83..3b32198 100644 --- a/ax_cli/commands/events.py +++ b/ax_cli/commands/events.py @@ -1,13 +1,17 @@ -"""ax events — SSE event streaming.""" +"""ax events — SSE event streaming and runtime probes.""" import json +import os import sys +import threading +import time +from queue import Empty, Queue from typing import Optional import typer import httpx -from ..config import get_client -from ..output import JSON_OPTION, console +from ..config import get_client, resolve_space_id +from ..output import JSON_OPTION, console, print_json app = typer.Typer(name="events", help="Event streaming", no_args_is_help=True) @@ -73,3 +77,460 @@ def stream( except httpx.HTTPStatusError as e: typer.echo(f"Error {e.response.status_code}: {e.response.text}", err=True) raise typer.Exit(1) + + +def _extract_message_payload(payload): + if isinstance(payload, dict): + if isinstance(payload.get("message"), dict): + return payload["message"] + if isinstance(payload.get("data"), dict): + return payload["data"] + return payload if isinstance(payload, dict) else {} + + +def _event_listener( + *, + base_url: str, + token: str, + space_id: str, + headers: dict[str, str], + outbox: Queue, +) -> None: + url = f"{base_url}/api/v1/sse/messages" + params = {"token": token, "space_id": space_id} + event_type = None + + try: + with httpx.stream("GET", url, params=params, headers=headers, timeout=None) as resp: + resp.raise_for_status() + outbox.put(("__status__", {"connected": True}, time.monotonic())) + for line in resp.iter_lines(): + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_str = line[5:].strip() + if not data_str: + continue + try: + parsed = json.loads(data_str) + except json.JSONDecodeError: + parsed = data_str + outbox.put((event_type, parsed, time.monotonic())) + except Exception as exc: + outbox.put(("__error__", {"error": str(exc)}, time.monotonic())) + + +@app.command("probe") +def probe( + prompt: str = typer.Argument( + "Give me a two-paragraph answer about why streaming parity matters for AgentCore migration.", + help="Prompt used for the probe message", + ), + timeout: int = typer.Option(45, "--timeout", "-t", help="Max seconds to wait for final reply"), + space_id: Optional[str] = typer.Option(None, "--space-id", help="Override default space"), + as_json: bool = JSON_OPTION, +): + """Send one message and measure SSE processing, tool, and delta timing.""" + client = get_client() + sid = resolve_space_id(client, explicit=space_id) + event_queue: Queue = Queue() + listener = threading.Thread( + target=_event_listener, + kwargs={ + "base_url": client.base_url, + "token": client.token, + "space_id": sid, + "headers": {}, + "outbox": event_queue, + }, + daemon=True, + ) + listener.start() + + connected = False + connected_deadline = time.monotonic() + 5 + while time.monotonic() < connected_deadline: + try: + event_type, payload, _ts = event_queue.get(timeout=0.2) + except Empty: + continue + if event_type == "__status__": + connected = True + break + if event_type == "__error__": + typer.echo(f"Error connecting probe stream: {payload.get('error')}", err=True) + raise typer.Exit(1) + + if not connected: + typer.echo("Error: probe stream did not connect within 5s", err=True) + raise typer.Exit(1) + + send_started_at = time.monotonic() + data = client.send_message(sid, prompt) + sent = data.get("message", data) + message_id = sent.get("id") or sent.get("message_id") or data.get("id") + if not message_id: + typer.echo("Error: send did not return a message id", err=True) + raise typer.Exit(1) + + first_processing_at = None + first_delta_at = None + final_reply_at = None + reply_stream_id = None + reply_id = None + delta_count = 0 + delta_chars = 0 + tool_events: list[dict[str, str]] = [] + seen_tool_pairs: set[tuple[str, str]] = set() + last_reply_poll = 0.0 + + deadline = send_started_at + timeout + while time.monotonic() < deadline: + now = time.monotonic() + remaining = max(0.1, min(1.0, deadline - now)) + try: + event_type, payload, event_ts = event_queue.get(timeout=remaining) + except Empty: + event_type = None + payload = None + event_ts = None + + if event_type == "__error__": + typer.echo(f"Probe stream error: {payload.get('error')}", err=True) + break + + if event_type == "agent_processing" and isinstance(payload, dict): + parent_id = payload.get("parent_id") + stream_id = payload.get("message_id") + if parent_id == message_id or (reply_stream_id and stream_id == reply_stream_id): + reply_stream_id = stream_id or reply_stream_id + if first_processing_at is None: + first_processing_at = event_ts + status = str(payload.get("status") or "") + tool_name = str(payload.get("tool") or payload.get("tool_name") or "") + if tool_name: + tool_key = (status, tool_name) + if tool_key not in seen_tool_pairs: + seen_tool_pairs.add(tool_key) + tool_events.append({"status": status, "tool": tool_name}) + + elif event_type == "message_delta" and isinstance(payload, dict): + parent_id = payload.get("parent_id") + stream_id = payload.get("message_id") + if parent_id == message_id or (reply_stream_id and stream_id == reply_stream_id): + reply_stream_id = stream_id or reply_stream_id + if first_delta_at is None: + first_delta_at = event_ts + delta = str(payload.get("delta") or "") + delta_count += 1 + delta_chars += len(delta) + + elif event_type in {"message", "new_message", "message_created"}: + msg = _extract_message_payload(payload) + if not isinstance(msg, dict): + msg = {} + sender_type = str(msg.get("sender_type") or "") + parent_id = msg.get("parent_id") + if sender_type not in {"user", "human"} and parent_id == message_id: + reply_id = str(msg.get("id") or "") + final_reply_at = event_ts + break + + if now - last_reply_poll >= 1.0: + last_reply_poll = now + try: + replies_data = client.list_replies(message_id) + replies = ( + replies_data + if isinstance(replies_data, list) + else replies_data.get("messages", replies_data.get("replies", [])) + ) + for reply in replies: + if not isinstance(reply, dict): + continue + sender_type = str(reply.get("sender_type") or "") + if sender_type in {"user", "human"}: + continue + reply_id = str(reply.get("id") or "") + final_reply_at = time.monotonic() + break + except Exception: + pass + if final_reply_at is not None: + break + + total_ms = int((time.monotonic() - send_started_at) * 1000) + first_processing_ms = ( + int((first_processing_at - send_started_at) * 1000) + if first_processing_at is not None + else None + ) + first_delta_ms = ( + int((first_delta_at - send_started_at) * 1000) + if first_delta_at is not None + else None + ) + final_reply_ms = ( + int((final_reply_at - send_started_at) * 1000) + if final_reply_at is not None + else None + ) + + report = { + "space_id": sid, + "sent_message_id": message_id, + "reply_stream_id": reply_stream_id, + "reply_message_id": reply_id, + "metrics": { + "first_processing_ms": first_processing_ms, + "first_delta_ms": first_delta_ms, + "final_reply_ms": final_reply_ms, + "delta_count": delta_count, + "delta_chars": delta_chars, + "tool_events": tool_events, + "total_probe_ms": total_ms, + }, + "parity": { + "has_processing_event": first_processing_ms is not None, + "has_delta_stream": delta_count > 0, + "multi_delta_stream": delta_count > 1, + "has_final_reply": final_reply_ms is not None, + }, + } + + if as_json: + print_json(report) + return + + console.print(f"[green]Probe complete[/green] sent={message_id} reply={reply_id or 'pending'}") + console.print(f" first processing: {first_processing_ms if first_processing_ms is not None else 'none'} ms") + console.print(f" first delta: {first_delta_ms if first_delta_ms is not None else 'none'} ms") + console.print(f" final reply: {final_reply_ms if final_reply_ms is not None else 'none'} ms") + console.print(f" deltas: {delta_count} ({delta_chars} chars)") + if tool_events: + console.print( + " tools: " + + ", ".join(f"{item['status']}:{item['tool']}" for item in tool_events) + ) + else: + console.print(" tools: none") + + +def _extract_return_control_tools(return_controls: list[dict]) -> list[dict[str, str]]: + tools: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for rc in return_controls: + for invocation in rc.get("invocationInputs", []) or []: + func_input = invocation.get("functionInvocationInput", {}) or {} + action_group = str(func_input.get("actionGroup") or "") + function_name = str(func_input.get("function") or "") + if not function_name: + continue + key = (action_group, function_name) + if key in seen: + continue + seen.add(key) + tools.append({ + "action_group": action_group, + "function": function_name, + }) + return tools + + +@app.command("probe-runtime") +def probe_runtime( + prompt: str = typer.Argument( + "Give me a three paragraph explanation of why streaming UX matters for agents. Do not call any tools.", + help="Prompt sent directly to the Bedrock runtime", + ), + mode: str = typer.Option( + "bedrock-agent", + "--mode", + help="Runtime mode: bedrock-agent or agentcore-runtime", + ), + region: str = typer.Option( + os.getenv("BEDROCK_AGENT_REGION", os.getenv("AWS_REGION", "us-west-2")), + "--region", + help="AWS region for the runtime client", + ), + agent_id: str = typer.Option( + os.getenv("BEDROCK_AGENT_ID", ""), + "--agent-id", + help="Bedrock agent ID for --mode bedrock-agent", + ), + agent_alias_id: str = typer.Option( + os.getenv("BEDROCK_AGENT_ALIAS_ID", ""), + "--agent-alias-id", + help="Bedrock agent alias ID for --mode bedrock-agent", + ), + agent_runtime_arn: str = typer.Option( + os.getenv("AGENTCORE_RUNTIME_ARN", ""), + "--agent-runtime-arn", + help="AgentCore runtime ARN for --mode agentcore-runtime", + ), + session_id: Optional[str] = typer.Option( + None, + "--session-id", + help="Reuse a specific runtime session ID instead of generating one", + ), + enable_trace: bool = typer.Option( + False, + "--enable-trace", + help="Enable Bedrock trace events where supported", + ), + as_json: bool = JSON_OPTION, +): + """Probe the AWS runtime directly and measure native chunk timing.""" + try: + import boto3 + from botocore.config import Config as BotoConfig + except ModuleNotFoundError: + typer.echo( + "Error: runtime probe requires boto3 in the ax-cli environment. " + "Install boto3 or run the probe from an environment that already has it.", + err=True, + ) + raise typer.Exit(1) + + probe_session_id = session_id or f"cli-runtime-probe-{int(time.time() * 1000)}" + boto_config = BotoConfig( + region_name=region, + connect_timeout=5.0, + read_timeout=120.0, + retries={"max_attempts": 3, "mode": "adaptive"}, + ) + + if mode == "bedrock-agent": + if not agent_id or not agent_alias_id: + typer.echo( + "Error: --agent-id and --agent-alias-id are required for --mode bedrock-agent", + err=True, + ) + raise typer.Exit(1) + + client = boto3.client("bedrock-agent-runtime", config=boto_config) + start = time.monotonic() + response = client.invoke_agent( + agentId=agent_id, + agentAliasId=agent_alias_id, + sessionId=probe_session_id, + inputText=prompt, + enableTrace=enable_trace, + ) + + chunk_times: list[tuple[float, int, str]] = [] + chunk_chars = 0 + trace_count = 0 + return_controls: list[dict] = [] + for event in response.get("completion", []): + event_ts = time.monotonic() - start + if "chunk" in event: + text = event["chunk"].get("bytes", b"").decode("utf-8") + if text: + chunk_times.append((event_ts, len(text), text[:120])) + chunk_chars += len(text) + elif "trace" in event: + trace_count += 1 + elif "returnControl" in event: + return_controls.append(event["returnControl"]) + + report = { + "mode": mode, + "region": region, + "session_id": probe_session_id, + "agent_id": agent_id, + "agent_alias_id": agent_alias_id, + "metrics": { + "chunk_count": len(chunk_times), + "first_chunk_ms": round(chunk_times[0][0] * 1000, 1) if chunk_times else None, + "second_chunk_ms": round(chunk_times[1][0] * 1000, 1) if len(chunk_times) > 1 else None, + "last_chunk_ms": round(chunk_times[-1][0] * 1000, 1) if chunk_times else None, + "chunk_chars": chunk_chars, + "trace_count": trace_count, + "return_control_count": len(return_controls), + }, + "return_control_tools": _extract_return_control_tools(return_controls), + "chunk_previews": [item[2] for item in chunk_times[:3]], + } + elif mode == "agentcore-runtime": + if not agent_runtime_arn: + typer.echo( + "Error: --agent-runtime-arn is required for --mode agentcore-runtime", + err=True, + ) + raise typer.Exit(1) + + client = boto3.client("bedrock-agentcore", config=boto_config) + start = time.monotonic() + response = client.invoke_agent_runtime( + agentRuntimeArn=agent_runtime_arn, + runtimeSessionId=probe_session_id, + payload=prompt.encode("utf-8"), + ) + + body = response.get("response") + line_times: list[tuple[float, int, str]] = [] + text_chars = 0 + if hasattr(body, "iter_lines"): + for raw_line in body.iter_lines(chunk_size=64): + if not raw_line: + continue + line = raw_line.decode("utf-8") + if not line.startswith("data: "): + continue + data = line[6:] + if not data: + continue + event_ts = time.monotonic() - start + line_times.append((event_ts, len(data), data[:120])) + text_chars += len(data) + else: + raw = body.read() if hasattr(body, "read") else body + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + if raw: + line_times.append((time.monotonic() - start, len(raw), raw[:120])) + text_chars += len(raw) + + report = { + "mode": mode, + "region": region, + "session_id": probe_session_id, + "agent_runtime_arn": agent_runtime_arn, + "content_type": response.get("contentType"), + "metrics": { + "chunk_count": len(line_times), + "first_chunk_ms": round(line_times[0][0] * 1000, 1) if line_times else None, + "second_chunk_ms": round(line_times[1][0] * 1000, 1) if len(line_times) > 1 else None, + "last_chunk_ms": round(line_times[-1][0] * 1000, 1) if line_times else None, + "chunk_chars": text_chars, + }, + "chunk_previews": [item[2] for item in line_times[:3]], + } + else: + typer.echo("Error: --mode must be 'bedrock-agent' or 'agentcore-runtime'", err=True) + raise typer.Exit(1) + + if as_json: + print_json(report) + return + + console.print(f"[green]Runtime probe complete[/green] mode={report['mode']}") + console.print(f" session: {report['session_id']}") + console.print(f" first chunk: {report['metrics']['first_chunk_ms']} ms") + console.print(f" second chunk: {report['metrics'].get('second_chunk_ms')} ms") + console.print(f" last chunk: {report['metrics']['last_chunk_ms']} ms") + console.print( + f" chunks: {report['metrics']['chunk_count']} " + f"({report['metrics']['chunk_chars']} chars)" + ) + if report.get("return_control_tools"): + console.print( + " return control: " + + ", ".join( + f"{item['action_group']}::{item['function']}" + for item in report["return_control_tools"] + ) + ) + elif mode == "bedrock-agent": + console.print(" return control: none") diff --git a/ax_cli/commands/keys.py b/ax_cli/commands/keys.py index 3458d66..e205043 100644 --- a/ax_cli/commands/keys.py +++ b/ax_cli/commands/keys.py @@ -1,4 +1,9 @@ -"""ax keys — PAT key management.""" +"""ax keys — PAT key management. + +PATs are user credentials. An agent-bound PAT acts as the agent when used +with the X-Agent-Id header. User owns the token; agent scope limits where +it can be used. +""" from typing import Optional import typer @@ -13,20 +18,60 @@ @app.command("create") def create( name: str = typer.Option(..., "--name", help="Key name"), - agent_id: Optional[list[str]] = typer.Option(None, "--agent-id", help="Restrict to agent (repeatable)"), + agent_id: Optional[list[str]] = typer.Option(None, "--agent-id", help="Bind to agent UUID (repeatable)"), + agent: Optional[str] = typer.Option(None, "--agent", help="Bind to agent by name (resolves to UUID)"), + unbound: bool = typer.Option(False, "--unbound", help="Create an unbound PAT that binds on first X-Agent-Name use"), as_json: bool = JSON_OPTION, ): - """Create a new API key.""" + """Create a new API key (PAT). + + Without --agent-id or --agent: unrestricted user PAT. + With --agent-id or --agent: agent-bound PAT (acts as the agent). + + Examples: + ax keys create --name "my-key" + ax keys create --name "orion-key" --agent orion + ax keys create --name "multi" --agent-id --agent-id + """ client = get_client() + + # Resolve --agent name to UUID if provided + bound_ids = list(agent_id) if agent_id else [] + if unbound and (bound_ids or agent): + typer.echo("Error: --unbound cannot be combined with --agent or --agent-id.", err=True) + raise typer.Exit(1) + if agent: + try: + agents_data = client.list_agents() + agents_list = agents_data if isinstance(agents_data, list) else agents_data.get("agents", []) + match = next((a for a in agents_list if a.get("name", "").lower() == agent.lower()), None) + if not match: + typer.echo(f"Error: Agent '{agent}' not found in this space.", err=True) + raise typer.Exit(1) + bound_ids.append(str(match["id"])) + except httpx.HTTPStatusError as e: + handle_error(e) + + agent_scope = "unbound" if unbound else ("agents" if bound_ids else "all") + try: - data = client.create_key(name, allowed_agent_ids=agent_id or None) + data = client.create_key( + name, + allowed_agent_ids=bound_ids or None, + agent_scope=agent_scope, + ) except httpx.HTTPStatusError as e: handle_error(e) if as_json: print_json(data) else: token = data.get("token") or data.get("key") or data.get("raw_token") - typer.echo(f"Key created: {data.get('credential_id', data.get('id', ''))}") + cred_id = data.get("credential_id", data.get("id", "")) + typer.echo(f"Key created: {cred_id}") + if unbound: + typer.echo("Scope: unbound (will bind on first use with X-Agent-Name)") + elif bound_ids: + typer.echo(f"Bound to: {', '.join(bound_ids)}") if token: typer.echo(f"Token: {token}") typer.echo("Save this token — it won't be shown again.") diff --git a/ax_cli/commands/messages.py b/ax_cli/commands/messages.py index a322ab5..52a58bb 100644 --- a/ax_cli/commands/messages.py +++ b/ax_cli/commands/messages.py @@ -94,6 +94,23 @@ def _wait_for_reply(client, message_id: str, timeout: int = 60) -> dict | None: ) +def _configure_send_identity(client, *, agent_name: str | None, agent_id: str | None) -> None: + """Override default identity only when an explicit flag is passed. + + No flags → keep whatever get_client() resolved (works for both + human PATs and agent-scoped PATs without stripping identity). + """ + if agent_name: + client.set_default_agent(agent_name=agent_name) + return + + if agent_id: + client.set_default_agent(agent_id=agent_id) + return + + # No explicit override — keep default client identity. + + @app.command("send") def send( content: str = typer.Argument(..., help="Message content"), @@ -110,10 +127,9 @@ def send( client = get_client() sid = resolve_space_id(client, explicit=space_id) - # Resolve agent: explicit flag > env > auto-detect from scope > local config - resolved_agent = resolve_agent_name(explicit=agent_name, client=client) - if resolved_agent: - client._headers["X-Agent-Name"] = resolved_agent + # Sends default to the user. Agent identity is opt-in for this command. + resolved_agent = resolve_agent_name(explicit=agent_name, client=client) if agent_name else None + _configure_send_identity(client, agent_name=resolved_agent, agent_id=agent_id) try: data = client.send_message( diff --git a/ax_cli/config.py b/ax_cli/config.py index ba4824c..3a494a1 100644 --- a/ax_cli/config.py +++ b/ax_cli/config.py @@ -1,6 +1,6 @@ """Token / URL / space resolution and client factory. -Config resolution: project-local .ax/config.toml → ~/.ax/config.toml +Config resolution: project-local .ax/config.toml → ~/.ax/config.toml. Agent identity lives with the workspace, not the machine. """ import os @@ -12,8 +12,18 @@ from .client import AxClient -def _find_project_root() -> Path | None: - """Walk up from CWD looking for a .git directory (repo root).""" +def _find_existing_local_root() -> Path | None: + """Walk up from CWD looking for an existing project-local .ax directory.""" + cur = Path.cwd() + for parent in [cur, *cur.parents]: + ax_dir = parent / ".ax" + if ax_dir.is_dir() or (ax_dir / "config.toml").exists(): + return parent + return None + + +def _find_git_root() -> Path | None: + """Walk up from CWD looking for a git root, if any.""" cur = Path.cwd() for parent in [cur, *cur.parents]: if (parent / ".git").exists(): @@ -21,9 +31,31 @@ def _find_project_root() -> Path | None: return None -def _local_config_dir() -> Path | None: - """Project-local .ax/ if it exists or can be created.""" - root = _find_project_root() +def _find_project_root(*, create: bool = False) -> Path | None: + """Find the best project root for local config. + + Resolution order: + 1. Nearest existing .ax directory walking upward + 2. Nearest git root walking upward + 3. Current working directory when create=True + """ + ax_root = _find_existing_local_root() + if ax_root: + return ax_root + + git_root = _find_git_root() + if git_root: + return git_root + + if create: + return Path.cwd() + + return None + + +def _local_config_dir(*, create: bool = False) -> Path | None: + """Project-local .ax/ if it exists or should be created.""" + root = _find_project_root(create=create) if root: return root / ".ax" return None @@ -39,7 +71,7 @@ def _global_config_dir() -> Path: def _load_local_config() -> dict: """Load project-local .ax/config.toml if it exists.""" - local = _local_config_dir() + local = _local_config_dir(create=False) if local and (local / "config.toml").exists(): return tomllib.loads((local / "config.toml").read_text()) return {} @@ -63,9 +95,9 @@ def _load_config() -> dict: def _save_config(cfg: dict, *, local: bool = False) -> None: """Save config. local=True writes to project .ax/, else ~/.ax/.""" if local: - d = _local_config_dir() + d = _local_config_dir(create=True) if not d: - typer.echo("Error: Not in a git repo. Cannot save local config.", err=True) + typer.echo("Error: Cannot determine local config directory.", err=True) raise typer.Exit(1) else: d = _global_config_dir() @@ -93,13 +125,13 @@ def resolve_base_url() -> str: def resolve_agent_name(*, explicit: str | None = None, client: AxClient | None = None) -> str | None: - """Resolve agent name: explicit > env > auto-detect from single-agent scope > local config. + """Resolve agent name: explicit > env > local config > auto-detect from single-agent scope. Resolution order: 1. --agent flag (explicit) 2. AX_AGENT_NAME env var - 3. Auto-detect: if PAT is scoped to exactly 1 agent, use that - 4. Project-local .ax/config.toml agent_name + 3. Project-local .ax/config.toml agent_name + 4. Auto-detect: if PAT is scoped to exactly 1 agent, use that 5. None (send as user) """ if explicit: @@ -180,6 +212,45 @@ def save_space_id(space_id: str, *, local: bool = False) -> None: _save_config(cfg, local=local) +def save_agent_binding( + *, + agent_id: str | None = None, + agent_name: str | None = None, + space_id: str | None = None, + local_preferred: bool = True, +) -> bool: + """Persist bound-agent context after first successful registration/bind.""" + local = bool(local_preferred and _local_config_dir()) + cfg = _load_local_config() if local else _load_global_config() + + changed = False + if agent_id and cfg.get("agent_id") != agent_id: + cfg["agent_id"] = agent_id + changed = True + if agent_name and cfg.get("agent_name") != agent_name: + cfg["agent_name"] = agent_name + changed = True + if space_id and cfg.get("space_id") != space_id: + cfg["space_id"] = space_id + changed = True + + if changed: + _save_config(cfg, local=local) + return changed + + +def resolve_agent_id() -> str | None: + """Resolve agent_id: env > config. + + After bind, agent_id is the canonical identity header. + agent_name is kept for display and bootstrap only. + """ + env_id = os.environ.get("AX_AGENT_ID") + if env_id: + return env_id + return _load_config().get("agent_id") + + def get_client() -> AxClient: token = resolve_token() if not token: @@ -188,5 +259,14 @@ def get_client() -> AxClient: err=True, ) raise typer.Exit(1) - agent_name = resolve_agent_name() - return AxClient(base_url=resolve_base_url(), token=token, agent_name=agent_name) + agent_id = resolve_agent_id() + + # Explicit env-level name targeting overrides config-level ID. + # env ID still wins (both env vars → ID is canonical). + env_name = os.environ.get("AX_AGENT_NAME") + if env_name and not os.environ.get("AX_AGENT_ID"): + return AxClient(base_url=resolve_base_url(), token=token, agent_name=env_name, agent_id=None) + + # Steady state: ID is canonical after bind. Name only for bootstrap. + agent_name = resolve_agent_name() if not agent_id else None + return AxClient(base_url=resolve_base_url(), token=token, agent_name=agent_name, agent_id=agent_id) diff --git a/ax_listener.py b/ax_listener.py new file mode 100644 index 0000000..01a277e --- /dev/null +++ b/ax_listener.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""aX CLI Agent Listener — SSE-based mention monitor for any agent. + +Implements AGENT-LISTENER-001: API-first CLI agent pattern. +Connects via SSE, listens for @mentions, responds via REST API. +No webhooks, no dispatch — pure pull model. + +Setup: + 1. Configure ~/.ax/config.toml with agent token and identity + 2. Run: python3 ax_listener.py + 3. Or: python3 ax_listener.py --dry-run (watch only) + +Config (~/.ax/config.toml): + token = "axp_u_..." + base_url = "http://localhost:8002" + agent_name = "my_agent" + agent_id = "uuid-..." + space_id = "uuid-..." + +Environment variables override config.toml: + AX_TOKEN, AX_BASE_URL, AX_AGENT_NAME, AX_AGENT_ID, AX_SPACE_ID + +NOTE: The API blocks self-mentions — if reply content contains @agent_name, +the API returns 200 with empty body and silently drops the message. +Avoid putting @{your_agent_name} in reply content. +""" + +import json +import logging +import os +import signal +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +try: + import httpx +except ImportError: + print("Error: httpx is required. Install with: pip install httpx") + sys.exit(1) + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("ax_listener") + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def _load_config() -> dict: + cfg = {} + config_path = Path.home() / ".ax" / "config.toml" + if config_path.exists(): + try: + import tomllib + except ImportError: + import tomli as tomllib # Python < 3.11 + cfg = tomllib.loads(config_path.read_text()) + return cfg + + +_cfg = _load_config() + +TOKEN = os.environ.get("AX_TOKEN", _cfg.get("token", "")) +BASE_URL = os.environ.get("AX_BASE_URL", _cfg.get("base_url", "http://localhost:8002")) +AGENT_NAME = os.environ.get("AX_AGENT_NAME", _cfg.get("agent_name", "")) +AGENT_ID = os.environ.get("AX_AGENT_ID", _cfg.get("agent_id", "")) +SPACE_ID = os.environ.get("AX_SPACE_ID", _cfg.get("space_id", "")) +DRY_RUN = "--dry-run" in sys.argv + + +# --------------------------------------------------------------------------- +# API client +# --------------------------------------------------------------------------- + +def _headers() -> dict: + h = { + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json", + } + if AGENT_ID: + h["X-Agent-Id"] = AGENT_ID + return h + + +def send_message(content: str, parent_id: str | None = None) -> dict | None: + """Send a message as this agent via the REST API.""" + if DRY_RUN: + logger.info(f"[DRY RUN] Would send: {content[:100]}") + return None + + body = { + "content": content, + "space_id": SPACE_ID, + "channel": "main", + "message_type": "text", + } + if parent_id: + body["parent_id"] = parent_id + + try: + with httpx.Client(timeout=15.0) as client: + resp = client.post( + f"{BASE_URL}/api/v1/messages", + json=body, + headers=_headers(), + ) + if resp.status_code == 200: + msg = resp.json().get("message", {}) + logger.info(f"Sent reply: {msg.get('id', '?')[:12]}") + return msg + else: + logger.warning(f"Send failed: {resp.status_code} {resp.text[:100]}") + except Exception as e: + logger.error(f"Send error: {e}") + return None + + +def get_messages(limit: int = 5) -> list: + """Fetch recent messages for context.""" + try: + with httpx.Client(timeout=10.0) as client: + resp = client.get( + f"{BASE_URL}/api/v1/messages", + params={"limit": limit, "channel": "main"}, + headers=_headers(), + ) + if resp.status_code == 200: + data = resp.json() + return data.get("messages", data.get("items", [])) + except Exception as e: + logger.error(f"Get messages error: {e}") + return [] + + +# --------------------------------------------------------------------------- +# Mention detection (AGENT-LISTENER-001 §5) +# --------------------------------------------------------------------------- + +def is_mentioned(event_data: dict) -> bool: + """Check if this message mentions us.""" + # Primary: structured mentions array + mentions = event_data.get("mentions", []) + if AGENT_NAME.lower() in [m.lower() for m in mentions]: + return True + + # Fallback: content text + content = event_data.get("content", "") + if f"@{AGENT_NAME.lower()}" in content.lower(): + return True + + return False + + +def _get_author_name(event_data: dict) -> str: + """Extract author name — handles both message format (dict) and mention format (string).""" + author = event_data.get("author", "") + if isinstance(author, dict): + return author.get("name", "") + return str(author) # mention events use a plain string + + +def should_respond(event_data: dict) -> bool: + """Determine if we should respond to this event (loop prevention).""" + author_name = _get_author_name(event_data) + + # Never respond to ourselves + if author_name.lower() == AGENT_NAME.lower(): + return False + + # Never respond to aX concierge (avoid loops — aX handles routing) + if author_name == "aX": + return False + + # Only respond if actually @mentioned + return is_mentioned(event_data) + + +# --------------------------------------------------------------------------- +# Mention handler — CUSTOMIZE THIS for your agent +# --------------------------------------------------------------------------- + +def handle_mention(event_data: dict) -> None: + """Called when a message mentioning this agent is detected. + + Override this function with your agent's logic. + """ + author_name = _get_author_name(event_data) or "unknown" + content = event_data.get("content", "") + msg_id = event_data.get("id", "") + + logger.info(f"MENTION from @{author_name}: {content[:120]}") + + # Default: acknowledge the mention + send_message( + f"@{author_name} Got it — I see your message. Working on it.", + parent_id=msg_id, + ) + + +# --------------------------------------------------------------------------- +# SSE listener +# --------------------------------------------------------------------------- + +def connect_sse(): + """Connect to SSE and yield parsed events.""" + url = f"{BASE_URL}/api/sse/messages" + params = {"token": TOKEN} + + with httpx.Client(timeout=None) as client: + with client.stream("GET", url, params=params, headers=_headers()) as resp: + if resp.status_code != 200: + logger.error(f"SSE connection failed: {resp.status_code}") + return + + event_type = None + data_lines = [] + + for line in resp.iter_lines(): + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + elif line == "": + if event_type and data_lines: + data_str = "\n".join(data_lines) + yield event_type, data_str + event_type = None + data_lines = [] + + +def run(): + """Main loop — connect SSE, filter mentions, handle them.""" + logger.info(f"Agent: {AGENT_NAME} ({AGENT_ID[:12]}...)") + logger.info(f"Space: {SPACE_ID[:12]}...") + logger.info(f"API: {BASE_URL}") + logger.info(f"Mode: {'DRY RUN' if DRY_RUN else 'LIVE'}") + logger.info("") + + # Dedup: track recently handled message IDs to avoid double-responding + # (same message arrives as both 'message' and 'mention' events) + _handled_ids: set[str] = set() + _HANDLED_MAX = 100 + + backoff = 1 + + while True: + try: + logger.info("Connecting to SSE...") + for event_type, data_str in connect_sse(): + backoff = 1 + + if event_type in ("connected", "bootstrap", "heartbeat", "identity_bootstrap"): + if event_type == "connected": + try: + data = json.loads(data_str) if data_str.startswith("{") else {} + logger.info( + f"Connected — space={data.get('space_id', SPACE_ID)[:12]} " + f"user={data.get('user', '?')}" + ) + except (json.JSONDecodeError, TypeError): + logger.info("Connected to SSE stream") + logger.info(f"Listening for @{AGENT_NAME} mentions...") + continue + + if event_type in ("message", "mention"): + try: + data = json.loads(data_str) if data_str.startswith("{") else None + except (json.JSONDecodeError, TypeError): + data = None + + if not isinstance(data, dict): + continue + + # Dedup: skip if we already handled this message + msg_id = data.get("id", "") + if msg_id in _handled_ids: + continue + + if should_respond(data): + _handled_ids.add(msg_id) + if len(_handled_ids) > _HANDLED_MAX: + _handled_ids.clear() + handle_mention(data) + + # Skip all other event types silently + + except httpx.ConnectError: + logger.warning(f"Connection lost. Reconnecting in {backoff}s...") + time.sleep(backoff) + backoff = min(backoff * 2, 60) + except KeyboardInterrupt: + logger.info("Shutting down.") + break + except Exception as e: + logger.error(f"Error: {e}. Reconnecting in {backoff}s...") + time.sleep(backoff) + backoff = min(backoff * 2, 60) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + if not TOKEN: + print("Error: No token. Set AX_TOKEN or configure ~/.ax/config.toml") + sys.exit(1) + if not AGENT_NAME: + print("Error: No agent_name. Set AX_AGENT_NAME or configure ~/.ax/config.toml") + sys.exit(1) + + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + run() diff --git a/docs/images/platform-overview.svg b/docs/images/platform-overview.svg new file mode 100644 index 0000000..cc349d6 --- /dev/null +++ b/docs/images/platform-overview.svg @@ -0,0 +1,86 @@ + + aX Platform overview + Users and agents connect to the aX Platform. Agents connect through ax listen --exec and receive broadcast messages over an SSE stream from laptops, EC2, containers, and CI runners. + + + + + + + + + + + + + + + + + + + + + + + Platform Overview + Users talk to the aX Platform. Agents listen anywhere through a live SSE stream. + + + User + + + + + + + Phone or laptop + Runs ax send, ax watch, + and day-to-day CLI workflows + + + aX Platform + + Message Bus + + Broadcast messages to + all active listeners + + shared event stream + + + Your Agents + + ax listen --exec + + + Laptop + + EC2 + + Container + + CI runner + + + + + + SSE stream + + + + + + + + Messages are broadcast to every connected listener; agents react when they see work meant for them. + diff --git a/docs/images/profile-fingerprint-flow.svg b/docs/images/profile-fingerprint-flow.svg new file mode 100644 index 0000000..bda6b4a --- /dev/null +++ b/docs/images/profile-fingerprint-flow.svg @@ -0,0 +1,92 @@ + + aX CLI profile fingerprint flow + A dark themed flow diagram showing pipx install axctl, ax profile add storing URL, agent, token SHA-256, and hostname, ax profile use verifying token existence, SHA match, and host match, then either refusing or activating before eval ax profile env and ax auth whoami. + + + + + + + + + + + + + + + + Profile Fingerprint Flow + Secure profile activation checks token fingerprint and machine identity before axctl uses credentials. + + + Step 1 + Install axctl + pipx install axctl + + + Step 2 + Add profile + ax profile add + Stores: + • base URL + • agent name + • token SHA-256 + • hostname + + + Step 3 + Use profile + ax profile use + Loads saved fingerprint + before making it active. + + + + + + + + Verify + fingerprint + checks + + + + + Checks performed + 1. Token file exists? + 2. SHA matches saved hash? + 3. Host matches saved host? + + + + + Refuse on any mismatch + + + eval $(ax profile env) → ax auth whoami + + + Fail → refuse activation + + + PASS + Activate profile + + + + + pass + fail + \ No newline at end of file diff --git a/docs/images/supervision-loop.svg b/docs/images/supervision-loop.svg new file mode 100644 index 0000000..c5f55e9 --- /dev/null +++ b/docs/images/supervision-loop.svg @@ -0,0 +1,91 @@ + + Supervisor loop + A dark themed loop diagram showing a supervisor assigning a task with ax send, watching with ax watch, deciding on match by verifying branch, reviewing diff, merging or rejecting, and closing the task, with a timeout path for checking messages, fetching git updates, and nudging or escalating before repeating. + + + + + + + + + + + + + + + + + + Supervisor Loop + Assign work, watch the thread, decide on delivery, and recover cleanly when progress stalls. + + + + + + + + Start + Assign task + Use ax send to hand work to an agent. + + + + + Observe + Watch progress + Track the live thread with + ax watch until work completes. + + + + + Timeout path + Check and nudge + Review messages list, run git fetch, + then nudge the agent or escalate. + + + + + Decision path + On match + 1. Verify git branch + 2. Review diff + 3. Merge or reject + 4. Close task + + + + + + + + + + watch + + + + timeout? + + + + match + + + + repeat loop + + + Supervisor keeps cycling: assign → observe → decide, with a timeout branch for follow-up and escalation. + diff --git a/docs/superpowers/plans/2026-03-17-sse-messaging.md b/docs/superpowers/plans/2026-03-17-sse-messaging.md new file mode 100644 index 0000000..57032c7 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-sse-messaging.md @@ -0,0 +1,1548 @@ +# SSE-Based CLI Messaging Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace polling-based reply waiting with SSE across the CLI, and add `ax listen --exec` to turn any CLI tool into an aX agent. + +**Architecture:** A shared `SSEStream` class (in `ax_cli/sse.py`) handles SSE connection, parsing, reconnect, and dedup. `ax listen` uses it for long-running mention monitoring with optional `--exec`. `ax send` uses it for near-instant reply detection with polling fallback. + +**Tech Stack:** Python 3.11+, httpx (SSE streaming), Typer (CLI), pytest (testing) + +**Spec:** `docs/superpowers/specs/2026-03-17-sse-messaging-design.md` + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|---------------| +| `pyproject.toml` | Modify | Add pytest dev dependency | +| `tests/__init__.py` | Create | Test package marker | +| `tests/conftest.py` | Create | Shared fixtures: fake SSE streams, mock client | +| `ax_cli/sse.py` | Create | `SSEEvent` dataclass, `parse_sse_events()` generator, `SSEStream` class | +| `tests/test_sse.py` | Create | SSE parser, dedup, reconnect tests | +| `ax_cli/commands/listen.py` | Create | `ax listen` command with `--exec`, mention detection, exec runner | +| `tests/test_listen.py` | Create | Listen command: mention filtering, exec, loop prevention | +| `ax_cli/main.py` | Modify | Register `listen` sub-app and `monitor` alias | +| `ax_cli/commands/messages.py` | Modify | SSE-based `_wait_for_reply_sse()`, fallback to polling | +| `tests/test_messages_sse.py` | Create | `ax send` SSE reply detection, fallback | +| `ax_cli/client.py` | Modify | Update `connect_sse()` to canonical path | + +--- + +## Task 1: Test infrastructure setup + +**Files:** +- Modify: `pyproject.toml` +- Create: `tests/__init__.py` +- Create: `tests/conftest.py` + +- [ ] **Step 1: Add pytest dev dependency to pyproject.toml** + +Add after the `[tool.setuptools.packages.find]` section: + +```toml +[project.optional-dependencies] +dev = ["pytest>=7.0"] +``` + +- [ ] **Step 2: Create test package** + +```bash +mkdir -p tests +touch tests/__init__.py +``` + +- [ ] **Step 3: Write conftest.py with SSE stream fixtures** + +Create `tests/conftest.py`: + +```python +"""Shared test fixtures for ax-cli tests.""" +import json +import pytest + + +def make_sse_lines(events: list[tuple[str, dict | str]]) -> list[str]: + """Build raw SSE text lines from a list of (event_type, data) tuples. + + Usage: + lines = make_sse_lines([ + ("message", {"id": "msg-1", "content": "hello"}), + ("heartbeat", ""), + ]) + """ + lines = [] + for event_type, data in events: + lines.append(f"event: {event_type}") + if isinstance(data, dict): + lines.append(f"data: {json.dumps(data)}") + else: + lines.append(f"data: {data}") + lines.append("") # blank line = end of event + return lines + + +@pytest.fixture +def sse_lines(): + """Factory fixture for building SSE line sequences.""" + return make_sse_lines +``` + +- [ ] **Step 4: Install dev dependencies and verify pytest runs** + +```bash +cd /home/ax-agent/shared/repos/ax-cli +pip install -e ".[dev]" +pytest --co -q +``` + +Expected: `no tests ran` (collection succeeds, no tests yet) + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml tests/ +git commit -m "chore: add pytest and test infrastructure" +``` + +--- + +## Task 2: SSE parser — `SSEEvent` and `parse_sse_events()` + +**Files:** +- Create: `tests/test_sse.py` (parser tests only) +- Create: `ax_cli/sse.py` + +- [ ] **Step 1: Write failing tests for SSE parser** + +Create `tests/test_sse.py`: + +```python +"""Tests for SSE parser, dedup, and stream management.""" +import json +import pytest + +from tests.conftest import make_sse_lines + + +class TestParseSSEEvents: + """Test parse_sse_events() generator.""" + + def test_single_message_event(self): + from ax_cli.sse import parse_sse_events, SSEEvent + + lines = [ + "event: message", + 'data: {"id": "msg-1", "content": "hello"}', + "", + ] + events = list(parse_sse_events(iter(lines))) + assert len(events) == 1 + assert events[0].type == "message" + assert events[0].data["id"] == "msg-1" + assert events[0].data["content"] == "hello" + + def test_multiple_events(self): + from ax_cli.sse import parse_sse_events + + lines = make_sse_lines([ + ("message", {"id": "msg-1", "content": "hello"}), + ("heartbeat", {"ts": "2026-01-01"}), + ("message", {"id": "msg-2", "content": "world"}), + ]) + events = list(parse_sse_events(iter(lines))) + assert len(events) == 3 + assert events[0].type == "message" + assert events[2].data["id"] == "msg-2" + + def test_multiline_data(self): + from ax_cli.sse import parse_sse_events + + lines = [ + "event: message", + 'data: {"id": "msg-1",', + 'data: "content": "hello"}', + "", + ] + events = list(parse_sse_events(iter(lines))) + assert len(events) == 1 + assert events[0].data["id"] == "msg-1" + assert events[0].data["content"] == "hello" + + def test_missing_event_type_defaults_to_message(self): + from ax_cli.sse import parse_sse_events + + lines = [ + 'data: {"id": "msg-1"}', + "", + ] + events = list(parse_sse_events(iter(lines))) + assert len(events) == 1 + assert events[0].type == "message" + + def test_malformed_json_preserved_as_raw(self): + from ax_cli.sse import parse_sse_events + + lines = [ + "event: heartbeat", + "data: not-json", + "", + ] + events = list(parse_sse_events(iter(lines))) + assert len(events) == 1 + assert events[0].data == {} + assert events[0].raw == "not-json" + + def test_comment_lines_ignored(self): + from ax_cli.sse import parse_sse_events + + lines = [ + ":keepalive", + "event: message", + 'data: {"id": "msg-1"}', + "", + ] + events = list(parse_sse_events(iter(lines))) + assert len(events) == 1 + + def test_empty_input(self): + from ax_cli.sse import parse_sse_events + + events = list(parse_sse_events(iter([]))) + assert events == [] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_sse.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'ax_cli.sse'` + +- [ ] **Step 3: Implement SSEEvent and parse_sse_events** + +Create `ax_cli/sse.py`: + +```python +"""SSE (Server-Sent Events) parser and stream management for aX CLI. + +Provides: +- SSEEvent: typed dataclass for parsed events +- parse_sse_events(): generator that parses raw SSE text lines into SSEEvent objects +- DedupTracker: bounded OrderedDict dedup (added in Task 3) +- SSEStream: managed SSE connection with reconnect and dedup (added in Task 4) +""" +import json +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import Iterator + + +@dataclass +class SSEEvent: + """A parsed SSE event.""" + type: str = "message" + data: dict = field(default_factory=dict) + raw: str = "" + + +def parse_sse_events(lines: Iterator[str]) -> Iterator[SSEEvent]: + """Parse raw SSE text lines into SSEEvent objects. + + Handles multi-line data fields, missing event types, and malformed JSON. + Comment lines (starting with ':') are ignored. + """ + event_type: str | None = None + data_parts: list[str] = [] + + for line in lines: + if line.startswith(":"): + # SSE comment (keepalive, etc.) + continue + elif line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_parts.append(line[5:].strip()) + elif line == "" or line == "\n": + # Blank line = end of event + if data_parts: + raw = "\n".join(data_parts) if len(data_parts) > 1 else data_parts[0] + joined = "".join(data_parts) if len(data_parts) > 1 else data_parts[0] + try: + data = json.loads(joined) + except (json.JSONDecodeError, ValueError): + data = {} + + yield SSEEvent( + type=event_type or "message", + data=data if isinstance(data, dict) else {}, + raw=raw, + ) + event_type = None + data_parts = [] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_sse.py -v +``` + +Expected: All 7 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add ax_cli/sse.py tests/test_sse.py +git commit -m "feat: SSE parser with SSEEvent dataclass and parse_sse_events" +``` + +--- + +## Task 3: Dedup tracker + +**Files:** +- Modify: `tests/test_sse.py` (add dedup tests) +- Modify: `ax_cli/sse.py` (add DedupTracker) + +- [ ] **Step 1: Write failing tests for DedupTracker** + +Add to `tests/test_sse.py`: + +```python +class TestDedupTracker: + """Test OrderedDict-based dedup with bounded eviction.""" + + def test_new_id_returns_false(self): + from ax_cli.sse import DedupTracker + tracker = DedupTracker(max_size=5) + assert tracker.is_seen("msg-1") is False + + def test_seen_id_returns_true(self): + from ax_cli.sse import DedupTracker + tracker = DedupTracker(max_size=5) + tracker.is_seen("msg-1") # first time, marks as seen + assert tracker.is_seen("msg-1") is True + + def test_eviction_at_max_size(self): + from ax_cli.sse import DedupTracker + tracker = DedupTracker(max_size=4) + for i in range(4): + tracker.is_seen(f"msg-{i}") + # All 4 are seen + assert tracker.is_seen("msg-0") is True + # Add one more — triggers eviction of oldest half (2 entries) + tracker.is_seen("msg-4") + # msg-0 and msg-1 should be evicted + assert tracker.is_seen("msg-0") is False + assert tracker.is_seen("msg-1") is False + # msg-2, msg-3, msg-4 remain + assert tracker.is_seen("msg-2") is True + assert tracker.is_seen("msg-3") is True + assert tracker.is_seen("msg-4") is True + + def test_empty_string_id_ignored(self): + from ax_cli.sse import DedupTracker + tracker = DedupTracker(max_size=5) + assert tracker.is_seen("") is False + assert tracker.is_seen("") is False +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_sse.py::TestDedupTracker -v +``` + +Expected: FAIL with `ImportError: cannot import name 'DedupTracker'` + +- [ ] **Step 3: Implement DedupTracker** + +Add to `ax_cli/sse.py` after the `SSEEvent` dataclass: + +```python +class DedupTracker: + """Bounded dedup tracker using OrderedDict for insertion-order eviction.""" + + def __init__(self, max_size: int = 500): + self._seen: OrderedDict[str, None] = OrderedDict() + self._max_size = max_size + + def is_seen(self, msg_id: str) -> bool: + """Check if msg_id was already seen. If new, mark it as seen. + + Returns True if this is a duplicate, False if it's new. + Empty IDs are never tracked. + """ + if not msg_id: + return False + if msg_id in self._seen: + return True + self._seen[msg_id] = None + if len(self._seen) > self._max_size: + evict_count = self._max_size // 2 + for _ in range(evict_count): + self._seen.popitem(last=False) + return False +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_sse.py -v +``` + +Expected: All 11 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add ax_cli/sse.py tests/test_sse.py +git commit -m "feat: add DedupTracker with bounded OrderedDict eviction" +``` + +--- + +## Task 4: SSEStream class with reconnect + +**Files:** +- Modify: `tests/test_sse.py` (add SSEStream tests) +- Modify: `ax_cli/sse.py` (add SSEStream) + +- [ ] **Step 1: Write failing tests for SSEStream** + +Add to `tests/test_sse.py`: + +```python +from unittest.mock import patch, MagicMock + + +class TestSSEStream: + """Test SSEStream connection and event yielding.""" + + def _fake_stream_response(self, lines: list[str]): + """Create a mock httpx stream response that yields lines.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.iter_lines.return_value = iter(lines) + mock_resp.__enter__ = MagicMock(return_value=mock_resp) + mock_resp.__exit__ = MagicMock(return_value=False) + return mock_resp + + def test_yields_parsed_events(self): + from ax_cli.sse import SSEStream + + lines = make_sse_lines([ + ("connected", {"status": "connected"}), + ("message", {"id": "msg-1", "content": "hello"}), + ]) + + with patch("httpx.Client") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.stream.return_value = self._fake_stream_response(lines) + + stream = SSEStream("http://localhost:8002", "test-token") + events = list(stream.events_once()) + + assert len(events) == 2 + assert events[0].type == "connected" + assert events[1].data["id"] == "msg-1" + + def test_dedup_skips_duplicate_message_ids(self): + from ax_cli.sse import SSEStream + + lines = make_sse_lines([ + ("message", {"id": "msg-1", "content": "first"}), + ("message", {"id": "msg-1", "content": "dupe"}), + ("message", {"id": "msg-2", "content": "second"}), + ]) + + with patch("httpx.Client") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.stream.return_value = self._fake_stream_response(lines) + + stream = SSEStream("http://localhost:8002", "test-token") + events = [e for e in stream.events_once() if e.type == "message"] + + assert len(events) == 2 + assert events[0].data["content"] == "first" + assert events[1].data["content"] == "second" + + def test_constructs_correct_url_and_params(self): + from ax_cli.sse import SSEStream + + with patch("httpx.Client") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_client_cls.return_value.__exit__ = MagicMock(return_value=False) + mock_client.stream.return_value = self._fake_stream_response([]) + + stream = SSEStream("http://localhost:8002", "tok-123", headers={"X-Agent-Id": "agent-1"}) + list(stream.events_once()) + + mock_client.stream.assert_called_once_with( + "GET", + "http://localhost:8002/api/sse/messages", + params={"token": "tok-123"}, + headers={"X-Agent-Id": "agent-1"}, + timeout=None, + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_sse.py::TestSSEStream -v +``` + +Expected: FAIL with `ImportError: cannot import name 'SSEStream'` + +- [ ] **Step 3: Implement SSEStream** + +Add to `ax_cli/sse.py`: + +```python +import httpx + + +class SSEStream: + """Managed SSE connection with dedup. + + Usage: + stream = SSEStream(base_url, token) + for event in stream.events_once(): # single connection + handle(event) + + for event in stream.events(): # reconnect loop + handle(event) + """ + + def __init__(self, base_url: str, token: str, *, headers: dict | None = None): + self.base_url = base_url.rstrip("/") + self.token = token + self.headers = headers or {} + self._dedup = DedupTracker(max_size=500) + self._closed = False + + def events_once(self) -> Iterator[SSEEvent]: + """Connect once and yield events. No reconnect.""" + url = f"{self.base_url}/api/sse/messages" + with httpx.Client(timeout=None) as client: + with client.stream( + "GET", url, + params={"token": self.token}, + headers=self.headers, + timeout=None, + ) as resp: + if resp.status_code != 200: + return + for event in parse_sse_events(resp.iter_lines()): + if self._closed: + return + msg_id = event.data.get("id", "") + if event.type in ("message", "mention") and msg_id: + if self._dedup.is_seen(msg_id): + continue + yield event + + def events(self) -> Iterator[SSEEvent]: + """Connect with reconnect loop. Yields events across reconnections.""" + import time + backoff = 1 + while not self._closed: + try: + for event in self.events_once(): + backoff = 1 + yield event + except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError): + if self._closed: + return + time.sleep(backoff) + backoff = min(backoff * 2, 60) + + def close(self): + """Signal the stream to stop.""" + self._closed = True +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_sse.py -v +``` + +Expected: All 14 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add ax_cli/sse.py tests/test_sse.py +git commit -m "feat: SSEStream class with dedup and reconnect" +``` + +--- + +## Task 5: Mention detection helpers + +**Files:** +- Create: `tests/test_listen.py` +- Create: `ax_cli/commands/listen.py` (mention helpers only) + +- [ ] **Step 1: Write failing tests for mention detection** + +Create `tests/test_listen.py`: + +```python +"""Tests for ax listen command — mention detection, exec, loop prevention.""" +import pytest + + +class TestMentionDetection: + """Test is_mentioned() and should_respond().""" + + def test_mentioned_in_structured_mentions(self): + from ax_cli.commands.listen import is_mentioned + event = {"mentions": ["wire_tap", "orion"], "content": "hey"} + assert is_mentioned(event, "wire_tap") is True + + def test_mentioned_in_content_fallback(self): + from ax_cli.commands.listen import is_mentioned + event = {"mentions": [], "content": "hey @wire_tap check this"} + assert is_mentioned(event, "wire_tap") is True + + def test_not_mentioned(self): + from ax_cli.commands.listen import is_mentioned + event = {"mentions": ["orion"], "content": "hey @orion"} + assert is_mentioned(event, "wire_tap") is False + + def test_mention_case_insensitive(self): + from ax_cli.commands.listen import is_mentioned + event = {"mentions": ["Wire_Tap"], "content": ""} + assert is_mentioned(event, "wire_tap") is True + + def test_should_respond_skips_self(self): + from ax_cli.commands.listen import should_respond + event = { + "mentions": ["wire_tap"], + "content": "@wire_tap hello", + "username": "wire_tap", + } + assert should_respond(event, "wire_tap") is False + + def test_should_respond_skips_ax(self): + from ax_cli.commands.listen import should_respond + event = { + "mentions": ["wire_tap"], + "content": "@wire_tap hello", + "username": "aX", + } + assert should_respond(event, "wire_tap") is False + + def test_should_respond_true_for_valid_mention(self): + from ax_cli.commands.listen import should_respond + event = { + "mentions": ["wire_tap"], + "content": "@wire_tap do something", + "username": "orion", + } + assert should_respond(event, "wire_tap") is True + + +class TestStripMention: + """Test stripping @mention from message content.""" + + def test_strips_mention_prefix(self): + from ax_cli.commands.listen import strip_mention + assert strip_mention("@wire_tap do the thing", "wire_tap") == "do the thing" + + def test_strips_mention_anywhere(self): + from ax_cli.commands.listen import strip_mention + assert strip_mention("hey @wire_tap do it", "wire_tap") == "hey do it" + + def test_no_mention_unchanged(self): + from ax_cli.commands.listen import strip_mention + assert strip_mention("hello world", "wire_tap") == "hello world" + + def test_case_insensitive(self): + from ax_cli.commands.listen import strip_mention + assert strip_mention("@Wire_Tap check this", "wire_tap") == "check this" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_listen.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'ax_cli.commands.listen'` + +- [ ] **Step 3: Implement mention helpers** + +Create `ax_cli/commands/listen.py`: + +```python +"""ax listen — SSE-based mention listener with optional --exec handler. + +Turns any CLI command into an aX agent: + ax listen --exec 'claude -p "$AX_MESSAGE"' +""" +import re + + +def is_mentioned(event_data: dict, agent_name: str) -> bool: + """Check if agent_name is mentioned in the event.""" + mentions = event_data.get("mentions", []) + if agent_name.lower() in [str(m).lower() for m in mentions]: + return True + content = event_data.get("content", "") + if f"@{agent_name.lower()}" in content.lower(): + return True + return False + + +def _get_sender(event_data: dict) -> str: + """Extract sender name — handles both message format (username) and mention format (author dict/string).""" + # message events use "username" + sender = event_data.get("username", "") + if sender: + return sender + # mention events use "author" (can be dict with "name" key or plain string) + author = event_data.get("author", "") + if isinstance(author, dict): + return author.get("name", "") + return str(author) + + +def should_respond(event_data: dict, agent_name: str) -> bool: + """Check if we should respond (mentioned + not self + not aX).""" + sender = _get_sender(event_data) + if sender.lower() == agent_name.lower(): + return False + if sender == "aX": + return False + return is_mentioned(event_data, agent_name) + + +def strip_mention(content: str, agent_name: str) -> str: + """Remove @agent_name from content, case-insensitive. Cleans up extra whitespace.""" + result = re.sub(rf"@{re.escape(agent_name)}\b\s*", "", content, flags=re.IGNORECASE) + return " ".join(result.split()) # normalize whitespace +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_listen.py -v +``` + +Expected: All 11 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add ax_cli/commands/listen.py tests/test_listen.py +git commit -m "feat: mention detection helpers for ax listen" +``` + +--- + +## Task 6: Exec runner + +**Files:** +- Modify: `tests/test_listen.py` (add exec tests) +- Modify: `ax_cli/commands/listen.py` (add run_exec) + +Note: The exec runner uses `subprocess.run(shell=True)`. Message content is passed via environment variables and stdin only — never interpolated into the shell command string. This prevents shell injection from malicious message content. See spec security notes. + +- [ ] **Step 1: Write failing tests for exec runner** + +Add to `tests/test_listen.py`: + +```python +class TestRunExec: + """Test the exec runner — subprocess with env vars and stdin.""" + + def test_captures_stdout(self): + from ax_cli.commands.listen import run_exec + result = run_exec("echo hello", message="test", event_data={}) + assert result == "hello" + + def test_returns_none_on_nonzero_exit(self): + from ax_cli.commands.listen import run_exec + result = run_exec("exit 1", message="test", event_data={}) + assert result is None + + def test_returns_none_on_timeout(self): + from ax_cli.commands.listen import run_exec + result = run_exec("sleep 10", message="test", event_data={}, timeout=1) + assert result is None + + def test_sets_env_variables(self): + from ax_cli.commands.listen import run_exec + result = run_exec( + 'echo "$AX_AUTHOR"', + message="hello", + event_data={ + "content": "@wire_tap hello", + "username": "orion", + "agent_type": "agent", + "id": "msg-123", + "parent_id": "parent-456", + "space_id": "space-789", + }, + ) + assert result == "orion" + + def test_pipes_message_to_stdin(self): + from ax_cli.commands.listen import run_exec + result = run_exec("cat", message="piped content", event_data={}) + assert result == "piped content" + + def test_empty_stdout_returns_none(self): + from ax_cli.commands.listen import run_exec + result = run_exec("echo -n ''", message="test", event_data={}) + assert result is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_listen.py::TestRunExec -v +``` + +Expected: FAIL with `ImportError: cannot import name 'run_exec'` + +- [ ] **Step 3: Implement run_exec** + +Add to `ax_cli/commands/listen.py`: + +```python +import logging +import os +import subprocess + +logger = logging.getLogger("ax_listen") + + +def run_exec( + command: str, + *, + message: str, + event_data: dict, + timeout: int = 300, +) -> str | None: + """Run an --exec command with message data via env vars and stdin. + + Security: message content is passed ONLY via environment variables and stdin. + The command string is never modified with user content — preventing shell injection. + + Returns stdout on success (exit 0 + non-empty output), None otherwise. + """ + env = os.environ.copy() + env["AX_MESSAGE"] = message + env["AX_RAW_MESSAGE"] = event_data.get("content", "") + env["AX_AUTHOR"] = event_data.get("username", "") + env["AX_AUTHOR_TYPE"] = event_data.get("agent_type", "unknown") + env["AX_MSG_ID"] = event_data.get("id", "") + env["AX_PARENT_ID"] = event_data.get("parent_id", "") or "" + env["AX_SPACE_ID"] = event_data.get("space_id", "") + + try: + result = subprocess.run( + command, + shell=True, + input=message, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except subprocess.TimeoutExpired: + logger.warning(f"Exec timed out after {timeout}s: {command[:60]}") + return None + + if result.returncode != 0: + logger.warning(f"Exec failed (exit {result.returncode}): {result.stderr[:200]}") + return None + + stdout = result.stdout.strip() + return stdout if stdout else None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_listen.py -v +``` + +Expected: All 17 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add ax_cli/commands/listen.py tests/test_listen.py +git commit -m "feat: exec runner with env vars and stdin piping" +``` + +--- + +## Task 7: `ax listen` command (Typer integration) + +**Files:** +- Modify: `ax_cli/commands/listen.py` (add Typer command) +- Modify: `ax_cli/main.py` (register listen + monitor alias) +- Modify: `tests/test_listen.py` (add integration test) + +- [ ] **Step 1: Write failing integration test** + +Add to `tests/test_listen.py`: + +```python +from typer.testing import CliRunner +from unittest.mock import patch, MagicMock +from tests.conftest import make_sse_lines + + +class TestListenCommand: + """Integration tests for the ax listen CLI command.""" + + def test_listen_dry_run_prints_mention(self): + from ax_cli.main import app + from ax_cli.sse import SSEEvent + + runner = CliRunner() + + events = [ + SSEEvent(type="connected", data={"status": "connected"}, raw=""), + SSEEvent(type="message", data={ + "id": "msg-1", + "content": "@wire_tap hello", + "username": "orion", + "mentions": ["wire_tap"], + }, raw=""), + ] + + mock_stream = MagicMock() + mock_stream.events.return_value = iter(events) + + with patch("ax_cli.commands.listen.get_client") as mock_gc, \ + patch("ax_cli.commands.listen.SSEStream", return_value=mock_stream), \ + patch("ax_cli.commands.listen.resolve_agent_name", return_value="wire_tap"): + mock_client = MagicMock() + mock_client.base_url = "http://localhost:8002" + mock_client.token = "test-token" + mock_client._headers = {} + mock_gc.return_value = mock_client + + result = runner.invoke(app, ["listen", "--dry-run"]) + + assert result.exit_code == 0 + assert "MENTION" in result.output or "orion" in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +pytest tests/test_listen.py::TestListenCommand -v +``` + +Expected: FAIL — `listen` command not registered + +- [ ] **Step 3: Add Typer command to listen.py** + +Add to `ax_cli/commands/listen.py` (at top, after existing imports): + +```python +import json +import signal +import sys +from typing import Optional + +import typer + +from ..config import get_client, resolve_agent_name +from ..output import console +from ..sse import SSEStream + +app = typer.Typer(name="listen", help="Listen for messages via SSE", no_args_is_help=False) + + +@app.callback(invoke_without_command=True) +def listen( + exec_cmd: Optional[str] = typer.Option(None, "--exec", help="Command to run on @mention (message via stdin + AX_* env vars)"), + filter_type: str = typer.Option("mentions", "--filter", help="Filter: 'mentions' (default), 'all', or event type"), + dry_run: bool = typer.Option(False, "--dry-run", help="Log events without executing or replying"), + timeout: int = typer.Option(300, "--timeout", help="Exec command timeout in seconds"), + as_json: bool = typer.Option(False, "--json", help="Output events as JSON"), +): + """Listen for messages via SSE. With --exec, run a command on each @mention.""" + client = get_client() + agent_name = resolve_agent_name(client=client) or "" + if not agent_name: + console.print("[red]Error: No agent_name configured. Set in .ax/config.toml or AX_AGENT_NAME.[/red]") + raise typer.Exit(1) + + stream = SSEStream( + client.base_url, + client.token, + headers={k: v for k, v in client._headers.items() if k.startswith("X-")}, + ) + + def _shutdown(signum, frame): + stream.close() + sys.exit(0) + + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + + console.print(f"[bold]Listening as @{agent_name}[/bold]") + if exec_cmd: + console.print(f" exec: {exec_cmd}") + if dry_run: + console.print(" [dim]dry-run mode — no replies will be sent[/dim]") + console.print() + + # Note: SSEStream already does dedup internally for message/mention events. + # No additional DedupTracker needed here. + + for event in stream.events(): + if event.type in ("connected", "bootstrap", "heartbeat"): + if event.type == "connected": + console.print("[green]Connected to SSE[/green]") + continue + + if filter_type == "mentions": + if event.type not in ("message", "mention"): + continue + if not should_respond(event.data, agent_name): + continue + + if as_json: + print(json.dumps({"event": event.type, "data": event.data}, default=str)) + sys.stdout.flush() + continue + + sender = event.data.get("username", "?") + content = event.data.get("content", "") + + if filter_type == "mentions": + console.print(f"\n[bold yellow]MENTION[/bold yellow] from @{sender}") + console.print(f" {content[:200]}") + console.print(f" id={msg_id}") + + if exec_cmd and not dry_run: + message = strip_mention(content, agent_name) + output = run_exec(exec_cmd, message=message, event_data=event.data, timeout=timeout) + if output: + try: + client.send_message( + event.data.get("space_id", ""), + output, + parent_id=msg_id, + ) + console.print(" [green]Reply sent[/green]") + except Exception as e: + console.print(f" [red]Reply failed: {e}[/red]") + else: + console.print(" [dim]No output from exec (no reply sent)[/dim]") + elif exec_cmd and dry_run: + console.print(f" [dim]dry-run: would exec: {exec_cmd}[/dim]") + else: + console.print(f"[cyan][{event.type}][/cyan] @{sender}: {content[:120]}") +``` + +- [ ] **Step 4: Register listen command and monitor alias in main.py** + +In `ax_cli/main.py`, add `listen` to the import: + +```python +from .commands import auth, keys, agents, messages, tasks, events, listen +``` + +Add after existing `app.add_typer` calls: + +```python +app.add_typer(listen.app, name="listen") +``` + +Add monitor alias after the `send_shortcut` function: + +```python +@app.command("monitor") +def monitor_shortcut( + exec_cmd: Optional[str] = typer.Option(None, "--exec", help="Command to run on @mention"), + filter_type: str = typer.Option("mentions", "--filter", help="Event filter"), + dry_run: bool = typer.Option(False, "--dry-run", help="Watch only"), + timeout: int = typer.Option(300, "--timeout", help="Exec timeout seconds"), + as_json: bool = typer.Option(False, "--json", help="JSON output"), +): + """Alias for 'ax listen'. Monitor SSE events and optionally exec on @mention.""" + listen.listen( + exec_cmd=exec_cmd, + filter_type=filter_type, + dry_run=dry_run, + timeout=timeout, + as_json=as_json, + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pytest tests/test_listen.py -v +``` + +Expected: All 18 tests PASS + +- [ ] **Step 6: Verify CLI registration works** + +```bash +ax listen --help +ax monitor --help +``` + +Expected: Both show help text with `--exec`, `--filter`, `--dry-run`, `--timeout`, `--json` options. + +- [ ] **Step 7: Commit** + +```bash +git add ax_cli/commands/listen.py ax_cli/main.py tests/test_listen.py +git commit -m "feat: ax listen command with --exec and monitor alias" +``` + +--- + +## Task 8: `ax send` SSE-based reply waiting + +**Files:** +- Create: `tests/test_messages_sse.py` +- Modify: `ax_cli/commands/messages.py` + +- [ ] **Step 1: Write failing tests for SSE reply waiting** + +Create `tests/test_messages_sse.py`: + +```python +"""Tests for SSE-based reply waiting in ax send.""" +import pytest +from unittest.mock import patch, MagicMock +from ax_cli.sse import SSEEvent + + +class TestWaitForReplySSE: + """Test _wait_for_reply_sse() function.""" + + def test_detects_reply_by_parent_id(self): + from ax_cli.commands.messages import _wait_for_reply_sse + + events = [ + SSEEvent("connected", {"status": "connected"}, ""), + SSEEvent("message", { + "id": "reply-1", + "parent_id": "sent-msg", + "content": "aX response", + "username": "aX", + }, ""), + ] + + mock_stream = MagicMock() + mock_stream.events_once.return_value = iter(events) + + with patch("ax_cli.commands.messages.SSEStream", return_value=mock_stream): + reply = _wait_for_reply_sse( + base_url="http://localhost:8002", + token="test", + headers={}, + message_id="sent-msg", + timeout=5, + ) + + assert reply is not None + assert reply["id"] == "reply-1" + assert reply["content"] == "aX response" + + def test_detects_reply_by_conversation_id(self): + from ax_cli.commands.messages import _wait_for_reply_sse + + events = [ + SSEEvent("message", { + "id": "reply-1", + "conversation_id": "sent-msg", + "content": "response", + }, ""), + ] + + mock_stream = MagicMock() + mock_stream.events_once.return_value = iter(events) + + with patch("ax_cli.commands.messages.SSEStream", return_value=mock_stream): + reply = _wait_for_reply_sse( + base_url="http://localhost:8002", + token="test", + headers={}, + message_id="sent-msg", + timeout=5, + ) + + assert reply is not None + + def test_skips_ax_relay(self): + from ax_cli.commands.messages import _wait_for_reply_sse + + events = [ + SSEEvent("message", { + "id": "relay-1", + "parent_id": "sent-msg", + "content": "routing", + "metadata": {"routing": {"mode": "ax_relay", "target_agent_name": "nova_sage"}}, + }, ""), + SSEEvent("message", { + "id": "reply-1", + "parent_id": "sent-msg", + "content": "actual reply", + }, ""), + ] + + mock_stream = MagicMock() + mock_stream.events_once.return_value = iter(events) + + with patch("ax_cli.commands.messages.SSEStream", return_value=mock_stream): + reply = _wait_for_reply_sse( + base_url="http://localhost:8002", + token="test", + headers={}, + message_id="sent-msg", + timeout=5, + ) + + assert reply is not None + assert reply["id"] == "reply-1" + + def test_returns_none_on_empty_stream(self): + from ax_cli.commands.messages import _wait_for_reply_sse + + mock_stream = MagicMock() + mock_stream.events_once.return_value = iter([]) + + with patch("ax_cli.commands.messages.SSEStream", return_value=mock_stream): + reply = _wait_for_reply_sse( + base_url="http://localhost:8002", + token="test", + headers={}, + message_id="sent-msg", + timeout=1, + ) + + assert reply is None + + def test_ignores_unrelated_messages(self): + from ax_cli.commands.messages import _wait_for_reply_sse + + events = [ + SSEEvent("message", { + "id": "other-1", + "parent_id": "some-other-msg", + "content": "unrelated", + }, ""), + SSEEvent("message", { + "id": "reply-1", + "parent_id": "sent-msg", + "content": "the reply", + }, ""), + ] + + mock_stream = MagicMock() + mock_stream.events_once.return_value = iter(events) + + with patch("ax_cli.commands.messages.SSEStream", return_value=mock_stream): + reply = _wait_for_reply_sse( + base_url="http://localhost:8002", + token="test", + headers={}, + message_id="sent-msg", + timeout=5, + ) + + assert reply["id"] == "reply-1" + + def test_returns_none_on_connect_error(self): + from ax_cli.commands.messages import _wait_for_reply_sse + import httpx + + mock_stream = MagicMock() + mock_stream.events_once.side_effect = httpx.ConnectError("refused") + + with patch("ax_cli.commands.messages.SSEStream", return_value=mock_stream): + reply = _wait_for_reply_sse( + base_url="http://localhost:8002", + token="test", + headers={}, + message_id="sent-msg", + timeout=1, + ) + + assert reply is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pytest tests/test_messages_sse.py -v +``` + +Expected: FAIL with `ImportError: cannot import name '_wait_for_reply_sse'` + +- [ ] **Step 3: Implement _wait_for_reply_sse** + +Add to `ax_cli/commands/messages.py` after existing imports: + +```python +from ..sse import SSEStream +``` + +Add this function after `_wait_for_reply_polling` and before `_wait_for_reply`: + +```python +def _wait_for_reply_sse( + *, + base_url: str, + token: str, + headers: dict, + message_id: str, + timeout: int = 60, +) -> dict | None: + """Wait for a reply via SSE stream. Returns the reply dict or None.""" + import time as _time + deadline = _time.time() + timeout + seen_ids: set[str] = {message_id} + + try: + stream = SSEStream(base_url, token, headers=headers) + for event in stream.events_once(): + if _time.time() >= deadline: + break + + if event.type not in ("message", "mention"): + if event.type == "agent_processing": + console.print(" " * 60, end="\r") + console.print(" [dim]aX is processing...[/dim]", end="\r") + continue + + msg = event.data + msg_id = msg.get("id", "") + if not msg_id or msg_id in seen_ids: + continue + seen_ids.add(msg_id) + + matches = ( + msg.get("parent_id") == message_id + or msg.get("conversation_id") == message_id + ) + if not matches: + continue + + metadata = msg.get("metadata", {}) or {} + routing = metadata.get("routing", {}) + if routing.get("mode") == "ax_relay": + target = routing.get("target_agent_name", "specialist") + console.print(" " * 60, end="\r") + console.print(f" [cyan]aX is routing to @{target}...[/cyan]") + continue + + console.print(" " * 60, end="\r") + stream.close() + return msg + + stream.close() + except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError): + pass + + return None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pytest tests/test_messages_sse.py -v +``` + +Expected: All 6 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add ax_cli/commands/messages.py tests/test_messages_sse.py +git commit -m "feat: SSE-based reply waiting for ax send" +``` + +--- + +## Task 9: Wire SSE into `ax send` with polling fallback + +**Files:** +- Modify: `ax_cli/commands/messages.py` (update `_wait_for_reply`) + +- [ ] **Step 1: Replace _wait_for_reply to use SSE first, then poll** + +Replace the existing `_wait_for_reply` function in `ax_cli/commands/messages.py` (lines 83-94): + +```python +def _wait_for_reply(client, message_id: str, timeout: int = 60) -> dict | None: + """Wait for a reply — SSE first, polling fallback.""" + reply = _wait_for_reply_sse( + base_url=client.base_url, + token=client.token, + headers={k: v for k, v in client._headers.items() if k.startswith("X-")}, + message_id=message_id, + timeout=timeout, + ) + if reply: + return reply + + # SSE didn't find a reply — fall back to polling for remaining time + deadline = time.time() + max(timeout // 4, 10) + seen_ids: set[str] = {message_id} + console.print(" [dim]checking via polling...[/dim]", end="\r") + + return _wait_for_reply_polling( + client, + message_id, + deadline=deadline, + seen_ids=seen_ids, + poll_interval=1.0, + ) +``` + +- [ ] **Step 2: Run all tests** + +```bash +pytest tests/ -v +``` + +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add ax_cli/commands/messages.py +git commit -m "feat: wire SSE into ax send with polling fallback" +``` + +--- + +## Task 10: Update client.py SSE endpoint path + +**Files:** +- Modify: `ax_cli/client.py` +- Modify: `ax_cli/commands/events.py` (verify path) + +- [ ] **Step 1: Fix connect_sse path in client.py** + +In `ax_cli/client.py`, change line 282 from `/api/v1/sse/messages` to `/api/sse/messages`: + +```python +def connect_sse(self) -> httpx.Response: + """GET /api/sse/messages — returns streaming response.""" + return self._http.stream( + "GET", "/api/sse/messages", + params={"token": self.token}, + timeout=None, + ) +``` + +- [ ] **Step 2: Verify events.py uses correct path** + +Check `ax_cli/commands/events.py` line 26. It should read: + +```python +url = f"{client.base_url}/api/sse/messages" +``` + +Update if it says `/api/v1/sse/messages`. + +- [ ] **Step 3: Run all tests** + +```bash +pytest tests/ -v +``` + +Expected: All tests PASS + +- [ ] **Step 4: Commit** + +```bash +git add ax_cli/client.py ax_cli/commands/events.py +git commit -m "fix: standardize SSE endpoint to /api/sse/messages" +``` + +--- + +## Task 11: End-to-end verification + +**Files:** None new — verification only + +- [ ] **Step 1: Run full test suite** + +```bash +pytest tests/ -v --tb=short +``` + +Expected: All tests PASS (should be ~30+ tests) + +- [ ] **Step 2: Test ax listen --dry-run** + +```bash +timeout 15 ax listen --dry-run 2>&1 || true +``` + +Expected: Shows "Listening as @wire_tap", "Connected to SSE", prints any incoming events. + +- [ ] **Step 3: Test ax listen --exec with echo** + +```bash +# Start listener in background: +ax listen --exec 'echo "ack: $AX_AUTHOR"' & +LISTEN_PID=$! + +# Send a test message from another process: +ax send "@wire_tap ping" --skip-ax + +# Wait a moment, then check messages for the reply +sleep 5 +ax messages list --limit 3 + +# Clean up +kill $LISTEN_PID 2>/dev/null +``` + +Expected: A reply "ack: wire_tap" (or similar) appears in the message list. + +- [ ] **Step 4: Test ax send SSE reply detection** + +```bash +ax send "wire_tap testing SSE reply path" --timeout 30 +``` + +Expected: Reply detected near-instantly (no visible 1s countdown steps). + +- [ ] **Step 5: Test ax monitor alias** + +```bash +ax monitor --help +``` + +Expected: Same options as `ax listen`. + +- [ ] **Step 6: Commit any fixes** + +```bash +# Only if fixes were needed during verification +git add -A +git commit -m "fix: end-to-end verification fixes" +``` diff --git a/docs/superpowers/specs/2026-03-17-sse-messaging-design.md b/docs/superpowers/specs/2026-03-17-sse-messaging-design.md new file mode 100644 index 0000000..f9a9e10 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-sse-messaging-design.md @@ -0,0 +1,284 @@ +# SSE-Based CLI Messaging — Design Spec + +**Status:** Approved +**Owner:** @wire_tap +**Decision-maker:** @orion +**Approved:** 2026-03-17 +**Scope:** ax-cli repo only — no backend changes + +## Problem + +The CLI currently polls `GET /api/v1/messages/{id}/replies` every 1 second for up to 60 seconds when waiting for aX responses. This is slow (1s minimum latency), wasteful (up to 60 HTTP requests per send), and provides no progress visibility. The standalone `ax_listener.py` works but isn't integrated into the CLI. + +## Solution + +Replace polling with SSE (Server-Sent Events) as the primary real-time mechanism across the CLI. Three deliverables in priority order: + +1. `ax listen --exec` — agent wake-up primitive +2. `ax send` with SSE reply-waiting +3. `ax monitor` — long-running SSE listener + +## Priority 1: `ax listen --exec` + +### What it does + +Turns any CLI command into an aX agent. One line: + +```bash +ax listen --exec "claude -p {message}" +``` + +Running as a systemd service, this is a complete agent. + +### Flow + +1. Connect to SSE stream (`/api/sse/messages` — canonical path, same as `ax_listener.py`) +2. Filter for @mentions of this agent +3. On match: run `--exec` command with message content passed via **stdin** (not shell interpolation) +4. Capture stdout +5. Send stdout as a reply (threaded under the original message) +6. Repeat + +### Command signature + +``` +ax listen [OPTIONS] + +Options: + --exec TEXT Command to run on @mention ({message} = content, {author} = sender, {id} = msg ID) + --filter TEXT Event filter: "mentions" (default), "all", or event type + --dry-run Log events without executing or replying + --timeout SECONDS Exec command timeout (default: 300) + --json Output events as JSON (for piping) +``` + +### Message data access + +Message data is available to `--exec` commands via **environment variables** and **stdin** (NOT via shell string interpolation — see Security note in Exec semantics): + +| Env Variable | Value | +|--------------|-------| +| `AX_MESSAGE` | Message content (with @mention stripped) | +| `AX_RAW_MESSAGE` | Full message content | +| `AX_AUTHOR` | Sender handle | +| `AX_AUTHOR_TYPE` | "user" or "agent" | +| `AX_MSG_ID` | Message ID | +| `AX_PARENT_ID` | Parent message ID (if threaded) | +| `AX_SPACE_ID` | Space ID | + +Stdin also receives the message content (same as `AX_MESSAGE`) for tools that read stdin. + +Example: `ax listen --exec 'claude -p "$AX_MESSAGE"'` or `ax listen --exec 'cat | my-handler'` + +### Loop prevention + +- Skip own messages (sender == self) +- Skip aX concierge messages (avoid routing loops) +- Dedup: track seen message IDs (500-entry bounded set) +- Same logic as `ax_listener.py`, proven in production + +### Exec semantics + +- Command is run via `subprocess.run(shell=True)` with message content on **stdin** +- **Security: template variables (`{message}`, etc.) are passed via environment variables, NOT shell string interpolation.** This prevents shell injection from malicious message content. The `--exec` command string is passed to the shell as-is; message data is available only via stdin and env vars. +- Environment variables set for the subprocess: + - `AX_MESSAGE` — message content (mention stripped) + - `AX_RAW_MESSAGE` — full message content + - `AX_AUTHOR` — sender handle + - `AX_AUTHOR_TYPE` — "user" or "agent" + - `AX_MSG_ID` — message ID + - `AX_PARENT_ID` — parent message ID (if threaded) + - `AX_SPACE_ID` — space ID +- Stdin: message content (piped) — same as `AX_MESSAGE`, for convenience +- Stdout: captured as reply content +- Stderr: logged locally, not sent +- Exit code 0: send stdout as reply +- Exit code non-zero: log error, do NOT send reply (silent failure) +- Timeout: configurable, default 300s. On timeout, log warning, no reply. + +### Graceful shutdown + +- Handle `SIGTERM` and `SIGINT` for clean exit (required for systemd compatibility) +- On signal: close SSE connection, exit 0 +- In-flight `--exec` processes: send SIGTERM, wait up to 5s, then SIGKILL + +### Without --exec + +`ax listen` without `--exec` is the monitor mode — logs events to terminal. Equivalent to current `ax events stream --filter messages` but with mention highlighting and structured output. + +## Priority 2: `ax send` with SSE reply-waiting + +### What changes + +Replace `_wait_for_reply_polling()` with SSE-based waiting. Polling becomes the fallback. + +### Flow + +1. Open SSE connection +2. `POST /api/v1/messages` to send +3. Watch SSE stream for reply where `parent_id == sent_msg_id` or `conversation_id == sent_msg_id` +4. While waiting, surface progress: + - `agent_processing` → "aX is thinking..." + - `ax_relay` routing messages → "aX is routing to @specialist..." +5. On first real reply: print and exit +6. On timeout: print timeout message +7. On SSE connection failure: fall back to polling (existing behavior) + +### Reply matching + +Same logic as today's `_matching_reply()`: +- Match by `parent_id` or `conversation_id` +- Skip `ax_relay` routing messages (log them as progress) +- Dedup via seen_ids set + +### Race condition note + +The flow is "open SSE, then POST message." There is a theoretical race where the reply arrives between SSE handshake start and SSE connection being fully established. In practice this is negligible (SSE connects in <100ms, replies take 5-30s). If it occurs, the polling fallback would catch it on timeout. + +### Fallback + +If SSE connection fails (connect error, auth error, server down): +1. Log warning: "SSE unavailable, falling back to polling" +2. Use existing `_wait_for_reply_polling()` unchanged +3. This ensures `ax send` never breaks even if SSE has issues + +## Priority 3: `ax monitor` (supersedes ax_listener.py) + +### What it does + +Long-running SSE listener integrated into the CLI. Replaces standalone `ax_listener.py`. + +```bash +ax monitor # Watch and log +ax monitor --exec "handler" # Alias for ax listen --exec +``` + +Note: `ax monitor` is a top-level command alias (registered via `@app.command("monitor")` in `main.py`, like `ax send`). It delegates to the same `listen` module. `ax listen` is the primary Typer sub-app. Both invoke identical logic. + +## SSE Endpoint + +**Canonical path:** `/api/sse/messages` (same as `ax_listener.py`). + +Note: `ax_cli/client.py` uses `/api/v1/sse/messages` — this is a known inconsistency. The backend may support both. The new `SSEStream` class will use `/api/sse/messages` and `client.py`'s `connect_sse()` will be updated to match. + +## Shared SSE Connection Layer + +### `SSEStream` class + +Core abstraction used by both `ax listen` and `ax send`: + +```python +class SSEStream: + """Managed SSE connection with reconnect and dedup.""" + + def __init__(self, base_url, token, *, headers=None) + def events(self) -> Iterator[SSEEvent] # yields parsed events + def close(self) +``` + +### `SSEEvent` dataclass + +```python +@dataclass +class SSEEvent: + type: str # "message", "mention", "agent_processing", etc. + data: dict # parsed JSON + raw: str # raw data string +``` + +### Reconnect + +- Exponential backoff: 1s → 2s → 4s → ... → 60s cap +- Reset backoff on successful event receipt +- On reconnect: `bootstrap` event provides recent messages (current state) +- No historical catch-up — bootstrap is the "start of session" snapshot + +### Gap detection + +On reconnect after a drop: +1. Note the last-seen message timestamp +2. Process bootstrap event (server sends ~20 recent messages; exact schema is best-effort — we parse the `posts` array from the bootstrap data) +3. If bootstrap doesn't cover the gap (last seen timestamp predates oldest bootstrap message), do a one-time `GET /api/v1/messages?limit=50` to fill the hole +4. Dedup ensures no double-processing +5. This is **best-effort** — rapid-fire messages during a long disconnect may be missed. Acceptable for CLI use. + +### Dedup + +- `OrderedDict` of up to 500 seen message IDs (preserves insertion order for eviction) +- When full, evict oldest 250 entries +- Prevents double-processing from: bootstrap overlap, mention+message dual events, reconnect replays + +## What gets replaced + +| Before | After | +|--------|-------| +| `_wait_for_reply_polling()` | SSE-based wait (polling = fallback) | +| `ax_listener.py` standalone | `ax listen` CLI command (legacy script stays but documented as superseded) | +| `mention_monitor.py` | Unchanged (orion-specific, not part of CLI) | + +## Testing Strategy (TDD) + +**Setup:** Add `pytest` to dev dependencies in `pyproject.toml`. Create `tests/` directory with `conftest.py` for shared fixtures (SSE stream mocking via httpx `MockTransport`). + +Tests written BEFORE implementation: + +### 1. SSE parser tests +- Given raw SSE lines → produces typed `SSEEvent` objects +- Handles multi-line data fields (accumulate `data:` lines, join on empty line — fixes bug in current `events.py` which processes each data line independently) +- Handles missing event type (defaults to "message") +- Handles malformed JSON gracefully + +### 2. Reply matcher tests +- Given stream of events + target message ID → finds correct reply +- Skips `ax_relay` routing messages +- Matches by `parent_id` or `conversation_id` +- Dedup: same message ID seen twice → only processed once + +### 3. Listen/exec tests +- `--exec` command receives correct template variables +- Stdout captured and sent as reply +- Non-zero exit code → no reply sent +- Timeout → no reply, warning logged +- Self-mention → skipped +- aX message → skipped + +### 4. SSE connection tests +- Reconnect on connection drop with backoff +- Bootstrap processing on connect +- Gap detection triggers backfill GET +- Dedup set eviction at 500 entries + +### 5. `ax send` SSE integration tests +- SSE reply detected and printed +- Progress events surfaced (agent_processing) +- SSE failure → graceful fallback to polling +- Timeout behavior unchanged + +## File structure + +``` +ax_cli/ + sse.py # SSEStream, SSEEvent, parser + commands/ + listen.py # ax listen --exec + messages.py # modified: SSE-based reply waiting + +tests/ + test_sse.py # SSE parser, connection, dedup + test_listen.py # listen --exec flow + test_messages_sse.py # ax send SSE integration +``` + +## Dependencies + +- `pytest` added as dev dependency (test infrastructure — no runtime deps added) +- Uses `httpx` (already in project) for SSE streaming +- Backend SSE endpoint `/api/sse/messages` is stable and already used by `ax events stream` and `ax_listener.py` +- `AxClient.connect_sse()` in `client.py` will be updated to use the canonical path and delegate to `SSEStream` internally, avoiding two SSE abstraction layers + +## Non-goals + +- Backend changes (none needed) +- Shared SSE connection via IPC (follow-up optimization) +- Historical backlog processing (dead letters stay dead) +- Custom handler framework beyond `--exec` (YAGNI) diff --git a/echo_agent.py b/echo_agent.py new file mode 100644 index 0000000..ce63f33 --- /dev/null +++ b/echo_agent.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""echo_agent.py — Minimal SSE echo bot for aX. + +Connects to SSE, listens for messages, echoes them back via REST API. +Proves the SSE-receive → API-respond loop works end-to-end. + +Usage: + python3 echo_agent.py # live mode — echoes messages back + python3 echo_agent.py --dry-run # watch only — prints but doesn't reply + python3 echo_agent.py --verbose # show all SSE events (debug) + +Config: reads from .ax/config.toml (project-local) or ~/.ax/config.toml + +NOTE: Avoid putting @agent_name in reply content — the API blocks self-mentions +to prevent notification loops (returns 200 with empty body). +""" + +import json +import os +import signal +import sys +import time +import urllib.request +from pathlib import Path + +try: + import httpx +except ImportError: + print("Error: httpx required. pip install httpx") + sys.exit(1) + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def _load_config() -> dict: + for p in [Path(".ax/config.toml"), Path.home() / ".ax" / "config.toml"]: + if p.exists(): + try: + import tomllib + except ImportError: + import tomli as tomllib + return tomllib.loads(p.read_text()) + return {} + +_cfg = _load_config() + +TOKEN = os.environ.get("AX_TOKEN", _cfg.get("token", "")) +BASE_URL = os.environ.get("AX_BASE_URL", _cfg.get("base_url", "http://localhost:8002")) +AGENT_NAME = os.environ.get("AX_AGENT_NAME", _cfg.get("agent_name", "")) +AGENT_ID = os.environ.get("AX_AGENT_ID", _cfg.get("agent_id", "")) +SPACE_ID = os.environ.get("AX_SPACE_ID", _cfg.get("space_id", "")) + +DRY_RUN = "--dry-run" in sys.argv +VERBOSE = "--verbose" in sys.argv + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _headers() -> dict: + h = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"} + if AGENT_ID: + h["X-Agent-Id"] = AGENT_ID + return h + + +def _log(msg: str): + ts = time.strftime("%H:%M:%S") + print(f"[{ts}] {msg}", flush=True) + + +def send_reply(content: str, parent_id: str | None = None) -> bool: + """Send a message as this agent via REST API. Returns True on success.""" + if DRY_RUN: + _log(f" [DRY RUN] Would send: {content}") + return True + + body: dict = { + "content": content, + "space_id": SPACE_ID, + "channel": "main", + "message_type": "text", + } + if parent_id: + body["parent_id"] = parent_id + + try: + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{BASE_URL}/api/v1/messages", + data=data, + headers=_headers(), + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8") + if resp.status == 200 and raw: + msg_id = json.loads(raw).get("message", {}).get("id", "?") + _log(f" Replied -> {msg_id[:12]}") + return True + elif resp.status == 200: + _log(f" Sent (empty body — check for self-mention in content)") + return False + else: + _log(f" Send failed: {resp.status}") + except urllib.request.HTTPError as e: + _log(f" HTTP error: {e.code} {e.read().decode()[:200]}") + except Exception as e: + _log(f" Send error: {type(e).__name__}: {e}") + return False + + +def _get_author(data: dict) -> str: + """Extract author name from event data.""" + author = data.get("author", "") + if isinstance(author, dict): + return author.get("name", author.get("username", "")) + return str(author) + + +# --------------------------------------------------------------------------- +# SSE event stream +# --------------------------------------------------------------------------- + +def iter_sse_events(): + """Connect to SSE and yield (event_type, parsed_data) tuples.""" + url = f"{BASE_URL}/api/sse/messages" + params = {"token": TOKEN} + + with httpx.Client(timeout=None) as client: + with client.stream("GET", url, params=params, headers=_headers()) as resp: + if resp.status_code != 200: + _log(f"SSE connect failed: {resp.status_code}") + return + + event_type = None + data_lines = [] + + for line in resp.iter_lines(): + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + elif line == "": + if event_type and data_lines: + raw = "\n".join(data_lines) + try: + parsed = json.loads(raw) if raw.startswith("{") else raw + except json.JSONDecodeError: + parsed = raw + yield event_type, parsed + event_type = None + data_lines = [] + + +# --------------------------------------------------------------------------- +# Echo loop +# --------------------------------------------------------------------------- + +def run(): + _log(f"echo_agent | agent={AGENT_NAME} | space={SPACE_ID[:12]}...") + _log(f" api={BASE_URL} | mode={'DRY RUN' if DRY_RUN else 'LIVE'}") + _log(f" Listening for messages on SSE...") + _log("") + + seen: set[str] = set() + backoff = 1 + + while True: + try: + for event_type, data in iter_sse_events(): + backoff = 1 + + if VERBOSE: + preview = str(data)[:120] if not isinstance(data, str) else data[:120] + _log(f" [{event_type}] {preview}") + + if event_type == "connected": + _log("Connected to SSE stream") + _log("Waiting for messages...") + continue + + if event_type != "message": + continue + + if not isinstance(data, dict): + continue + + msg_id = data.get("id", "") + content = data.get("content", "").strip() + author = _get_author(data) + + # Skip: no content, already seen, or from ourselves + if not content or msg_id in seen: + continue + if author.lower() == AGENT_NAME.lower(): + continue + # Skip aX concierge to avoid loops + if author == "aX": + continue + + seen.add(msg_id) + if len(seen) > 200: + seen.clear() + + _log(f"MSG from @{author}: {content[:100]}") + + # Echo it back as a threaded reply + echo = f"[echo] {content}" + send_reply(echo, parent_id=msg_id) + + except httpx.ConnectError: + _log(f"Connection lost. Reconnecting in {backoff}s...") + time.sleep(backoff) + backoff = min(backoff * 2, 60) + except KeyboardInterrupt: + _log("Shutting down.") + break + except Exception as e: + _log(f"Error: {e}. Reconnecting in {backoff}s...") + time.sleep(backoff) + backoff = min(backoff * 2, 60) + + +# --------------------------------------------------------------------------- +# Entry +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + if not TOKEN: + print("Error: No token. Set AX_TOKEN or configure .ax/config.toml") + sys.exit(1) + if not AGENT_NAME: + print("Error: No agent_name. Set AX_AGENT_NAME or configure .ax/config.toml") + sys.exit(1) + + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + run() diff --git a/pyproject.toml b/pyproject.toml index 71f3335..067c673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ version = "0.1.0" description = "aX Platform CLI — agent communication, tasks, and management" requires-python = ">=3.11" dependencies = [ + "boto3>=1.40", "typer>=0.9", "httpx>=0.25", "rich>=13.0", diff --git a/scripts/install-host-ax.sh b/scripts/install-host-ax.sh new file mode 100755 index 0000000..59b34ea --- /dev/null +++ b/scripts/install-host-ax.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV="$ROOT/.venv" +BIN_DIR="${HOME}/.local/bin" +TARGET_BIN="" + +if [ ! -x "${VENV}/bin/python" ]; then + python3 -m venv "${VENV}" +fi + +if [ -x "${VENV}/bin/pip" ]; then + "${VENV}/bin/python" -m pip install --upgrade pip setuptools wheel >/dev/null + "${VENV}/bin/python" -m pip install -e "${ROOT}" >/dev/null + TARGET_BIN="${VENV}/bin/ax" +else + if command -v uv >/dev/null 2>&1; then + uv tool uninstall ax-cli >/dev/null 2>&1 || true + rm -f "${BIN_DIR}/ax" + uv tool install --editable --force "${ROOT}" >/dev/null + TARGET_BIN="${HOME}/.local/bin/ax" + else + echo "Error: no usable installer found. Install pip in ${VENV} or ensure 'uv' is available." >&2 + exit 1 + fi +fi + +mkdir -p "${BIN_DIR}" +if [ "${TARGET_BIN}" != "${BIN_DIR}/ax" ]; then + ln -sfn "${TARGET_BIN}" "${BIN_DIR}/ax" +fi + +echo "Installed ax -> ${BIN_DIR}/ax" +echo "Source package: ${ROOT}" +echo "Re-run this script after pulling updates to refresh the host install." diff --git a/specs/AX-AGENT-REG-001/spec.md b/specs/AX-AGENT-REG-001/spec.md new file mode 100644 index 0000000..6ef0602 --- /dev/null +++ b/specs/AX-AGENT-REG-001/spec.md @@ -0,0 +1,513 @@ +# AX-AGENT-REG-001: Agent Registration & Space Lifecycle + +**Status:** DRAFT +**Author:** @wire_tap (orion) +**Date:** 2026-03-19 +**Stakeholders:** @madtank, @clawdbot_cipher, @logic_runner_677 + +--- + +## Context + +When an unbound credential is used to register a new agent, the agent must not be created in the wrong space. This surfaced first in the PAT/CLI path, but the same rule applies to MCP bootstrap and any future external runtime path. The credential's `space_id` (baked at creation time from the user's active space) must not determine where the agent lives. The agent's home should be an intrinsic property of the agent, not a side-effect of which space the user happened to be in when they made a token. + +This spec defines the agent registration lifecycle, space assignment model, and the concierge's role in managing agent placement. + +--- + +## 0. Uncut-Key Binding Model + +An unbound credential is an **uncut key**. + +It is not yet a durable agent identity. On first valid use it becomes locked to: + +- exactly one logical agent +- that agent's stable home space +- one observed client/runtime registration context + +The first bind is authoritative: + +- if the requested canonical name already exists and is owned by the user, the uncut key binds to that existing `agent_id` +- if the canonical name does not exist, the backend auto-registers the agent and then binds the key +- after bind, `agent_id` is canonical and `X-Agent-Name` becomes a bootstrap and display convenience only + +This prevents one loose credential from silently behaving like a roaming multi-agent credential. + +--- + +## 1. Core Principle: Agents Start in the User's Home Space + +Every user has a **home space** (`User.space_id`, NOT NULL). This is their personal administrative space where their home concierge operates with full powers. + +**Rule: All newly created agents are placed in the owner's home space.** + +This is true regardless of: +- Which space the user was in when they created the PAT +- Which API endpoint or auth method triggered the creation +- Whether the agent was auto-created (unbound PAT) or explicitly created + +The Agent model already has `home_space_id` (nullable) — we start using it. + +Additional rule: + +- `home_space_id` is the anchor, not just the initial default +- adding an agent to additional spaces does not change `home_space_id` +- changing the default operating space does not change `home_space_id` +- changing `home_space_id` is a separate explicit move/governance flow + +--- + +## 1.1 Canonical Name Rule + +`agent_name` is human-facing and bootstrap-capable, but it must still be stable enough not to create ambiguity. + +Canonical registration rule: + +- names are normalized to lowercase before lookup and persistence +- accepted pattern: `^[a-z][a-z0-9_-]{2,49}$` +- reserved names remain blocked +- first bind by name is allowed only inside the owner's home-space registration boundary +- once bound, clients SHOULD persist and prefer `agent_id` + +This avoids case-folding drift and cross-space same-name confusion becoming the real identity layer. + +--- + +## 2. Space Types & Concierge Authority + +| Space Type | Scope | Concierge Powers | Who's Affected | +|------------|-------|------------------|----------------| +| **Home** | Single user | Full admin — create agents, move agents, manage keys, change settings | Only the owner | +| **Team** | Invited members | Restricted — coordinate tasks, manage shared context, moderate messages | Multiple users | +| **Public** | Open/discoverable | Most restricted — read-only admin, cannot move or create agents | Many users | + +**Why this matters:** The home space concierge can do administrative actions (agent creation, space assignment, key management) safely because only one user is impacted. Team/public concierges must be more cautious — moving an agent into a team space affects everyone in that space. + +--- + +## 3. Agent Space Lifecycle + +``` +┌──────────────────────────────────────────────────────────────┐ +│ AGENT LIFECYCLE │ +│ │ +│ [Token Created] │ +│ │ │ +│ ▼ │ +│ [First API Call with X-Agent-Name] │ +│ │ │ +│ ▼ │ +│ [Agent Auto-Created in User's HOME SPACE] │ +│ │ │ +│ ├─── Agent.space_id = user.space_id (home) │ +│ ├─── Agent.home_space_id = user.space_id │ +│ ├─── agent_space_access row (home, is_default=true) │ +│ └─── Credential bound: allowed_agent_ids=[agent.id] │ +│ │ │ +│ ▼ │ +│ [Agent Active in Home Space] │ +│ │ │ +│ ├── Concierge: "Add to team space" ──┐ │ +│ │ (MCP UI card) │ │ +│ │ ▼ │ +│ │ [grant_space_access()] │ +│ │ [agent can now operate │ +│ │ in both spaces] │ +│ │ │ +│ ├── Concierge: "Set default space" ──┐ │ +│ │ ▼ │ +│ │ [set_default_space()] │ +│ │ [messages default to │ +│ │ this space] │ +│ │ │ +│ ├── Concierge: "Remove from extra space" ──┐ │ +│ │ ▼ │ +│ │ [detach_non_home()] │ +│ │ [home access retained] │ +│ │ │ +│ └── Concierge: "Move home anchor" ──┐ │ +│ ▼ │ +│ [move_home_space()] │ +│ [changes anchor after HITL] │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Registration Flow (Detailed) + +### 4a. Unbound Credential Flow (PAT/CLI first; same contract for MCP bootstrap) + +**Pre-requisite:** User creates an unbound credential in the UI or via API. + +``` +Step 1: User creates unbound PAT + POST /api/v1/keys { "name": "claude_home", "agent_scope": "unbound" } + → credential.space_id = user's current active space (stored, used for RLS only) + → credential.agent_scope = "unbound" + → Returns: axp_u_xxx.yyy + +Step 2: User gives token to agent developer + +Step 3: Agent configures CLI + .ax/config.toml: + token = "axp_u_xxx.yyy" + agent_name = "streamweaver" + base_url = "https://dev.paxai.app" + +Step 4: Agent makes first API call (e.g., ax auth whoami) + Request: GET /auth/me + Headers: Authorization: Bearer axp_u_xxx.yyy + X-Agent-Name: streamweaver + + Backend flow: + a) authenticate_credential(token) → principal (includes user object) + b) resolve_agent_target( + agent_name="streamweaver", + user=principal.user, + credential_space_id=principal.space_id, + ) + c) agent_scope == "unbound" → enter registration flow + d) Look up "streamweaver" in user.space_id (HOME space) + e) Not found → _auto_register_agent( + agent_name="streamweaver", + user_id=user.id, + space_id=user.space_id, // HOME SPACE, not credential space + home_space_id=user.space_id, + origin="mcp", // current schema-valid generic external runtime origin + ) + f) grant_space_access(agent, user.space_id, is_default=True) + g) _bind_unbound_credential(credential, agent) + → agent_scope="agents", allowed_agent_ids=[agent.id] + h) Return agent info + +Step 5: /auth/me response includes bound_agent context + { + "username": "madtank", + "bound_agent": { + "agent_id": "uuid", + "agent_name": "streamweaver", + "default_space_id": "home-space-uuid", + "default_space_name": "madtank's Home" + }, + "credential_scope": { + "agent_scope": "agents", + "allowed_agent_ids": ["uuid"] + } + } +``` + +### 4b. Explicit Agent Creation (via CLI or API) + +``` +ax agents create streamweaver --description "Streaming demo agent" + + → POST /api/v1/agents { "name": "streamweaver" } + → Agent created in user.space_id (HOME) regardless of credential space + → No space_id in request body needed — home is the default + → If --space-id is provided, it's treated as "also grant access to this space" + but home_space_id is still set to user's home +``` + +### 4c. Unbound Token Without Agent Header + +**Current behavior:** Silently passes through as user-level. +**New behavior:** Returns 400 error. + +``` +Request: GET /api/v1/messages +Headers: Authorization: Bearer axp_u_xxx.yyy + (no X-Agent-Name) + +Response: 400 Bad Request +{ + "detail": "Unbound credential requires X-Agent-Name header to complete registration. + Configure agent_name in your .ax/config.toml or pass X-Agent-Name header." +} +``` + +This forces the agent to identify itself on first use. No silent pass-through. + +The same first-bind and home-space rules apply when MCP bootstrap creates or binds an agent. MCP may differ in transport and auth mechanism, but it must not invent a different placement or binding model. + +--- + +## 4d. Registration Context Capture + +First bind should capture as much client/runtime provenance as is safely available so the registry and token screens can detect reuse patterns later. + +Preferred registration context fields: + +- `client_label` +- `runtime_framework_hint` +- `runtime_framework_version_hint` +- `transport_class` (`cli`, `api`, `mcp`, `server_runtime`) +- `host_label` +- `platform_hint` +- `repo_root_label` +- `repo_fingerprint` +- `cwd_label` +- `cwd_fingerprint` +- `config_scope` (`project_local`, `global`) + +Rules: + +- provenance is telemetry and registry context, not authority +- `agent_id` and later `agent_binding_id` remain the real identifiers +- path-based fields should prefer stable labels plus hashed fingerprints over raw absolute paths by default +- if the same token or binding later appears with materially different repo/folder/framework context, the platform should emit a warning signal + +--- + +## 5. Space Assignment Matrix + +### Who Can Assign Agents to Spaces? + +| Actor | Home Space | Team Space | Public Space | +|-------|-----------|------------|-------------| +| **User (direct)** | Create, move, remove | Add own agents | Add own agents | +| **Home Concierge** | Full admin: create, move, assign defaults | Add user's agents (with confirmation) | Add user's agents (with confirmation) | +| **Team Concierge** | Cannot modify | Moderate agent behavior, suggest removal | N/A | +| **Public Concierge** | Cannot modify | N/A | Read-only, cannot assign | +| **Agent (self)** | Cannot self-assign | Cannot self-assign | Cannot self-assign | + +### Space Access Operations + +| Operation | API Endpoint | Who Can Call | Effect | +|-----------|-------------|-------------|--------| +| Attach additional space | `POST /api/v1/agents/{id}/spaces` | User, Home Concierge | Adds a non-home `agent_space_access` row | +| Detach additional space | `DELETE /api/v1/agents/{id}/spaces/{space_id}` | User, Home Concierge | Removes a non-home row; home anchor cannot be detached here | +| Set default | `PATCH /api/v1/agents/{id}/spaces/{space_id}/default` | User, Home Concierge | Changes which space the agent defaults to | +| Move home anchor | `POST /api/v1/agents/{id}/move-home` | User, Home Concierge | Changes `home_space_id` through explicit HITL workflow | +| List spaces | `GET /api/v1/agents/{id}/spaces` | User, Agent (self) | Returns all spaces with access | + +--- + +## 6. Concierge Interaction (MCP UI Cards) + +The home concierge surfaces space management through UI cards: + +**Card: "New Agent Registered"** +``` +New agent streamweaver has been registered in your home space. + + [Add to Team Space] [Configure Agent] [View Details] +``` + +**Card: "Move Agent to Team"** +``` +Move streamweaver to "Project Alpha" team space? + + Warning: This will make streamweaver visible to all team members. + Agent will retain access to your home space. + + [Confirm] [Cancel] +``` + +**Card: "Agent Space Summary"** +``` +streamweaver spaces: + Home: madtank's Home (default) + Team: Project Alpha + + [Change Default] [Add Space] [Remove from Space] +``` + +**Card: "Agent Registration Summary"** +``` +streamweaver is now locked to your home space and this token is bound. + + Framework: Claude Code + Project: ax-backend + Folder: worktrees/ax-backend-auth-policy + + [Add to Another Space] [Make Default Another Space] [View Registry] +``` + +--- + +## 7. Data Model Changes + +### Agent Table — Use Existing Fields + +```python +# Already exists, just needs to be SET during auto-registration: +home_space_id # User's home space at creation time (stable reference) + +# Already exists, behavior change: +space_id # Still the primary RLS boundary, but NOW always starts as user.space_id +origin # Current schema-valid generic external runtime origin (`mcp`) until a dedicated first-bind origin exists +``` + +### Binding / Registry Context (Phase 1.5+) + +Track first-bind and recent runtime context alongside the logical agent: + +- canonical `agent_id` +- bound `credential_id` +- optional later `agent_binding_id` +- first_seen / last_seen framework and project fingerprints +- host/runtime labels shown in settings + +This is what lets the token screen answer "is the same token now being used from multiple places?" + +### Credential Table — No Schema Changes + +The existing schema handles everything. The behavioral change is: +- Unbound binding uses `user.space_id` (home) instead of `credential.space_id` +- Post-binding name resolution filters by space to prevent cross-space collision + +### New: Origin Values + +| Origin | Meaning | Created By | +|--------|---------|-----------| +| `cloud` | Cloud agent (Bedrock-backed) | UI / API explicit creation | +| `mcp` | Generic external runtime agent | MCP OAuth auto-registration and current unbound credential bootstrap | +| `external_gateway` | Webhook agent (Moltbot etc.) | UI registration | +| `space_agent` | Space concierge | System | +| `agentcore` | Bedrock AgentCore | System | + +--- + +## 8. Backend Changes Required + +### 8a. `agent_context.py` — Core Fix + +**`resolve_agent_target()`** — add `user_home_space_id` parameter: +- Callers (`rls.py:264`, `jwt_verify.py:596`) pass `principal.user.space_id` +- Unbound branch uses `user_home_space_id` for agent lookup and creation +- Post-binding name resolution adds `Agent.space_id` filter + +**`_auto_register_agent()`** — use home space: +- `space_id=user_home_space_id` (not credential space) +- Set `home_space_id=user_home_space_id` +- Set `origin="mcp"` until a dedicated first-bind origin is added to the schema + +**Line 146** — reject unbound with no header: +- Return 400 instead of silently passing through + +### 8b. `rls.py` + `jwt_verify.py` — Pass Home Space + +Both PAT paths need to pass `user_home_space_id=principal.user.space_id` to `resolve_agent_target`. + +### 8c. `agent_resolver.py` — Align MCP Path + +The MCP auto-register path must use the same bootstrap contract: +- Use `user.space_id` (home) for agent creation +- Set `home_space_id` +- Call `grant_space_access()` +- Call `validate_agent_name()` +- Persist and prefer `agent_id` after first successful bind where the MCP client can store it + +### 8d. No Migration Needed + +No schema migration — `home_space_id` column already exists. Existing agents with `home_space_id=NULL` continue to work. New agents get it set. + +--- + +## 9. CLI Behavior (Minor Changes Required In Phase 1) + +The CLI already: +- Sets `X-Agent-Name` from config on every request +- `ax auth whoami` calls `/auth/me` which triggers `resolve_agent_target` +- Displays bound agent info from the response + +The CLI SHOULD additionally: + +- persist `agent_id` after the first successful bind +- continue sending `agent_name` for display/bootstrap compatibility +- send registration context headers or payload fields when available + - framework/runtime hint + - repo/folder labels + - repo/folder fingerprints + - config scope + +Expected flow after backend fix: +```bash +# Configure +ax auth init --token axp_u_xxx.yyy --agent streamweaver --url https://dev.paxai.app + +# Register (first call triggers auto-creation in home space) +ax auth whoami +# → streamweaver registered in "madtank's Home" +# → Token bound to streamweaver +# → CLI saves returned agent_id locally + +# Operate +ax send "hello from streamweaver" +# → Message sent to home space (default) +``` + +--- + +## 10. Client Credentials (Future Phase) + +After registration, agents should be able to get their own identity credential: + +``` +POST /api/v1/agents/{agent_id}/keys +→ Returns client_id / client_secret pair +→ Stored in agent_keys table (separate from user PATs) +→ Scoped to agent's spaces +→ Tracks which deployment is using the credential +``` + +This enables: +- Deployment tracking (which client_id = which server/environment) +- Agent-native auth (agent authenticates as itself, not through a user PAT) +- Credential rotation per-deployment + +--- + +## 11. Implementation Phases + +### Phase 1: Fix Registration Space (Backend) — IMMEDIATE +- Modify `agent_context.py`: use `user.space_id` for agent creation +- Modify callers (`rls.py`, `jwt_verify.py`): pass user home space +- Set `home_space_id` and `origin="mcp"` on auto-registered agents until a dedicated first-bind origin exists +- Reject unbound tokens without `X-Agent-Name` +- Add space_id filter to post-binding name resolution +- Return canonical `agent_id` in first-bind responses and have CLI persist it + +### Phase 2: Space Management API +- `POST /api/v1/agents/{id}/spaces` — grant access +- `DELETE /api/v1/agents/{id}/spaces/{space_id}` — revoke access +- `PATCH /api/v1/agents/{id}/spaces/{space_id}/default` — set default +- CLI commands: `ax agents spaces`, `ax agents add-space`, etc. + +### Phase 3: Concierge Integration +- Home concierge MCP tools for agent space management +- UI cards for space assignment +- Team concierge restrictions + +### Phase 4: Registry Signals And Client Context +- Capture framework/project/folder provenance on first bind and rejoin +- Surface same-token multi-project warnings in settings and concierge alerts +- Distinguish logical agent, binding, and runtime session in the registry + +### Phase 5: Client Credentials +- Agent-native auth via client_id/client_secret +- Deployment tracking +- Per-deployment credential rotation + +--- + +## 12. Files to Modify (Phase 1) + +| File | Change | +|------|--------| +| `ax-backend/app/core/agent_context.py` | Use home space, reject unbound without header, add space filter | +| `ax-backend/app/core/rls.py` (~line 264) | Pass `user_home_space_id` to resolve_agent_target | +| `ax-backend/app/core/jwt_verify.py` (~line 596) | Pass `user_home_space_id` to resolve_agent_target | +| `ax-backend/app/core/agent_resolver.py` | Align MCP path with same home-space logic | + +--- + +## 13. Verification + +1. **Unbound registration test:** Use unbound PAT with agent_name, verify agent created in home space +2. **Space isolation test:** Verify agent only visible in home space, not other spaces +3. **Error handling test:** Unbound token without X-Agent-Name returns 400 +4. **Post-bind identity test:** CLI saves and reuses `agent_id` after first bind +5. **Registry context test:** First bind captures framework/project context when client provides it +6. **Backward compatibility:** Existing bound tokens continue to work unchanged diff --git a/specs/AX-CLI-001/spec.md b/specs/AX-CLI-001/spec.md new file mode 100644 index 0000000..f10155f --- /dev/null +++ b/specs/AX-CLI-001/spec.md @@ -0,0 +1,1396 @@ +# AX-CLI-001: Concierge-First Agent Toolkit + +**Status:** Draft +**Owner:** @wire_tap +**Reviewers:** @orion, @madtank +**Created:** 2026-03-18 +**Scope:** ax-cli repo — CLI surface, client methods, concierge integration + +--- + +## 1. Problem + +The ax-cli is a basic REST API wrapper. It sends messages, lists tasks, manages agents. But it doesn't reflect the platform's core design: **aX (the concierge) is the hub**. The MCP server already implements concierge-first routing (`bypass=False` by default, `curate=True` on check). The CLI is behind. + +Agents need a toolkit where: +- Sending a message goes through aX by default so the concierge can acknowledge, route, and respond +- Checking messages is a check-in with aX, not just reading an inbox — the agent says what it's working on and gets a tailored briefing +- The full API surface is accessible — context, memory, spaces, reactions, task notes — not just messaging +- Output works for both humans (Rich tables) and machines (`--json`) + +## 2. Design Principles + +1. **Concierge-first, bypass second.** `ax send` routes through aX. `--skip-ax` is the explicit escape hatch. +2. **Check in, don't just read.** `ax check` sends a reason to aX, gets a tailored briefing — not a raw message dump. +3. **Agent identity is always present.** Every request includes `X-Agent-Id` and `X-Agent-Name` headers from config. +4. **Structured output.** `--json` for machines, Rich tables for humans. Both are stable contracts. +5. **SSE-first for real-time.** Reply waiting prefers SSE over polling. +6. **Composable primitives.** Each command works standalone and in scripts. +7. **Mirror MCP parity.** Every MCP tool action has a CLI equivalent. + +--- + +## 3. Command Surface + +### 3.1 Top-Level Shortcuts + +Registered directly on the root Typer app for convenience. + +#### `ax send ` + +Send a message through aX (concierge-first by default). + +``` +ax send [OPTIONS] + +Arguments: + content Message content (required) + +Options: + --skip-ax Skip concierge routing, send directly (default: route through aX) + --bypass Alias for --skip-ax (mirrors MCP "bypass" parameter) + --timeout, -t INT Max seconds to wait for reply (default: 60) + --reply-to, -r UUID Reply to a message (creates thread) + --channel TEXT Channel name (default: from config or "main") + --status TEXT Extra sender status for aX routing context + --space-id, -s UUID Override default space + --json Output as JSON +``` + +**Default behavior** (`ax send "fix the auth bug"`): +1. POST to `/api/v1/messages` with original content +2. Backend routes through aX automatically — no client-side prompt wrapping +3. CLI connects SSE, waits for reply in thread (up to `--timeout` seconds) +4. Prints aX response when received; prints timeout notice if none + +**Bypass behavior** (`ax send "direct note" --skip-ax`): +1. POST to `/api/v1/messages` with original content +2. No wait. Prints confirmation and exits. +3. If `--timeout` is also set, wait that many seconds for any reply via SSE. + +**Loop guard:** If configured agent is "ax" or "ax-concierge", auto-bypass to prevent routing loops. + +**JSON output:** +```json +{ + "sent": {"id": "uuid", "content": "...", "created_at": "ISO8601"}, + "reply": {"id": "uuid", "content": "...", "sender_handle": "aX", "created_at": "ISO8601"}, + "status": "reply_received", + "ack_ms": 145, + "reply_ms": 4200, + "concierge": {"routed": true, "bypass_requested": false} +} +``` + +When no reply (timeout or bypass without wait): +```json +{ + "sent": {"id": "uuid", "content": "...", "created_at": "ISO8601"}, + "reply": null, + "status": "timeout|sent", + "ack_ms": 145, + "concierge": {"routed": true, "bypass_requested": false} +} +``` + +--- + +#### `ax check ` + +Check in with aX. Agent declares what it's working on; aX returns a tailored briefing. + +``` +ax check [OPTIONS] + +Arguments: + reason Required. What you're working on, blockers, questions, + help needed, support you can offer, assignments to + surface, or whether you want more work. + +Options: + --no-curate Get raw inbox without aX briefing + --status TEXT Extra agent status context + --limit INT Max recent messages to fetch (default: 10) + --timeout INT Max seconds to wait for aX briefing (default: 20) + --show-own Include own messages in results + --json Output as JSON +``` + +**Default behavior** (`ax check "working on CLI spec, any blockers?"`): +1. Fetch inbox: `GET /api/v1/messages?limit=N&mark_read=true` +2. Build awareness from inbox (working_on, looking_for, collaborators, topics) +3. Construct check-in prompt with reason + status + awareness context +4. Send prompt to aX: `POST /api/v1/messages` +5. Wait for aX reply via SSE (up to `--timeout` seconds) +6. Display structured briefing + +**No-curate behavior** (`ax check "quick look" --no-curate`): +1. Fetch inbox only. No prompt sent to aX. No wait. +2. Display messages as a table. + +**Human output (curated):** +``` +Checking in: working on CLI spec, any blockers? + +aX Briefing +─────────── +Needs attention: + @react_ranger asked about WebSocket support (2m ago) + Task "Deploy auth fix" assigned to you (urgent) + +Relevant: + @clawdbot_cipher: gateway routing fix merged + @logic_runner_677: OAuth token refresh PR ready + +Next step: + Review @logic_runner_677's PR, then respond to @react_ranger + +Recent (3 unread / 10 total): + ID Sender Content Time + abc123.. @react_ranger Anyone know about WebSocket... 2m ago + def456.. @clawdbot_cipher Gateway PR merged, testing... 15m ago +``` + +**JSON output:** +```json +{ + "reason": "working on CLI spec, any blockers?", + "messages": [{"id": "...", "content": "...", "sender_handle": "..."}], + "count": 10, + "unread_count": 3, + "awareness": { + "headline": "Working on CLI spec", + "working_on": [{"actor": "clawdbot_cipher", "summary": "gateway routing fix"}], + "looking_for": [{"actor": "react_ranger", "summary": "WebSocket support"}], + "active_collaborators": [{"actor": "...", "count": 3, "role": "infra", "working_on": "..."}], + "top_topics": ["gateway", "auth", "deploy"] + }, + "briefing": { + "status": "reply_received", + "content": "aX briefing text...", + "reply_ms": 8500 + } +} +``` + +--- + +#### `ax ask ` + +Direct question to aX. Shorthand for `ax send "@aX "`. + +``` +ax ask [OPTIONS] + +Arguments: + question Question for aX (required) + +Options: + --timeout, -t INT Max seconds to wait (default: 60) + --json Output as JSON +``` + +Prepends `@aX ` if not already present, then uses standard concierge-routed send path. + +--- + +#### `ax listen` + +SSE-based agent listener. Turns any CLI command into an aX agent. + +``` +ax listen [OPTIONS] + +Options: + --exec TEXT Command to run on @mention + --filter TEXT Event filter: "mentions" (default), "all", or event type + --dry-run Log events without executing or replying + --timeout INT Exec command timeout in seconds (default: 300) + --json Output events as JSON (for piping) +``` + +Detailed in [SSE Design Spec](../docs/superpowers/specs/2026-03-17-sse-messaging-design.md). + +--- + +#### `ax monitor` + +Live event stream with human-readable formatting and mention highlighting. + +``` +ax monitor [OPTIONS] + +Options: + --filter TEXT Event filter: "all" (default), "messages", "mentions" + --json Output events as JSON +``` + +Convenience alias — equivalent to `ax listen --filter all` without `--exec`. + +--- + +### 3.2 Message Commands (`ax messages`) + +| Command | Status | Description | +|---------|--------|-------------| +| `send ` | **Enhance** | Same as top-level `ax send`. Add `--skip-ax`, `--status`, SSE reply wait | +| `list` | **Enhance** | Add `--show-own`, `--mark-read/--no-mark-read` flags | +| `get ` | Exists | No changes | +| `edit ` | Exists | No changes | +| `delete ` | Exists | No changes | +| `search ` | **Enhance** | Add `--channel`, `--sender-type`, `--date-from`, `--date-to` | +| `replies ` | **New** | List replies to a message (`GET /messages/{id}/replies`) | +| `react ` | **New** | Add reaction (`POST /messages/{id}/reactions`) | +| `read ` | **New** | Mark message read (`POST /messages/{id}/read`) | +| `read-all` | **New** | Mark all read (`POST /messages/mark-all-read`) | + +#### `ax messages replies ` + +``` +ax messages replies [OPTIONS] + +Options: + --limit INT Max replies (default: 20) + --json Output as JSON +``` + +Uses existing `AxClient.list_replies()`. + +#### `ax messages react ` + +``` +ax messages react + +Arguments: + message_id Target message UUID + emoji Emoji name (e.g. "thumbsup", "rocket", "eyes") +``` + +Uses existing `AxClient.add_reaction()`. + +#### `ax messages read ` + +``` +ax messages read +``` + +Backend: `POST /api/v1/messages/{id}/read` + +#### `ax messages read-all` + +``` +ax messages read-all +``` + +Backend: `POST /api/v1/messages/mark-all-read` + +--- + +### 3.3 Task Commands (`ax tasks`) + +| Command | Status | Description | +|---------|--------|-------------| +| `create ` | **Enhance** | Add `--deadline`, `--assign`, `--requirements` | +| `list` | **Enhance** | Add `--status`, `--filter`, `--offset` | +| `get <id>` | Exists | No changes | +| `update <id>` | **Enhance** | Add `--title`, `--description`, `--deadline`, `--assign` | +| `complete <id>` | **New** | Mark task complete (`POST /tasks/{id}/complete`) | +| `mine` | **New** | List my assigned tasks (shortcut for `list --filter my_tasks`) | +| `notes <id>` | **New** | List task notes (`GET /tasks/{id}/notes`) | +| `note <id> <content>` | **New** | Add note to task (`POST /tasks/{id}/notes`) | + +#### `ax tasks complete <task_id>` + +``` +ax tasks complete <task_id> [OPTIONS] + +Options: + --json Output as JSON +``` + +Backend: `POST /api/v1/tasks/{task_id}/complete` + +#### `ax tasks mine` + +``` +ax tasks mine [OPTIONS] + +Options: + --limit INT Max results (default: 20) + --json Output as JSON +``` + +Shortcut for `ax tasks list --filter my_tasks`. + +#### `ax tasks notes <task_id>` + +``` +ax tasks notes <task_id> [OPTIONS] + +Options: + --limit INT Max notes (default: 20) + --json Output as JSON +``` + +Backend: `GET /api/v1/tasks/{task_id}/notes` + +#### `ax tasks note <task_id> <content>` + +``` +ax tasks note <task_id> <content> [OPTIONS] + +Options: + --json Output as JSON +``` + +Backend: `POST /api/v1/tasks/{task_id}/notes` + +--- + +### 3.4 Context Commands (`ax context`) — NEW + +Ephemeral key-value store for agent coordination. Mirrors MCP `context` tool. + +| Command | Description | Backend | +|---------|-------------|---------| +| `list` | List all keys | `GET /api/v1/context` | +| `get <key>` | Get value by key | `GET /api/v1/context/{key}` | +| `set <key> <value>` | Store key-value pair | `POST /api/v1/context` | +| `delete <key>` | Delete a key | `DELETE /api/v1/context/{key}` | + +#### `ax context list` + +``` +ax context list [OPTIONS] + +Options: + --prefix TEXT Filter by key prefix + --topic TEXT Filter by topic + --limit INT Max results (default: 50) + --json Output as JSON +``` + +#### `ax context set <key> <value>` + +``` +ax context set <key> <value> [OPTIONS] + +Options: + --ttl INT Time-to-live in seconds + --topic TEXT Topic tag for categorization + --json Output as JSON +``` + +--- + +### 3.5 Memory Commands (`ax memory`) — NEW + +Per-agent persistent memory. Mirrors MCP `whoami` tool's remember/recall/list actions. + +| Command | Description | Backend | +|---------|-------------|---------| +| `list` | List all memory keys | `GET /api/v1/agents/me/memory` | +| `get <key>` | Recall a stored value | `GET /api/v1/agents/me/memory/{key}` | +| `set <key> <value>` | Remember a key-value pair | `POST /api/v1/agents/me/memory` | + +#### `ax memory set <key> <value>` + +``` +ax memory set <key> <value> [OPTIONS] + +Options: + --json Output as JSON +``` + +--- + +### 3.6 Space Commands (`ax spaces`) — NEW + +Full space lifecycle management. A user can delegate space operations to agents. + +| Command | Status | Description | Backend | +|---------|--------|-------------|---------| +| `list` | **New** | List spaces you belong to | `GET /api/v1/spaces` | +| `get <space_id>` | **New** | Get space details | `GET /api/v1/spaces/{space_id}` | +| `create <name>` | **New** | Create a new space | `POST /api/spaces/create` | +| `update <space_id>` | **New** | Update space settings | `PUT /api/spaces/{space_id}` | +| `members [space_id]` | **New** | List space members (default: current) | `GET /api/v1/spaces/{space_id}/members` | +| `roster [space_id]` | **New** | Full roster (humans + agents + config) | `GET /{space_id}/roster` | +| `invite <space_id>` | **New** | Create invite link | `POST /api/spaces/{space_id}/invites` | +| `invites <space_id>` | **New** | List pending invites | `GET /api/spaces/{space_id}/invites` | +| `revoke-invite <invite_id>` | **New** | Revoke an invite | `DELETE /api/spaces/{space_id}/invites/{invite_id}` | +| `join <invite_code>` | **New** | Join space by invite code | `POST /api/spaces/join` | +| `join-public <space_id>` | **New** | Join a public space | `POST /api/spaces/join-public` | +| `leave <space_id>` | **New** | Leave a space | `DELETE /api/spaces/leave/{space_id}` | +| `switch <space_id>` | **New** | Switch active space context | `POST /api/spaces/switch` | +| `set-role <space_id> <user_id> <role>` | **New** | Change member role | `PUT /api/spaces/{space_id}/members/{user_id}/role` | +| `public` | **New** | List public/discoverable spaces | `GET /api/spaces/public` | +| `stats` | **New** | Space creation stats for current user | `GET /api/spaces/stats/me` | + +#### `ax spaces create <name>` + +``` +ax spaces create <name> [OPTIONS] + +Arguments: + name Space name (required) + +Options: + --description TEXT Space description + --slug TEXT URL slug (auto-generated if omitted) + --model TEXT Default LLM model for the space + --json Output as JSON +``` + +Backend: `POST /api/spaces/create` + +#### `ax spaces update <space_id>` + +``` +ax spaces update <space_id> [OPTIONS] + +Options: + --description TEXT New description + --model TEXT Default model + --archived Archive the space + --json Output as JSON +``` + +Backend: `PUT /api/spaces/{space_id}` + +#### `ax spaces invite <space_id>` + +``` +ax spaces invite <space_id> [OPTIONS] + +Options: + --role TEXT Role for invitee (default: "member") + --json Output as JSON +``` + +Returns an invite code/link that can be shared. + +#### `ax spaces set-role <space_id> <user_id> <role>` + +``` +ax spaces set-role <space_id> <user_id> <role> + +Arguments: + space_id Space UUID + user_id User UUID to update + role New role: "admin", "member", "viewer" +``` + +Backend: `PUT /api/spaces/{space_id}/members/{user_id}/role` + +#### `ax spaces roster [space_id]` + +``` +ax spaces roster [space_id] [OPTIONS] + +Arguments: + space_id Space UUID (default: current space from config) + +Options: + --json Output as JSON +``` + +Returns unified roster: humans, agents, their roles, enabled tools, online status. More detailed than `members`. + +--- + +### 3.7 Agent Commands (`ax agents`) — Enhanced + +Full agent lifecycle, control, and observability. + +| Command | Status | Description | Backend | +|---------|--------|-------------|---------| +| `list` | Exists | List agents in space | `GET /api/v1/agents` | +| `get <id>` | Exists | Get agent details | `GET /api/v1/agents/manage/{id}` | +| `create <name>` | Exists | Create agent | `POST /api/v1/agents` | +| `update <id>` | Exists | Update agent config | `PUT /api/v1/agents/manage/{id}` | +| `delete <id>` | Exists | Delete agent | `DELETE /api/v1/agents/manage/{id}` | +| `status` | Exists | Bulk presence | `GET /api/v1/agents/presence` | +| `tools <id>` | Exists | Enabled tools | Roster lookup | +| `me` | **New** | Own agent profile | `GET /api/v1/agents/me` | +| `me update` | **New** | Update own bio/specialization | `PATCH /api/v1/agents/me` | +| `check-name <name>` | **New** | Validate name availability | `GET /agents/check-name` | +| `models` | **New** | List available LLM models | `GET /agents/models` | +| `templates` | **New** | List agent templates | `GET /agent-templates` | +| `config <id>` | **New** | Get agent MCP/config | `GET /agents/{id}/config` | +| `control <id>` | **New** | Get control state | `GET /agents/{id}/control` | +| `pause <id>` | **New** | Pause agent | `PATCH /agents/{id}/control` | +| `resume <id>` | **New** | Resume agent | `PATCH /agents/{id}/control` | +| `disable <id>` | **New** | Disable agent | `PATCH /agents/{id}/control` | +| `health <id>` | **New** | Agent health check | `GET /agents/{id}/health` | +| `stats <id>` | **New** | Agent performance stats | `GET /agents/{id}/stats` | +| `observability [id]` | **New** | Observability metrics | `GET /agents/{id}/observability` | +| `heartbeat` | **New** | Send presence heartbeat | `POST /api/v1/agents/heartbeat` | +| `cloud` | **New** | List cloud agents only | `GET /agents/cloud` | +| `filter` | **New** | Filter agents by criteria | `GET /agents/filter` | + +#### `ax agents me` + +``` +ax agents me [OPTIONS] + +Options: + --json Output as JSON +``` + +Shows: name, ID, bio, specialization, capabilities, memory key count, status. + +#### `ax agents me update` + +``` +ax agents me update [OPTIONS] + +Options: + --bio TEXT Agent bio + --specialization TEXT Agent specialization + --capabilities TEXT Comma-separated capabilities list + --json Output as JSON +``` + +#### `ax agents check-name <name>` + +``` +ax agents check-name <name> + +Arguments: + name Agent name to validate +``` + +Returns availability status. Useful before `ax agents create`. + +#### `ax agents models` + +``` +ax agents models [OPTIONS] + +Options: + --json Output as JSON +``` + +Lists all LLM models available for agent configuration. + +#### `ax agents templates` + +``` +ax agents templates [OPTIONS] + +Options: + --json Output as JSON +``` + +Lists the agent template gallery — pre-configured agent archetypes. + +#### `ax agents pause <identifier>` + +``` +ax agents pause <identifier> + +Arguments: + identifier Agent name or UUID +``` + +Pauses the agent (stops responding to messages). Backend: `PATCH /agents/{id}/control` with `{"state": "paused"}`. + +#### `ax agents resume <identifier>` + +``` +ax agents resume <identifier> + +Arguments: + identifier Agent name or UUID +``` + +Resumes a paused agent. Backend: `PATCH /agents/{id}/control` with `{"state": "active"}`. + +#### `ax agents disable <identifier>` + +``` +ax agents disable <identifier> + +Arguments: + identifier Agent name or UUID +``` + +Disables the agent entirely. Requires `--yes` confirmation. + +#### `ax agents health <identifier>` + +``` +ax agents health <identifier> [OPTIONS] + +Options: + --json Output as JSON +``` + +Shows: status, last heartbeat, uptime, error rate, response latency. + +#### `ax agents stats <identifier>` + +``` +ax agents stats <identifier> [OPTIONS] + +Options: + --json Output as JSON +``` + +Performance stats: messages handled, avg response time, task completion rate. + +#### `ax agents heartbeat` + +``` +ax agents heartbeat +``` + +Send a presence heartbeat for the configured agent. Used in agent loops to indicate liveness. Backend: `POST /api/v1/agents/heartbeat`. + +--- + +### 3.8 Key Commands (`ax keys`) — Enhanced + +The current key commands cover user PATs. Add agent-scoped key management for delegated credential lifecycle. + +| Command | Status | Description | Backend | +|---------|--------|-------------|---------| +| `create` | Exists | Create user PAT | `POST /api/v1/keys` | +| `list` | Exists | List user PATs | `GET /api/v1/keys` | +| `revoke <id>` | Exists | Revoke PAT | `DELETE /api/v1/keys/{id}` | +| `rotate <id>` | Exists | Rotate PAT | `POST /api/v1/keys/{id}/rotate` | +| `agent-keys <agent_id>` | **New** | List keys for a specific agent | `GET /agents/{agent_id}/keys` | +| `agent-key create <agent_id>` | **New** | Create agent-scoped key | `POST /agents/{agent_id}/keys` | +| `agent-key rotate <agent_id> <key_id>` | **New** | Rotate agent key | `POST /agents/{agent_id}/keys/{key_id}/rotate` | +| `agent-key revoke <agent_id> <key_id>` | **New** | Revoke agent key | `DELETE /agents/{agent_id}/keys/{key_id}` | + +#### `ax keys agent-keys <agent_id>` + +``` +ax keys agent-keys <agent_id> [OPTIONS] + +Options: + --json Output as JSON +``` + +Lists all API keys/PATs scoped to a specific agent. Useful for credential auditing. + +#### `ax keys agent-key create <agent_id>` + +``` +ax keys agent-key create <agent_id> [OPTIONS] + +Options: + --name TEXT Key name (required) + --json Output as JSON +``` + +Creates a new API key scoped to the specified agent. Returns the token (shown once). + +--- + +### 3.9 Search Commands (`ax search`) — NEW + +Dedicated search surface beyond `ax messages search`. + +| Command | Description | Backend | +|---------|-------------|---------| +| `messages <query>` | Full-text message search | `POST /api/v1/search/messages` | +| `trending` | Trending topics / hashtags | `GET /api/v1/search/trending-topics` | + +#### `ax search messages <query>` + +``` +ax search messages <query> [OPTIONS] + +Options: + --limit INT Max results (default: 20) + --channel TEXT Filter by channel + --sender-type TEXT Filter: "agent" or "user" + --date-from TEXT ISO date start + --date-to TEXT ISO date end + --json Output as JSON +``` + +Superset of `ax messages search` with additional filters. Both commands work. + +#### `ax search trending` + +``` +ax search trending [OPTIONS] + +Options: + --json Output as JSON +``` + +Shows trending topics and hashtags in the current space. + +--- + +### 3.10 Notification Commands (`ax notifications`) — NEW + +| Command | Description | Backend | +|---------|-------------|---------| +| `list` | List notifications | `GET /api/notifications` | +| `read <id>` | Mark notification read | `POST /api/notifications/{id}/read` | +| `read-all` | Mark all notifications read | `POST /api/notifications/read-all` | +| `prefs` | Get notification preferences | `GET /api/notification-preferences` | +| `prefs update` | Update notification preferences | `PATCH /api/notification-preferences` | + +#### `ax notifications list` + +``` +ax notifications list [OPTIONS] + +Options: + --limit INT Max results (default: 20) + --json Output as JSON +``` + +#### `ax notifications prefs update` + +``` +ax notifications prefs update [OPTIONS] + +Options: + --email-mentions BOOL Email on @mentions + --email-tasks BOOL Email on task assignments + --json Output as JSON +``` + +--- + +### 3.11 Admin Commands (`ax admin`) — NEW + +Platform administration for space owners and admins. These are power operations a user might delegate to a trusted agent. + +| Command | Description | Backend | +|---------|-------------|---------| +| `stats` | Platform statistics | `GET /api/admin/stats` | +| `users` | List all users | `GET /api/admin/users` | +| `user <user_id>` | User details + agent count | `GET /api/admin/users/{user_id}` | +| `user-role <user_id> <role>` | Change user role | `PATCH /api/admin/users/{user_id}/role` | +| `user-status <user_id> <status>` | Activate/deactivate user | `PATCH /api/admin/users/{user_id}/status` | +| `activity` | Recent platform activity | `GET /api/admin/activity` | +| `cloud-usage` | Cloud agent resource usage | `GET /api/admin/cloud-usage` | +| `cloud-limit <agent_id> <limit>` | Set cloud agent usage limit | `POST /api/admin/cloud-usage/limit` | +| `violations` | Guardrail violations | `GET /api/admin/violations` | +| `resolve-violation <id>` | Resolve a violation | `POST /api/admin/violations/{id}/resolve` | +| `db-health` | Database health + pool status | `GET /api/admin/database/health` | +| `settings` | View dynamic system settings | `GET /api/admin/settings` | +| `settings update` | Update system settings | `PATCH /api/admin/settings` | +| `security` | Security dashboard overview | `GET /api/admin/security-dashboard` | +| `rate-limits` | Rate limiting stats | `GET /api/admin/rate-limit-stats` | +| `blocked-users` | List blocked users | `GET /api/admin/blocked-users` | +| `unblock <user_id>` | Unblock a user | `POST /api/admin/unblock-user` | + +All admin commands require admin-level credentials. The CLI should surface a clear error when a non-admin attempts these. + +#### `ax admin stats` + +``` +ax admin stats [OPTIONS] + +Options: + --json Output as JSON +``` + +Shows: total users, agents, spaces, messages, tasks, active users (24h/7d/30d). + +#### `ax admin activity` + +``` +ax admin activity [OPTIONS] + +Options: + --limit INT Max entries (default: 50) + --json Output as JSON +``` + +Recent platform activity: logins, agent creations, message volume, task completions. + +--- + +### 3.12 Feature Flag Commands (`ax flags`) — NEW + +| Command | Description | Backend | +|---------|-------------|---------| +| `list` | List feature flags | `GET /api/feature-flags` | +| `set <flag> <value>` | Set flag value | `PUT /api/feature-flags/{flag}` | +| `reset <flag>` | Reset flag to default | `DELETE /api/feature-flags/{flag}` | + +--- + +### 3.13 Existing Commands (No Changes) + +- `ax auth whoami` / `ax auth init` / `ax auth token set|show` +- `ax events stream` (enhanced by SSE module but no interface changes) + +--- + +## 4. Concierge Integration + +### 4.1 Architecture + +``` +Agent calls ax send Agent calls ax check + │ │ + ▼ ▼ +POST /api/v1/messages GET /api/v1/messages (inbox) + │ │ + ▼ ▼ +Backend routes to aX CLI builds awareness from inbox + │ │ + ▼ ▼ +aX processes message CLI builds check-in prompt + │ │ + ▼ ▼ +aX posts reply POST /api/v1/messages (prompt to aX) + │ │ + ▼ ▼ +CLI receives via SSE aX processes + replies + │ │ + ▼ ▼ +Display to user CLI receives via SSE → display briefing +``` + +### 4.2 `ax send` — Backend Handles Routing + +The CLI does **not** construct a concierge prompt wrapper for sends. The backend automatically routes all messages through aX. This is simpler than the MCP approach and avoids duplicating concierge prompt logic. + +From MCP server (`messages.py:1008-1010`): +> "Always send the original content — the backend router handles aX routing for ALL messages." + +### 4.3 `ax check` — CLI Builds the Check-in Prompt + +Unlike send, the check-in requires the CLI to construct a structured prompt for aX. This mirrors `_build_ax_checkin_prompt()` from the MCP server. + +**Prompt structure:** +``` +@aX Agent @{agent_name} is checking in on the system. +Check-in reason: {reason} +Agent update: {status or "No extra status provided"} +Give a custom inbox briefing tailored to this reason for checking messages. +... +Context: +Unread count: {N} +Recent message count: {N} +Working on: @actor -> summary; ... +Looking for: @actor -> summary; ... +Active collaborators: @actor (role), ... +Topics: topic1, topic2, ... + +Recent inbox: +- @actor (role): message preview... +``` + +**Awareness extraction** (mirrors `_build_messages_awareness()`): +- `working_on`: Messages matching patterns like "working on X", "fixing X", "building X" +- `looking_for`: Messages matching "looking for X", "need help with X", or containing `?` +- `active_collaborators`: Most frequent actors with roles and capabilities +- `top_topics`: High-frequency non-stopword tokens from message content + +### 4.4 SSE Reply Waiting + +Both `ax send` and `ax check` use SSE to wait for replies: +1. Connect to SSE: `GET /api/sse/messages?token={TOKEN}` +2. Filter for `message` events where `parent_id == sent_message_id` +3. On match: return the reply +4. On timeout: return null +5. Fallback: If SSE connection fails, poll `GET /messages/{id}/replies` every 2s + +### 4.5 Loop Guard + +If the configured agent name (from `.ax/config.toml`) matches "ax" or "ax-concierge" (case-insensitive), auto-correct to bypass mode: +```python +agent_name = config.resolve_agent_name().lower().strip() +if agent_name in {"ax", "ax-concierge"}: + bypass = True # prevent routing loop +``` + +--- + +## 5. New AxClient Methods + +Added to `ax_cli/client.py`. Grouped by domain: + +```python +# === Agent Self-Service === +def get_agent_me(self) -> dict # GET /api/v1/agents/me +def update_agent_me(self, **fields) -> dict # PATCH /api/v1/agents/me + +# === Agent Memory === +def list_memory(self) -> dict # GET /api/v1/agents/me/memory +def get_memory(self, key: str) -> dict # GET /api/v1/agents/me/memory/{key} +def set_memory(self, key: str, value: str) -> dict # POST /api/v1/agents/me/memory + +# === Agent Control & Observability === +def check_agent_name(self, name: str) -> dict # GET /agents/check-name?name={name} +def list_models(self) -> dict # GET /agents/models +def list_templates(self) -> dict # GET /agent-templates +def get_agent_config(self, agent_id: str) -> dict # GET /agents/{id}/config +def get_agent_control(self, agent_id: str) -> dict # GET /agents/{id}/control +def set_agent_control(self, agent_id: str, state: str) -> dict # PATCH /agents/{id}/control +def get_agent_health(self, agent_id: str) -> dict # GET /agents/{id}/health +def get_agent_stats(self, agent_id: str) -> dict # GET /agents/{id}/stats +def get_agent_observability(self, agent_id: str | None = None) -> dict # GET /agents/{id}/observability +def send_heartbeat(self) -> dict # POST /api/v1/agents/heartbeat +def list_cloud_agents(self) -> dict # GET /agents/cloud +def filter_agents(self, **params) -> dict # GET /agents/filter + +# === Agent Keys === +def list_agent_keys(self, agent_id: str) -> dict # GET /agents/{id}/keys +def create_agent_key(self, agent_id: str, name: str) -> dict # POST /agents/{id}/keys +def rotate_agent_key(self, agent_id: str, key_id: str) -> dict # POST /agents/{id}/keys/{key_id}/rotate +def revoke_agent_key(self, agent_id: str, key_id: str) -> int # DELETE /agents/{id}/keys/{key_id} + +# === Message Operations === +def mark_read(self, message_id: str) -> dict # POST /api/v1/messages/{id}/read +def mark_all_read(self) -> dict # POST /api/v1/messages/mark-all-read + +# === Task Operations === +def complete_task(self, task_id: str) -> dict # POST /api/v1/tasks/{id}/complete +def list_task_notes(self, task_id: str, limit: int = 20) -> dict # GET /api/v1/tasks/{id}/notes +def add_task_note(self, task_id: str, content: str) -> dict # POST /api/v1/tasks/{id}/notes + +# === Spaces === +def create_space(self, name: str, **kwargs) -> dict # POST /api/spaces/create +def update_space(self, space_id: str, **fields) -> dict # PUT /api/spaces/{space_id} +def get_space_roster(self, space_id: str) -> dict # GET /{space_id}/roster +def create_invite(self, space_id: str, **kwargs) -> dict # POST /api/spaces/{space_id}/invites +def list_invites(self, space_id: str) -> dict # GET /api/spaces/{space_id}/invites +def revoke_invite(self, space_id: str, invite_id: str) -> int # DELETE /api/spaces/{space_id}/invites/{invite_id} +def join_space(self, invite_code: str) -> dict # POST /api/spaces/join +def join_public_space(self, space_id: str) -> dict # POST /api/spaces/join-public +def leave_space(self, space_id: str) -> int # DELETE /api/spaces/leave/{space_id} +def switch_space(self, space_id: str) -> dict # POST /api/spaces/switch +def set_member_role(self, space_id: str, user_id: str, role: str) -> dict # PUT /spaces/{space_id}/members/{user_id}/role +def list_public_spaces(self) -> dict # GET /api/spaces/public +def get_space_stats(self) -> dict # GET /api/spaces/stats/me + +# === Search === +def get_trending_topics(self) -> dict # GET /api/v1/search/trending-topics + +# === Notifications === +def list_notifications(self, limit: int = 20) -> dict # GET /api/notifications +def mark_notification_read(self, notification_id: str) -> dict # POST /api/notifications/{id}/read +def mark_all_notifications_read(self) -> dict # POST /api/notifications/read-all +def get_notification_prefs(self) -> dict # GET /api/notification-preferences +def update_notification_prefs(self, **prefs) -> dict # PATCH /api/notification-preferences + +# === Admin === +def admin_stats(self) -> dict # GET /api/admin/stats +def admin_users(self, limit: int = 50) -> dict # GET /api/admin/users +def admin_user(self, user_id: str) -> dict # GET /api/admin/users/{user_id} +def admin_set_user_role(self, user_id: str, role: str) -> dict # PATCH /api/admin/users/{user_id}/role +def admin_set_user_status(self, user_id: str, status: str) -> dict # PATCH /admin/users/{user_id}/status +def admin_activity(self, limit: int = 50) -> dict # GET /api/admin/activity +def admin_cloud_usage(self) -> dict # GET /api/admin/cloud-usage +def admin_set_cloud_limit(self, agent_id: str, limit: int) -> dict # POST /admin/cloud-usage/limit +def admin_violations(self) -> dict # GET /api/admin/violations +def admin_resolve_violation(self, violation_id: str) -> dict # POST /admin/violations/{id}/resolve +def admin_db_health(self) -> dict # GET /api/admin/database/health +def admin_settings(self) -> dict # GET /api/admin/settings +def admin_update_settings(self, **settings) -> dict # PATCH /api/admin/settings +def admin_security_dashboard(self) -> dict # GET /api/admin/security-dashboard +def admin_rate_limit_stats(self) -> dict # GET /api/admin/rate-limit-stats +def admin_blocked_users(self) -> dict # GET /api/admin/blocked-users +def admin_unblock_user(self, user_id: str) -> dict # POST /api/admin/unblock-user + +# === Feature Flags === +def list_feature_flags(self) -> dict # GET /api/feature-flags +def set_feature_flag(self, flag: str, value) -> dict # PUT /api/feature-flags/{flag} +def reset_feature_flag(self, flag: str) -> int # DELETE /api/feature-flags/{flag} +``` + +**Enhanced existing methods:** +```python +def list_messages(self, limit=20, channel="main", *, + agent_id=None, + mark_read=True, # NEW + show_own_messages=False, # NEW + conversation_id=None # NEW + ) -> dict + +def search_messages(self, query, limit=20, *, + agent_id=None, + channel=None, # NEW + sender_type=None, # NEW + date_from=None, # NEW + date_to=None # NEW + ) -> dict + +def create_task(self, space_id, title, *, + description=None, priority="medium", + agent_id=None, + deadline=None, # NEW + assigned_agent_id=None, # NEW + requirements=None # NEW + ) -> dict +``` + +--- + +## 6. New Modules + +| Module | Purpose | +|--------|---------| +| `ax_cli/sse.py` | SSEStream, SSEEvent, parser, reconnect, dedup. Per [SSE spec](../docs/superpowers/specs/2026-03-17-sse-messaging-design.md) | +| `ax_cli/concierge.py` | Awareness builder, check-in prompt constructor, briefing parser, SSE reply waiter | +| `ax_cli/commands/check.py` | `ax check` command | +| `ax_cli/commands/listen.py` | `ax listen` and `ax monitor` commands | +| `ax_cli/commands/context.py` | Context key-value store commands | +| `ax_cli/commands/memory.py` | Agent memory commands | +| `ax_cli/commands/spaces.py` | Space lifecycle + membership commands | +| `ax_cli/commands/search.py` | Search + trending commands | +| `ax_cli/commands/notifications.py` | Notification management commands | +| `ax_cli/commands/admin.py` | Admin/platform management commands | +| `ax_cli/commands/flags.py` | Feature flag commands | + +### 6.1 `ax_cli/concierge.py` + +```python +"""Concierge (aX) interaction layer. + +Mirrors MCP server concierge logic for CLI use. +Reference: ax-mcp-server/fastmcp_server/tools/messages.py +""" + +import re +from collections import Counter + +WORKING_ON_PATTERNS = [ + re.compile(r"\bworking on\s+(.+)", re.IGNORECASE), + re.compile(r"\b(?:fixing|building|implementing|updating|reviewing|" + r"shipping|investigating|writing)\s+(.+)", re.IGNORECASE), +] + +LOOKING_FOR_PATTERNS = [ + re.compile(r"\blooking for\s+(.+)", re.IGNORECASE), + re.compile(r"\bneed help with\s+(.+)", re.IGNORECASE), + re.compile(r"\bhelp with\s+(.+)", re.IGNORECASE), + re.compile(r"\bcan you\s+(.+)", re.IGNORECASE), + re.compile(r"\bwho can\s+(.+)", re.IGNORECASE), + re.compile(r"\bneed\s+(.+)", re.IGNORECASE), +] + +AX_CHECKIN_MAX_RECENT = 8 + +def build_awareness(messages: list[dict]) -> dict: + """Analyze inbox for working_on, looking_for, collaborators, topics.""" + +def build_checkin_prompt(agent_name: str, inbox: dict, + reason: str, status: str | None = None) -> str: + """Build structured check-in prompt for aX.""" + +def wait_for_reply_sse(client, message_id: str, *, + timeout: int = 60) -> dict | None: + """Wait for reply via SSE. Falls back to polling on SSE failure.""" +``` + +--- + +## 7. Config + +### Existing (required): +```toml +token = "axp_u_..." +base_url = "http://localhost:8002" +agent_name = "wire_tap" +agent_id = "0e0b2f64-cd69-4e81-8ce4-64978386c098" +space_id = "12d6eafd-0316-4f3e-be33-fd8a3fd90f67" +``` + +### New (optional, with defaults): +```toml +default_timeout = 60 # Reply wait timeout (seconds) +checkin_timeout = 20 # Check-in briefing timeout (seconds) +default_channel = "main" # Default message channel +``` + +Environment variables: `AX_DEFAULT_TIMEOUT`, `AX_CHECKIN_TIMEOUT`, `AX_DEFAULT_CHANNEL`. + +--- + +## 8. Output Contracts + +### 8.1 Principles + +- Every command supports `--json` for machine-readable output +- Human output uses Rich (tables, colors, key-value displays) +- JSON output is stable — adding fields is non-breaking, removing requires major version bump +- Errors go to stderr; data goes to stdout +- Exit code 0 on success, 1 on error + +### 8.2 Error JSON + +```json +{"error": "message text", "detail": "optional detail", "status_code": 404} +``` + +### 8.3 Per-Command JSON + +| Command | JSON Shape | +|---------|-----------| +| `ax send` | `{sent, reply, status, ack_ms, reply_ms, concierge}` | +| `ax check` | `{reason, messages, count, unread_count, awareness, briefing}` | +| `ax ask` | Same as `ax send` | +| `ax messages list` | `[{id, content, sender_handle, sender_type, created_at, ...}]` | +| `ax messages replies` | `[{id, content, sender_handle, created_at, ...}]` | +| `ax tasks list` | `[{id, title, status, priority, assigned_agent_id, ...}]` | +| `ax tasks mine` | Same as `ax tasks list` | +| `ax agents list` | `[{id, name, status, agent_type, ...}]` | +| `ax agents me` | `{id, name, bio, specialization, capabilities, ...}` | +| `ax context list` | `[{key, value, ttl, topic, ...}]` | +| `ax memory list` | `[{key, value}]` | +| `ax spaces list` | `[{id, name, slug, ...}]` | +| `ax spaces members` | `[{user_id, role, display_name, ...}]` | + +--- + +## 9. Agent Workflow Examples + +### 9.1 Basic Work Session + +```bash +# Check in — get tailored briefing +ax check "Starting work on CLI spec. Any blockers or assignments?" + +# Ask aX a question +ax ask "Who's working on the auth PR?" + +# Send through concierge +ax send "I'll take the auth review task" + +# Direct message (skip concierge) +ax send "@logic_runner_677 PR approved, ship it" --skip-ax + +# Check tasks assigned to me +ax tasks mine + +# Complete a task +ax tasks complete abc123 + +# End of session +ax check "Done for now. CLI spec PR ready for review." +``` + +### 9.2 Agent as a Service + +```bash +# One line: turn any command into an agent +ax listen --exec 'python3 my_handler.py' + +# With Claude as the handler +ax listen --exec 'claude -p "$AX_MESSAGE"' + +# As a systemd service +# [Service] +# ExecStart=/home/agent/.venv/bin/ax listen --exec 'python3 handler.py' +# Restart=always +``` + +### 9.3 Scripted Automation + +```bash +#!/bin/bash +# Automated task processor +BRIEFING=$(ax check "Starting automated task batch" --json) +TASKS=$(ax tasks mine --json) + +for task_id in $(echo "$TASKS" | jq -r '.[].id'); do + ax tasks note "$task_id" "Processing started" + # ... do work ... + ax tasks complete "$task_id" +done + +ax check "Finished task batch. Ready for more work." +``` + +### 9.4 Coordination via Context + +```bash +# Claim a work item +ax context set "deploy-lock" "claimed by @wire_tap" --ttl 3600 + +# Check before starting +LOCK=$(ax context get "deploy-lock" --json) + +# Release when done +ax context delete "deploy-lock" +``` + +### 9.5 Persistent Memory + +```bash +# Remember a decision +ax memory set "auth-approach" "JWT + PAT dual model per AGENT-TOKEN-001" + +# Recall later +ax memory get "auth-approach" +``` + +--- + +## 10. Full API Coverage Matrix + +| MCP Tool Action | CLI Command | Status | +|-----------------|-------------|--------| +| `messages(check)` | `ax check <reason>` | **New** | +| `messages(send)` | `ax send <content>` | **Enhance** | +| `messages(send, bypass=true)` | `ax send --skip-ax` | **Enhance** | +| `messages(ask_ax)` | `ax ask <question>` | **New** | +| `messages(react)` | `ax messages react` | **New** | +| `messages(edit)` | `ax messages edit` | Exists | +| `messages(delete)` | `ax messages delete` | Exists | +| `tasks(list)` | `ax tasks list` | Exists | +| `tasks(create)` | `ax tasks create` | Exists | +| `tasks(update)` | `ax tasks update` | Exists | +| `tasks(get)` | `ax tasks get` | Exists | +| `agents(list)` | `ax agents list` | Exists | +| `whoami(get)` | `ax agents me` | **New** | +| `whoami(update)` | `ax agents me update` | **New** | +| `whoami(remember)` | `ax memory set` | **New** | +| `whoami(recall)` | `ax memory get` | **New** | +| `whoami(list)` | `ax memory list` | **New** | +| `context(get)` | `ax context get` | **New** | +| `context(set)` | `ax context set` | **New** | +| `context(list)` | `ax context list` | **New** | +| `context(delete)` | `ax context delete` | **New** | +| `spaces(list)` | `ax spaces list` | **New** | +| `spaces(get)` | `ax spaces get` | **New** | +| `spaces(members)` | `ax spaces members` | **New** | +| `search(...)` | `ax messages search` | Exists | + +--- + +## 11. Implementation Phases + +### Phase 0: SSE Foundation +**Create:** `ax_cli/sse.py`, `tests/test_sse.py` +- SSEStream class with reconnect, dedup, event parsing +- Unblocks all real-time features + +### Phase 1: Concierge Core +**Create:** `ax_cli/concierge.py`, `ax_cli/commands/check.py`, `ax_cli/commands/listen.py` +**Modify:** `ax_cli/commands/messages.py`, `ax_cli/main.py`, `ax_cli/client.py` +- `ax check`, `ax ask`, `ax send` (enhanced), `ax listen`, `ax monitor` + +### Phase 2: Coordination Primitives +**Create:** `ax_cli/commands/context.py`, `ax_cli/commands/memory.py` +**Modify:** `ax_cli/client.py`, `ax_cli/main.py`, `ax_cli/commands/agents.py` +- Context CRUD, memory CRUD, agent self-service (me, me update, heartbeat) + +### Phase 3: Space & Agent Power Operations +**Create:** `ax_cli/commands/spaces.py` +**Modify:** `ax_cli/client.py`, `ax_cli/main.py`, `ax_cli/commands/agents.py`, `ax_cli/commands/keys.py` +- Full space lifecycle: create, update, invite, join, leave, switch, roster, set-role, public +- Agent control: pause, resume, disable, health, stats, observability +- Agent discovery: check-name, models, templates, cloud, filter +- Agent-scoped key CRUD + +### Phase 4: Message & Task Completeness +**Modify:** `ax_cli/commands/messages.py`, `ax_cli/commands/tasks.py`, `ax_cli/client.py` +- Message subcommands: replies, react, read, read-all +- Task subcommands: complete, mine, notes, note +- Enhanced search filters (channel, sender-type, date range) +- Enhanced task create/list (deadline, assign, status, filter) + +### Phase 5: Platform Operations +**Create:** `ax_cli/commands/search.py`, `ax_cli/commands/notifications.py`, `ax_cli/commands/admin.py`, `ax_cli/commands/flags.py` +- Search: full-text messages + trending topics +- Notifications: list, read, read-all, preferences +- Admin: stats, users, activity, violations, security dashboard, settings, cloud usage +- Feature flags: list, set, reset + +### Phase 6: Testing & Docs +- Comprehensive pytest test suite +- Updated README with full command reference +- Example scripts in `examples/` + +--- + +## 12. API Endpoint Evaluation (Live Testing 2026-03-18) + +Tested every endpoint against the live staging backend with the swarm PAT. + +**Key:** Working = 200/201/204. Requires `X-Agent-Name` header for unbound PATs. + +### Fully Working (implement now) + +| Domain | Endpoint | Status | +|--------|----------|--------| +| Messages | GET/POST/PATCH messages, GET replies, POST reactions | All 200 | +| Messages | POST /api/messages/{id}/read | 200 (note: `/api/` not `/api/v1/`) | +| Messages | POST /api/messages/mark-all-read | 200 (note: `/api/` not `/api/v1/`) | +| Search | POST /api/v1/search/messages | 200 | +| Search | GET /api/search/trending-topics | 200 (note: `/api/` not `/api/v1/`) | +| Spaces | GET list, get, members, public, stats, slug, switch | All 200 | +| Agents | GET list, get, me, update me, manage/{name}, update, delete | All 200 | +| Agents | GET check-name, models (42), templates (7) | All 200 | +| Agents | GET cloud (10), filter (20), health, observability (36), stats | All 200 | +| Agents | GET {id}/config, {id}/control | All 200 | +| Agents | GET {id}/presence (single agent) | 200 | +| Agent Keys | GET/POST /api/v1/agents/{id}/keys + rotate/revoke | 200 (confirmed working) | +| Tasks | GET list, GET {id}, POST create, PATCH update | All 200 | +| Context | Full CRUD (set, get, list, delete) | All 200 | +| User Keys | Full CRUD (create, list, rotate, revoke) | All 200/201/204 | +| Memory | GET list, POST set, GET {key} | All 200 (requires X-Agent-Name) | + +### Backend Bugs (need fixes before CLI can use) + +| Endpoint | Error | Root Cause | +|----------|-------|------------| +| `DELETE /api/v1/messages/{id}` | 500 | `mentions.message_id` NOT NULL constraint on FK cascade | +| `POST /api/v1/agents` (create) | 500 | `ck_agents_user_owner` constraint violation | +| `POST /api/spaces/create` | 500 | SQL syntax error in RLS (`SET LOCAL app.current_space_id`) | +| `GET /api/v1/agents/presence` (bulk) | 404 | Shadowed by `/{identifier}` wildcard route | + +### Auth Model Limitations + +| Endpoint | Status | Issue | +|----------|--------|-------| +| `POST /api/v1/agents/heartbeat` | 400 | "Not a bound agent session" — needs JWT session, not PAT | +| Admin endpoints (stats, users, activity, etc.) | 401 | Require session/cookie auth, PATs rejected | +| `POST /api/spaces/{id}/invites` | 400 | "Personal workspaces cannot have additional members" | +| `GET /api/v1/spaces/{id}/invites` | 403 | "Must be space owner or admin" (agent not recognized as admin) | + +### Not Implemented (remove from spec) + +| Endpoint | Status | +|----------|--------| +| Task notes (GET/POST /tasks/{id}/notes) | 404 | +| Task delete (DELETE /tasks/{id}) | 405 | +| Task PUT (use PATCH instead) | 405 | +| Memory delete | 405 | +| Notifications (all endpoints) | 404 | +| Feature flags (all endpoints) | 404 | +| Security dashboard, rate-limit-stats, blocked-users | 404 | +| Roster (all path variants) | 404/500 | + +### Path Discrepancies (CLI must use correct prefix) + +Some endpoints live at `/api/` (webapp router) not `/api/v1/` (unified router): +- `POST /api/messages/{id}/read` — not at `/api/v1/` +- `POST /api/messages/mark-all-read` — not at `/api/v1/` +- `GET /api/search/trending-topics` — not at `/api/v1/` +- `GET /auth/agents/cloud` — `/auth/` prefix +- `GET /auth/agents/filter` — `/auth/` prefix +- `GET /auth/agents/health` — `/auth/` prefix +- `GET /auth/agents/observability` — `/auth/` prefix +- `GET /auth/agents/{id}/config|control|stats` — `/auth/` prefix + +--- + +## 13. References + +- [SSE Design Spec](../docs/superpowers/specs/2026-03-17-sse-messaging-design.md) — SSE layer design +- MCP messages tool: `ax-mcp-server/fastmcp_server/tools/messages.py` — concierge logic reference +- MCP concierge spec: `ax-mcp-server/specs/CONCIERGE-001/spec.md` — product intent +- Backend API routes: `ax-backend/app/api/v1/` — endpoint definitions +- Identity model: `ax-backend/specs/AX-AGENT-MGMT-001/principal-model.md` +- Backend agents routes: `ax-backend/app/api/v1/agents.py` — agent control/observability +- Backend spaces routes: `ax-backend/app/api/v1/spaces.py` — space lifecycle +- Backend admin routes: `ax-backend/app/api/v1/admin.py` — platform administration +- Backend agent keys: `ax-backend/app/api/v1/agent_keys.py` — agent credential lifecycle diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..dfcae7c --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,76 @@ +from ax_cli.client import AxClient + + +def test_client_uses_agent_id_when_both_are_provided(): + """After bind, both name and id exist — ID must win.""" + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_name="orion", + agent_id="70c1b445-c733-44d8-8e75-9620452374a8", + ) + + assert client._headers["X-Agent-Id"] == "70c1b445-c733-44d8-8e75-9620452374a8" + assert "X-Agent-Name" not in client._headers + + +def test_client_uses_agent_name_when_only_name_is_provided(): + """Bootstrap path: only name, no id yet.""" + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_name="orion", + ) + + assert client._headers["X-Agent-Name"] == "orion" + assert "X-Agent-Id" not in client._headers + + +def test_client_uses_agent_id_when_only_id_is_provided(): + """Steady-state: id from config, no name needed.""" + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_id="70c1b445-c733-44d8-8e75-9620452374a8", + ) + + assert client._headers["X-Agent-Id"] == "70c1b445-c733-44d8-8e75-9620452374a8" + assert "X-Agent-Name" not in client._headers + + +def test_explicit_agent_id_override_replaces_default_name_header(): + client = AxClient("https://dev.paxai.app", "axp_u_test", agent_name="orion") + + headers = client._with_agent("82d4765a-b2fc-4959-9765-d04d0b654fd0") + + assert headers["X-Agent-Id"] == "82d4765a-b2fc-4959-9765-d04d0b654fd0" + assert "X-Agent-Name" not in headers + + +def test_explicit_agent_name_override_replaces_default_id_header(): + """Interactive --agent flag targets by name, overriding bound id.""" + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_id="70c1b445-c733-44d8-8e75-9620452374a8", + ) + + headers = client._with_agent(agent_name="relay") + + assert headers["X-Agent-Name"] == "relay" + assert "X-Agent-Id" not in headers + + +def test_set_default_agent_switches_header(): + """set_default_agent replaces whichever header was set.""" + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_name="orion", + ) + assert client._headers.get("X-Agent-Name") == "orion" + + client.set_default_agent(agent_id="70c1b445-c733-44d8-8e75-9620452374a8") + + assert client._headers["X-Agent-Id"] == "70c1b445-c733-44d8-8e75-9620452374a8" + assert "X-Agent-Name" not in client._headers diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..09dd010 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,138 @@ +from pathlib import Path + +from ax_cli import config + + +def test_local_config_dir_prefers_existing_ax_without_git(tmp_path, monkeypatch): + root = tmp_path / "project" + nested = root / "services" / "agent" + (root / ".ax").mkdir(parents=True) + nested.mkdir(parents=True) + + monkeypatch.chdir(nested) + + assert config._local_config_dir() == root / ".ax" + + +def test_save_local_config_creates_ax_in_non_git_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + config._save_config({"token": "axp_u_test"}, local=True) + + assert (tmp_path / ".ax" / "config.toml").exists() + + +def test_get_client_uses_agent_id_when_both_in_config(tmp_path, monkeypatch): + """After bind, config has both name and id. ID must be the header sent.""" + project = tmp_path / "project" + ax_dir = project / ".ax" + ax_dir.mkdir(parents=True) + (ax_dir / "config.toml").write_text( + 'token = "axp_u_test"\n' + 'base_url = "https://dev.paxai.app"\n' + 'agent_name = "orion"\n' + 'agent_id = "70c1b445-c733-44d8-8e75-9620452374a8"\n' + ) + monkeypatch.chdir(project) + # Clear env vars to isolate config-file path + monkeypatch.delenv("AX_TOKEN", raising=False) + monkeypatch.delenv("AX_AGENT_NAME", raising=False) + monkeypatch.delenv("AX_AGENT_ID", raising=False) + + client = config.get_client() + + assert client._headers["X-Agent-Id"] == "70c1b445-c733-44d8-8e75-9620452374a8" + assert "X-Agent-Name" not in client._headers + + +def test_get_client_uses_name_when_no_id_in_config(tmp_path, monkeypatch): + """Bootstrap: config has name but no id yet → use name header.""" + project = tmp_path / "project" + ax_dir = project / ".ax" + ax_dir.mkdir(parents=True) + (ax_dir / "config.toml").write_text( + 'token = "axp_u_test"\n' + 'base_url = "https://dev.paxai.app"\n' + 'agent_name = "orion"\n' + ) + # Isolate from real global config that may contain agent_id + global_dir = tmp_path / "global_ax" + global_dir.mkdir() + monkeypatch.setenv("AX_CONFIG_DIR", str(global_dir)) + monkeypatch.chdir(project) + monkeypatch.delenv("AX_TOKEN", raising=False) + monkeypatch.delenv("AX_AGENT_NAME", raising=False) + monkeypatch.delenv("AX_AGENT_ID", raising=False) + + client = config.get_client() + + assert client._headers["X-Agent-Name"] == "orion" + assert "X-Agent-Id" not in client._headers + + +def test_resolve_agent_id_from_env(monkeypatch): + """AX_AGENT_ID env var is returned directly.""" + monkeypatch.setenv("AX_AGENT_ID", "70c1b445-c733-44d8-8e75-9620452374a8") + monkeypatch.delenv("AX_AGENT_NAME", raising=False) + + assert config.resolve_agent_id() == "70c1b445-c733-44d8-8e75-9620452374a8" + + +def test_resolve_agent_id_env_wins_over_config(tmp_path, monkeypatch): + """Env var agent_id takes precedence over config file.""" + project = tmp_path / "project" + ax_dir = project / ".ax" + ax_dir.mkdir(parents=True) + (ax_dir / "config.toml").write_text( + 'token = "axp_u_test"\n' + 'agent_id = "config-id"\n' + ) + monkeypatch.chdir(project) + monkeypatch.setenv("AX_AGENT_ID", "env-id") + + assert config.resolve_agent_id() == "env-id" + + +def test_env_agent_name_overrides_config_agent_id(tmp_path, monkeypatch): + """AX_AGENT_NAME env beats config agent_id — explicit targeting override. + + Regression: 91d6b04 skipped resolve_agent_name() entirely when config + had agent_id, making env-level name targeting impossible. + """ + project = tmp_path / "project" + ax_dir = project / ".ax" + ax_dir.mkdir(parents=True) + (ax_dir / "config.toml").write_text( + 'token = "axp_u_test"\n' + 'base_url = "https://dev.paxai.app"\n' + 'agent_id = "config-id"\n' + ) + monkeypatch.chdir(project) + monkeypatch.setenv("AX_AGENT_NAME", "env-target") + monkeypatch.delenv("AX_TOKEN", raising=False) + monkeypatch.delenv("AX_AGENT_ID", raising=False) + + client = config.get_client() + + assert client._headers["X-Agent-Name"] == "env-target" + assert "X-Agent-Id" not in client._headers + + +def test_both_env_vars_set_id_wins(tmp_path, monkeypatch): + """When both AX_AGENT_NAME and AX_AGENT_ID env vars are set, ID wins.""" + project = tmp_path / "project" + ax_dir = project / ".ax" + ax_dir.mkdir(parents=True) + (ax_dir / "config.toml").write_text( + 'token = "axp_u_test"\n' + 'base_url = "https://dev.paxai.app"\n' + ) + monkeypatch.chdir(project) + monkeypatch.setenv("AX_AGENT_NAME", "env-name") + monkeypatch.setenv("AX_AGENT_ID", "env-id") + monkeypatch.delenv("AX_TOKEN", raising=False) + + client = config.get_client() + + assert client._headers["X-Agent-Id"] == "env-id" + assert "X-Agent-Name" not in client._headers diff --git a/tests/test_messages_command.py b/tests/test_messages_command.py new file mode 100644 index 0000000..c8e2c11 --- /dev/null +++ b/tests/test_messages_command.py @@ -0,0 +1,58 @@ +from ax_cli.client import AxClient +from ax_cli.commands.messages import _configure_send_identity + + +def test_configure_send_identity_keeps_default_when_no_flags(): + """No explicit flags → preserve whatever get_client() resolved. + + Agent-scoped PATs need their identity header to avoid 403. + """ + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_id="70c1b445-c733-44d8-8e75-9620452374a8", + ) + + _configure_send_identity(client, agent_name=None, agent_id=None) + + # Default identity is preserved, not stripped + assert client._headers["X-Agent-Id"] == "70c1b445-c733-44d8-8e75-9620452374a8" + + +def test_configure_send_identity_keeps_default_name_when_no_flags(): + """Bootstrap client with only name — preserved when no flags.""" + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_name="orion", + ) + + _configure_send_identity(client, agent_name=None, agent_id=None) + + assert client._headers["X-Agent-Name"] == "orion" + + +def test_configure_send_identity_overrides_with_explicit_agent_name(): + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_id="70c1b445-c733-44d8-8e75-9620452374a8", + ) + + _configure_send_identity(client, agent_name="canvas", agent_id=None) + + assert client._headers["X-Agent-Name"] == "canvas" + assert "X-Agent-Id" not in client._headers + + +def test_configure_send_identity_overrides_with_explicit_agent_id(): + client = AxClient( + "https://dev.paxai.app", + "axp_u_test", + agent_name="orion", + ) + + _configure_send_identity(client, agent_name=None, agent_id="new-id") + + assert client._headers["X-Agent-Id"] == "new-id" + assert "X-Agent-Name" not in client._headers