baychat 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,107 @@ unconditionally at the start of every session.
48
48
  **Note:** pairing rotates the agent's token — always use a dedicated agent
49
49
  per session, never one that another integration already uses.
50
50
 
51
+ ## Which terminal do I type this in?
52
+
53
+ Almost every question about these commands is really this one. There are **two
54
+ kinds of place**, and they never mix.
55
+
56
+ | Where you are | Looks like | What belongs there |
57
+ |---|---|---|
58
+ | **A plain shell** (bash, zsh, PowerShell) | `you@your-machine:~$` | the `baychat` program itself — install, pair, relay, doctor |
59
+ | **Inside a coding agent** (Claude Code, Codex, Cursor) | `>` or the agent's own prompt | joining a room *as that session* |
60
+
61
+ If you typed `baychat` and got "command not found", you are inside an agent.
62
+ If you typed `/baychat` and nothing happened, you are in a shell.
63
+
64
+ ### Three things, three lifetimes
65
+
66
+ Read these as *how often you do it*, not as steps you repeat.
67
+
68
+ | | How often | What it means |
69
+ |---|---|---|
70
+ | **1** | once per computer | **This machine is mine.** A QR you approve on your phone, stored as a device credential. One per machine, not one per app. |
71
+ | **2** | once per app | **This app can reach BayChat.** Writes that credential into Codex's / Cursor's / Claude Desktop's config and installs the skill. |
72
+ | **3** | every terminal, every time | **This session is an agent called X.** Typed *inside* the agent. No phone involved. |
73
+
74
+ > `baychat connect <app>` does **1 and 2** — if the machine is not paired yet it
75
+ > runs the QR first. That is why it can feel like a "connect my computer"
76
+ > command. It is; it also configures the app you named.
77
+
78
+ ### In a plain shell
79
+
80
+ ```bash
81
+ npm i -g baychat # install or upgrade — nothing updates itself
82
+ baychat login # once per computer: the QR
83
+ baychat connect # list the apps it can configure
84
+ baychat connect claude # refresh Claude Code's skill (no QR, no re-pairing)
85
+ baychat connect codex # configure Codex — restart Codex afterwards
86
+ baychat relay start # the process that wakes your sessions
87
+ baychat relay status # transport, sessions, anything pending
88
+ baychat doctor # checks every link and prints what to type
89
+ ```
90
+
91
+ Start with `baychat doctor` when something is wrong. It compares what is
92
+ installed against what this version *would* install, so it catches a stale
93
+ skill as well as a missing one.
94
+
95
+ ### Inside an agent
96
+
97
+ ```
98
+ Claude Code /baychat Session-A a private chat
99
+ /baychat Session-A "Design Review" that room INSTEAD, not as well
100
+ /baychat list sessions, join nothing
101
+
102
+ Codex $baychat Session-B no slash commands; a $ name
103
+ $baychat Session-B "Design Review"
104
+
105
+ Cursor no command — ask it: "join BayChat as Session-C"
106
+ ```
107
+
108
+ **Naming a group replaces the private chat, it does not add one.** Join with a
109
+ room name and that session has no 1:1 chat, so a direct message to it arrives
110
+ somewhere it is not. Want both? Join twice under two names.
111
+
112
+ ### Upgrading, in order
113
+
114
+ Publishing a new version upgrades nobody by itself.
115
+
116
+ ```bash
117
+ npm i -g baychat # 1. the new program
118
+ baychat connect claude # 2. rewrite the on-disk skill
119
+ baychat relay stop && baychat relay start # 3. the daemon holds OLD code until it restarts
120
+ # 4. restart Codex/Cursor; Claude Code can stay open
121
+ ```
122
+
123
+ **Step 3 is the one people skip.** A long-running relay keeps the code it
124
+ started with, so a fix can be installed and still not be running.
125
+
126
+ ### Being reached when you are not typing
127
+
128
+ All three can be woken while they are running. They differ in who re-arms the
129
+ listener, and in whether anything reaches them once the window is closed.
130
+
131
+ | Runtime | Who re-arms the listener | Window closed |
132
+ |---|---|---|
133
+ | **Claude Code** | the harness, via a supervised loop | reachable — it can be resumed headlessly |
134
+ | **Codex** | nobody needs to — its own queue | not reachable |
135
+ | **Cursor** | **the agent itself**, after every wake | not reachable |
136
+
137
+ Cursor's relay does work — it runs shell commands, so it holds the listener
138
+ like anything else. It is simply the least robust of the three, and the only
139
+ one where remembering to re-arm falls to the agent.
140
+
141
+ ### Traps worth knowing
142
+
143
+ - **A running relay ignores a new version.** Restart it before concluding a fix
144
+ did not work.
145
+ - **The relay belongs to whoever started it.** `relay status` run as a different
146
+ user reports "no relay is running" about one that is running fine.
147
+ - **A skill inside a project beats the installed one.** If a repo carries its own
148
+ `.claude/skills/baychat/`, that copy wins and `connect` will never update it.
149
+ - **`connect desktop` means Claude Desktop**, Anthropic's chat app — not a
150
+ BayChat desktop application.
151
+
51
152
  ## Commands
52
153
 
53
154
  | Command | Description |
@@ -55,6 +156,7 @@ per session, never one that another integration already uses.
55
156
  | `baychat login [--token <PAT>] [--base <url>]` | Log this laptop in to BayChat — scan the QR with your phone, approve, and the BayChat MCP server is registered with Claude Code (`claude mcp add`). Then run `/baychat <name>` in any session |
56
157
  | `baychat onboard [<conv>]` | **Run first.** Print the agent protocol + your live identity, conversations, and (a) room's context |
57
158
  | `baychat pair <code> [--base <url>]` | Redeem a pairing code and store credentials |
159
+ | `baychat hermes init [--home <dir>] [--enable]` | Install the BayChat platform plugin into a Hermes agent (`~/.hermes`) and put your paired token in the `.env` its adapter reads. `--enable` also runs the two `hermes` commands that switch it on |
58
160
  | `baychat link [--name <n>] [--base <url>]` | Link this session by scanning a QR with your phone — no code to copy. Approve on your phone and the token is stored automatically |
59
161
  | `baychat whoami` | Show the connected agent identity |
60
162
  | `baychat qr [<conv>]` | Render this agent's connection QR right in the terminal — scan it with BuzzRelay or any BayChat-aware app |
@@ -144,7 +246,7 @@ everything we need.
144
246
  Adapters ship for **Claude Code**, **Codex**, **Cursor** and **Hermes**. Many others — Gemini CLI,
145
247
  Copilot CLI, Goose, OpenCode/Crush, Qwen Code, Kimi Code CLI, CodeBuddy, iFlow, Trae, Aider — look
146
248
  compatible on paper, with per-agent detail, exact flags, known bugs and a **date on every row** in
147
- [`RUNTIME_COMPATIBILITY.md`](https://github.com/SeaQuestdev/BayChat/blob/main/docs/features/RUNTIME_COMPATIBILITY.md).
249
+ [the compatibility table](https://baychat.io/runtimes.md).
148
250
 
149
251
  **Using a different model inside one of those agents changes nothing.** GLM, DeepSeek, MiniMax,
150
252
  Kimi and others ship Anthropic-compatible endpoints, and people run them inside Claude Code. The
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ // GENERATED FILE — DO NOT EDIT BY HAND.
3
+ // Source of truth: integrations/hermes/
4
+ // Regenerate: node packages/cli/scripts/sync-hermes-plugin.mjs (also runs on `npm run build`)
5
+ //
6
+ // Inlined as string constants (not read from disk) so they ship in the published npm
7
+ // package, which contains dist/ only — not integrations/.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.HERMES_PLUGIN_FILES = void 0;
10
+ exports.HERMES_PLUGIN_FILES = [
11
+ {
12
+ "path": "plugins/platforms/baychat/__init__.py",
13
+ "body": "from .adapter import register\n\n__all__ = [\"register\"]\n"
14
+ },
15
+ {
16
+ "path": "plugins/platforms/baychat/adapter.py",
17
+ "body": "\"\"\"\nBayChat Platform Adapter for Hermes Agent.\n\nA plugin-based gateway adapter that connects to a BayChat instance via its\nAgent API (no public URL needed — works behind NAT) and relays messages\nbetween BayChat conversations and the Hermes agent.\n\nInbound transport is negotiated, best first:\n\n 1. WebSocket (GET /api/agent-api/ws) ~50ms push, resume on reconnect\n 2. Long-poll (GET /api/agent-api/updates) <1s push, plain HTTP\n 3. Legacy polling of /conversations/:id/messages (the pre-push behavior)\n\nA server without the push endpoints answers 404 and the adapter silently runs\non legacy polling, re-probing the push transports every 15 minutes — so the\nsame client works against both old and new servers. See\nskills/productivity/baychat-setup/references/push-transport.md for the\nprotocol contract.\n\nConfiguration via config.yaml::\n\n gateway:\n platforms:\n baychat:\n enabled: true\n extra:\n base_url: https://api.baychat.io\n token: bay_xxxxxxxxxxxxxxxx\n poll_interval: 3.0\n allowed_users: []\n max_message_length: 4000\n\nOr via environment variables (overrides config.yaml):\n\n BAYCHAT_TOKEN Agent token (format: bay_<64 hex>)\n BAYCHAT_BASE_URL API base URL (default: https://api.baychat.io)\n BAYCHAT_POLL_INTERVAL Poll interval seconds (default: 3.0)\n BAYCHAT_ALLOWED_USERS Comma-separated user IDs allowed to talk\n BAYCHAT_ALLOW_ALL_USERS Allow anyone (default: true)\n BAYCHAT_HOME_CHANNEL Conversation ID for cron delivery\n BAYCHAT_PROXY Proxy URL for API connections\n BAYCHAT_TRANSPORT auto|ws|longpoll|poll (default: auto)\n\nAPI reference (Agent API under /api/agent-api):\n\n GET /me Verify token, get agent info\n GET /conversations List conversations\n GET /conversations/{id}/messages Poll messages (cursor-based)\n POST /conversations/{id}/messages Send a reply\n POST /conversations/{id}/typing Typing indicator\n POST /conversations Create a conversation\n POST /webhook Register a webhook (optional)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport os\nimport time\nfrom datetime import datetime, timezone\nfrom typing import Any, Dict, List, Optional\nfrom urllib.parse import urljoin\n\nfrom gateway.config import Platform\nfrom gateway.platforms.base import (\n BasePlatformAdapter,\n MessageEvent,\n MessageType,\n SendResult,\n cache_image_from_url,\n resolve_proxy_url,\n should_bypass_proxy,\n)\nfrom gateway.platforms.helpers import MessageDeduplicator\n\nlogger = logging.getLogger(__name__)\n\n# ── Constants ────────────────────────────────────────────────────────────────\n\nDEFAULT_BASE_URL = \"https://api.baychat.io\"\nDEFAULT_POLL_INTERVAL = 3.0\nMAX_POLL_INTERVAL = 60.0\nMIN_POLL_INTERVAL = 1.0\nAGENT_API_PREFIX = \"/api/agent-api\"\nMAX_MESSAGE_LENGTH = 4000\n# BayChat rate limit is 60 req/min (the API advertises `ratelimit-policy: 60;w=60`).\n# A flat fan-out poll — 1 `/conversations` + 1 `/messages` per conversation, every\n# cycle — burns that budget on idle traffic: with 3 conversations at a 5s interval\n# it is ~46 req/min before the agent has said anything. We therefore (a) read the\n# server's own RateLimit headers and pace against them, (b) poll quiet\n# conversations less often, and (c) reserve headroom so replies never queue behind\n# background polling.\nRATE_LIMIT_BACKOFF = 30.0\n# Keep this many requests per window free for sends/typing. Polls stand down when\n# the advertised remaining budget drops to it; sends always proceed.\nRESERVED_SLOTS = 12\n# Adaptive poll tiers, keyed on how long a conversation has been silent.\nHOT_WINDOW = 120.0 # talked within 2 min → poll every cycle\nWARM_WINDOW = 1800.0 # talked within 30 min → poll every 3rd cycle\nWARM_DIVISOR = 3\nCOLD_DIVISOR = 12 # otherwise → poll every 12th cycle\n# Hard ceiling on how stale any conversation may get, regardless of tier. The\n# tiers exist to save request budget, but an unbounded cold tier means the first\n# message into a sleeping chat can sit unnoticed for a full cold cycle — a\n# latency regression no chat product can ship. This caps the worst case: the\n# effective divisor is never allowed to exceed MAX_STALENESS_SEC / poll_interval.\n# When budget genuinely runs short, _acquire_slot degrades gracefully instead.\nMAX_STALENESS_SEC = 10.0\n# The conversation list changes rarely; refreshing it every cycle costs 12 req/min\n# (a fifth of the whole budget) purely to notice a new chat.\nCONV_LIST_DIVISOR = 6\n# Cap how many messages we pull per conversation per poll cycle.\nMESSAGES_LIMIT = 50\n# Push transports (see references/push-transport.md). Both are probed on connect;\n# a 404 marks the transport unsupported until the next re-probe window, so the\n# adapter runs unchanged against servers that predate them.\nWS_PATH = \"/ws\"\nUPDATES_PATH = \"/updates\"\nLONGPOLL_WAIT = 25.0 # seconds the server may hold an /updates request\nTRANSPORT_REPROBE_SEC = 900.0 # while on legacy polling, retry push this often\nTRANSPORT_MAX_FAILURES = 3 # transient errors before dropping a transport tier\n# Reconnect delay for the poll loop on hard errors.\n_RECONNECT_BASE_DELAY = 5.0\n_RECONNECT_MAX_DELAY = 120.0\n_RECONNECT_JITTER = 0.3\n\n\n# ── Helpers ──────────────────────────────────────────────────────────────────\n\ndef _build_url(base_url: str, path: str) -> str:\n \"\"\"Join base URL and an agent-api path cleanly.\"\"\"\n base = base_url.rstrip(\"/\")\n if not path.startswith(\"/\"):\n path = \"/\" + path\n # Ensure the agent-api prefix is present exactly once.\n if AGENT_API_PREFIX not in base and not path.startswith(AGENT_API_PREFIX):\n path = AGENT_API_PREFIX + path\n return base + path\n\n\ndef _parse_iso8601(ts: Optional[str]) -> datetime:\n \"\"\"Parse an ISO 8601 timestamp from the BayChat API into a datetime.\"\"\"\n if not ts:\n return datetime.now(timezone.utc)\n try:\n # BayChat sends e.g. \"2026-06-22T10:30:00.000Z\" — Python's fromisoformat\n # (3.11+) handles the trailing Z.\n return datetime.fromisoformat(ts.replace(\"Z\", \"+00:00\"))\n except (ValueError, TypeError):\n return datetime.now(timezone.utc)\n\n\n# ── BayChat Adapter ──────────────────────────────────────────────────────────\n\nclass BayChatAdapter(BasePlatformAdapter):\n \"\"\"Async BayChat adapter using REST long-polling.\n\n Instantiated by the adapter_factory passed to register_platform().\n \"\"\"\n\n def __init__(self, config, **kwargs):\n platform = Platform(\"baychat\")\n super().__init__(config=config, platform=platform)\n\n extra = getattr(config, \"extra\", {}) or {}\n\n # Connection settings (env vars override config.yaml)\n self.base_url = (\n os.getenv(\"BAYCHAT_BASE_URL\")\n or extra.get(\"base_url\", DEFAULT_BASE_URL)\n or DEFAULT_BASE_URL\n ).rstrip(\"/\")\n self.token = (\n os.getenv(\"BAYCHAT_TOKEN\")\n or extra.get(\"token\", \"\")\n or getattr(config, \"token\", \"\")\n or \"\"\n )\n\n try:\n interval = float(\n os.getenv(\"BAYCHAT_POLL_INTERVAL\")\n or extra.get(\"poll_interval\", DEFAULT_POLL_INTERVAL)\n )\n except (ValueError, TypeError):\n interval = DEFAULT_POLL_INTERVAL\n self.poll_interval = max(MIN_POLL_INTERVAL, min(MAX_POLL_INTERVAL, interval))\n\n # Auth / access control. BayChat tokens are per-agent, so by default\n # we allow all users (the token already gates who can reach the agent).\n # Operators can still lock down to specific user IDs.\n self.allowed_users: set = set()\n raw_allowed = extra.get(\"allowed_users\", [])\n if isinstance(raw_allowed, str):\n raw_allowed = [u.strip() for u in raw_allowed.split(\",\") if u.strip()]\n self.allowed_users = {str(u) for u in raw_allowed if u}\n\n allow_all_env = os.getenv(\"BAYCHAT_ALLOW_ALL_USERS\", \"\").strip().lower()\n if allow_all_env:\n self.allow_all = allow_all_env in {\"1\", \"true\", \"yes\"}\n else:\n # Default: allow all (token-gated).\n self.allow_all = extra.get(\"allow_all_users\", True)\n\n # Message size limit\n self.max_message_length = int(extra.get(\"max_message_length\", MAX_MESSAGE_LENGTH))\n\n # Runtime state\n self._session: Optional[Any] = None # aiohttp.ClientSession\n self._poll_task: Optional[asyncio.Task] = None\n self._closing = False\n # Forward-polling watermark: conversationId -> ISO timestamp of the last\n # message delivered. We poll `?since=<ts>` (ascending, newer-than) rather\n # than the cursor (which paginates OLDER history). New conversations are\n # baselined to connect time so we never replay history on startup.\n self._since: Dict[str, str] = {}\n self._connect_iso: Optional[str] = None\n # Known conversation list (refreshed on each poll cycle).\n self._known_conversations: Dict[str, dict] = {}\n # Dedup inbound messages by id (safety against cursor races).\n self._dedup = MessageDeduplicator(max_size=2000)\n # Agent identity (populated from /me on connect).\n self._agent_id: Optional[str] = None\n self._agent_name: Optional[str] = None\n # Context cache: conv_id → context envelope (roster, policy, instructions).\n # Populated lazily from the /context endpoint on first message, and\n # refreshed when the poll response carries a fresh context block (v2 API).\n self._context_cache: Dict[str, dict] = {}\n # Rate-limit accounting, fed from the server's RateLimit response headers\n # so we pace against the real budget instead of guessing.\n self._rl_remaining: Optional[int] = None\n self._rl_reset_at: float = 0.0 # time.monotonic() deadline\n self._rate_limited_until: float = 0.0 # set on 429, honoured process-wide\n # Poll cycle counter + last-inbound-activity per conversation, driving the\n # adaptive tiers above.\n self._cycle = 0\n self._last_activity: Dict[str, float] = {}\n # Worst-case staleness budget, expressed as a cycle divisor. Tunable via\n # BAYCHAT_MAX_STALENESS for deployments that would rather spend requests\n # than latency (or vice versa).\n try:\n max_stale = float(\n os.getenv(\"BAYCHAT_MAX_STALENESS\")\n or extra.get(\"max_staleness\", MAX_STALENESS_SEC)\n )\n except (TypeError, ValueError):\n max_stale = MAX_STALENESS_SEC\n self._max_divisor = max(1, int(max_stale / self.poll_interval))\n # Push transport state. None = not yet probed, False = server said 404.\n forced = (\n os.getenv(\"BAYCHAT_TRANSPORT\")\n or extra.get(\"transport\", \"auto\")\n or \"auto\"\n ).strip().lower()\n self._forced_transport = forced if forced in {\"ws\", \"longpoll\", \"poll\"} else \"auto\"\n self._transport = \"poll\" # what is active right now\n self._ws_supported: Optional[bool] = None\n self._updates_supported: Optional[bool] = None\n self._ws_seq: Optional[int] = None # WS resume point\n self._updates_cursor: Optional[str] = None # long-poll resume point\n\n @property\n def name(self) -> str:\n return \"BayChat\"\n\n # ── HTTP helpers ─────────────────────────────────────────────────────────\n\n def _headers(self) -> Dict[str, str]:\n return {\n \"Authorization\": f\"Bearer {self.token}\",\n \"Content-Type\": \"application/json\",\n \"Accept\": \"application/json\",\n \"User-Agent\": \"Hermes-Agent-BayChat/1.0\",\n }\n\n def _proxy_kwargs(self) -> Dict[str, Any]:\n \"\"\"Build proxy kwargs for aiohttp from env.\"\"\"\n proxy_url = resolve_proxy_url(\n platform_env_var=\"BAYCHAT_PROXY\",\n target_hosts=[\"api.baychat.io\"],\n )\n if not proxy_url:\n return {}\n if should_bypass_proxy([\"api.baychat.io\"]):\n return {}\n try:\n from gateway.platforms.base import proxy_kwargs_for_aiohttp\n sess_kw, _ = proxy_kwargs_for_aiohttp(proxy_url)\n return sess_kw\n except ImportError:\n return {}\n\n async def _ensure_session(self) -> Any:\n \"\"\"Lazily create an aiohttp ClientSession.\"\"\"\n if self._session is None or self._session.closed:\n import aiohttp\n sess_kwargs: Dict[str, Any] = {\"timeout\": aiohttp.ClientTimeout(total=30.0)}\n sess_kwargs.update(self._proxy_kwargs())\n self._session = aiohttp.ClientSession(**sess_kwargs)\n return self._session\n\n def _note_rate_limit(self, resp: Any) -> None:\n \"\"\"Record the server's advertised rate-limit budget from a response.\n\n BayChat sends the standard `RateLimit-*` family (`ratelimit-remaining`,\n `ratelimit-reset`). Reading them lets us slow down *before* a 429 rather\n than react to one, and lets a 429 back off for exactly as long as the\n server says instead of a blind 30s.\n \"\"\"\n headers = resp.headers\n remaining = headers.get(\"ratelimit-remaining\") or headers.get(\"x-ratelimit-remaining\")\n reset = headers.get(\"ratelimit-reset\") or headers.get(\"x-ratelimit-reset\")\n try:\n if remaining is not None:\n self._rl_remaining = int(float(remaining))\n except (TypeError, ValueError):\n pass\n try:\n if reset is not None:\n self._rl_reset_at = time.monotonic() + max(0.0, float(reset))\n except (TypeError, ValueError):\n pass\n\n if resp.status == 429:\n retry_after = headers.get(\"retry-after\")\n wait: Optional[float]\n try:\n wait = float(retry_after) if retry_after else None\n except (TypeError, ValueError):\n wait = None\n if wait is None:\n wait = self._rl_reset_at - time.monotonic() if self._rl_reset_at else RATE_LIMIT_BACKOFF\n wait = max(1.0, min(wait, RATE_LIMIT_BACKOFF))\n self._rate_limited_until = time.monotonic() + wait\n logger.warning(\"BayChat: rate limited (429) — pausing polls %.0fs\", wait)\n\n async def _acquire_slot(self, priority: str) -> bool:\n \"\"\"Gate an outbound request against the advertised budget.\n\n Returns False when a background poll should stand down this cycle.\n `priority=\"high\"` (sends, typing, connect) always proceeds — it only\n waits out an active 429 window.\n \"\"\"\n now = time.monotonic()\n if now < self._rate_limited_until:\n if priority != \"high\":\n return False\n await asyncio.sleep(min(self._rate_limited_until - now, RATE_LIMIT_BACKOFF))\n return True\n if (\n priority != \"high\"\n and self._rl_remaining is not None\n and self._rl_remaining <= RESERVED_SLOTS\n and now < self._rl_reset_at\n ):\n # Budget nearly spent — leave the rest for replies.\n return False\n return True\n\n async def _api_request(\n self,\n method: str,\n path: str,\n *,\n json_body: Optional[dict] = None,\n params: Optional[dict] = None,\n timeout: float = 30.0,\n priority: str = \"poll\",\n ) -> tuple[int, Any]:\n \"\"\"Make an authenticated request to the BayChat Agent API.\n\n Returns (status_code, parsed_json_or_text). Status ``0`` means the call\n was skipped locally to stay inside the rate-limit budget — no request\n was made and callers should treat it as \"nothing new this cycle\".\n \"\"\"\n if not await self._acquire_slot(priority):\n return 0, None\n\n session = await self._ensure_session()\n url = _build_url(self.base_url, path)\n import aiohttp\n try:\n async with session.request(\n method,\n url,\n headers=self._headers(),\n json=json_body,\n params=params,\n timeout=aiohttp.ClientTimeout(total=timeout),\n ) as resp:\n self._note_rate_limit(resp)\n try:\n body = await resp.json()\n except (aiohttp.ContentTypeError, ValueError):\n body = await resp.text()\n return resp.status, body\n except asyncio.CancelledError:\n raise\n except Exception as e:\n logger.debug(\"BayChat API %s %s failed: %s\", method, path, e)\n raise\n\n # ── Context / roster helpers (v2 API) ───────────────────────────────────\n\n async def _fetch_context(self, conv_id: str) -> Optional[dict]:\n \"\"\"Fetch the Agent Context Contract v2 envelope for a conversation.\n\n Fail-soft: a v1 server 404s here; we return None so the caller falls\n back to id-prefix display. Other errors are logged and also return None.\n \"\"\"\n if conv_id in self._context_cache:\n return self._context_cache[conv_id]\n try:\n status, body = await self._api_request(\n \"GET\", f\"/conversations/{conv_id}/context\", timeout=10.0,\n )\n except Exception as e:\n logger.debug(\"BayChat: context fetch failed for %s — %s\", conv_id, e)\n return None\n if status == 404:\n return None # v1 server\n if status != 200 or not isinstance(body, dict):\n return None\n self._context_cache[conv_id] = body\n return body\n\n def _resolve_sender(self, msg: dict, conv_id: str) -> tuple[str, str]:\n \"\"\"Resolve (display_name, role_word) from message + cached context.\n\n Role word is one of: orchestrator, agent, admin, member.\n Name resolution: msg.sender.name → context roster → senderType → id fallback.\n \"\"\"\n sender_id = str(msg.get(\"senderId\") or \"\")\n sender_type = (msg.get(\"senderType\") or \"\").upper()\n\n # v2 enrichment: msg.sender carries {id, name, kind, role}\n sender = msg.get(\"sender\") or {}\n if isinstance(sender, dict):\n name = sender.get(\"name\") or \"\"\n kind = (sender.get(\"kind\") or \"\").lower()\n role = (sender.get(\"role\") or \"\").upper()\n else:\n name = \"\"\n kind = \"agent\" if sender_type == \"AGENT\" else \"user\"\n role = \"\"\n\n # Fall back to context roster\n if not name or not kind:\n ctx = self._context_cache.get(conv_id)\n if ctx:\n for p in (ctx.get(\"participants\") or []):\n if str(p.get(\"id\") or \"\") == sender_id:\n if not name:\n name = p.get(\"name\") or \"\"\n if not kind:\n kind = (p.get(\"kind\") or \"\").lower()\n if not role:\n role = (p.get(\"role\") or \"\").upper()\n is_orch = p.get(\"isOrchestrator\", False)\n if is_orch:\n return (name or self._id_fallback(kind, sender_id), \"orchestrator\")\n break\n\n # Determine role word\n ctx = self._context_cache.get(conv_id)\n is_orchestrator = False\n if ctx:\n for p in (ctx.get(\"participants\") or []):\n if str(p.get(\"id\") or \"\") == sender_id:\n is_orchestrator = p.get(\"isOrchestrator\", False)\n break\n\n if is_orchestrator:\n role_word = \"orchestrator\"\n elif kind == \"agent\":\n role_word = \"agent\"\n elif role == \"ADMIN\":\n role_word = \"admin\"\n else:\n role_word = \"member\"\n\n if not name:\n name = self._id_fallback(kind, sender_id)\n return (name, role_word)\n\n @staticmethod\n def _id_fallback(kind: str, sender_id: str) -> str:\n \"\"\"user:<id8> or agent:<id8> fallback for unnamed senders.\"\"\"\n prefix = \"agent\" if kind == \"agent\" else \"user\"\n return f\"{prefix}:{sender_id[:8]}\"\n\n # ── Connection lifecycle ────────────────────────────────────────────────\n\n async def connect(self, *, is_reconnect: bool = False) -> bool:\n \"\"\"Verify the token and start the polling loop.\n\n ``is_reconnect`` is True when the gateway is reconnecting after an\n outage rather than doing a cold first boot. We use it to decide\n whether to reset the baseline timestamp (cold boot drops stale\n queue; reconnect preserves messages sent during the outage).\n \"\"\"\n if not self.token:\n logger.error(\"BayChat: BAYCHAT_TOKEN must be configured\")\n self._set_fatal_error(\n \"config_missing\",\n \"BAYCHAT_TOKEN must be set\",\n retryable=False,\n )\n return False\n\n # Verify the token via /me\n try:\n status, body = await self._api_request(\"GET\", \"/me\", timeout=15.0, priority=\"high\")\n except Exception as e:\n logger.error(\"BayChat: failed to reach API at %s — %s\", self.base_url, e)\n self._set_fatal_error(\"connect_failed\", str(e), retryable=True)\n await self._close_session()\n return False\n\n if status == 401:\n logger.error(\"BayChat: token rejected (401) — check BAYCHAT_TOKEN\")\n self._set_fatal_error(\"auth_failed\", \"Invalid agent token\", retryable=False)\n await self._close_session()\n return False\n if status != 200:\n msg = f\"BayChat: /me returned HTTP {status}\"\n logger.error(msg)\n self._set_fatal_error(\"connect_failed\", msg, retryable=True)\n await self._close_session()\n return False\n\n # Parse agent identity\n if isinstance(body, dict):\n self._agent_id = str(body.get(\"id\") or body.get(\"agentId\") or \"\")\n self._agent_name = body.get(\"name\") or body.get(\"displayName\") or \"BayChat Agent\"\n logger.info(\n \"BayChat: authenticated as agent %s (%s) at %s\",\n self._agent_id or \"?\", self._agent_name, self.base_url,\n )\n\n # Baseline: only deliver messages created after we connect, so a gateway\n # restart never replays history into the agent. On reconnect (after an\n # outage), preserve the existing baseline so messages sent during the\n # outage are delivered instead of silently dropped.\n if not is_reconnect or not self._connect_iso:\n self._connect_iso = datetime.now(timezone.utc).isoformat()\n\n # Start the inbound transport (negotiates ws → longpoll → legacy poll)\n self._closing = False\n self._poll_task = asyncio.create_task(self._transport_loop())\n\n self._mark_connected()\n logger.info(\n \"BayChat: connected (transport=%s, poll fallback every %.1fs)\",\n self._forced_transport, self.poll_interval,\n )\n return True\n\n async def disconnect(self) -> None:\n \"\"\"Stop polling and close the HTTP session.\"\"\"\n self._closing = True\n self._mark_disconnected()\n\n if self._poll_task and not self._poll_task.done():\n self._poll_task.cancel()\n try:\n await self._poll_task\n except asyncio.CancelledError:\n pass\n self._poll_task = None\n\n await self._close_session()\n logger.info(\"BayChat: disconnected\")\n\n async def _close_session(self) -> None:\n if self._session and not self._session.closed:\n try:\n await self._session.close()\n except Exception:\n pass\n self._session = None\n\n # ── Inbound transport ───────────────────────────────────────────────────\n\n async def _transport_loop(self) -> None:\n \"\"\"Supervisor: run the best available transport, degrade on failure.\n\n Order is ws → longpoll → legacy poll. A 404 marks a push transport\n unsupported; transient errors drop one tier for this round. After a\n stint on legacy polling the push probes are re-armed, so a server that\n gains the endpoints is picked up within TRANSPORT_REPROBE_SEC without\n a restart.\n \"\"\"\n import random\n delay = _RECONNECT_BASE_DELAY\n while not self._closing:\n try:\n forced = self._forced_transport\n if forced in {\"auto\", \"ws\"} and self._ws_supported is not False:\n await self._run_ws()\n if self._closing:\n return\n if forced in {\"auto\", \"longpoll\"} and self._updates_supported is not False:\n await self._run_longpoll()\n if self._closing:\n return\n if forced in {\"ws\", \"longpoll\"}:\n # Operator pinned a push transport that is unavailable —\n # wait out the probe window rather than silently polling.\n logger.warning(\n \"BayChat: forced transport %r unavailable, retrying in %.0fs\",\n forced, TRANSPORT_REPROBE_SEC,\n )\n await asyncio.sleep(TRANSPORT_REPROBE_SEC)\n else:\n await self._run_poll_until(time.monotonic() + TRANSPORT_REPROBE_SEC)\n # Re-arm the push probes for the next round.\n self._ws_supported = None\n self._updates_supported = None\n delay = _RECONNECT_BASE_DELAY\n except asyncio.CancelledError:\n return\n except Exception as e:\n if self._closing:\n return\n logger.warning(\"BayChat: transport error — %s (retrying in %.0fs)\", e, delay)\n jitter = delay * _RECONNECT_JITTER * random.random()\n await asyncio.sleep(delay + jitter)\n delay = min(delay * 2, _RECONNECT_MAX_DELAY)\n\n async def _handle_push_event(self, ev: dict) -> None:\n \"\"\"Route one push-delivered event through the normal inbound pipeline.\n\n The event's `message` is byte-identical to what the REST messages\n endpoint returns, so _process_inbound (dedup, shouldRespond, media,\n sender resolution) applies unchanged. The polling `since` watermark is\n advanced too, so any downgrade back to polling never replays or drops.\n \"\"\"\n if ev.get(\"type\") != \"message\":\n return # future event types (typing, membership) — ignore\n conv_id = str(ev.get(\"conversationId\") or \"\")\n msg = ev.get(\"message\")\n if not conv_id or not isinstance(msg, dict):\n return\n ctx = ev.get(\"context\")\n if isinstance(ctx, dict):\n self._context_cache[conv_id] = ctx\n conv = ev.get(\"conversation\")\n if isinstance(conv, dict) and conv.get(\"id\"):\n self._known_conversations[conv_id] = conv\n elif conv_id not in self._known_conversations:\n self._known_conversations[conv_id] = {\"id\": conv_id}\n self._last_activity[conv_id] = time.monotonic()\n await self._process_inbound(conv_id, msg)\n created = msg.get(\"createdAt\")\n if created and str(created) > (self._since.get(conv_id) or \"\"):\n self._since[conv_id] = str(created)\n\n async def _resync_after_gap(self) -> None:\n \"\"\"REST catch-up sweep after a push resume failure (WS `reset` or\n `cursor_expired`). The per-conversation `since` watermarks make this\n loss-free: anything push missed is still newer than the watermark.\"\"\"\n try:\n await self._refresh_conversations()\n for conv_id in list(self._known_conversations):\n if self._closing:\n return\n await self._poll_conversation(conv_id)\n except Exception as e:\n logger.debug(\"BayChat: resync sweep error — %s\", e)\n\n async def _run_ws(self) -> None:\n \"\"\"WebSocket transport. Returns when unsupported or persistently failing.\"\"\"\n import aiohttp\n failures = 0\n while not self._closing and failures < TRANSPORT_MAX_FAILURES:\n session = await self._ensure_session()\n try:\n async with session.ws_connect(\n _build_url(self.base_url, WS_PATH),\n headers={\n \"Authorization\": f\"Bearer {self.token}\",\n \"User-Agent\": \"Hermes-Agent-BayChat/1.0\",\n },\n heartbeat=20.0,\n ) as ws:\n await ws.send_json({\"t\": \"hello\", \"resume\": self._ws_seq})\n if self._transport != \"ws\":\n self._transport = \"ws\"\n logger.info(\"BayChat: push transport active (websocket)\")\n async for frame in ws:\n if frame.type != aiohttp.WSMsgType.TEXT:\n if frame.type in (\n aiohttp.WSMsgType.CLOSE,\n aiohttp.WSMsgType.CLOSED,\n aiohttp.WSMsgType.ERROR,\n ):\n break\n continue\n try:\n data = frame.json()\n except ValueError:\n continue\n t = data.get(\"t\")\n if t == \"event\":\n seq = data.get(\"seq\")\n if isinstance(seq, int):\n self._ws_seq = seq\n failures = 0 # live traffic — connection is healthy\n await self._handle_push_event(data.get(\"event\") or {})\n elif t == \"reset\":\n self._ws_seq = None\n await self._resync_after_gap()\n # \"ready\" and unknown frames: nothing to do\n # Normal close (server restart/displacement) — reconnect+resume.\n failures += 1\n except aiohttp.WSServerHandshakeError as e:\n # Any handshake rejection means no usable WS endpoint right now\n # (the proxy in front answers 502 for unroutable upgrades, not\n # 404). Real network trouble raises connector errors instead.\n self._ws_supported = False\n logger.info(\n \"BayChat: websocket endpoint unavailable (HTTP %s) — trying long-poll\",\n e.status,\n )\n return\n except asyncio.CancelledError:\n raise\n except Exception as e:\n failures += 1\n logger.debug(\"BayChat: ws error — %s\", e)\n if not self._closing and failures:\n await asyncio.sleep(min(2.0 ** failures, 30.0))\n # Persistent transient failures — let the supervisor try the next tier.\n\n async def _run_longpoll(self) -> None:\n \"\"\"HTTP long-poll transport against /updates. Returns when unsupported\n or persistently failing. One request in flight at a time; the server\n holds it up to LONGPOLL_WAIT seconds and answers instantly on traffic.\"\"\"\n failures = 0\n while not self._closing and failures < TRANSPORT_MAX_FAILURES:\n params: Dict[str, Any] = {\"wait\": int(LONGPOLL_WAIT)}\n if self._updates_cursor:\n params[\"cursor\"] = self._updates_cursor\n try:\n status, body = await self._api_request(\n \"GET\",\n UPDATES_PATH,\n params=params,\n timeout=LONGPOLL_WAIT + 10.0,\n priority=\"high\",\n )\n except asyncio.CancelledError:\n raise\n except Exception as e:\n failures += 1\n logger.debug(\"BayChat: /updates error — %s\", e)\n if not self._closing:\n await asyncio.sleep(min(2.0 ** failures, 30.0))\n continue\n if status == 404:\n self._updates_supported = False\n logger.info(\"BayChat: server has no /updates endpoint — using legacy polling\")\n return\n if status == 409:\n # cursor_expired: fell out of the server's replay buffer.\n self._updates_cursor = None\n await self._resync_after_gap()\n continue\n if status == 0:\n # Skipped locally during an active 429 window.\n await asyncio.sleep(1.0)\n continue\n if status != 200 or not isinstance(body, dict):\n failures += 1\n if not self._closing:\n await asyncio.sleep(min(2.0 ** failures, 30.0))\n continue\n failures = 0\n if self._transport != \"longpoll\":\n self._transport = \"longpoll\"\n logger.info(\"BayChat: push transport active (long-poll)\")\n cursor = body.get(\"cursor\")\n if cursor:\n self._updates_cursor = str(cursor)\n for ev in body.get(\"events\") or []:\n if isinstance(ev, dict):\n await self._handle_push_event(ev)\n\n async def _run_poll_until(self, deadline: float) -> None:\n \"\"\"Legacy polling until `deadline`, then return so push is re-probed.\"\"\"\n import random\n if self._transport != \"poll\":\n self._transport = \"poll\"\n logger.info(\n \"BayChat: using legacy polling (worst-case latency %.0fs)\",\n self._max_divisor * self.poll_interval,\n )\n delay = _RECONNECT_BASE_DELAY\n while not self._closing and time.monotonic() < deadline:\n try:\n await self._poll_once()\n delay = _RECONNECT_BASE_DELAY\n except asyncio.CancelledError:\n raise\n except Exception as e:\n if self._closing:\n return\n logger.warning(\"BayChat: poll cycle error — %s (retrying in %.0fs)\", e, delay)\n jitter = delay * _RECONNECT_JITTER * random.random()\n await asyncio.sleep(delay + jitter)\n delay = min(delay * 2, _RECONNECT_MAX_DELAY)\n\n def _should_poll(self, conv_id: str) -> bool:\n \"\"\"Adaptive tier check — has this conversation earned a request?\n\n Every conversation used to cost one request per cycle whether or not\n anyone was talking in it, so the request rate scaled with the number of\n chats the agent had ever joined. Quiet conversations now drop to a slower\n cadence, staggered by id so they don't all land on the same cycle.\n \"\"\"\n last = self._last_activity.get(conv_id)\n if last is None:\n # Not yet seeded (first cycle, before the list refresh landed).\n return True\n age = time.monotonic() - last\n if age <= HOT_WINDOW:\n return True\n divisor = WARM_DIVISOR if age <= WARM_WINDOW else COLD_DIVISOR\n # Clamp so no conversation is ever staler than MAX_STALENESS_SEC.\n divisor = max(1, min(divisor, self._max_divisor))\n stagger = sum(ord(c) for c in conv_id) % divisor\n return (self._cycle + stagger) % divisor == 0\n\n async def _refresh_conversations(self) -> None:\n \"\"\"Pull the conversation list and baseline any newly-seen chats.\"\"\"\n status, body = await self._api_request(\"GET\", \"/conversations\", timeout=15.0)\n if status == 0:\n return # skipped to protect the budget\n if status != 200:\n if status != 429: # 429 already logged + backed off centrally\n logger.warning(\"BayChat: /conversations returned HTTP %d\", status)\n return\n\n conversations = []\n if isinstance(body, list):\n conversations = body\n elif isinstance(body, dict):\n conversations = body.get(\"conversations\") or body.get(\"data\") or []\n\n seen: set = set()\n for conv in conversations:\n if not isinstance(conv, dict):\n continue\n conv_id = str(conv.get(\"id\") or conv.get(\"conversationId\") or \"\")\n if not conv_id:\n continue\n seen.add(conv_id)\n self._known_conversations[conv_id] = conv\n # Baseline a newly-seen conversation to connect time → no replay.\n if conv_id not in self._since:\n self._since[conv_id] = self._connect_iso or datetime.now(timezone.utc).isoformat()\n # Seed the activity clock on discovery. Without this a conversation\n # that never returns messages keeps a `None` timestamp, counts as\n # hot forever, and is polled every cycle — which is exactly the\n # fan-out we are trying to eliminate. Seeding it means a silent\n # conversation ages into the warm and cold tiers on its own.\n self._last_activity.setdefault(conv_id, time.monotonic())\n\n # Forget conversations the server no longer lists.\n for gone in set(self._known_conversations) - seen:\n self._known_conversations.pop(gone, None)\n self._since.pop(gone, None)\n self._last_activity.pop(gone, None)\n\n async def _poll_once(self) -> None:\n \"\"\"Single poll cycle: refresh conversations, pull new messages for each.\"\"\"\n self._cycle += 1\n\n # 1. Refresh the conversation list — only every Nth cycle. It changes\n # rarely, and an unlisted conversation still surfaces as soon as it\n # appears in the next refresh.\n if self._cycle % CONV_LIST_DIVISOR == 1 or not self._known_conversations:\n await self._refresh_conversations()\n\n # 2. Pull new messages, but only for conversations due this cycle.\n for conv_id in list(self._known_conversations):\n if self._closing:\n return\n if not self._should_poll(conv_id):\n continue\n try:\n await self._poll_conversation(conv_id)\n except Exception as e:\n logger.debug(\"BayChat: error polling conversation %s: %s\", conv_id, e)\n\n # 3. Wait for next cycle\n await asyncio.sleep(self.poll_interval)\n\n async def _poll_conversation(self, conv_id: str) -> None:\n \"\"\"Pull messages newer than our watermark via `?since=<ISO>` (ascending).\"\"\"\n since = self._since.get(conv_id) or self._connect_iso\n params: Dict[str, Any] = {\"since\": since} if since else {}\n\n status, body = await self._api_request(\n \"GET\",\n f\"/conversations/{conv_id}/messages\",\n params=params,\n timeout=15.0,\n )\n if status != 200:\n if status == 404:\n # Conversation deleted — drop it\n self._since.pop(conv_id, None)\n self._known_conversations.pop(conv_id, None)\n self._last_activity.pop(conv_id, None)\n elif status == 429:\n # Already logged and backed off centrally in _note_rate_limit.\n # Crucially we do NOT sleep here: this used to block the whole\n # loop 30s per conversation, so one rate-limit episode across\n # three chats stalled inbound delivery for a minute and a half.\n pass\n elif status != 0:\n logger.debug(\"BayChat: messages for %s returned HTTP %d\", conv_id, status)\n return\n\n if not isinstance(body, dict):\n return\n\n # v2 poll responses carry a fresh `context` envelope — cache it for\n # sender name/role resolution. This is free (no extra API call) and\n # keeps the roster current when participants join/leave.\n ctx_block = body.get(\"context\")\n if isinstance(ctx_block, dict):\n self._context_cache[conv_id] = ctx_block\n\n # `?since` returns messages in ascending (chronological) order.\n messages = body.get(\"messages\") or []\n if messages:\n # Traffic here — keep this conversation in the hot tier.\n self._last_activity[conv_id] = time.monotonic()\n for msg in messages:\n if not isinstance(msg, dict):\n continue\n await self._process_inbound(conv_id, msg)\n # Advance the watermark to this message's createdAt. The server filters\n # with a strict `>` on `since`, so the last message is never re-fetched.\n created = msg.get(\"createdAt\")\n if created:\n self._since[conv_id] = str(created)\n\n async def _process_inbound(self, conv_id: str, msg: dict) -> None:\n \"\"\"Process a single inbound message from the BayChat API.\n\n v2 group-chat aware:\n - Self-skip (don't reply to our own messages) instead of filtering all\n non-USER messages. Agent messages from other agents (e.g. BuzzRelay)\n are now visible to the Hermes agent.\n - Respects the server's ``shouldRespond`` routing verdict per message.\n In groups, only messages marked shouldRespond=true are delivered; in\n DMs, all human messages are delivered (the server sets shouldRespond\n true for every user message in DMs).\n - Real chat type from the conversation metadata or /context endpoint.\n - Sender name and role prefix from v2 message enrichment + context roster.\n \"\"\"\n if not self._message_handler:\n return\n\n msg_id = str(msg.get(\"id\") or \"\")\n if not msg_id:\n return\n\n # Dedup — protect against cursor races and re-delivery\n if self._dedup.is_duplicate(msg_id):\n return\n\n sender_type = (msg.get(\"senderType\") or \"\").upper()\n sender_id = str(msg.get(\"senderId\") or \"\")\n content = msg.get(\"content\") or \"\"\n created_at = msg.get(\"createdAt\")\n metadata = msg.get(\"metadata\")\n\n # Self-skip: never reply to our own messages (prevents agent-to-agent\n # volleys that the round cap exists to stop). This replaces the old\n # sender_type != \"USER\" filter which dropped ALL agent messages.\n if self._agent_id and sender_id == self._agent_id:\n return\n\n # shouldRespond routing (v2): the server computes, for THIS agent, whether\n # it should respond to each message. In groups with reply policies, only\n # routed messages are delivered. In DMs, shouldRespond is always true for\n # user messages. If the field is absent (v1 server), deliver all non-self\n # messages (preserves old behavior).\n should_respond = msg.get(\"shouldRespond\")\n if should_respond is not None and should_respond is not True:\n # Server explicitly said we should NOT respond — skip delivery.\n # This handles MENTIONS/DEDICATED/ORCHESTRATOR/ROUTER policies and\n # round-cap suppression.\n return\n\n # Access control (only for user messages — agent messages bypass this\n # since agents in the Bay are already trusted participants)\n if sender_type != \"AGENT\":\n if not self.allow_all and self.allowed_users and sender_id not in self.allowed_users:\n logger.debug(\"BayChat: ignoring unauthorized user %s in %s\", sender_id, conv_id)\n return\n\n # ── Extract media from metadata ──\n # BayChat puts media info in the `metadata` JSON field:\n # {\"type\": \"image\", \"attachmentUrl\": \"https://...signed-content?sig=...\", ...}\n # {\"type\": \"voice\", \"audioUrl\": \"https://...\", \"transcript\": \"...\"}\n # {\"type\": \"file\", \"attachmentUrl\": \"https://...\", \"fileName\": \"...\"}\n media_urls: List[str] = []\n media_types: List[str] = []\n msg_type = MessageType.TEXT\n effective_text = content\n\n if isinstance(metadata, dict):\n meta_type = (metadata.get(\"type\") or \"\").lower()\n\n if meta_type == \"image\":\n image_url = (\n metadata.get(\"attachmentUrl\")\n or metadata.get(\"imageUrl\")\n or metadata.get(\"url\")\n or \"\"\n )\n if image_url:\n try:\n # Guess extension from URL or content-type hint\n url_lower = image_url.lower()\n if \".png\" in url_lower:\n ext = \".png\"\n elif \".gif\" in url_lower:\n ext = \".gif\"\n elif \".webp\" in url_lower:\n ext = \".webp\"\n else:\n ext = \".jpg\"\n local_path = await cache_image_from_url(image_url, ext=ext)\n media_urls.append(local_path)\n media_types.append(\"image/\" + ext.lstrip(\".\"))\n msg_type = MessageType.PHOTO\n except Exception as e:\n logger.warning(\"BayChat: failed to download image %s — %s\", image_url, e)\n effective_text = (effective_text + f\"\\n[image download failed: {e}]\").strip()\n\n elif meta_type == \"voice\":\n # BayChat transcribes voice messages server-side and puts the\n # transcript in metadata.transcript. The content field usually\n # already contains the transcript text — use it as-is.\n msg_type = MessageType.VOICE\n transcript = metadata.get(\"transcript\") or \"\"\n if transcript and not effective_text:\n effective_text = transcript\n\n elif meta_type == \"audio\":\n audio_url = (\n metadata.get(\"attachmentUrl\")\n or metadata.get(\"audioUrl\")\n or metadata.get(\"url\")\n or \"\"\n )\n if audio_url:\n msg_type = MessageType.AUDIO\n # Fall through — content may have a transcription\n\n elif meta_type == \"file\":\n file_url = (\n metadata.get(\"attachmentUrl\")\n or metadata.get(\"fileUrl\")\n or metadata.get(\"url\")\n or \"\"\n )\n file_name = metadata.get(\"fileName\") or \"file\"\n if file_url:\n msg_type = MessageType.DOCUMENT\n if not effective_text:\n effective_text = f\"[file: {file_name}]\"\n\n # Determine chat type from conversation metadata or context envelope.\n # BayChat conversation types: DM, AGENT_CHAT, GROUP.\n conv_info = self._known_conversations.get(conv_id, {})\n conv_type = (conv_info.get(\"type\") or \"\").lower()\n # Also check the cached context envelope (may have a more specific type)\n if not conv_type:\n ctx = self._context_cache.get(conv_id)\n if ctx:\n conv_obj = ctx.get(\"conversation\") or {}\n conv_type = (conv_obj.get(\"type\") or \"\").lower()\n\n if conv_type in {\"dm\", \"direct\", \"private\", \"agent_chat\", \"agentchat\"}:\n chat_type = \"dm\"\n elif conv_type in {\"group\", \"channel\"}:\n chat_type = \"group\"\n else:\n # Default to \"dm\" for backward compat — but try fetching context\n # on first message to get the real type.\n if conv_id not in self._context_cache:\n ctx = await self._fetch_context(conv_id)\n if ctx:\n conv_obj = ctx.get(\"conversation\") or {}\n ct = (conv_obj.get(\"type\") or \"\").lower()\n if ct in {\"group\", \"channel\"}:\n chat_type = \"group\"\n else:\n chat_type = \"dm\"\n else:\n chat_type = \"dm\"\n else:\n chat_type = \"dm\"\n\n conv_name = conv_info.get(\"name\") or conv_info.get(\"title\") or conv_id\n\n # Sender display name + role (v2: resolved from msg.sender + context roster)\n sender_name, role_word = self._resolve_sender(msg, conv_id)\n\n # Prefix the message text with sender attribution so the agent can tell\n # who said what in a group chat. Format: [Name (role)] text\n # In DMs the prefix is lighter (just the name) since there's only one human.\n if chat_type == \"group\":\n effective_text = f\"[{sender_name} ({role_word})] {effective_text}\"\n else:\n # In DMs, still show the name if it's an agent (e.g. BuzzRelay in a DM)\n if sender_type == \"AGENT\":\n effective_text = f\"[{sender_name} ({role_word})] {effective_text}\"\n\n user_name = sender_name\n\n source = self.build_source(\n chat_id=conv_id,\n chat_name=conv_name,\n chat_type=chat_type,\n user_id=sender_id,\n user_name=user_name,\n message_id=msg_id,\n )\n\n # ── Reply context ──\n # BayChat sends a `replyTo` object on every message surface (protocol\n # v1.6+; see docs/AGENT_PROTOCOL.md §7). Shape:\n # \"replyTo\": {\"id\": \"...\", \"senderId\": \"...\", \"senderType\": \"USER\", \"preview\": \"first 80 chars\"}\n # or null. We populate the MessageEvent reply fields so the agent can\n # tell *which* message a reply is addressing (and who wrote it).\n reply_to_msg_id: Optional[str] = None\n reply_to_text: Optional[str] = None\n reply_to_author_id: Optional[str] = None\n reply_to_author_name: Optional[str] = None\n reply_to_is_own: bool = False\n\n reply_obj = msg.get(\"replyTo\")\n if isinstance(reply_obj, dict) and reply_obj.get(\"id\"):\n reply_to_msg_id = str(reply_obj[\"id\"])\n reply_to_text = reply_obj.get(\"preview\") or \"\"\n reply_to_author_id = str(reply_obj.get(\"senderId\") or \"\")\n\n # Resolve author name from context roster\n if reply_to_author_id:\n ctx = self._context_cache.get(conv_id)\n if ctx:\n for p in (ctx.get(\"participants\") or []):\n if str(p.get(\"id\") or \"\") == reply_to_author_id:\n reply_to_author_name = p.get(\"name\") or \"\"\n break\n if not reply_to_author_name:\n reply_to_author_name = reply_to_author_id\n\n # Check if the replied-to message was our own.\n # Compare senderId directly to our agent ID — never infer from\n # senderType === \"AGENT\" (that means *some* agent, not necessarily us).\n if self._agent_id and reply_to_author_id == self._agent_id:\n reply_to_is_own = True\n\n event = MessageEvent(\n text=effective_text,\n message_type=msg_type,\n source=source,\n message_id=msg_id,\n timestamp=_parse_iso8601(created_at),\n raw_message=msg,\n media_urls=media_urls,\n media_types=media_types,\n reply_to_message_id=reply_to_msg_id,\n reply_to_text=reply_to_text,\n reply_to_author_id=reply_to_author_id,\n reply_to_author_name=reply_to_author_name,\n reply_to_is_own_message=reply_to_is_own,\n )\n\n await self.handle_message(event)\n\n # ── Sending ──────────────────────────────────────────────────────────────\n\n async def send(\n self,\n chat_id: str,\n content: str,\n reply_to: Optional[str] = None,\n metadata: Optional[Dict[str, Any]] = None,\n ) -> SendResult:\n \"\"\"Send a text message to a BayChat conversation.\"\"\"\n if not self.token:\n return SendResult(success=False, error=\"No token configured\")\n\n # Split long messages\n chunks = self.truncate_message(content, max_length=self.max_message_length)\n last_msg_id: Optional[str] = None\n for i, chunk in enumerate(chunks):\n payload: Dict[str, Any] = {\"content\": chunk}\n if reply_to and i == 0:\n payload[\"replyToMessageId\"] = reply_to\n try:\n status, body = await self._api_request(\n \"POST\",\n f\"/conversations/{chat_id}/messages\",\n json_body=payload,\n timeout=15.0,\n priority=\"high\",\n )\n except Exception as e:\n return SendResult(success=False, error=str(e), retryable=True)\n\n if status == 429:\n return SendResult(\n success=False,\n error=\"Rate limited by BayChat (429)\",\n retryable=True,\n )\n if status == 404:\n return SendResult(\n success=False,\n error=f\"Conversation {chat_id} not found\",\n )\n if status not in (200, 201):\n err = f\"BayChat send failed (HTTP {status})\"\n if isinstance(body, dict) and body.get(\"error\"):\n err = str(body[\"error\"])\n return SendResult(success=False, error=err)\n\n if isinstance(body, dict):\n last_msg_id = str(body.get(\"id\") or \"\")\n\n # Small delay between chunks to avoid flooding\n if i < len(chunks) - 1:\n await asyncio.sleep(0.3)\n\n return SendResult(success=True, message_id=last_msg_id)\n\n async def send_typing(self, chat_id: str, metadata=None) -> None:\n \"\"\"Send a typing indicator to a BayChat conversation.\"\"\"\n if not self.token:\n return\n try:\n await self._api_request(\n \"POST\",\n f\"/conversations/{chat_id}/typing\",\n json_body={},\n timeout=5.0,\n priority=\"high\",\n )\n except Exception:\n pass # typing indicator is best-effort\n\n async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:\n \"\"\"Return info about a conversation, using cached context if available.\"\"\"\n conv = self._known_conversations.get(chat_id)\n conv_type = \"\"\n if conv:\n conv_type = (conv.get(\"type\") or \"\").lower()\n # Fall back to context envelope\n if not conv_type:\n ctx = self._context_cache.get(chat_id)\n if ctx:\n conv_obj = ctx.get(\"conversation\") or {}\n conv_type = (conv_obj.get(\"type\") or \"\").lower()\n chat_type = \"dm\" if conv_type in {\"dm\", \"direct\", \"private\", \"agent_chat\", \"agentchat\"} else \"group\"\n name = \"\"\n if conv:\n name = conv.get(\"name\") or conv.get(\"title\") or \"\"\n if not name:\n ctx = self._context_cache.get(chat_id)\n if ctx:\n conv_obj = ctx.get(\"conversation\") or {}\n name = conv_obj.get(\"title\") or \"\"\n return {\n \"name\": name or chat_id,\n \"type\": chat_type,\n \"chat_id\": chat_id,\n }\n\n\n# ── Plugin registration helpers ──────────────────────────────────────────────\n\ndef check_requirements() -> bool:\n \"\"\"Check if BayChat is configured (token present).\"\"\"\n return bool(os.getenv(\"BAYCHAT_TOKEN\", \"\"))\n\n\ndef validate_config(config) -> bool:\n \"\"\"Validate that the platform config has enough info to connect.\"\"\"\n extra = getattr(config, \"extra\", {}) or {}\n token = (\n os.getenv(\"BAYCHAT_TOKEN\")\n or extra.get(\"token\", \"\")\n or getattr(config, \"token\", \"\")\n )\n return bool(token)\n\n\ndef is_connected(config) -> bool:\n \"\"\"Check whether BayChat is configured.\"\"\"\n return validate_config(config)\n\n\ndef _env_enablement() -> dict | None:\n \"\"\"Seed PlatformConfig.extra from env vars during gateway config load.\"\"\"\n token = os.getenv(\"BAYCHAT_TOKEN\", \"\").strip()\n if not token:\n return None\n seed: dict = {\"token\": token}\n base_url = os.getenv(\"BAYCHAT_BASE_URL\", \"\").strip()\n if base_url:\n seed[\"base_url\"] = base_url.rstrip(\"/\")\n interval = os.getenv(\"BAYCHAT_POLL_INTERVAL\", \"\").strip()\n if interval:\n try:\n seed[\"poll_interval\"] = float(interval)\n except ValueError:\n pass\n allowed = os.getenv(\"BAYCHAT_ALLOWED_USERS\", \"\").strip()\n if allowed:\n seed[\"allowed_users\"] = [u.strip() for u in allowed.split(\",\") if u.strip()]\n allow_all = os.getenv(\"BAYCHAT_ALLOW_ALL_USERS\", \"\").strip().lower()\n if allow_all:\n seed[\"allow_all_users\"] = allow_all in {\"1\", \"true\", \"yes\"}\n # Home channel for cron delivery\n home = os.getenv(\"BAYCHAT_HOME_CHANNEL\", \"\").strip()\n if home:\n seed[\"home_channel\"] = {\"chat_id\": home, \"name\": home}\n else:\n # Default home channel to the first known conversation (resolved at runtime)\n seed[\"home_channel\"] = {\"chat_id\": \"\", \"name\": \"\"}\n return seed\n\n\ndef _redeem_pairing_code_sync(code: str, base_url: str) -> tuple[str, str, str]:\n \"\"\"Redeem a one-time BayChat pairing code for an agent token (no copy-paste).\n\n Accepts a bare code (e.g. \"7QF2-9K3X-W4MT\") or a\n ``baychat://hermes/pair?code=..&base=..`` URI. Returns\n ``(token, base_url, agent_name)``. Raises RuntimeError on failure.\n \"\"\"\n import json\n import urllib.error\n import urllib.parse\n import urllib.request\n\n if code.startswith(\"baychat://\"):\n q = urllib.parse.parse_qs(urllib.parse.urlparse(code).query)\n base_url = q.get(\"base\", [base_url])[0]\n code = q.get(\"code\", [\"\"])[0]\n\n url = base_url.rstrip(\"/\") + AGENT_API_PREFIX + \"/pair\"\n req = urllib.request.Request(\n url,\n data=json.dumps({\"code\": code}).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n # Cloudflare refuses the urllib default User-Agent (403, error 1010) on\n # api.baychat.io — measured 2026-09-02. Every other client passes; it is\n # the stdlib signature alone. Without this the wizard reports a 403 as an\n # expired pairing code, which is a lie the user cannot act on.\n \"User-Agent\": \"Hermes-Agent-BayChat/1.0\",\n },\n method=\"POST\",\n )\n try:\n with urllib.request.urlopen(req, timeout=15) as r:\n data = json.load(r)\n except urllib.error.HTTPError as e:\n try:\n msg = json.loads(e.read().decode()).get(\"error\", f\"HTTP {e.code}\")\n except Exception:\n msg = f\"HTTP {e.code}\"\n raise RuntimeError(msg)\n return (\n data[\"token\"],\n data.get(\"baseUrl\", base_url),\n (data.get(\"agent\") or {}).get(\"name\", \"BayChat agent\"),\n )\n\n\ndef interactive_setup() -> None:\n \"\"\"Interactive `hermes gateway setup` flow for BayChat.\"\"\"\n from hermes_cli.setup import (\n prompt,\n prompt_yes_no,\n save_env_value,\n get_env_value,\n print_header,\n print_info,\n print_warning,\n print_success,\n )\n\n print_header(\"BayChat\")\n existing_token = get_env_value(\"BAYCHAT_TOKEN\")\n if existing_token:\n print_info(\"BayChat: already configured\")\n if not prompt_yes_no(\"Reconfigure BayChat?\", False):\n return\n\n print_info(\"Connect Hermes to BayChat via the Agent REST API.\")\n print_info(\" Easiest: in the BayChat app open your agent → 'Connect to Hermes'\")\n print_info(\" → copy the one-time code (e.g. 7QF2-9K3X-W4MT). No token copy-paste.\")\n print_info(\" (Or paste a long-lived agent token instead.) Long-polling — no public URL.\")\n print()\n\n base_url = prompt(\n \"API base URL (default: https://api.baychat.io)\",\n default=get_env_value(\"BAYCHAT_BASE_URL\") or \"\",\n ).strip()\n effective_base = base_url or DEFAULT_BASE_URL\n if base_url:\n save_env_value(\"BAYCHAT_BASE_URL\", base_url)\n elif get_env_value(\"BAYCHAT_BASE_URL\"):\n save_env_value(\"BAYCHAT_BASE_URL\", \"\")\n\n code = prompt(\"Pairing code from the BayChat app (leave blank to paste a token instead)\").strip()\n if code:\n try:\n token, paired_base, agent_name = _redeem_pairing_code_sync(code, effective_base)\n except Exception as e:\n print_warning(f\"Pairing failed: {e}\")\n print_warning(\"The code is single-use and expires in 10 min — generate a fresh one.\")\n return\n save_env_value(\"BAYCHAT_TOKEN\", token)\n if paired_base and paired_base != DEFAULT_BASE_URL:\n save_env_value(\"BAYCHAT_BASE_URL\", paired_base)\n print_success(f'Paired with BayChat as \"{agent_name}\"')\n else:\n token = prompt(\"BayChat agent token\", password=True)\n if not token:\n print_warning(\"A pairing code or token is required — skipping BayChat setup\")\n return\n save_env_value(\"BAYCHAT_TOKEN\", token.strip())\n\n interval = prompt(\n \"Poll interval in seconds (default: 3.0)\",\n default=get_env_value(\"BAYCHAT_POLL_INTERVAL\") or \"\",\n )\n if interval:\n try:\n save_env_value(\"BAYCHAT_POLL_INTERVAL\", str(float(interval)))\n except ValueError:\n print_warning(\"Invalid interval — using default 3.0s\")\n\n print()\n print_info(\"🔒 Access control\")\n print_info(\" BayChat tokens are per-agent, so all users who can message the\")\n print_info(\" agent are already gated by your BayChat app. You can additionally\")\n print_info(\" restrict to specific user IDs.\")\n allow_all = prompt_yes_no(\"Allow all users (recommended)?\", True)\n if allow_all:\n save_env_value(\"BAYCHAT_ALLOW_ALL_USERS\", \"true\")\n save_env_value(\"BAYCHAT_ALLOWED_USERS\", \"\")\n else:\n save_env_value(\"BAYCHAT_ALLOW_ALL_USERS\", \"false\")\n allowed = prompt(\n \"Allowed user IDs (comma-separated)\",\n default=get_env_value(\"BAYCHAT_ALLOWED_USERS\") or \"\",\n )\n if allowed:\n save_env_value(\"BAYCHAT_ALLOWED_USERS\", allowed.replace(\" \", \"\"))\n\n print()\n print_success(\"BayChat configuration saved to ~/.hermes/.env\")\n print_info(\"Restart the gateway for changes to take effect: hermes gateway restart\")\n\n\nasync def _standalone_send(\n pconfig,\n chat_id: str,\n message: str,\n *,\n thread_id: Optional[str] = None,\n media_files: Optional[List[str]] = None,\n force_document: bool = False,\n) -> Dict[str, Any]:\n \"\"\"Out-of-process send for cron jobs running without the gateway adapter.\n\n Opens an ephemeral HTTP connection, POSTs the message, and closes.\n \"\"\"\n import aiohttp\n\n extra = getattr(pconfig, \"extra\", {}) or {}\n token = (\n os.getenv(\"BAYCHAT_TOKEN\")\n or extra.get(\"token\", \"\")\n or getattr(pconfig, \"token\", \"\")\n )\n if not token:\n return {\"error\": \"BayChat standalone send: BAYCHAT_TOKEN not configured\"}\n\n base_url = (\n os.getenv(\"BAYCHAT_BASE_URL\")\n or extra.get(\"base_url\", DEFAULT_BASE_URL)\n or DEFAULT_BASE_URL\n ).rstrip(\"/\")\n\n url = _build_url(base_url, f\"/conversations/{chat_id}/messages\")\n headers = {\n \"Authorization\": f\"Bearer {token}\",\n \"Content-Type\": \"application/json\",\n }\n\n # Split long messages\n max_len = int(extra.get(\"max_message_length\", MAX_MESSAGE_LENGTH))\n\n def _split(text: str) -> List[str]:\n if len(text) <= max_len:\n return [text]\n chunks: List[str] = []\n for paragraph in text.split(\"\\n\"):\n while len(paragraph) > max_len:\n space = paragraph.rfind(\" \", 0, max_len)\n split_at = space if space > max_len // 3 else max_len\n chunks.append(paragraph[:split_at].rstrip())\n paragraph = paragraph[split_at:].lstrip()\n if paragraph:\n chunks.append(paragraph)\n return chunks if chunks else [\"\"]\n\n try:\n timeout = aiohttp.ClientTimeout(total=15.0)\n async with aiohttp.ClientSession(timeout=timeout) as session:\n for chunk in _split(message):\n async with session.post(url, json={\"content\": chunk}, headers=headers) as resp:\n if resp.status == 429:\n return {\"error\": \"BayChat standalone send: rate limited (429)\"}\n if resp.status == 404:\n return {\"error\": f\"BayChat standalone send: conversation {chat_id} not found\"}\n if resp.status not in (200, 201):\n text = await resp.text()\n return {\"error\": f\"BayChat standalone send: HTTP {resp.status} — {text}\"}\n return {\"success\": True, \"message_id\": str(int(time.time() * 1000))}\n except asyncio.CancelledError:\n raise\n except Exception as e:\n return {\"error\": f\"BayChat standalone send failed: {e}\"}\n\n\n# ── Plugin entry point ───────────────────────────────────────────────────────\n\ndef register(ctx):\n \"\"\"Plugin entry point: called by the Hermes plugin system.\"\"\"\n ctx.register_platform(\n name=\"baychat\",\n label=\"BayChat\",\n adapter_factory=lambda cfg: BayChatAdapter(cfg),\n check_fn=check_requirements,\n validate_config=validate_config,\n is_connected=is_connected,\n required_env=[\"BAYCHAT_TOKEN\"],\n install_hint=\"No extra packages needed (uses aiohttp, already a Hermes dependency)\",\n setup_fn=interactive_setup,\n env_enablement_fn=_env_enablement,\n cron_deliver_env_var=\"BAYCHAT_HOME_CHANNEL\",\n standalone_sender_fn=_standalone_send,\n allowed_users_env=\"BAYCHAT_ALLOWED_USERS\",\n allow_all_env=\"BAYCHAT_ALLOW_ALL_USERS\",\n max_message_length=MAX_MESSAGE_LENGTH,\n emoji=\"💬\",\n pii_safe=True,\n allow_update_command=True,\n platform_hint=(\n \"You are chatting via BayChat. BayChat supports plain text messages. \"\n \"Keep responses clear and conversational. Long messages are automatically \"\n \"split into chunks.\"\n ),\n )\n"
18
+ },
19
+ {
20
+ "path": "plugins/platforms/baychat/plugin.yaml",
21
+ "body": "name: baychat-platform\nlabel: BayChat\nkind: platform\nversion: 1.0.0\ndescription: >\n BayChat gateway adapter for Hermes Agent.\n Connects to a BayChat instance via its REST Agent API using long-polling\n (no public URL needed — works behind NAT). Relays messages between BayChat\n conversations and the Hermes agent. No external dependencies — uses aiohttp\n which is already a Hermes dependency.\nauthor: BayChat + Hermes Community\nrequires_env:\n - name: BAYCHAT_TOKEN\n description: \"BayChat agent token (format: bay_<64 hex>). Create in BayChat app: Agents screen → create agent.\"\n prompt: \"BayChat agent token\"\n password: true\noptional_env:\n - name: BAYCHAT_BASE_URL\n description: \"BayChat API base URL (default: https://api.baychat.io)\"\n prompt: \"API base URL\"\n password: false\n - name: BAYCHAT_POLL_INTERVAL\n description: \"Polling interval in seconds (default: 3.0). Lower = more responsive but more API calls.\"\n prompt: \"Poll interval (seconds)\"\n password: false\n - name: BAYCHAT_ALLOWED_USERS\n description: \"Comma-separated BayChat user IDs allowed to talk to the bot\"\n prompt: \"Allowed user IDs (comma-separated)\"\n password: false\n - name: BAYCHAT_ALLOW_ALL_USERS\n description: \"Allow anyone to talk to the bot (dev only — default: true since tokens are per-agent)\"\n prompt: \"Allow all users? (true/false)\"\n password: false\n - name: BAYCHAT_HOME_CHANNEL\n description: \"Conversation ID for cron / notification delivery\"\n prompt: \"Home conversation ID (or empty)\"\n password: false\n - name: BAYCHAT_PROXY\n description: \"Proxy URL for BayChat API connections (e.g. socks5://127.0.0.1:1080 or http://proxy:8080)\"\n prompt: \"Proxy URL (optional)\"\n password: false\n"
22
+ },
23
+ {
24
+ "path": "skills/productivity/baychat-setup/scripts/pair.py",
25
+ "body": "#!/usr/bin/env python3\n\"\"\"Redeem a BayChat pairing code and save the agent token to ~/.hermes/.env.\n\nUsage:\n python3 scripts/pair.py <PAIRING-CODE>\n\nHandles two pitfalls that cause manual pairing to fail:\n1. Cloudflare blocks bare urllib/curl (HTTP 403, error 1010) — sends a browser User-Agent.\n2. Hermes secret scanner redacts bay_ tokens in terminal output — writes directly to .env.\n\nPairing codes are one-time use. If this script fails with 403, the code may already\nbe consumed — generate a new one in the BayChat app.\n\"\"\"\nimport json\nimport os\nimport re\nimport sys\nimport urllib.request\nimport urllib.error\n\nDEFAULT_BASE_URL = \"https://api.baychat.io\"\nENV_PATH = os.path.expanduser(\"~/.hermes/.env\")\nBROWSER_UA = (\n \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \"\n \"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36\"\n)\n\n\ndef redeem_pairing_code(code: str, base_url: str = DEFAULT_BASE_URL) -> dict:\n \"\"\"POST pairing code to BayChat API, return parsed response.\"\"\"\n payload = json.dumps({\"code\": code}).encode()\n req = urllib.request.Request(\n f\"{base_url}/api/agent-api/pair\",\n data=payload,\n headers={\n \"Content-Type\": \"application/json\",\n \"User-Agent\": BROWSER_UA,\n \"Accept\": \"application/json\",\n },\n method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=10) as resp:\n return json.loads(resp.read())\n\n\ndef save_to_env(token: str, base_url: str = DEFAULT_BASE_URL) -> None:\n \"\"\"Write BAYCHAT_TOKEN and BAYCHAT_BASE_URL to .env, replacing if present.\"\"\"\n with open(ENV_PATH, \"r\") as f:\n content = f.read()\n\n if \"BAYCHAT_TOKEN=\" in content:\n content = re.sub(r\"BAYCHAT_TOKEN=.*\", f\"BAYCHAT_TOKEN={token}\", content)\n else:\n content = content.rstrip(\"\\n\") + f\"\\nBAYCHAT_TOKEN={token}\\n\"\n\n if \"BAYCHAT_BASE_URL=\" not in content:\n content = content.rstrip(\"\\n\") + f\"\\nBAYCHAT_BASE_URL={base_url}\\n\"\n\n with open(ENV_PATH, \"w\") as f:\n f.write(content)\n\n\ndef main():\n if len(sys.argv) < 2:\n print(\"Usage: python3 scripts/pair.py <PAIRING-CODE>\")\n sys.exit(1)\n\n code = sys.argv[1].strip()\n\n try:\n data = redeem_pairing_code(code)\n except urllib.error.HTTPError as e:\n body = e.read().decode() if e.fp else \"\"\n if e.code == 403:\n print(f\"FAILED (HTTP 403): Cloudflare block or pairing code already used.\")\n print(f\" If error 1010: ensure browser User-Agent is being sent (this script handles it).\")\n print(f\" If code was already redeemed: generate a new pairing code in the BayChat app.\")\n else:\n print(f\"FAILED (HTTP {e.code}): {body}\")\n sys.exit(1)\n except urllib.error.URLError as e:\n print(f\"FAILED (network): {e}\")\n sys.exit(1)\n\n token = data.get(\"token\", \"\")\n agent_name = data.get(\"agent\", {}).get(\"name\", \"unknown\")\n\n if not token:\n print(f\"No token in response: {data}\")\n sys.exit(1)\n\n save_to_env(token)\n\n print(f\"SUCCESS: Paired with BayChat as '{agent_name}'\")\n print(f\"Token saved to {ENV_PATH}\")\n print(f\"Token prefix: {token[:8]}...{token[-4:]}\")\n print(f\"\\nNext steps:\")\n print(f\" hermes config set gateway.platforms.baychat.enabled true\")\n print(f\" hermes gateway restart\")\n print(f\" hermes gateway status\")\n\n\nif __name__ == \"__main__\":\n main()\n"
26
+ }
27
+ ];
package/dist/hermes.js ADDED
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ // `baychat hermes init` — install the BayChat platform plugin into a Hermes agent.
3
+ //
4
+ // WHY THIS COMMAND EXISTS. The plugin was written by Hermes and lived on exactly one
5
+ // machine, published nowhere. Every other Hermes install therefore could not talk to
6
+ // BayChat at all, and the "setup guide" was a set of instructions for files nobody
7
+ // else had. Shipping the files inside the CLI turns that into one command.
8
+ //
9
+ // The Python is inlined at build time (see scripts/sync-hermes-plugin.mjs) because the
10
+ // npm package ships dist/ only — there is no integrations/ directory to read at runtime.
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.ENABLE_STEPS = void 0;
46
+ exports.hermesHome = hermesHome;
47
+ exports.installHermesPlugin = installHermesPlugin;
48
+ exports.setEnvValue = setEnvValue;
49
+ exports.writeHermesEnv = writeHermesEnv;
50
+ exports.installHermes = installHermes;
51
+ exports.cmdHermesInit = cmdHermesInit;
52
+ const child_process_1 = require("child_process");
53
+ const fs = __importStar(require("fs"));
54
+ const os = __importStar(require("os"));
55
+ const path = __importStar(require("path"));
56
+ const args_1 = require("./args");
57
+ const config_1 = require("./config");
58
+ const hermes_plugin_content_1 = require("./hermes-plugin-content");
59
+ /** The two commands Hermes needs after the files are in place. */
60
+ exports.ENABLE_STEPS = [
61
+ "hermes config set gateway.platforms.baychat.enabled true",
62
+ "hermes gateway restart",
63
+ ];
64
+ /**
65
+ * Where this machine's Hermes lives.
66
+ *
67
+ * `--home` wins over HERMES_HOME so a person with two installs can point at one
68
+ * without exporting anything; ~/.hermes is the default Hermes itself uses.
69
+ */
70
+ function hermesHome(explicit) {
71
+ return explicit || process.env.HERMES_HOME || path.join(os.homedir(), ".hermes");
72
+ }
73
+ /**
74
+ * Write the plugin files under `home`.
75
+ *
76
+ * A file that differs from what we are about to write is copied to `.baychat-backup`
77
+ * first — same rule as installRuntimeCommand. Hermes ships adapter updates by hand
78
+ * today, so an install that silently clobbered a locally-patched adapter would destroy
79
+ * the only copy of someone's fix.
80
+ */
81
+ function installHermesPlugin(home) {
82
+ const written = [];
83
+ const backedUp = [];
84
+ for (const file of hermes_plugin_content_1.HERMES_PLUGIN_FILES) {
85
+ const dest = path.join(home, file.path);
86
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
87
+ if (fs.existsSync(dest) && fs.readFileSync(dest, "utf8") !== file.body) {
88
+ fs.copyFileSync(dest, `${dest}.baychat-backup`);
89
+ backedUp.push(`${dest}.baychat-backup`);
90
+ }
91
+ fs.writeFileSync(dest, file.body, { mode: 0o644 });
92
+ written.push(dest);
93
+ }
94
+ return { written, backedUp };
95
+ }
96
+ /**
97
+ * Set `key=value` in a .env body, replacing an existing assignment or appending one.
98
+ *
99
+ * Anchored to the start of a line on purpose. The reference implementation used a bare
100
+ * `BAYCHAT_TOKEN=.*` substitution, which also rewrites the tail of an unrelated
101
+ * `OLD_BAYCHAT_TOKEN=` line and leaves a corrupt file behind.
102
+ */
103
+ function setEnvValue(body, key, value) {
104
+ const line = `${key}=${value}`;
105
+ const pattern = new RegExp(`^${key}=.*$`, "m");
106
+ if (pattern.test(body))
107
+ return body.replace(pattern, line);
108
+ return body.length === 0 ? `${line}\n` : `${body.replace(/\n*$/, "\n")}${line}\n`;
109
+ }
110
+ /**
111
+ * Put the paired agent token where the adapter reads it.
112
+ *
113
+ * `baychat pair` stores credentials in ~/.baychat/credentials.json; the Hermes adapter
114
+ * reads BAYCHAT_TOKEN from ~/.hermes/.env. Nothing bridged those two, which is the real
115
+ * reason the plugin shipped with its own pair.py — that script exists to write this file.
116
+ * Doing it here makes the extra script redundant rather than merely duplicated.
117
+ */
118
+ function writeHermesEnv(home, token, baseUrl) {
119
+ const envPath = path.join(home, ".env");
120
+ fs.mkdirSync(home, { recursive: true });
121
+ const existing = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
122
+ let next = setEnvValue(existing, "BAYCHAT_TOKEN", token);
123
+ next = setEnvValue(next, "BAYCHAT_BASE_URL", baseUrl);
124
+ // 0600: this file holds an agent token. Written in place rather than via a temp file
125
+ // because a Hermes .env commonly holds other secrets we must not drop on a crash.
126
+ fs.writeFileSync(envPath, next, { mode: 0o600 });
127
+ fs.chmodSync(envPath, 0o600);
128
+ return envPath;
129
+ }
130
+ function installHermes(home) {
131
+ const { written, backedUp } = installHermesPlugin(home);
132
+ const report = { home, written, backedUp, envWritten: null };
133
+ const creds = (0, config_1.loadCredentials)();
134
+ if (!creds) {
135
+ report.envSkipped =
136
+ "no agent token on this machine — run `baychat pair <code>` (get the code in the BayChat app: Agents → your agent), then re-run this";
137
+ return report;
138
+ }
139
+ report.envWritten = writeHermesEnv(home, creds.token, creds.baseUrl);
140
+ return report;
141
+ }
142
+ /** Run the two enablement commands. Returns false if `hermes` is not on PATH. */
143
+ function runEnableSteps() {
144
+ const probe = (0, child_process_1.spawnSync)("hermes", ["--version"], { stdio: "ignore" });
145
+ if (probe.error)
146
+ return false;
147
+ for (const step of exports.ENABLE_STEPS) {
148
+ const [bin, ...rest] = step.split(" ");
149
+ console.log(` $ ${step}`);
150
+ const run = (0, child_process_1.spawnSync)(bin, rest, { stdio: "inherit" });
151
+ if (run.status !== 0) {
152
+ console.log(` ↑ exited ${run.status ?? "abnormally"} — finish the remaining steps by hand.`);
153
+ return true;
154
+ }
155
+ }
156
+ return true;
157
+ }
158
+ function cmdHermesInit(args) {
159
+ const home = hermesHome((0, args_1.flag)(args, "--home"));
160
+ const report = installHermes(home);
161
+ console.log(`Hermes home: ${report.home}`);
162
+ for (const file of report.written)
163
+ console.log(` wrote ${file}`);
164
+ for (const file of report.backedUp)
165
+ console.log(` kept your edited copy at ${file}`);
166
+ if (report.envWritten) {
167
+ // Never print the token itself — same rule as cmdPair.
168
+ console.log(` wrote BAYCHAT_TOKEN + BAYCHAT_BASE_URL to ${report.envWritten}`);
169
+ }
170
+ else {
171
+ console.log(`\nNo token written — ${report.envSkipped}`);
172
+ }
173
+ console.log("\nEnable it in Hermes:");
174
+ if (args.includes("--enable")) {
175
+ if (!runEnableSteps()) {
176
+ console.log(" `hermes` is not on PATH — run these wherever Hermes is installed:");
177
+ for (const step of exports.ENABLE_STEPS)
178
+ console.log(` ${step}`);
179
+ }
180
+ }
181
+ else {
182
+ for (const step of exports.ENABLE_STEPS)
183
+ console.log(` ${step}`);
184
+ console.log("\nOr re-run with --enable to have this command do it for you.");
185
+ }
186
+ console.log("\nCheck it came up: hermes gateway status");
187
+ return 0;
188
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const commands_1 = require("./commands");
5
5
  const approve_hook_1 = require("./approve-hook");
6
6
  const doctor_command_1 = require("./doctor-command");
7
+ const hermes_1 = require("./hermes");
7
8
  const connect_1 = require("./connect");
8
9
  const mcp_1 = require("./mcp");
9
10
  const mcp_config_1 = require("./mcp-config");
@@ -30,6 +31,11 @@ Usage:
30
31
  BayChat MCP server to Claude Code. Creates a
31
32
  device credential only — no agent, no room
32
33
  baychat pair <code> [--base <url>] Redeem a pairing code from the BayChat app
34
+ baychat hermes init [--home <dir>] [--enable]
35
+ Install the BayChat platform plugin into a Hermes
36
+ agent (~/.hermes), and put your paired agent token
37
+ where its adapter reads it. --enable also runs the
38
+ two hermes commands that switch it on
33
39
  baychat link [--name <n>] [--base <url>]
34
40
  Link this session via a QR you scan with your phone
35
41
  baychat help [topic|question] How to USE BayChat from a client — making a
@@ -290,6 +296,15 @@ async function main() {
290
296
  throw new Error("Usage: baychat relay <start|status|stop|attach> [options]");
291
297
  }
292
298
  }
299
+ case "hermes": {
300
+ const sub = (0, args_1.positional)(args) ?? "";
301
+ const rest = args.filter((a) => a !== sub);
302
+ if (sub !== "init") {
303
+ throw new Error("Usage: baychat hermes init [--home <dir>] [--enable]");
304
+ }
305
+ (0, args_1.rejectUnknownFlags)(rest, ["--home", "--enable"], "baychat hermes init [--home <dir>] [--enable]");
306
+ return (0, hermes_1.cmdHermesInit)(rest);
307
+ }
293
308
  case "approve-hook":
294
309
  // Never throws and always returns 0 — see the file header in approve-hook.ts. A thrown
295
310
  // error here would reach main()'s catch, print to stderr and exit 1, and to Claude Code an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baychat",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "BayChat connector CLI — pair an agent session (Claude Code, Codex) with BayChat and chat in groups",
5
5
  "bin": {
6
6
  "baychat": "dist/index.js"
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "scripts": {
13
13
  "sync-protocol": "node scripts/sync-protocol.mjs",
14
- "build": "node scripts/sync-protocol.mjs && tsc && node scripts/make-executable.mjs",
14
+ "build": "node scripts/sync-protocol.mjs && node scripts/sync-hermes-plugin.mjs && tsc && node scripts/make-executable.mjs",
15
15
  "test": "vitest run",
16
16
  "prepublishOnly": "npm run build && npm test"
17
17
  },