diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py
index e3c03938af40..8d5238e75f0a 100644
--- a/agent/copilot_acp_client.py
+++ b/agent/copilot_acp_client.py
@@ -1,679 +1,688 @@
-"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`.
-
-This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style
-backend. Each request starts a short-lived ACP session, sends the formatted
-conversation as a single prompt, collects text chunks, and converts the result
-back into the minimal shape Hermes expects from an OpenAI client.
-"""
-
-from __future__ import annotations
-
-import json
-import os
-import queue
-import re
-import shlex
-import subprocess
-import threading
-import time
-from collections import deque
-from pathlib import Path
-from types import SimpleNamespace
-from typing import Any
-
-from agent.file_safety import get_read_block_error, is_write_denied
-from agent.redact import redact_sensitive_text
-
-ACP_MARKER_BASE_URL = "acp://copilot"
-_DEFAULT_TIMEOUT_SECONDS = 900.0
-
-_TOOL_CALL_BLOCK_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL)
-_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL)
-
-# Stderr fingerprint of the deprecated `gh copilot` CLI extension
-# (https://github.blog/changelog/2025-09-25-upcoming-deprecation-of-gh-copilot-cli-extension).
-# We require BOTH the literal product name ("gh-copilot") AND a deprecation
-# marker, so generic stderr from the NEW `@github/copilot` CLI — whose repo
-# is github.com/github/copilot-cli and which legitimately mentions "copilot-cli"
-# in its own banners and error messages — doesn't get misclassified as the
-# deprecated extension.
-_DEPRECATION_REQUIRED = ("gh-copilot",)
-_DEPRECATION_MARKERS = (
- "has been deprecated",
- "no commands will be executed",
-)
-
-
-def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool:
- """True iff stderr looks like the deprecated gh-copilot extension's banner."""
-
- lower = stderr_text.lower()
- if not any(req in lower for req in _DEPRECATION_REQUIRED):
- return False
- return any(marker in lower for marker in _DEPRECATION_MARKERS)
-
-
-def _resolve_command() -> str:
- return (
- os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
- or os.getenv("COPILOT_CLI_PATH", "").strip()
- or "copilot"
- )
-
-
-def _resolve_args() -> list[str]:
- raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
- if not raw:
- return ["--acp", "--stdio"]
- return shlex.split(raw)
-
-
-def _resolve_home_dir() -> str:
- """Return a stable HOME for child ACP processes."""
- home = os.environ.get("HOME", "").strip()
- if home:
- return home
-
- expanded = os.path.expanduser("~")
- if expanded and expanded != "~":
- return expanded
-
- try:
- import pwd
-
- resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows)
- if resolved:
- return resolved
- except Exception:
- pass
-
- # Last resort: /tmp (writable on any POSIX system). Avoids crashing the
- # subprocess with no HOME; callers can set HERMES_HOME explicitly if they
- # need a different writable dir.
- return "/tmp"
-
-
-def _build_subprocess_env() -> dict[str, str]:
- env = os.environ.copy()
- home = _resolve_home_dir()
- env["HOME"] = home
- from hermes_constants import apply_subprocess_home_env
- apply_subprocess_home_env(env)
- return env
-
-
-def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]:
- return {
- "jsonrpc": "2.0",
- "id": message_id,
- "error": {
- "code": code,
- "message": message,
- },
- }
-
-
-def _permission_denied(message_id: Any) -> dict[str, Any]:
- return {
- "jsonrpc": "2.0",
- "id": message_id,
- "result": {
- "outcome": {
- "outcome": "cancelled",
- }
- },
- }
-
-
-def _format_messages_as_prompt(
- messages: list[dict[str, Any]],
- model: str | None = None,
- tools: list[dict[str, Any]] | None = None,
- tool_choice: Any = None,
-) -> str:
- sections: list[str] = [
- "You are being used as the active ACP agent backend for Hermes.",
- "Use ACP capabilities to complete tasks.",
- "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.",
- "If no tool is needed, answer normally.",
- ]
- if model:
- sections.append(f"Hermes requested model hint: {model}")
-
- if isinstance(tools, list) and tools:
- tool_specs: list[dict[str, Any]] = []
- for t in tools:
- if not isinstance(t, dict):
- continue
- fn = t.get("function") or {}
- if not isinstance(fn, dict):
- continue
- name = fn.get("name")
- if not isinstance(name, str) or not name.strip():
- continue
- tool_specs.append(
- {
- "name": name.strip(),
- "description": fn.get("description", ""),
- "parameters": fn.get("parameters", {}),
- }
- )
- if tool_specs:
- sections.append(
- "Available tools (OpenAI function schema). "
- "When using a tool, emit ONLY {...} with one JSON object "
- "containing id/type/function{name,arguments}. arguments must be a JSON string.\n"
- + json.dumps(tool_specs, ensure_ascii=False)
- )
-
- if tool_choice is not None:
- sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}")
-
- transcript: list[str] = []
- for message in messages:
- if not isinstance(message, dict):
- continue
- role = str(message.get("role") or "unknown").strip().lower()
- if role == "tool":
- role = "tool"
- elif role not in {"system", "user", "assistant"}:
- role = "context"
-
- content = message.get("content")
- rendered = _render_message_content(content)
- if not rendered:
- continue
-
- label = {
- "system": "System",
- "user": "User",
- "assistant": "Assistant",
- "tool": "Tool",
- "context": "Context",
- }.get(role, role.title())
- transcript.append(f"{label}:\n{rendered}")
-
- if transcript:
- sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript))
-
- sections.append("Continue the conversation from the latest user request.")
- return "\n\n".join(section.strip() for section in sections if section and section.strip())
-
-
-def _render_message_content(content: Any) -> str:
- if content is None:
- return ""
- if isinstance(content, str):
- return content.strip()
- if isinstance(content, dict):
- if "text" in content:
- return str(content.get("text") or "").strip()
- if "content" in content and isinstance(content.get("content"), str):
- return str(content.get("content") or "").strip()
- return json.dumps(content, ensure_ascii=True)
- if isinstance(content, list):
- parts: list[str] = []
- for item in content:
- if isinstance(item, str):
- parts.append(item)
- elif isinstance(item, dict):
- text = item.get("text")
- if isinstance(text, str) and text.strip():
- parts.append(text.strip())
- return "\n".join(parts).strip()
- return str(content).strip()
-
-
-def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
- if not isinstance(text, str) or not text.strip():
- return [], ""
-
- extracted: list[SimpleNamespace] = []
- consumed_spans: list[tuple[int, int]] = []
-
- def _try_add_tool_call(raw_json: str) -> None:
- try:
- obj = json.loads(raw_json)
- except Exception:
- return
- if not isinstance(obj, dict):
- return
- fn = obj.get("function")
- if not isinstance(fn, dict):
- return
- fn_name = fn.get("name")
- if not isinstance(fn_name, str) or not fn_name.strip():
- return
- fn_args = fn.get("arguments", "{}")
- if not isinstance(fn_args, str):
- fn_args = json.dumps(fn_args, ensure_ascii=False)
- call_id = obj.get("id")
- if not isinstance(call_id, str) or not call_id.strip():
- call_id = f"acp_call_{len(extracted)+1}"
-
- extracted.append(
- SimpleNamespace(
- id=call_id,
- call_id=call_id,
- response_item_id=None,
- type="function",
- function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
- )
- )
-
- for m in _TOOL_CALL_BLOCK_RE.finditer(text):
- raw = m.group(1)
- _try_add_tool_call(raw)
- consumed_spans.append((m.start(), m.end()))
-
- # Only try bare-JSON fallback when no XML blocks were found.
- if not extracted:
- for m in _TOOL_CALL_JSON_RE.finditer(text):
- raw = m.group(0)
- _try_add_tool_call(raw)
- consumed_spans.append((m.start(), m.end()))
-
- if not consumed_spans:
- return extracted, text.strip()
-
- consumed_spans.sort()
- merged: list[tuple[int, int]] = []
- for start, end in consumed_spans:
- if not merged or start > merged[-1][1]:
- merged.append((start, end))
- else:
- merged[-1] = (merged[-1][0], max(merged[-1][1], end))
-
- parts: list[str] = []
- cursor = 0
- for start, end in merged:
- if cursor < start:
- parts.append(text[cursor:start])
- cursor = max(cursor, end)
- if cursor < len(text):
- parts.append(text[cursor:])
-
- cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip()
- return extracted, cleaned
-
-
-
-def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path:
- candidate = Path(path_text)
- if not candidate.is_absolute():
- raise PermissionError("ACP file-system paths must be absolute.")
- resolved = candidate.resolve()
- root = Path(cwd).resolve()
- try:
- resolved.relative_to(root)
- except ValueError as exc:
- raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc
- return resolved
-
-
-class _ACPChatCompletions:
- def __init__(self, client: "CopilotACPClient"):
- self._client = client
-
- def create(self, **kwargs: Any) -> Any:
- return self._client._create_chat_completion(**kwargs)
-
-
-class _ACPChatNamespace:
- def __init__(self, client: "CopilotACPClient"):
- self.completions = _ACPChatCompletions(client)
-
-
-class CopilotACPClient:
- """Minimal OpenAI-client-compatible facade for Copilot ACP."""
-
- def __init__(
- self,
- *,
- api_key: str | None = None,
- base_url: str | None = None,
- default_headers: dict[str, str] | None = None,
- acp_command: str | None = None,
- acp_args: list[str] | None = None,
- acp_cwd: str | None = None,
- command: str | None = None,
- args: list[str] | None = None,
- **_: Any,
- ):
- self.api_key = api_key or "copilot-acp"
- self.base_url = base_url or ACP_MARKER_BASE_URL
- self._default_headers = dict(default_headers or {})
- self._acp_command = acp_command or command or _resolve_command()
- self._acp_args = list(acp_args or args or _resolve_args())
- self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve())
- self.chat = _ACPChatNamespace(self)
- self.is_closed = False
- self._active_process: subprocess.Popen[str] | None = None
- self._active_process_lock = threading.Lock()
-
- def close(self) -> None:
- proc: subprocess.Popen[str] | None
- with self._active_process_lock:
- proc = self._active_process
- self._active_process = None
- self.is_closed = True
- if proc is None:
- return
- try:
- proc.terminate()
- proc.wait(timeout=2)
- except Exception:
- try:
- proc.kill()
- except Exception:
- pass
-
- def _create_chat_completion(
- self,
- *,
- model: str | None = None,
- messages: list[dict[str, Any]] | None = None,
- timeout: float | None = None,
- tools: list[dict[str, Any]] | None = None,
- tool_choice: Any = None,
- **_: Any,
- ) -> Any:
- prompt_text = _format_messages_as_prompt(
- messages or [],
- model=model,
- tools=tools,
- tool_choice=tool_choice,
- )
- # Normalise timeout: run_agent.py may pass an httpx.Timeout object
- # (used natively by the OpenAI SDK) rather than a plain float.
- if timeout is None:
- _effective_timeout = _DEFAULT_TIMEOUT_SECONDS
- elif isinstance(timeout, (int, float)):
- _effective_timeout = float(timeout)
- else:
- # httpx.Timeout or similar — pick the largest component so the
- # subprocess has enough wall-clock time for the full response.
- _candidates = [
- getattr(timeout, attr, None)
- for attr in ("read", "write", "connect", "pool", "timeout")
- ]
- _numeric = [float(v) for v in _candidates if isinstance(v, (int, float))]
- _effective_timeout = max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS
-
- response_text, reasoning_text = self._run_prompt(
- prompt_text,
- timeout_seconds=_effective_timeout,
- )
-
- tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text)
-
- usage = SimpleNamespace(
- prompt_tokens=0,
- completion_tokens=0,
- total_tokens=0,
- prompt_tokens_details=SimpleNamespace(cached_tokens=0),
- )
- assistant_message = SimpleNamespace(
- content=cleaned_text,
- tool_calls=tool_calls,
- reasoning=reasoning_text or None,
- reasoning_content=reasoning_text or None,
- reasoning_details=None,
- )
- finish_reason = "tool_calls" if tool_calls else "stop"
- choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
- return SimpleNamespace(
- choices=[choice],
- usage=usage,
- model=model or "copilot-acp",
- )
-
- def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
- try:
- proc = subprocess.Popen(
- [self._acp_command] + self._acp_args,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- cwd=self._acp_cwd,
- env=_build_subprocess_env(),
- )
- except FileNotFoundError as exc:
- raise RuntimeError(
- f"Could not start Copilot ACP command '{self._acp_command}'. "
- "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH."
- ) from exc
-
- if proc.stdin is None or proc.stdout is None:
- proc.kill()
- raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.")
-
- self.is_closed = False
- with self._active_process_lock:
- self._active_process = proc
-
- inbox: queue.Queue[dict[str, Any]] = queue.Queue()
- stderr_tail: deque[str] = deque(maxlen=40)
-
- def _stdout_reader() -> None:
- if proc.stdout is None:
- return
- for line in proc.stdout:
- try:
- inbox.put(json.loads(line))
- except Exception:
- inbox.put({"raw": line.rstrip("\n")})
-
- def _stderr_reader() -> None:
- if proc.stderr is None:
- return
- for line in proc.stderr:
- stderr_tail.append(line.rstrip("\n"))
-
- out_thread = threading.Thread(target=_stdout_reader, daemon=True)
- err_thread = threading.Thread(target=_stderr_reader, daemon=True)
- out_thread.start()
- err_thread.start()
-
- next_id = 0
-
- def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any:
- nonlocal next_id
- next_id += 1
- request_id = next_id
- payload = {
- "jsonrpc": "2.0",
- "id": request_id,
- "method": method,
- "params": params,
- }
- proc.stdin.write(json.dumps(payload) + "\n")
- proc.stdin.flush()
-
- deadline = time.monotonic() + timeout_seconds
- while time.monotonic() < deadline:
- if proc.poll() is not None:
- break
- try:
- msg = inbox.get(timeout=0.1)
- except queue.Empty:
- continue
-
- if self._handle_server_message(
- msg,
- process=proc,
- cwd=self._acp_cwd,
- text_parts=text_parts,
- reasoning_parts=reasoning_parts,
- ):
- continue
-
- if msg.get("id") != request_id:
- continue
- if "error" in msg:
- err = msg.get("error") or {}
- raise RuntimeError(
- f"Copilot ACP {method} failed: {err.get('message') or err}"
- )
- return msg.get("result")
-
- stderr_text = "\n".join(stderr_tail).strip()
- if proc.poll() is not None and stderr_text:
- if _is_gh_copilot_deprecation_message(stderr_text):
- raise RuntimeError(
- "Hermes ACP mode requires the NEW GitHub Copilot CLI "
- "(github.com/github/copilot-cli), but the binary it just "
- "spawned is the deprecated `gh copilot` extension.\n\n"
- "Install the new CLI:\n"
- " npm install -g @github/copilot\n"
- " # then verify with: copilot --help\n\n"
- "If `copilot` already resolves to the new CLI but you still see this,\n"
- "point Hermes at it explicitly:\n"
- " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n"
- "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n"
- "directly with a Copilot subscription token) via `hermes setup`.\n\n"
- f"Original error:\n{stderr_text}"
- )
- raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}")
- raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.")
-
- try:
- _request(
- "initialize",
- {
- "protocolVersion": 1,
- "clientCapabilities": {
- "fs": {
- "readTextFile": True,
- "writeTextFile": True,
- }
- },
- "clientInfo": {
- "name": "hermes-agent",
- "title": "Hermes Agent",
- "version": "0.0.0",
- },
- },
- )
- session = _request(
- "session/new",
- {
- "cwd": self._acp_cwd,
- "mcpServers": [],
- },
- ) or {}
- session_id = str(session.get("sessionId") or "").strip()
- if not session_id:
- raise RuntimeError("Copilot ACP did not return a sessionId.")
-
- text_parts: list[str] = []
- reasoning_parts: list[str] = []
- _request(
- "session/prompt",
- {
- "sessionId": session_id,
- "prompt": [
- {
- "type": "text",
- "text": prompt_text,
- }
- ],
- },
- text_parts=text_parts,
- reasoning_parts=reasoning_parts,
- )
- return "".join(text_parts), "".join(reasoning_parts)
- finally:
- self.close()
-
- def _handle_server_message(
- self,
- msg: dict[str, Any],
- *,
- process: subprocess.Popen[str],
- cwd: str,
- text_parts: list[str] | None,
- reasoning_parts: list[str] | None,
- ) -> bool:
- method = msg.get("method")
- if not isinstance(method, str):
- return False
-
- if method == "session/update":
- params = msg.get("params") or {}
- update = params.get("update") or {}
- kind = str(update.get("sessionUpdate") or "").strip()
- content = update.get("content") or {}
- chunk_text = ""
- if isinstance(content, dict):
- chunk_text = str(content.get("text") or "")
- if kind == "agent_message_chunk" and chunk_text and text_parts is not None:
- text_parts.append(chunk_text)
- elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None:
- reasoning_parts.append(chunk_text)
- return True
-
- if process.stdin is None:
- return True
-
- message_id = msg.get("id")
- params = msg.get("params") or {}
-
- if method == "session/request_permission":
- response = _permission_denied(message_id)
- elif method == "fs/read_text_file":
- try:
- path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
- block_error = get_read_block_error(str(path))
- if block_error:
- raise PermissionError(block_error)
- try:
- content = path.read_text()
- except FileNotFoundError:
- content = ""
- line = params.get("line")
- limit = params.get("limit")
- if isinstance(line, int) and line > 1:
- lines = content.splitlines(keepends=True)
- start = line - 1
- end = start + limit if isinstance(limit, int) and limit > 0 else None
- content = "".join(lines[start:end])
- if content:
- content = redact_sensitive_text(content, force=True)
- response = {
- "jsonrpc": "2.0",
- "id": message_id,
- "result": {
- "content": content,
- },
- }
- except Exception as exc:
- response = _jsonrpc_error(message_id, -32602, str(exc))
- elif method == "fs/write_text_file":
- try:
- path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
- if is_write_denied(str(path)):
- raise PermissionError(
- f"Write denied: '{path}' is a protected system/credential file."
- )
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(str(params.get("content") or ""))
- response = {
- "jsonrpc": "2.0",
- "id": message_id,
- "result": None,
- }
- except Exception as exc:
- response = _jsonrpc_error(message_id, -32602, str(exc))
- else:
- response = _jsonrpc_error(
- message_id,
- -32601,
- f"ACP client method '{method}' is not supported by Hermes yet.",
- )
-
- process.stdin.write(json.dumps(response) + "\n")
- process.stdin.flush()
- return True
+"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`.
+
+This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style
+backend. Each request starts a short-lived ACP session, sends the formatted
+conversation as a single prompt, collects text chunks, and converts the result
+back into the minimal shape Hermes expects from an OpenAI client.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import queue
+import re
+import shlex
+import subprocess
+import threading
+import time
+from collections import deque
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+from agent.file_safety import get_read_block_error, is_write_denied
+from agent.redact import redact_sensitive_text
+
+ACP_MARKER_BASE_URL = "acp://copilot"
+_DEFAULT_TIMEOUT_SECONDS = 900.0
+
+_TOOL_CALL_BLOCK_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL)
+_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL)
+
+# Stderr fingerprint of the deprecated `gh copilot` CLI extension
+# (https://github.blog/changelog/2025-09-25-upcoming-deprecation-of-gh-copilot-cli-extension).
+# We require BOTH the literal product name ("gh-copilot") AND a deprecation
+# marker, so generic stderr from the NEW `@github/copilot` CLI — whose repo
+# is github.com/github/copilot-cli and which legitimately mentions "copilot-cli"
+# in its own banners and error messages — doesn't get misclassified as the
+# deprecated extension.
+_DEPRECATION_REQUIRED = ("gh-copilot",)
+_DEPRECATION_MARKERS = (
+ "has been deprecated",
+ "no commands will be executed",
+)
+
+
+def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool:
+ """True iff stderr looks like the deprecated gh-copilot extension's banner."""
+
+ lower = stderr_text.lower()
+ if not any(req in lower for req in _DEPRECATION_REQUIRED):
+ return False
+ return any(marker in lower for marker in _DEPRECATION_MARKERS)
+
+
+def _resolve_command() -> str:
+ return (
+ os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
+ or os.getenv("COPILOT_CLI_PATH", "").strip()
+ or "copilot"
+ )
+
+
+def _resolve_args() -> list[str]:
+ raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
+ if not raw:
+ return ["--acp", "--stdio"]
+ return shlex.split(raw)
+
+
+def _resolve_home_dir() -> str:
+ """Return a stable HOME for child ACP processes."""
+ home = os.environ.get("HOME", "").strip()
+ if home:
+ return home
+
+ expanded = os.path.expanduser("~")
+ if expanded and expanded != "~":
+ return expanded
+
+ try:
+ import pwd
+
+ resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows)
+ if resolved:
+ return resolved
+ except Exception:
+ pass
+
+ # Last resort: /tmp (writable on any POSIX system). Avoids crashing the
+ # subprocess with no HOME; callers can set HERMES_HOME explicitly if they
+ # need a different writable dir.
+ return "/tmp"
+
+
+def _build_subprocess_env() -> dict[str, str]:
+ env = os.environ.copy()
+ home = _resolve_home_dir()
+ env["HOME"] = home
+ from hermes_constants import apply_subprocess_home_env
+ apply_subprocess_home_env(env)
+ return env
+
+
+def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]:
+ return {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "error": {
+ "code": code,
+ "message": message,
+ },
+ }
+
+
+def _permission_denied(message_id: Any) -> dict[str, Any]:
+ return {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "result": {
+ "outcome": {
+ "outcome": "cancelled",
+ }
+ },
+ }
+
+
+def _format_messages_as_prompt(
+ messages: list[dict[str, Any]],
+ model: str | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: Any = None,
+) -> str:
+ sections: list[str] = [
+ "You are being used as the active ACP agent backend for Hermes.",
+ "Use ACP capabilities to complete tasks.",
+ "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.",
+ "If no tool is needed, answer normally.",
+ ]
+ if model:
+ sections.append(f"Hermes requested model hint: {model}")
+
+ if isinstance(tools, list) and tools:
+ tool_specs: list[dict[str, Any]] = []
+ for t in tools:
+ if not isinstance(t, dict):
+ continue
+ fn = t.get("function") or {}
+ if not isinstance(fn, dict):
+ continue
+ name = fn.get("name")
+ if not isinstance(name, str) or not name.strip():
+ continue
+ tool_specs.append(
+ {
+ "name": name.strip(),
+ "description": fn.get("description", ""),
+ "parameters": fn.get("parameters", {}),
+ }
+ )
+ if tool_specs:
+ sections.append(
+ "Available tools (OpenAI function schema). "
+ "When using a tool, emit ONLY {...} with one JSON object "
+ "containing id/type/function{name,arguments}. arguments must be a JSON string.\n"
+ + json.dumps(tool_specs, ensure_ascii=False)
+ )
+
+ if tool_choice is not None:
+ sections.append(f"Tool choice hint: {json.dumps(tool_choice, ensure_ascii=False)}")
+
+ transcript: list[str] = []
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ role = str(message.get("role") or "unknown").strip().lower()
+ if role == "tool":
+ role = "tool"
+ elif role not in {"system", "user", "assistant"}:
+ role = "context"
+
+ content = message.get("content")
+ rendered = _render_message_content(content)
+ if not rendered:
+ continue
+
+ label = {
+ "system": "System",
+ "user": "User",
+ "assistant": "Assistant",
+ "tool": "Tool",
+ "context": "Context",
+ }.get(role, role.title())
+ transcript.append(f"{label}:\n{rendered}")
+
+ if transcript:
+ sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript))
+
+ sections.append("Continue the conversation from the latest user request.")
+ return "\n\n".join(section.strip() for section in sections if section and section.strip())
+
+
+def _render_message_content(content: Any) -> str:
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content.strip()
+ if isinstance(content, dict):
+ if "text" in content:
+ return str(content.get("text") or "").strip()
+ if "content" in content and isinstance(content.get("content"), str):
+ return str(content.get("content") or "").strip()
+ return json.dumps(content, ensure_ascii=True)
+ if isinstance(content, list):
+ parts: list[str] = []
+ for item in content:
+ if isinstance(item, str):
+ parts.append(item)
+ elif isinstance(item, dict):
+ text = item.get("text")
+ if isinstance(text, str) and text.strip():
+ parts.append(text.strip())
+ return "\n".join(parts).strip()
+ return str(content).strip()
+
+
+def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
+ if not isinstance(text, str) or not text.strip():
+ return [], ""
+
+ extracted: list[SimpleNamespace] = []
+ consumed_spans: list[tuple[int, int]] = []
+
+ def _try_add_tool_call(raw_json: str) -> None:
+ try:
+ obj = json.loads(raw_json)
+ except Exception:
+ return
+ if not isinstance(obj, dict):
+ return
+ fn = obj.get("function")
+ if not isinstance(fn, dict):
+ return
+ fn_name = fn.get("name")
+ if not isinstance(fn_name, str) or not fn_name.strip():
+ return
+ fn_args = fn.get("arguments", "{}")
+ if not isinstance(fn_args, str):
+ fn_args = json.dumps(fn_args, ensure_ascii=False)
+ call_id = obj.get("id")
+ if not isinstance(call_id, str) or not call_id.strip():
+ call_id = f"acp_call_{len(extracted)+1}"
+
+ extracted.append(
+ SimpleNamespace(
+ id=call_id,
+ call_id=call_id,
+ response_item_id=None,
+ type="function",
+ function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
+ )
+ )
+
+ for m in _TOOL_CALL_BLOCK_RE.finditer(text):
+ raw = m.group(1)
+ _try_add_tool_call(raw)
+ consumed_spans.append((m.start(), m.end()))
+
+ # Only try bare-JSON fallback when no XML blocks were found.
+ if not extracted:
+ for m in _TOOL_CALL_JSON_RE.finditer(text):
+ raw = m.group(0)
+ _try_add_tool_call(raw)
+ consumed_spans.append((m.start(), m.end()))
+
+ if not consumed_spans:
+ return extracted, text.strip()
+
+ consumed_spans.sort()
+ merged: list[tuple[int, int]] = []
+ for start, end in consumed_spans:
+ if not merged or start > merged[-1][1]:
+ merged.append((start, end))
+ else:
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
+
+ parts: list[str] = []
+ cursor = 0
+ for start, end in merged:
+ if cursor < start:
+ parts.append(text[cursor:start])
+ cursor = max(cursor, end)
+ if cursor < len(text):
+ parts.append(text[cursor:])
+
+ cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip()
+ return extracted, cleaned
+
+
+
+def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path:
+ candidate = Path(path_text)
+ if not candidate.is_absolute():
+ raise PermissionError("ACP file-system paths must be absolute.")
+ resolved = candidate.resolve()
+ root = Path(cwd).resolve()
+ try:
+ resolved.relative_to(root)
+ except ValueError as exc:
+ raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc
+ return resolved
+
+
+class _ACPChatCompletions:
+ def __init__(self, client: "CopilotACPClient"):
+ self._client = client
+
+ def create(self, **kwargs: Any) -> Any:
+ return self._client._create_chat_completion(**kwargs)
+
+
+class _ACPChatNamespace:
+ def __init__(self, client: "CopilotACPClient"):
+ self.completions = _ACPChatCompletions(client)
+
+
+class CopilotACPClient:
+ """Minimal OpenAI-client-compatible facade for Copilot ACP."""
+
+ def __init__(
+ self,
+ *,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ default_headers: dict[str, str] | None = None,
+ acp_command: str | None = None,
+ acp_args: list[str] | None = None,
+ acp_cwd: str | None = None,
+ command: str | None = None,
+ args: list[str] | None = None,
+ **_: Any,
+ ):
+ self.api_key = api_key or "copilot-acp"
+ self.base_url = base_url or ACP_MARKER_BASE_URL
+ self._default_headers = dict(default_headers or {})
+ self._acp_command = acp_command or command or _resolve_command()
+ self._acp_args = list(acp_args or args or _resolve_args())
+ self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve())
+ self.chat = _ACPChatNamespace(self)
+ self.is_closed = False
+ self._active_process: subprocess.Popen[str] | None = None
+ self._active_process_lock = threading.Lock()
+
+ def close(self) -> None:
+ proc: subprocess.Popen[str] | None
+ with self._active_process_lock:
+ proc = self._active_process
+ self._active_process = None
+ self.is_closed = True
+ if proc is None:
+ return
+ try:
+ proc.terminate()
+ proc.wait(timeout=2)
+ except Exception:
+ try:
+ proc.kill()
+ except Exception:
+ pass
+
+ def _create_chat_completion(
+ self,
+ *,
+ model: str | None = None,
+ messages: list[dict[str, Any]] | None = None,
+ timeout: float | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_choice: Any = None,
+ **_: Any,
+ ) -> Any:
+ prompt_text = _format_messages_as_prompt(
+ messages or [],
+ model=model,
+ tools=tools,
+ tool_choice=tool_choice,
+ )
+ # Normalise timeout: run_agent.py may pass an httpx.Timeout object
+ # (used natively by the OpenAI SDK) rather than a plain float.
+ if timeout is None:
+ _effective_timeout = _DEFAULT_TIMEOUT_SECONDS
+ elif isinstance(timeout, (int, float)):
+ _effective_timeout = float(timeout)
+ else:
+ # httpx.Timeout or similar — pick the largest component so the
+ # subprocess has enough wall-clock time for the full response.
+ _candidates = [
+ getattr(timeout, attr, None)
+ for attr in ("read", "write", "connect", "pool", "timeout")
+ ]
+ _numeric = [float(v) for v in _candidates if isinstance(v, (int, float))]
+ _effective_timeout = max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS
+
+ response_text, reasoning_text = self._run_prompt(
+ prompt_text,
+ timeout_seconds=_effective_timeout,
+ )
+
+ tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text)
+
+ usage = SimpleNamespace(
+ prompt_tokens=0,
+ completion_tokens=0,
+ total_tokens=0,
+ prompt_tokens_details=SimpleNamespace(cached_tokens=0),
+ )
+ assistant_message = SimpleNamespace(
+ content=cleaned_text,
+ tool_calls=tool_calls,
+ reasoning=reasoning_text or None,
+ reasoning_content=reasoning_text or None,
+ reasoning_details=None,
+ )
+ finish_reason = "tool_calls" if tool_calls else "stop"
+ choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
+ return SimpleNamespace(
+ choices=[choice],
+ usage=usage,
+ model=model or "copilot-acp",
+ )
+
+ def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:
+ try:
+ _extra: dict = {}
+ if sys.platform == "win32":
+ from hermes_cli._subprocess_compat import windows_hide_flags
+ _si = subprocess.STARTUPINFO()
+ _si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ _si.wShowWindow = 0 # SW_HIDE
+ _extra["creationflags"] = windows_hide_flags()
+ _extra["startupinfo"] = _si
+ proc = subprocess.Popen(
+ [self._acp_command] + self._acp_args,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ cwd=self._acp_cwd,
+ env=_build_subprocess_env(),
+ **_extra,
+ )
+ except FileNotFoundError as exc:
+ raise RuntimeError(
+ f"Could not start Copilot ACP command '{self._acp_command}'. "
+ "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH."
+ ) from exc
+
+ if proc.stdin is None or proc.stdout is None:
+ proc.kill()
+ raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.")
+
+ self.is_closed = False
+ with self._active_process_lock:
+ self._active_process = proc
+
+ inbox: queue.Queue[dict[str, Any]] = queue.Queue()
+ stderr_tail: deque[str] = deque(maxlen=40)
+
+ def _stdout_reader() -> None:
+ if proc.stdout is None:
+ return
+ for line in proc.stdout:
+ try:
+ inbox.put(json.loads(line))
+ except Exception:
+ inbox.put({"raw": line.rstrip("\n")})
+
+ def _stderr_reader() -> None:
+ if proc.stderr is None:
+ return
+ for line in proc.stderr:
+ stderr_tail.append(line.rstrip("\n"))
+
+ out_thread = threading.Thread(target=_stdout_reader, daemon=True)
+ err_thread = threading.Thread(target=_stderr_reader, daemon=True)
+ out_thread.start()
+ err_thread.start()
+
+ next_id = 0
+
+ def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any:
+ nonlocal next_id
+ next_id += 1
+ request_id = next_id
+ payload = {
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "method": method,
+ "params": params,
+ }
+ proc.stdin.write(json.dumps(payload) + "\n")
+ proc.stdin.flush()
+
+ deadline = time.monotonic() + timeout_seconds
+ while time.monotonic() < deadline:
+ if proc.poll() is not None:
+ break
+ try:
+ msg = inbox.get(timeout=0.1)
+ except queue.Empty:
+ continue
+
+ if self._handle_server_message(
+ msg,
+ process=proc,
+ cwd=self._acp_cwd,
+ text_parts=text_parts,
+ reasoning_parts=reasoning_parts,
+ ):
+ continue
+
+ if msg.get("id") != request_id:
+ continue
+ if "error" in msg:
+ err = msg.get("error") or {}
+ raise RuntimeError(
+ f"Copilot ACP {method} failed: {err.get('message') or err}"
+ )
+ return msg.get("result")
+
+ stderr_text = "\n".join(stderr_tail).strip()
+ if proc.poll() is not None and stderr_text:
+ if _is_gh_copilot_deprecation_message(stderr_text):
+ raise RuntimeError(
+ "Hermes ACP mode requires the NEW GitHub Copilot CLI "
+ "(github.com/github/copilot-cli), but the binary it just "
+ "spawned is the deprecated `gh copilot` extension.\n\n"
+ "Install the new CLI:\n"
+ " npm install -g @github/copilot\n"
+ " # then verify with: copilot --help\n\n"
+ "If `copilot` already resolves to the new CLI but you still see this,\n"
+ "point Hermes at it explicitly:\n"
+ " export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n"
+ "Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n"
+ "directly with a Copilot subscription token) via `hermes setup`.\n\n"
+ f"Original error:\n{stderr_text}"
+ )
+ raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}")
+ raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.")
+
+ try:
+ _request(
+ "initialize",
+ {
+ "protocolVersion": 1,
+ "clientCapabilities": {
+ "fs": {
+ "readTextFile": True,
+ "writeTextFile": True,
+ }
+ },
+ "clientInfo": {
+ "name": "hermes-agent",
+ "title": "Hermes Agent",
+ "version": "0.0.0",
+ },
+ },
+ )
+ session = _request(
+ "session/new",
+ {
+ "cwd": self._acp_cwd,
+ "mcpServers": [],
+ },
+ ) or {}
+ session_id = str(session.get("sessionId") or "").strip()
+ if not session_id:
+ raise RuntimeError("Copilot ACP did not return a sessionId.")
+
+ text_parts: list[str] = []
+ reasoning_parts: list[str] = []
+ _request(
+ "session/prompt",
+ {
+ "sessionId": session_id,
+ "prompt": [
+ {
+ "type": "text",
+ "text": prompt_text,
+ }
+ ],
+ },
+ text_parts=text_parts,
+ reasoning_parts=reasoning_parts,
+ )
+ return "".join(text_parts), "".join(reasoning_parts)
+ finally:
+ self.close()
+
+ def _handle_server_message(
+ self,
+ msg: dict[str, Any],
+ *,
+ process: subprocess.Popen[str],
+ cwd: str,
+ text_parts: list[str] | None,
+ reasoning_parts: list[str] | None,
+ ) -> bool:
+ method = msg.get("method")
+ if not isinstance(method, str):
+ return False
+
+ if method == "session/update":
+ params = msg.get("params") or {}
+ update = params.get("update") or {}
+ kind = str(update.get("sessionUpdate") or "").strip()
+ content = update.get("content") or {}
+ chunk_text = ""
+ if isinstance(content, dict):
+ chunk_text = str(content.get("text") or "")
+ if kind == "agent_message_chunk" and chunk_text and text_parts is not None:
+ text_parts.append(chunk_text)
+ elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None:
+ reasoning_parts.append(chunk_text)
+ return True
+
+ if process.stdin is None:
+ return True
+
+ message_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ if method == "session/request_permission":
+ response = _permission_denied(message_id)
+ elif method == "fs/read_text_file":
+ try:
+ path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
+ block_error = get_read_block_error(str(path))
+ if block_error:
+ raise PermissionError(block_error)
+ try:
+ content = path.read_text()
+ except FileNotFoundError:
+ content = ""
+ line = params.get("line")
+ limit = params.get("limit")
+ if isinstance(line, int) and line > 1:
+ lines = content.splitlines(keepends=True)
+ start = line - 1
+ end = start + limit if isinstance(limit, int) and limit > 0 else None
+ content = "".join(lines[start:end])
+ if content:
+ content = redact_sensitive_text(content, force=True)
+ response = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "result": {
+ "content": content,
+ },
+ }
+ except Exception as exc:
+ response = _jsonrpc_error(message_id, -32602, str(exc))
+ elif method == "fs/write_text_file":
+ try:
+ path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
+ if is_write_denied(str(path)):
+ raise PermissionError(
+ f"Write denied: '{path}' is a protected system/credential file."
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(str(params.get("content") or ""))
+ response = {
+ "jsonrpc": "2.0",
+ "id": message_id,
+ "result": None,
+ }
+ except Exception as exc:
+ response = _jsonrpc_error(message_id, -32602, str(exc))
+ else:
+ response = _jsonrpc_error(
+ message_id,
+ -32601,
+ f"ACP client method '{method}' is not supported by Hermes yet.",
+ )
+
+ process.stdin.write(json.dumps(response) + "\n")
+ process.stdin.flush()
+ return True
diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py
index 4e2b2ddd7c3d..f99c619dcc71 100644
--- a/agent/shell_hooks.py
+++ b/agent/shell_hooks.py
@@ -1,847 +1,852 @@
-"""
-Shell-script hooks bridge.
-
-Reads the ``hooks:`` block from ``cli-config.yaml``, prompts the user for
-consent on first use of each ``(event, command)`` pair, and registers
-callbacks on the existing plugin hook manager so every existing
-``invoke_hook()`` site dispatches to the configured shell scripts — with
-zero changes to call sites.
-
-Design notes
-------------
-* Python plugins and shell hooks compose naturally: both flow through
- :func:`hermes_cli.plugins.invoke_hook` and its aggregators. Python
- plugins are registered first (via ``discover_and_load()``) so their
- block decisions win ties over shell-hook blocks.
-* Subprocess execution uses ``shlex.split(os.path.expanduser(command))``
- with ``shell=False`` — no shell injection footguns. Users that need
- pipes/redirection wrap their logic in a script.
-* First-use consent is gated by the allowlist under
- ``~/.hermes/shell-hooks-allowlist.json``. Non-TTY callers must pass
- ``accept_hooks=True`` (resolved from ``--accept-hooks``,
- ``HERMES_ACCEPT_HOOKS``, or ``hooks_auto_accept: true`` in config)
- for registration to succeed without a prompt.
-* Registration is idempotent — safe to invoke from both the CLI entry
- point (``hermes_cli/main.py``) and the gateway entry point
- (``gateway/run.py``).
-
-Wire protocol
--------------
-**stdin** (JSON, piped to the script)::
-
- {
- "hook_event_name": "pre_tool_call",
- "tool_name": "terminal",
- "tool_input": {"command": "rm -rf /"},
- "session_id": "sess_abc123",
- "cwd": "/home/user/project",
- "extra": {...} # event-specific kwargs
- }
-
-**stdout** (JSON, optional — anything else is ignored)::
-
- # Block a pre_tool_call (either shape accepted; normalised internally):
- {"decision": "block", "reason": "Forbidden command"} # Claude-Code-style
- {"action": "block", "message": "Forbidden command"} # Hermes-canonical
-
- # Inject context for pre_llm_call:
- {"context": "Today is Friday"}
-
- # Silent no-op:
-
-"""
-
-from __future__ import annotations
-
-import difflib
-import json
-import logging
-import os
-import re
-import shlex
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from contextlib import contextmanager
-from dataclasses import dataclass, field
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
-
-try:
- import fcntl # POSIX only; Windows falls back to best-effort without flock.
-except ImportError: # pragma: no cover
- fcntl = None # type: ignore[assignment]
-
-from hermes_constants import get_hermes_home
-from utils import atomic_replace
-
-logger = logging.getLogger(__name__)
-
-DEFAULT_TIMEOUT_SECONDS = 60
-MAX_TIMEOUT_SECONDS = 300
-ALLOWLIST_FILENAME = "shell-hooks-allowlist.json"
-_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook."
-
-# (event, matcher, command) triples that have been wired to the plugin
-# manager in the current process. Matcher is part of the key because
-# the same script can legitimately register for different matchers under
-# the same event (e.g. one entry per tool the user wants to gate).
-# Second registration attempts for the exact same triple become no-ops
-# so the CLI and gateway can both call register_from_config() safely.
-_registered: Set[Tuple[str, Optional[str], str]] = set()
-_registered_lock = threading.Lock()
-
-# Intra-process lock for allowlist read-modify-write on platforms that
-# lack ``fcntl`` (non-POSIX). Kept separate from ``_registered_lock``
-# because ``register_from_config`` already holds ``_registered_lock`` when
-# it triggers ``_record_approval`` — reusing it here would self-deadlock
-# (``threading.Lock`` is non-reentrant). POSIX callers use the sibling
-# ``.lock`` file via ``fcntl.flock`` and bypass this.
-_allowlist_write_lock = threading.Lock()
-
-
-@dataclass
-class ShellHookSpec:
- """Parsed and validated representation of a single ``hooks:`` entry."""
-
- event: str
- command: str
- matcher: Optional[str] = None
- timeout: int = DEFAULT_TIMEOUT_SECONDS
- compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False)
-
- def __post_init__(self) -> None:
- # Strip whitespace introduced by YAML quirks (e.g. multi-line string
- # folding) — a matcher of " terminal" would otherwise silently fail
- # to match "terminal" without any diagnostic.
- if isinstance(self.matcher, str):
- stripped = self.matcher.strip()
- self.matcher = stripped if stripped else None
- if self.matcher:
- try:
- self.compiled_matcher = re.compile(self.matcher)
- except re.error as exc:
- logger.warning(
- "shell hook matcher %r is invalid (%s) — treating as "
- "literal equality", self.matcher, exc,
- )
- self.compiled_matcher = None
-
- def matches_tool(self, tool_name: Optional[str]) -> bool:
- if not self.matcher:
- return True
- if tool_name is None:
- return False
- if self.compiled_matcher is not None:
- return self.compiled_matcher.fullmatch(tool_name) is not None
- # compiled_matcher is None only when the regex failed to compile,
- # in which case we already warned and fall back to literal equality.
- return tool_name == self.matcher
-
-
-# ---------------------------------------------------------------------------
-# Public API
-# ---------------------------------------------------------------------------
-
-def register_from_config(
- cfg: Optional[Dict[str, Any]],
- *,
- accept_hooks: bool = False,
-) -> List[ShellHookSpec]:
- """Register every configured shell hook on the plugin manager.
-
- ``cfg`` is the full parsed config dict (``hermes_cli.config.load_config``
- output). The ``hooks:`` key is read out of it. Missing, empty, or
- non-dict ``hooks`` is treated as zero configured hooks.
-
- ``accept_hooks=True`` skips the TTY consent prompt — the caller is
- promising that the user has opted in via a flag, env var, or config
- setting. ``HERMES_ACCEPT_HOOKS=1`` and ``hooks_auto_accept: true`` are
- also honored inside this function so either CLI or gateway call sites
- pick them up.
-
- Returns the list of :class:`ShellHookSpec` entries that ended up wired
- up on the plugin manager. Skipped entries (unknown events, malformed,
- not allowlisted, already registered) are logged but not returned.
- """
- if not isinstance(cfg, dict):
- return []
-
- effective_accept = _resolve_effective_accept(cfg, accept_hooks)
-
- specs = _parse_hooks_block(cfg.get("hooks"))
- if not specs:
- return []
-
- registered: List[ShellHookSpec] = []
-
- # Import lazily — avoids circular imports at module-load time.
- from hermes_cli.plugins import get_plugin_manager
-
- manager = get_plugin_manager()
-
- # Idempotence + allowlist read happen under the lock; the TTY
- # prompt runs outside so other threads aren't parked on a blocking
- # input(). Mutation re-takes the lock with a defensive idempotence
- # re-check in case two callers ever race through the prompt.
- for spec in specs:
- key = (spec.event, spec.matcher, spec.command)
- with _registered_lock:
- if key in _registered:
- continue
- already_allowlisted = _is_allowlisted(spec.event, spec.command)
-
- if not already_allowlisted:
- if not _prompt_and_record(
- spec.event, spec.command, accept_hooks=effective_accept,
- ):
- logger.warning(
- "shell hook for %s (%s) not allowlisted — skipped. "
- "Use --accept-hooks / HERMES_ACCEPT_HOOKS=1 / "
- "hooks_auto_accept: true, or approve at the TTY "
- "prompt next run.",
- spec.event, spec.command,
- )
- continue
-
- with _registered_lock:
- if key in _registered:
- continue
- manager._hooks.setdefault(spec.event, []).append(_make_callback(spec))
- _registered.add(key)
- registered.append(spec)
- logger.info(
- "shell hook registered: %s -> %s (matcher=%s, timeout=%ds)",
- spec.event, spec.command, spec.matcher, spec.timeout,
- )
-
- return registered
-
-
-def iter_configured_hooks(cfg: Optional[Dict[str, Any]]) -> List[ShellHookSpec]:
- """Return the parsed ``ShellHookSpec`` entries from config without
- registering anything. Used by ``hermes hooks list`` and ``doctor``."""
- if not isinstance(cfg, dict):
- return []
- return _parse_hooks_block(cfg.get("hooks"))
-
-
-def reset_for_tests() -> None:
- """Clear the idempotence set. Test-only helper."""
- with _registered_lock:
- _registered.clear()
-
-
-# ---------------------------------------------------------------------------
-# Config parsing
-# ---------------------------------------------------------------------------
-
-def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]:
- """Normalise the ``hooks:`` dict into a flat list of ``ShellHookSpec``.
-
- Malformed entries warn-and-skip — we never raise from config parsing
- because a broken hook must not crash the agent.
- """
- from hermes_cli.plugins import VALID_HOOKS
-
- if not isinstance(hooks_cfg, dict):
- return []
-
- specs: List[ShellHookSpec] = []
-
- for event_name, entries in hooks_cfg.items():
- if event_name not in VALID_HOOKS:
- suggestion = difflib.get_close_matches(
- str(event_name), VALID_HOOKS, n=1, cutoff=0.6,
- )
- if suggestion:
- logger.warning(
- "unknown hook event %r in hooks: config — did you mean %r?",
- event_name, suggestion[0],
- )
- else:
- logger.warning(
- "unknown hook event %r in hooks: config (valid: %s)",
- event_name, ", ".join(sorted(VALID_HOOKS)),
- )
- continue
-
- if entries is None:
- continue
-
- if not isinstance(entries, list):
- logger.warning(
- "hooks.%s must be a list of hook definitions; got %s",
- event_name, type(entries).__name__,
- )
- continue
-
- for i, raw in enumerate(entries):
- spec = _parse_single_entry(event_name, i, raw)
- if spec is not None:
- specs.append(spec)
-
- return specs
-
-
-def _parse_single_entry(
- event: str, index: int, raw: Any,
-) -> Optional[ShellHookSpec]:
- if not isinstance(raw, dict):
- logger.warning(
- "hooks.%s[%d] must be a mapping with a 'command' key; got %s",
- event, index, type(raw).__name__,
- )
- return None
-
- command = raw.get("command")
- if not isinstance(command, str) or not command.strip():
- logger.warning(
- "hooks.%s[%d] is missing a non-empty 'command' field",
- event, index,
- )
- return None
-
- matcher = raw.get("matcher")
- if matcher is not None and not isinstance(matcher, str):
- logger.warning(
- "hooks.%s[%d].matcher must be a string regex; ignoring",
- event, index,
- )
- matcher = None
-
- if matcher is not None and event not in {"pre_tool_call", "post_tool_call"}:
- logger.warning(
- "hooks.%s[%d].matcher=%r will be ignored at runtime — the "
- "matcher field is only honored for pre_tool_call / "
- "post_tool_call. The hook will fire on every %s event.",
- event, index, matcher, event,
- )
- matcher = None
-
- timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS)
- try:
- timeout = int(timeout_raw)
- except (TypeError, ValueError):
- logger.warning(
- "hooks.%s[%d].timeout must be an int (got %r); using default %ds",
- event, index, timeout_raw, DEFAULT_TIMEOUT_SECONDS,
- )
- timeout = DEFAULT_TIMEOUT_SECONDS
-
- if timeout < 1:
- logger.warning(
- "hooks.%s[%d].timeout must be >=1; using default %ds",
- event, index, DEFAULT_TIMEOUT_SECONDS,
- )
- timeout = DEFAULT_TIMEOUT_SECONDS
-
- if timeout > MAX_TIMEOUT_SECONDS:
- logger.warning(
- "hooks.%s[%d].timeout=%ds exceeds max %ds; clamping",
- event, index, timeout, MAX_TIMEOUT_SECONDS,
- )
- timeout = MAX_TIMEOUT_SECONDS
-
- return ShellHookSpec(
- event=event,
- command=command.strip(),
- matcher=matcher,
- timeout=timeout,
- )
-
-
-# ---------------------------------------------------------------------------
-# Subprocess callback
-# ---------------------------------------------------------------------------
-
-_TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"}
-
-
-def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
- """Run ``spec.command`` as a subprocess with ``stdin_json`` on stdin.
-
- Returns a diagnostic dict with the same keys for every outcome
- (``returncode``, ``stdout``, ``stderr``, ``timed_out``,
- ``elapsed_seconds``, ``error``). This is the single place the
- subprocess is actually invoked — both the live callback path
- (:func:`_make_callback`) and the CLI test helper (:func:`run_once`)
- go through it.
- """
- result: Dict[str, Any] = {
- "returncode": None,
- "stdout": "",
- "stderr": "",
- "timed_out": False,
- "elapsed_seconds": 0.0,
- "error": None,
- }
- try:
- argv = shlex.split(os.path.expanduser(spec.command))
- except ValueError as exc:
- result["error"] = f"command {spec.command!r} cannot be parsed: {exc}"
- return result
- if not argv:
- result["error"] = "empty command"
- return result
-
- t0 = time.monotonic()
- try:
- proc = subprocess.run(
- argv,
- input=stdin_json,
- capture_output=True,
- timeout=spec.timeout,
- text=True,
- shell=False,
- )
- except subprocess.TimeoutExpired:
- result["timed_out"] = True
- result["elapsed_seconds"] = round(time.monotonic() - t0, 3)
- return result
- except FileNotFoundError:
- result["error"] = "command not found"
- return result
- except PermissionError:
- result["error"] = "command not executable"
- return result
- except Exception as exc: # pragma: no cover — defensive
- result["error"] = str(exc)
- return result
-
- result["returncode"] = proc.returncode
- result["stdout"] = proc.stdout or ""
- result["stderr"] = proc.stderr or ""
- result["elapsed_seconds"] = round(time.monotonic() - t0, 3)
- return result
-
-
-def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]:
- """Build the closure that ``invoke_hook()`` will call per firing."""
-
- def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]:
- # Matcher gate — only meaningful for tool-scoped events.
- if spec.event in {"pre_tool_call", "post_tool_call"}:
- if not spec.matches_tool(kwargs.get("tool_name")):
- return None
-
- r = _spawn(spec, _serialize_payload(spec.event, kwargs))
-
- if r["error"]:
- logger.warning(
- "shell hook failed (event=%s command=%s): %s",
- spec.event, spec.command, r["error"],
- )
- return None
- if r["timed_out"]:
- logger.warning(
- "shell hook timed out after %.2fs (event=%s command=%s)",
- r["elapsed_seconds"], spec.event, spec.command,
- )
- return None
-
- stderr = r["stderr"].strip()
- if stderr:
- logger.debug(
- "shell hook stderr (event=%s command=%s): %s",
- spec.event, spec.command, stderr[:400],
- )
- # Non-zero exits: log but still parse stdout so scripts that
- # signal failure via exit code can also return a block directive.
- if r["returncode"] != 0:
- logger.warning(
- "shell hook exited %d (event=%s command=%s); stderr=%s",
- r["returncode"], spec.event, spec.command, stderr[:400],
- )
- return _parse_response(spec.event, r["stdout"])
-
- _callback.__name__ = f"shell_hook[{spec.event}:{spec.command}]"
- _callback.__qualname__ = _callback.__name__
- return _callback
-
-
-def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str:
- """Render the stdin JSON payload. Unserialisable values are
- stringified via ``default=str`` rather than dropped."""
- extras = {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS}
- try:
- cwd = str(Path.cwd())
- except OSError:
- cwd = ""
- payload = {
- "hook_event_name": event,
- "tool_name": kwargs.get("tool_name"),
- "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None,
- "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "",
- "cwd": cwd,
- "extra": extras,
- }
- return json.dumps(payload, ensure_ascii=False, default=str)
-
-
-def _block_message(primary: Any, secondary: Any) -> str:
- """Return a validated string block message, falling back to the default.
-
- Accepts two candidate fields (primary wins over secondary) so callers
- can express field-priority differences between the two hook wire formats
- without duplicating the type-check logic.
- """
- raw = primary or secondary
- return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE
-
-
-def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
- """Translate stdout JSON into a Hermes wire-shape dict.
-
- For ``pre_tool_call`` the Claude-Code-style ``{"decision": "block",
- "reason": "..."}`` payload is translated into the canonical Hermes
- ``{"action": "block", "message": "..."}`` shape expected by
- :func:`hermes_cli.plugins.get_pre_tool_call_block_message`. This is
- the single most important correctness invariant in this module —
- skipping the translation silently breaks every ``pre_tool_call``
- block directive.
-
- For ``pre_llm_call``, ``{"context": "..."}`` is passed through
- unchanged to match the existing plugin-hook contract.
-
- Anything else returns ``None``.
- """
- stdout = (stdout or "").strip()
- if not stdout:
- return None
-
- try:
- data = json.loads(stdout)
- except json.JSONDecodeError:
- logger.warning(
- "shell hook stdout was not valid JSON (event=%s): %s",
- event, stdout[:200],
- )
- return None
-
- if not isinstance(data, dict):
- return None
-
- if event == "pre_tool_call":
- if data.get("action") == "block":
- return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))}
- if data.get("decision") == "block":
- return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
- return None
-
- context = data.get("context")
- if isinstance(context, str) and context.strip():
- return {"context": context}
-
- return None
-
-
-# ---------------------------------------------------------------------------
-# Allowlist / consent
-# ---------------------------------------------------------------------------
-
-def allowlist_path() -> Path:
- """Path to the per-user shell-hook allowlist file."""
- return get_hermes_home() / ALLOWLIST_FILENAME
-
-
-def load_allowlist() -> Dict[str, Any]:
- """Return the parsed allowlist, or an empty skeleton if absent."""
- try:
- raw = json.loads(allowlist_path().read_text())
- except (FileNotFoundError, json.JSONDecodeError, OSError):
- return {"approvals": []}
- if not isinstance(raw, dict):
- return {"approvals": []}
- approvals = raw.get("approvals")
- if not isinstance(approvals, list):
- raw["approvals"] = []
- return raw
-
-
-def save_allowlist(data: Dict[str, Any]) -> None:
- """Atomically persist the allowlist via per-process ``mkstemp`` +
- ``os.replace``. Cross-process read-modify-write races are handled
- by :func:`_locked_update_approvals` (``fcntl.flock``). On OSError
- the failure is logged; the in-process hook still registers but
- the approval won't survive across runs."""
- p = allowlist_path()
- try:
- p.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp_path = tempfile.mkstemp(
- prefix=f"{p.name}.", suffix=".tmp", dir=str(p.parent),
- )
- try:
- with os.fdopen(fd, "w") as fh:
- fh.write(json.dumps(data, indent=2, sort_keys=True))
- atomic_replace(tmp_path, p)
- except Exception:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- raise
- except OSError as exc:
- logger.warning(
- "Failed to persist shell hook allowlist to %s: %s. "
- "The approval is in-memory for this run, but the next "
- "startup will re-prompt (or skip registration on non-TTY "
- "runs without --accept-hooks / HERMES_ACCEPT_HOOKS).",
- p, exc,
- )
-
-
-def _is_allowlisted(event: str, command: str) -> bool:
- data = load_allowlist()
- return any(
- isinstance(e, dict)
- and e.get("event") == event
- and e.get("command") == command
- for e in data.get("approvals", [])
- )
-
-
-@contextmanager
-def _locked_update_approvals() -> Iterator[Dict[str, Any]]:
- """Serialise read-modify-write on the allowlist across processes.
-
- Holds an exclusive ``flock`` on a sibling lock file for the duration
- of the update so concurrent ``_record_approval``/``revoke`` callers
- cannot clobber each other's changes (the race Codex reproduced with
- 20–50 simultaneous writers). Falls back to an in-process lock on
- platforms without ``fcntl``.
- """
- p = allowlist_path()
- p.parent.mkdir(parents=True, exist_ok=True)
- lock_path = p.with_suffix(p.suffix + ".lock")
-
- if fcntl is None: # pragma: no cover — non-POSIX fallback
- with _allowlist_write_lock:
- data = load_allowlist()
- yield data
- save_allowlist(data)
- return
-
- with open(lock_path, "a+", encoding="utf-8") as lock_fh:
- fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
- try:
- data = load_allowlist()
- yield data
- save_allowlist(data)
- finally:
- try:
- fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
- except (OSError, IOError):
- pass
-
-
-def _prompt_and_record(
- event: str, command: str, *, accept_hooks: bool,
-) -> bool:
- """Decide whether to approve an unseen ``(event, command)`` pair.
- Returns ``True`` iff the approval was granted and recorded.
- """
- if accept_hooks:
- _record_approval(event, command)
- logger.info(
- "shell hook auto-approved via --accept-hooks / env / config: "
- "%s -> %s", event, command,
- )
- return True
-
- if not sys.stdin.isatty():
- return False
-
- print(
- f"\n⚠ Hermes is about to register a shell hook that will run a\n"
- f" command on your behalf.\n\n"
- f" Event: {event}\n"
- f" Command: {command}\n\n"
- f" Commands run with your full user credentials. Only approve\n"
- f" commands you trust."
- )
- try:
- answer = input("Allow this hook to run? [y/N]: ").strip().lower()
- except (EOFError, KeyboardInterrupt):
- print() # keep the terminal tidy after ^C
- return False
-
- if answer in {"y", "yes"}:
- _record_approval(event, command)
- return True
-
- return False
-
-
-def _record_approval(event: str, command: str) -> None:
- entry = {
- "event": event,
- "command": command,
- "approved_at": _utc_now_iso(),
- "script_mtime_at_approval": script_mtime_iso(command),
- }
- with _locked_update_approvals() as data:
- data["approvals"] = [
- e for e in data.get("approvals", [])
- if not (
- isinstance(e, dict)
- and e.get("event") == event
- and e.get("command") == command
- )
- ] + [entry]
-
-
-def _utc_now_iso() -> str:
- return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
-
-
-def revoke(command: str) -> int:
- """Remove every allowlist entry matching ``command``.
-
- Returns the number of entries removed. Does not unregister any
- callbacks that are already live on the plugin manager in the current
- process — restart the CLI / gateway to drop them.
- """
- with _locked_update_approvals() as data:
- before = len(data.get("approvals", []))
- data["approvals"] = [
- e for e in data.get("approvals", [])
- if not (isinstance(e, dict) and e.get("command") == command)
- ]
- after = len(data["approvals"])
- return before - after
-
-
-_SCRIPT_EXTENSIONS: Tuple[str, ...] = (
- ".sh", ".bash", ".zsh", ".fish",
- ".py", ".pyw",
- ".rb", ".pl", ".lua",
- ".js", ".mjs", ".cjs", ".ts",
-)
-
-
-def _command_script_path(command: str) -> str:
- """Return the script path from ``command`` for doctor / drift checks.
-
- Prefers a token ending in a known script extension, then a token
- containing ``/`` or leading ``~``, then the first token. Handles
- ``python3 /path/hook.py``, ``/usr/bin/env bash hook.sh``, and the
- common bare-path form.
- """
- try:
- parts = shlex.split(command)
- except ValueError:
- return command
- if not parts:
- return command
- for part in parts:
- if part.lower().endswith(_SCRIPT_EXTENSIONS):
- return part
- for part in parts:
- if "/" in part or part.startswith("~"):
- return part
- return parts[0]
-
-
-# ---------------------------------------------------------------------------
-# Helpers for accept-hooks resolution
-# ---------------------------------------------------------------------------
-
-def _resolve_effective_accept(
- cfg: Dict[str, Any], accept_hooks_arg: bool,
-) -> bool:
- """Combine all three opt-in channels into a single boolean.
-
- Precedence (any truthy source flips us on):
- 1. ``--accept-hooks`` flag (CLI) / explicit argument
- 2. ``HERMES_ACCEPT_HOOKS`` env var
- 3. ``hooks_auto_accept: true`` in ``cli-config.yaml``
- """
- if accept_hooks_arg:
- return True
- env = os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower()
- if env in {"1", "true", "yes", "on"}:
- return True
- cfg_val = cfg.get("hooks_auto_accept", False)
- if isinstance(cfg_val, bool):
- return cfg_val
- if isinstance(cfg_val, str):
- return cfg_val.strip().lower() in {"1", "true", "yes", "on"}
- return False
-
-
-# ---------------------------------------------------------------------------
-# Introspection (used by `hermes hooks` CLI)
-# ---------------------------------------------------------------------------
-
-def allowlist_entry_for(event: str, command: str) -> Optional[Dict[str, Any]]:
- """Return the allowlist record for this pair, if any."""
- for e in load_allowlist().get("approvals", []):
- if (
- isinstance(e, dict)
- and e.get("event") == event
- and e.get("command") == command
- ):
- return e
- return None
-
-
-def script_mtime_iso(command: str) -> Optional[str]:
- """ISO-8601 mtime of the resolved script path, or ``None`` if the
- script is missing."""
- path = _command_script_path(command)
- if not path:
- return None
- try:
- expanded = os.path.expanduser(path)
- return datetime.fromtimestamp(
- os.path.getmtime(expanded), tz=timezone.utc,
- ).isoformat().replace("+00:00", "Z")
- except OSError:
- return None
-
-
-def script_is_executable(command: str) -> bool:
- """Return ``True`` iff ``command`` is runnable as configured.
-
- For a bare invocation (``/path/hook.sh``) the script itself must be
- executable. For interpreter-prefixed commands (``python3
- /path/hook.py``, ``/usr/bin/env bash hook.sh``) the script just has
- to be readable — the interpreter doesn't care about the ``X_OK``
- bit. Mirrors what ``_spawn`` would actually do at runtime."""
- path = _command_script_path(command)
- if not path:
- return False
- expanded = os.path.expanduser(path)
- if not os.path.isfile(expanded):
- return False
- try:
- argv = shlex.split(command)
- except ValueError:
- return False
- is_bare_invocation = bool(argv) and argv[0] == path
- required = os.X_OK if is_bare_invocation else os.R_OK
- return os.access(expanded, required)
-
-
-def run_once(
- spec: ShellHookSpec, kwargs: Dict[str, Any],
-) -> Dict[str, Any]:
- """Fire a single shell-hook invocation with a synthetic payload.
- Used by ``hermes hooks test`` and ``hermes hooks doctor``.
-
- ``kwargs`` is the same dict that :func:`hermes_cli.plugins.invoke_hook`
- would pass at runtime. It is routed through :func:`_serialize_payload`
- so the synthetic stdin exactly matches what a real hook firing would
- produce — otherwise scripts tested via ``hermes hooks test`` could
- diverge silently from production behaviour.
-
- Returns the :func:`_spawn` diagnostic dict plus a ``parsed`` field
- holding the canonical Hermes-wire-shape response."""
- stdin_json = _serialize_payload(spec.event, kwargs)
- result = _spawn(spec, stdin_json)
- result["parsed"] = _parse_response(spec.event, result["stdout"])
- return result
+"""
+Shell-script hooks bridge.
+
+Reads the ``hooks:`` block from ``cli-config.yaml``, prompts the user for
+consent on first use of each ``(event, command)`` pair, and registers
+callbacks on the existing plugin hook manager so every existing
+``invoke_hook()`` site dispatches to the configured shell scripts — with
+zero changes to call sites.
+
+Design notes
+------------
+* Python plugins and shell hooks compose naturally: both flow through
+ :func:`hermes_cli.plugins.invoke_hook` and its aggregators. Python
+ plugins are registered first (via ``discover_and_load()``) so their
+ block decisions win ties over shell-hook blocks.
+* Subprocess execution uses ``shlex.split(os.path.expanduser(command))``
+ with ``shell=False`` — no shell injection footguns. Users that need
+ pipes/redirection wrap their logic in a script.
+* First-use consent is gated by the allowlist under
+ ``~/.hermes/shell-hooks-allowlist.json``. Non-TTY callers must pass
+ ``accept_hooks=True`` (resolved from ``--accept-hooks``,
+ ``HERMES_ACCEPT_HOOKS``, or ``hooks_auto_accept: true`` in config)
+ for registration to succeed without a prompt.
+* Registration is idempotent — safe to invoke from both the CLI entry
+ point (``hermes_cli/main.py``) and the gateway entry point
+ (``gateway/run.py``).
+
+Wire protocol
+-------------
+**stdin** (JSON, piped to the script)::
+
+ {
+ "hook_event_name": "pre_tool_call",
+ "tool_name": "terminal",
+ "tool_input": {"command": "rm -rf /"},
+ "session_id": "sess_abc123",
+ "cwd": "/home/user/project",
+ "extra": {...} # event-specific kwargs
+ }
+
+**stdout** (JSON, optional — anything else is ignored)::
+
+ # Block a pre_tool_call (either shape accepted; normalised internally):
+ {"decision": "block", "reason": "Forbidden command"} # Claude-Code-style
+ {"action": "block", "message": "Forbidden command"} # Hermes-canonical
+
+ # Inject context for pre_llm_call:
+ {"context": "Today is Friday"}
+
+ # Silent no-op:
+
+"""
+
+from __future__ import annotations
+
+import difflib
+import json
+import logging
+import os
+import re
+import shlex
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from contextlib import contextmanager
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Callable, Dict, Iterator, List, Optional, Set, Tuple
+
+try:
+ import fcntl # POSIX only; Windows falls back to best-effort without flock.
+except ImportError: # pragma: no cover
+ fcntl = None # type: ignore[assignment]
+
+from hermes_constants import get_hermes_home
+from utils import atomic_replace
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_TIMEOUT_SECONDS = 60
+MAX_TIMEOUT_SECONDS = 300
+ALLOWLIST_FILENAME = "shell-hooks-allowlist.json"
+_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook."
+
+# (event, matcher, command) triples that have been wired to the plugin
+# manager in the current process. Matcher is part of the key because
+# the same script can legitimately register for different matchers under
+# the same event (e.g. one entry per tool the user wants to gate).
+# Second registration attempts for the exact same triple become no-ops
+# so the CLI and gateway can both call register_from_config() safely.
+_registered: Set[Tuple[str, Optional[str], str]] = set()
+_registered_lock = threading.Lock()
+
+# Intra-process lock for allowlist read-modify-write on platforms that
+# lack ``fcntl`` (non-POSIX). Kept separate from ``_registered_lock``
+# because ``register_from_config`` already holds ``_registered_lock`` when
+# it triggers ``_record_approval`` — reusing it here would self-deadlock
+# (``threading.Lock`` is non-reentrant). POSIX callers use the sibling
+# ``.lock`` file via ``fcntl.flock`` and bypass this.
+_allowlist_write_lock = threading.Lock()
+
+
+@dataclass
+class ShellHookSpec:
+ """Parsed and validated representation of a single ``hooks:`` entry."""
+
+ event: str
+ command: str
+ matcher: Optional[str] = None
+ timeout: int = DEFAULT_TIMEOUT_SECONDS
+ compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False)
+
+ def __post_init__(self) -> None:
+ # Strip whitespace introduced by YAML quirks (e.g. multi-line string
+ # folding) — a matcher of " terminal" would otherwise silently fail
+ # to match "terminal" without any diagnostic.
+ if isinstance(self.matcher, str):
+ stripped = self.matcher.strip()
+ self.matcher = stripped if stripped else None
+ if self.matcher:
+ try:
+ self.compiled_matcher = re.compile(self.matcher)
+ except re.error as exc:
+ logger.warning(
+ "shell hook matcher %r is invalid (%s) — treating as "
+ "literal equality", self.matcher, exc,
+ )
+ self.compiled_matcher = None
+
+ def matches_tool(self, tool_name: Optional[str]) -> bool:
+ if not self.matcher:
+ return True
+ if tool_name is None:
+ return False
+ if self.compiled_matcher is not None:
+ return self.compiled_matcher.fullmatch(tool_name) is not None
+ # compiled_matcher is None only when the regex failed to compile,
+ # in which case we already warned and fall back to literal equality.
+ return tool_name == self.matcher
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def register_from_config(
+ cfg: Optional[Dict[str, Any]],
+ *,
+ accept_hooks: bool = False,
+) -> List[ShellHookSpec]:
+ """Register every configured shell hook on the plugin manager.
+
+ ``cfg`` is the full parsed config dict (``hermes_cli.config.load_config``
+ output). The ``hooks:`` key is read out of it. Missing, empty, or
+ non-dict ``hooks`` is treated as zero configured hooks.
+
+ ``accept_hooks=True`` skips the TTY consent prompt — the caller is
+ promising that the user has opted in via a flag, env var, or config
+ setting. ``HERMES_ACCEPT_HOOKS=1`` and ``hooks_auto_accept: true`` are
+ also honored inside this function so either CLI or gateway call sites
+ pick them up.
+
+ Returns the list of :class:`ShellHookSpec` entries that ended up wired
+ up on the plugin manager. Skipped entries (unknown events, malformed,
+ not allowlisted, already registered) are logged but not returned.
+ """
+ if not isinstance(cfg, dict):
+ return []
+
+ effective_accept = _resolve_effective_accept(cfg, accept_hooks)
+
+ specs = _parse_hooks_block(cfg.get("hooks"))
+ if not specs:
+ return []
+
+ registered: List[ShellHookSpec] = []
+
+ # Import lazily — avoids circular imports at module-load time.
+ from hermes_cli.plugins import get_plugin_manager
+
+ manager = get_plugin_manager()
+
+ # Idempotence + allowlist read happen under the lock; the TTY
+ # prompt runs outside so other threads aren't parked on a blocking
+ # input(). Mutation re-takes the lock with a defensive idempotence
+ # re-check in case two callers ever race through the prompt.
+ for spec in specs:
+ key = (spec.event, spec.matcher, spec.command)
+ with _registered_lock:
+ if key in _registered:
+ continue
+ already_allowlisted = _is_allowlisted(spec.event, spec.command)
+
+ if not already_allowlisted:
+ if not _prompt_and_record(
+ spec.event, spec.command, accept_hooks=effective_accept,
+ ):
+ logger.warning(
+ "shell hook for %s (%s) not allowlisted — skipped. "
+ "Use --accept-hooks / HERMES_ACCEPT_HOOKS=1 / "
+ "hooks_auto_accept: true, or approve at the TTY "
+ "prompt next run.",
+ spec.event, spec.command,
+ )
+ continue
+
+ with _registered_lock:
+ if key in _registered:
+ continue
+ manager._hooks.setdefault(spec.event, []).append(_make_callback(spec))
+ _registered.add(key)
+ registered.append(spec)
+ logger.info(
+ "shell hook registered: %s -> %s (matcher=%s, timeout=%ds)",
+ spec.event, spec.command, spec.matcher, spec.timeout,
+ )
+
+ return registered
+
+
+def iter_configured_hooks(cfg: Optional[Dict[str, Any]]) -> List[ShellHookSpec]:
+ """Return the parsed ``ShellHookSpec`` entries from config without
+ registering anything. Used by ``hermes hooks list`` and ``doctor``."""
+ if not isinstance(cfg, dict):
+ return []
+ return _parse_hooks_block(cfg.get("hooks"))
+
+
+def reset_for_tests() -> None:
+ """Clear the idempotence set. Test-only helper."""
+ with _registered_lock:
+ _registered.clear()
+
+
+# ---------------------------------------------------------------------------
+# Config parsing
+# ---------------------------------------------------------------------------
+
+def _parse_hooks_block(hooks_cfg: Any) -> List[ShellHookSpec]:
+ """Normalise the ``hooks:`` dict into a flat list of ``ShellHookSpec``.
+
+ Malformed entries warn-and-skip — we never raise from config parsing
+ because a broken hook must not crash the agent.
+ """
+ from hermes_cli.plugins import VALID_HOOKS
+
+ if not isinstance(hooks_cfg, dict):
+ return []
+
+ specs: List[ShellHookSpec] = []
+
+ for event_name, entries in hooks_cfg.items():
+ if event_name not in VALID_HOOKS:
+ suggestion = difflib.get_close_matches(
+ str(event_name), VALID_HOOKS, n=1, cutoff=0.6,
+ )
+ if suggestion:
+ logger.warning(
+ "unknown hook event %r in hooks: config — did you mean %r?",
+ event_name, suggestion[0],
+ )
+ else:
+ logger.warning(
+ "unknown hook event %r in hooks: config (valid: %s)",
+ event_name, ", ".join(sorted(VALID_HOOKS)),
+ )
+ continue
+
+ if entries is None:
+ continue
+
+ if not isinstance(entries, list):
+ logger.warning(
+ "hooks.%s must be a list of hook definitions; got %s",
+ event_name, type(entries).__name__,
+ )
+ continue
+
+ for i, raw in enumerate(entries):
+ spec = _parse_single_entry(event_name, i, raw)
+ if spec is not None:
+ specs.append(spec)
+
+ return specs
+
+
+def _parse_single_entry(
+ event: str, index: int, raw: Any,
+) -> Optional[ShellHookSpec]:
+ if not isinstance(raw, dict):
+ logger.warning(
+ "hooks.%s[%d] must be a mapping with a 'command' key; got %s",
+ event, index, type(raw).__name__,
+ )
+ return None
+
+ command = raw.get("command")
+ if not isinstance(command, str) or not command.strip():
+ logger.warning(
+ "hooks.%s[%d] is missing a non-empty 'command' field",
+ event, index,
+ )
+ return None
+
+ matcher = raw.get("matcher")
+ if matcher is not None and not isinstance(matcher, str):
+ logger.warning(
+ "hooks.%s[%d].matcher must be a string regex; ignoring",
+ event, index,
+ )
+ matcher = None
+
+ if matcher is not None and event not in {"pre_tool_call", "post_tool_call"}:
+ logger.warning(
+ "hooks.%s[%d].matcher=%r will be ignored at runtime — the "
+ "matcher field is only honored for pre_tool_call / "
+ "post_tool_call. The hook will fire on every %s event.",
+ event, index, matcher, event,
+ )
+ matcher = None
+
+ timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS)
+ try:
+ timeout = int(timeout_raw)
+ except (TypeError, ValueError):
+ logger.warning(
+ "hooks.%s[%d].timeout must be an int (got %r); using default %ds",
+ event, index, timeout_raw, DEFAULT_TIMEOUT_SECONDS,
+ )
+ timeout = DEFAULT_TIMEOUT_SECONDS
+
+ if timeout < 1:
+ logger.warning(
+ "hooks.%s[%d].timeout must be >=1; using default %ds",
+ event, index, DEFAULT_TIMEOUT_SECONDS,
+ )
+ timeout = DEFAULT_TIMEOUT_SECONDS
+
+ if timeout > MAX_TIMEOUT_SECONDS:
+ logger.warning(
+ "hooks.%s[%d].timeout=%ds exceeds max %ds; clamping",
+ event, index, timeout, MAX_TIMEOUT_SECONDS,
+ )
+ timeout = MAX_TIMEOUT_SECONDS
+
+ return ShellHookSpec(
+ event=event,
+ command=command.strip(),
+ matcher=matcher,
+ timeout=timeout,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Subprocess callback
+# ---------------------------------------------------------------------------
+
+_TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"}
+
+
+def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
+ """Run ``spec.command`` as a subprocess with ``stdin_json`` on stdin.
+
+ Returns a diagnostic dict with the same keys for every outcome
+ (``returncode``, ``stdout``, ``stderr``, ``timed_out``,
+ ``elapsed_seconds``, ``error``). This is the single place the
+ subprocess is actually invoked — both the live callback path
+ (:func:`_make_callback`) and the CLI test helper (:func:`run_once`)
+ go through it.
+ """
+ result: Dict[str, Any] = {
+ "returncode": None,
+ "stdout": "",
+ "stderr": "",
+ "timed_out": False,
+ "elapsed_seconds": 0.0,
+ "error": None,
+ }
+ try:
+ argv = shlex.split(os.path.expanduser(spec.command))
+ except ValueError as exc:
+ result["error"] = f"command {spec.command!r} cannot be parsed: {exc}"
+ return result
+ if not argv:
+ result["error"] = "empty command"
+ return result
+
+ t0 = time.monotonic()
+ try:
+ _extra: dict = {}
+ if sys.platform == "win32":
+ from hermes_cli._subprocess_compat import windows_hide_flags
+ _extra["creationflags"] = windows_hide_flags()
+ proc = subprocess.run(
+ argv,
+ input=stdin_json,
+ capture_output=True,
+ timeout=spec.timeout,
+ text=True,
+ shell=False,
+ **_extra,
+ )
+ except subprocess.TimeoutExpired:
+ result["timed_out"] = True
+ result["elapsed_seconds"] = round(time.monotonic() - t0, 3)
+ return result
+ except FileNotFoundError:
+ result["error"] = "command not found"
+ return result
+ except PermissionError:
+ result["error"] = "command not executable"
+ return result
+ except Exception as exc: # pragma: no cover — defensive
+ result["error"] = str(exc)
+ return result
+
+ result["returncode"] = proc.returncode
+ result["stdout"] = proc.stdout or ""
+ result["stderr"] = proc.stderr or ""
+ result["elapsed_seconds"] = round(time.monotonic() - t0, 3)
+ return result
+
+
+def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]:
+ """Build the closure that ``invoke_hook()`` will call per firing."""
+
+ def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]:
+ # Matcher gate — only meaningful for tool-scoped events.
+ if spec.event in {"pre_tool_call", "post_tool_call"}:
+ if not spec.matches_tool(kwargs.get("tool_name")):
+ return None
+
+ r = _spawn(spec, _serialize_payload(spec.event, kwargs))
+
+ if r["error"]:
+ logger.warning(
+ "shell hook failed (event=%s command=%s): %s",
+ spec.event, spec.command, r["error"],
+ )
+ return None
+ if r["timed_out"]:
+ logger.warning(
+ "shell hook timed out after %.2fs (event=%s command=%s)",
+ r["elapsed_seconds"], spec.event, spec.command,
+ )
+ return None
+
+ stderr = r["stderr"].strip()
+ if stderr:
+ logger.debug(
+ "shell hook stderr (event=%s command=%s): %s",
+ spec.event, spec.command, stderr[:400],
+ )
+ # Non-zero exits: log but still parse stdout so scripts that
+ # signal failure via exit code can also return a block directive.
+ if r["returncode"] != 0:
+ logger.warning(
+ "shell hook exited %d (event=%s command=%s); stderr=%s",
+ r["returncode"], spec.event, spec.command, stderr[:400],
+ )
+ return _parse_response(spec.event, r["stdout"])
+
+ _callback.__name__ = f"shell_hook[{spec.event}:{spec.command}]"
+ _callback.__qualname__ = _callback.__name__
+ return _callback
+
+
+def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str:
+ """Render the stdin JSON payload. Unserialisable values are
+ stringified via ``default=str`` rather than dropped."""
+ extras = {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS}
+ try:
+ cwd = str(Path.cwd())
+ except OSError:
+ cwd = ""
+ payload = {
+ "hook_event_name": event,
+ "tool_name": kwargs.get("tool_name"),
+ "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None,
+ "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "",
+ "cwd": cwd,
+ "extra": extras,
+ }
+ return json.dumps(payload, ensure_ascii=False, default=str)
+
+
+def _block_message(primary: Any, secondary: Any) -> str:
+ """Return a validated string block message, falling back to the default.
+
+ Accepts two candidate fields (primary wins over secondary) so callers
+ can express field-priority differences between the two hook wire formats
+ without duplicating the type-check logic.
+ """
+ raw = primary or secondary
+ return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE
+
+
+def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
+ """Translate stdout JSON into a Hermes wire-shape dict.
+
+ For ``pre_tool_call`` the Claude-Code-style ``{"decision": "block",
+ "reason": "..."}`` payload is translated into the canonical Hermes
+ ``{"action": "block", "message": "..."}`` shape expected by
+ :func:`hermes_cli.plugins.get_pre_tool_call_block_message`. This is
+ the single most important correctness invariant in this module —
+ skipping the translation silently breaks every ``pre_tool_call``
+ block directive.
+
+ For ``pre_llm_call``, ``{"context": "..."}`` is passed through
+ unchanged to match the existing plugin-hook contract.
+
+ Anything else returns ``None``.
+ """
+ stdout = (stdout or "").strip()
+ if not stdout:
+ return None
+
+ try:
+ data = json.loads(stdout)
+ except json.JSONDecodeError:
+ logger.warning(
+ "shell hook stdout was not valid JSON (event=%s): %s",
+ event, stdout[:200],
+ )
+ return None
+
+ if not isinstance(data, dict):
+ return None
+
+ if event == "pre_tool_call":
+ if data.get("action") == "block":
+ return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))}
+ if data.get("decision") == "block":
+ return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
+ return None
+
+ context = data.get("context")
+ if isinstance(context, str) and context.strip():
+ return {"context": context}
+
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Allowlist / consent
+# ---------------------------------------------------------------------------
+
+def allowlist_path() -> Path:
+ """Path to the per-user shell-hook allowlist file."""
+ return get_hermes_home() / ALLOWLIST_FILENAME
+
+
+def load_allowlist() -> Dict[str, Any]:
+ """Return the parsed allowlist, or an empty skeleton if absent."""
+ try:
+ raw = json.loads(allowlist_path().read_text())
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
+ return {"approvals": []}
+ if not isinstance(raw, dict):
+ return {"approvals": []}
+ approvals = raw.get("approvals")
+ if not isinstance(approvals, list):
+ raw["approvals"] = []
+ return raw
+
+
+def save_allowlist(data: Dict[str, Any]) -> None:
+ """Atomically persist the allowlist via per-process ``mkstemp`` +
+ ``os.replace``. Cross-process read-modify-write races are handled
+ by :func:`_locked_update_approvals` (``fcntl.flock``). On OSError
+ the failure is logged; the in-process hook still registers but
+ the approval won't survive across runs."""
+ p = allowlist_path()
+ try:
+ p.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(
+ prefix=f"{p.name}.", suffix=".tmp", dir=str(p.parent),
+ )
+ try:
+ with os.fdopen(fd, "w") as fh:
+ fh.write(json.dumps(data, indent=2, sort_keys=True))
+ atomic_replace(tmp_path, p)
+ except Exception:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+ except OSError as exc:
+ logger.warning(
+ "Failed to persist shell hook allowlist to %s: %s. "
+ "The approval is in-memory for this run, but the next "
+ "startup will re-prompt (or skip registration on non-TTY "
+ "runs without --accept-hooks / HERMES_ACCEPT_HOOKS).",
+ p, exc,
+ )
+
+
+def _is_allowlisted(event: str, command: str) -> bool:
+ data = load_allowlist()
+ return any(
+ isinstance(e, dict)
+ and e.get("event") == event
+ and e.get("command") == command
+ for e in data.get("approvals", [])
+ )
+
+
+@contextmanager
+def _locked_update_approvals() -> Iterator[Dict[str, Any]]:
+ """Serialise read-modify-write on the allowlist across processes.
+
+ Holds an exclusive ``flock`` on a sibling lock file for the duration
+ of the update so concurrent ``_record_approval``/``revoke`` callers
+ cannot clobber each other's changes (the race Codex reproduced with
+ 20–50 simultaneous writers). Falls back to an in-process lock on
+ platforms without ``fcntl``.
+ """
+ p = allowlist_path()
+ p.parent.mkdir(parents=True, exist_ok=True)
+ lock_path = p.with_suffix(p.suffix + ".lock")
+
+ if fcntl is None: # pragma: no cover — non-POSIX fallback
+ with _allowlist_write_lock:
+ data = load_allowlist()
+ yield data
+ save_allowlist(data)
+ return
+
+ with open(lock_path, "a+", encoding="utf-8") as lock_fh:
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
+ try:
+ data = load_allowlist()
+ yield data
+ save_allowlist(data)
+ finally:
+ try:
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
+ except (OSError, IOError):
+ pass
+
+
+def _prompt_and_record(
+ event: str, command: str, *, accept_hooks: bool,
+) -> bool:
+ """Decide whether to approve an unseen ``(event, command)`` pair.
+ Returns ``True`` iff the approval was granted and recorded.
+ """
+ if accept_hooks:
+ _record_approval(event, command)
+ logger.info(
+ "shell hook auto-approved via --accept-hooks / env / config: "
+ "%s -> %s", event, command,
+ )
+ return True
+
+ if not sys.stdin.isatty():
+ return False
+
+ print(
+ f"\n⚠ Hermes is about to register a shell hook that will run a\n"
+ f" command on your behalf.\n\n"
+ f" Event: {event}\n"
+ f" Command: {command}\n\n"
+ f" Commands run with your full user credentials. Only approve\n"
+ f" commands you trust."
+ )
+ try:
+ answer = input("Allow this hook to run? [y/N]: ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ print() # keep the terminal tidy after ^C
+ return False
+
+ if answer in {"y", "yes"}:
+ _record_approval(event, command)
+ return True
+
+ return False
+
+
+def _record_approval(event: str, command: str) -> None:
+ entry = {
+ "event": event,
+ "command": command,
+ "approved_at": _utc_now_iso(),
+ "script_mtime_at_approval": script_mtime_iso(command),
+ }
+ with _locked_update_approvals() as data:
+ data["approvals"] = [
+ e for e in data.get("approvals", [])
+ if not (
+ isinstance(e, dict)
+ and e.get("event") == event
+ and e.get("command") == command
+ )
+ ] + [entry]
+
+
+def _utc_now_iso() -> str:
+ return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def revoke(command: str) -> int:
+ """Remove every allowlist entry matching ``command``.
+
+ Returns the number of entries removed. Does not unregister any
+ callbacks that are already live on the plugin manager in the current
+ process — restart the CLI / gateway to drop them.
+ """
+ with _locked_update_approvals() as data:
+ before = len(data.get("approvals", []))
+ data["approvals"] = [
+ e for e in data.get("approvals", [])
+ if not (isinstance(e, dict) and e.get("command") == command)
+ ]
+ after = len(data["approvals"])
+ return before - after
+
+
+_SCRIPT_EXTENSIONS: Tuple[str, ...] = (
+ ".sh", ".bash", ".zsh", ".fish",
+ ".py", ".pyw",
+ ".rb", ".pl", ".lua",
+ ".js", ".mjs", ".cjs", ".ts",
+)
+
+
+def _command_script_path(command: str) -> str:
+ """Return the script path from ``command`` for doctor / drift checks.
+
+ Prefers a token ending in a known script extension, then a token
+ containing ``/`` or leading ``~``, then the first token. Handles
+ ``python3 /path/hook.py``, ``/usr/bin/env bash hook.sh``, and the
+ common bare-path form.
+ """
+ try:
+ parts = shlex.split(command)
+ except ValueError:
+ return command
+ if not parts:
+ return command
+ for part in parts:
+ if part.lower().endswith(_SCRIPT_EXTENSIONS):
+ return part
+ for part in parts:
+ if "/" in part or part.startswith("~"):
+ return part
+ return parts[0]
+
+
+# ---------------------------------------------------------------------------
+# Helpers for accept-hooks resolution
+# ---------------------------------------------------------------------------
+
+def _resolve_effective_accept(
+ cfg: Dict[str, Any], accept_hooks_arg: bool,
+) -> bool:
+ """Combine all three opt-in channels into a single boolean.
+
+ Precedence (any truthy source flips us on):
+ 1. ``--accept-hooks`` flag (CLI) / explicit argument
+ 2. ``HERMES_ACCEPT_HOOKS`` env var
+ 3. ``hooks_auto_accept: true`` in ``cli-config.yaml``
+ """
+ if accept_hooks_arg:
+ return True
+ env = os.environ.get("HERMES_ACCEPT_HOOKS", "").strip().lower()
+ if env in {"1", "true", "yes", "on"}:
+ return True
+ cfg_val = cfg.get("hooks_auto_accept", False)
+ if isinstance(cfg_val, bool):
+ return cfg_val
+ if isinstance(cfg_val, str):
+ return cfg_val.strip().lower() in {"1", "true", "yes", "on"}
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Introspection (used by `hermes hooks` CLI)
+# ---------------------------------------------------------------------------
+
+def allowlist_entry_for(event: str, command: str) -> Optional[Dict[str, Any]]:
+ """Return the allowlist record for this pair, if any."""
+ for e in load_allowlist().get("approvals", []):
+ if (
+ isinstance(e, dict)
+ and e.get("event") == event
+ and e.get("command") == command
+ ):
+ return e
+ return None
+
+
+def script_mtime_iso(command: str) -> Optional[str]:
+ """ISO-8601 mtime of the resolved script path, or ``None`` if the
+ script is missing."""
+ path = _command_script_path(command)
+ if not path:
+ return None
+ try:
+ expanded = os.path.expanduser(path)
+ return datetime.fromtimestamp(
+ os.path.getmtime(expanded), tz=timezone.utc,
+ ).isoformat().replace("+00:00", "Z")
+ except OSError:
+ return None
+
+
+def script_is_executable(command: str) -> bool:
+ """Return ``True`` iff ``command`` is runnable as configured.
+
+ For a bare invocation (``/path/hook.sh``) the script itself must be
+ executable. For interpreter-prefixed commands (``python3
+ /path/hook.py``, ``/usr/bin/env bash hook.sh``) the script just has
+ to be readable — the interpreter doesn't care about the ``X_OK``
+ bit. Mirrors what ``_spawn`` would actually do at runtime."""
+ path = _command_script_path(command)
+ if not path:
+ return False
+ expanded = os.path.expanduser(path)
+ if not os.path.isfile(expanded):
+ return False
+ try:
+ argv = shlex.split(command)
+ except ValueError:
+ return False
+ is_bare_invocation = bool(argv) and argv[0] == path
+ required = os.X_OK if is_bare_invocation else os.R_OK
+ return os.access(expanded, required)
+
+
+def run_once(
+ spec: ShellHookSpec, kwargs: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Fire a single shell-hook invocation with a synthetic payload.
+ Used by ``hermes hooks test`` and ``hermes hooks doctor``.
+
+ ``kwargs`` is the same dict that :func:`hermes_cli.plugins.invoke_hook`
+ would pass at runtime. It is routed through :func:`_serialize_payload`
+ so the synthetic stdin exactly matches what a real hook firing would
+ produce — otherwise scripts tested via ``hermes hooks test`` could
+ diverge silently from production behaviour.
+
+ Returns the :func:`_spawn` diagnostic dict plus a ``parsed`` field
+ holding the canonical Hermes-wire-shape response."""
+ stdin_json = _serialize_payload(spec.event, kwargs)
+ result = _spawn(spec, stdin_json)
+ result["parsed"] = _parse_response(spec.event, result["stdout"])
+ return result
diff --git a/cron/scheduler.py b/cron/scheduler.py
index 359069966195..be7630c25233 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -965,7 +965,24 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
)
argv = [_bash, str(path)]
else:
- argv = [sys.executable, str(path)]
+ if sys.platform == "win32":
+ # On Windows, wrap Python scripts so hermes_bootstrap is
+ # imported first. The bootstrap monkey-patches subprocess.Popen
+ # to inject CREATE_NO_WINDOW -- without it, any subprocess call
+ # inside the script (e.g. spawning pwsh) flashes a console
+ # window because the cron child process does not load bootstrap
+ # on its own.
+ _script_path = str(path).replace("\\", "\\\\")
+ argv = [
+ sys.executable, "-c",
+ (
+ "import hermes_bootstrap; "
+ f"exec(compile(open(r'{_script_path}').read(),"
+ f" r'{_script_path}', 'exec'))"
+ ),
+ ]
+ else:
+ argv = [sys.executable, str(path)]
try:
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
diff --git a/hermes_bootstrap.py b/hermes_bootstrap.py
index 890336c3448e..354b3898684c 100644
--- a/hermes_bootstrap.py
+++ b/hermes_bootstrap.py
@@ -1,129 +1,159 @@
-"""Windows UTF-8 bootstrap for Hermes entry points.
-
-Python on Windows has two long-standing text-encoding footguns:
-
-1. ``sys.stdout`` / ``sys.stderr`` are bound to the console code page
- (``cp1252`` on US-locale installs), so ``print("café")`` crashes with
- ``UnicodeEncodeError: 'charmap' codec can't encode character``.
-
-2. Child processes spawned via ``subprocess`` don't know to use UTF-8
- unless ``PYTHONUTF8`` and/or ``PYTHONIOENCODING`` are set in their
- environment — so any Python subprocess (the execute_code sandbox,
- delegation children, linter subprocesses, etc.) inherits the same
- cp1252 defaults and hits the same UnicodeEncodeError.
-
-This module fixes both on Windows *only* — POSIX is untouched. It
-should be imported at the very top of every Hermes entry point
-(``hermes``, ``hermes-agent``, ``hermes-acp``, ``python -m gateway.run``,
-``batch_runner.py``, ``cron/scheduler.py``) before any other imports
-that might do file I/O or print to stdout.
-
-What this module does on Windows:
-
- - Sets ``os.environ["PYTHONUTF8"] = "1"`` (PEP 540 UTF-8 mode) so
- every child process we spawn uses UTF-8 for ``open()`` and stdio.
- - Sets ``os.environ["PYTHONIOENCODING"] = "utf-8"`` for belt-and-
- suspenders — some tools read this instead of / in addition to
- ``PYTHONUTF8``.
- - Reconfigures ``sys.stdout`` / ``sys.stderr`` to UTF-8 in the current
- process, using the ``reconfigure()`` API (Python 3.7+). This fixes
- ``print("café")`` in the parent without a re-exec.
-
-What this module does NOT do:
-
- - It does not re-exec Python with ``-X utf8``, so ``open()`` calls in
- the *current* process still default to locale encoding. Those need
- an explicit ``encoding="utf-8"`` at the call site (lint rule
- ``PLW1514`` / ``PYI058``). Ruff is the right tool for that sweep.
-
-What this module does on POSIX:
-
- - Nothing. POSIX systems are already UTF-8 by default in 99% of cases,
- and we don't want to touch ``LANG``/``LC_*`` behavior that users may
- have configured intentionally. If someone hits a C/POSIX locale on
- Linux, they can export ``PYTHONUTF8=1`` themselves — we won't override.
-
-Idempotent: safe to call multiple times. ``_bootstrap_once`` guards
-against double-reconfigure.
-"""
-
-from __future__ import annotations
-
-import os
-import sys
-
-_IS_WINDOWS = sys.platform == "win32"
-_bootstrap_applied = False
-
-
-def apply_windows_utf8_bootstrap() -> bool:
- """Apply the Windows UTF-8 bootstrap if we're on Windows.
-
- Returns True if bootstrap was applied (i.e. we're on Windows and
- haven't already done this), False otherwise. The return value is
- advisory — callers normally don't need it, but tests may want to
- assert the path was taken.
-
- Idempotent: subsequent calls after the first are a no-op.
- """
- global _bootstrap_applied
-
- if not _IS_WINDOWS:
- return False
- if _bootstrap_applied:
- return False
-
- # 1. Child processes inherit these and run in UTF-8 mode.
- # We use setdefault() rather than overwriting so the user can
- # explicitly opt out by setting PYTHONUTF8=0 in their environment
- # (or PYTHONIOENCODING=something-else) if they really want to.
- os.environ.setdefault("PYTHONUTF8", "1")
- os.environ.setdefault("PYTHONIOENCODING", "utf-8")
-
- # 2. Reconfigure the current process's stdio to UTF-8. Needed
- # because os.environ changes don't retroactively rebind sys.stdout
- # — those were bound at interpreter startup based on the console
- # code page. ``reconfigure`` is a TextIOWrapper method since 3.7.
- #
- # errors="replace" means that if we ever *read* something from
- # stdin that isn't UTF-8 (unlikely but possible with piped input
- # from legacy tools), we'll get U+FFFD replacement chars rather
- # than a crash. Output is pure UTF-8.
- for stream_name in ("stdout", "stderr"):
- stream = getattr(sys, stream_name, None)
- if stream is None:
- continue
- reconfigure = getattr(stream, "reconfigure", None)
- if reconfigure is None:
- # Not a TextIOWrapper (could be redirected to a BytesIO in
- # tests, or a non-standard stream in some embedded cases).
- # Skip silently — the env-var fix is still in effect for
- # child processes, which is the bigger win.
- continue
- try:
- reconfigure(encoding="utf-8", errors="replace")
- except (OSError, ValueError):
- # Already closed, or someone replaced it with something
- # non-reconfigurable. Non-fatal.
- pass
-
- # stdin is reconfigured separately with errors="replace" too — input
- # from a legacy pipe shouldn't crash the process.
- stdin = getattr(sys, "stdin", None)
- if stdin is not None:
- reconfigure = getattr(stdin, "reconfigure", None)
- if reconfigure is not None:
- try:
- reconfigure(encoding="utf-8", errors="replace")
- except (OSError, ValueError):
- pass
-
- _bootstrap_applied = True
- return True
-
-
-# Apply on import — entry points just need ``import hermes_bootstrap``
-# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at
-# the very top of their module, before importing anything else. The
-# import side effect does the right thing.
-apply_windows_utf8_bootstrap()
+"""Windows UTF-8 bootstrap for Hermes entry points.
+
+Python on Windows has two long-standing text-encoding footguns:
+
+1. ``sys.stdout`` / ``sys.stderr`` are bound to the console code page
+ (``cp1252`` on US-locale installs), so ``print("café")`` crashes with
+ ``UnicodeEncodeError: 'charmap' codec can't encode character``.
+
+2. Child processes spawned via ``subprocess`` don't know to use UTF-8
+ unless ``PYTHONUTF8`` and/or ``PYTHONIOENCODING`` are set in their
+ environment — so any Python subprocess (the execute_code sandbox,
+ delegation children, linter subprocesses, etc.) inherits the same
+ cp1252 defaults and hits the same UnicodeEncodeError.
+
+This module fixes both on Windows *only* — POSIX is untouched. It
+should be imported at the very top of every Hermes entry point
+(``hermes``, ``hermes-agent``, ``hermes-acp``, ``python -m gateway.run``,
+``batch_runner.py``, ``cron/scheduler.py``) before any other imports
+that might do file I/O or print to stdout.
+
+What this module does on Windows:
+
+ - Sets ``os.environ["PYTHONUTF8"] = "1"`` (PEP 540 UTF-8 mode) so
+ every child process we spawn uses UTF-8 for ``open()`` and stdio.
+ - Sets ``os.environ["PYTHONIOENCODING"] = "utf-8"`` for belt-and-
+ suspenders — some tools read this instead of / in addition to
+ ``PYTHONUTF8``.
+ - Reconfigures ``sys.stdout`` / ``sys.stderr`` to UTF-8 in the current
+ process, using the ``reconfigure()`` API (Python 3.7+). This fixes
+ ``print("café")`` in the parent without a re-exec.
+
+What this module does NOT do:
+
+ - It does not re-exec Python with ``-X utf8``, so ``open()`` calls in
+ the *current* process still default to locale encoding. Those need
+ an explicit ``encoding="utf-8"`` at the call site (lint rule
+ ``PLW1514`` / ``PYI058``). Ruff is the right tool for that sweep.
+
+What this module does on POSIX:
+
+ - Nothing. POSIX systems are already UTF-8 by default in 99% of cases,
+ and we don't want to touch ``LANG``/``LC_*`` behavior that users may
+ have configured intentionally. If someone hits a C/POSIX locale on
+ Linux, they can export ``PYTHONUTF8=1`` themselves — we won't override.
+
+Idempotent: safe to call multiple times. ``_bootstrap_once`` guards
+against double-reconfigure.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+
+_IS_WINDOWS = sys.platform == "win32"
+_bootstrap_applied = False
+
+
+def apply_windows_utf8_bootstrap() -> bool:
+ """Apply the Windows UTF-8 bootstrap if we're on Windows.
+
+ Returns True if bootstrap was applied (i.e. we're on Windows and
+ haven't already done this), False otherwise. The return value is
+ advisory — callers normally don't need it, but tests may want to
+ assert the path was taken.
+
+ Idempotent: subsequent calls after the first are a no-op.
+ """
+ global _bootstrap_applied
+
+ if not _IS_WINDOWS:
+ return False
+ if _bootstrap_applied:
+ return False
+
+ # 1. Child processes inherit these and run in UTF-8 mode.
+ # We use setdefault() rather than overwriting so the user can
+ # explicitly opt out by setting PYTHONUTF8=0 in their environment
+ # (or PYTHONIOENCODING=something-else) if they really want to.
+ os.environ.setdefault("PYTHONUTF8", "1")
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
+
+ # 2. Reconfigure the current process's stdio to UTF-8. Needed
+ # because os.environ changes don't retroactively rebind sys.stdout
+ # — those were bound at interpreter startup based on the console
+ # code page. ``reconfigure`` is a TextIOWrapper method since 3.7.
+ #
+ # errors="replace" means that if we ever *read* something from
+ # stdin that isn't UTF-8 (unlikely but possible with piped input
+ # from legacy tools), we'll get U+FFFD replacement chars rather
+ # than a crash. Output is pure UTF-8.
+ for stream_name in ("stdout", "stderr"):
+ stream = getattr(sys, stream_name, None)
+ if stream is None:
+ continue
+ reconfigure = getattr(stream, "reconfigure", None)
+ if reconfigure is None:
+ # Not a TextIOWrapper (could be redirected to a BytesIO in
+ # tests, or a non-standard stream in some embedded cases).
+ # Skip silently — the env-var fix is still in effect for
+ # child processes, which is the bigger win.
+ continue
+ try:
+ reconfigure(encoding="utf-8", errors="replace")
+ except (OSError, ValueError):
+ # Already closed, or someone replaced it with something
+ # non-reconfigurable. Non-fatal.
+ pass
+
+ # stdin is reconfigured separately with errors="replace" too — input
+ # from a legacy pipe shouldn't crash the process.
+ stdin = getattr(sys, "stdin", None)
+ if stdin is not None:
+ reconfigure = getattr(stdin, "reconfigure", None)
+ if reconfigure is not None:
+ try:
+ reconfigure(encoding="utf-8", errors="replace")
+ except (OSError, ValueError):
+ pass
+
+ _bootstrap_applied = True
+ return True
+
+
+# Apply on import — entry points just need ``import hermes_bootstrap``
+# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at
+# the very top of their module, before importing anything else. The
+# import side effect does the right thing.
+apply_windows_utf8_bootstrap()
+
+
+def _patch_subprocess_no_window() -> None:
+ """Monkey-patch subprocess.Popen on Windows to always set CREATE_NO_WINDOW.
+
+ Prevents console window flashes when the gateway (running under pythonw.exe,
+ which has no console) spawns child processes (copilot CLI, git, rg, ffprobe,
+ etc.). Without this flag every console-subsystem .exe briefly shows a black
+ window before hiding itself.
+
+ Only active on win32. Uses the lowest-level hook point (the class __init__)
+ so it covers every caller — our code, third-party libs, asyncio subprocesses.
+ """
+ if not (hasattr(__import__("sys"), "platform") and __import__("sys").platform == "win32"):
+ return
+ import subprocess as _sp
+
+ _CREATE_NO_WINDOW = 0x08000000
+ _orig_popen_init = _sp.Popen.__init__
+
+ def _patched_popen_init(self, args, **kwargs):
+ flags = kwargs.get("creationflags", 0) or 0
+ if not (flags & _CREATE_NO_WINDOW):
+ kwargs["creationflags"] = flags | _CREATE_NO_WINDOW
+ _orig_popen_init(self, args, **kwargs)
+
+ _sp.Popen.__init__ = _patched_popen_init
+
+
+_patch_subprocess_no_window()
diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py
index e5dfd358ed61..aaf79a5064d1 100644
--- a/plugins/platforms/photon/adapter.py
+++ b/plugins/platforms/photon/adapter.py
@@ -1,1529 +1,1536 @@
-"""
-Photon Spectrum (iMessage) platform adapter for Hermes Agent.
-
-Both directions of traffic flow through a small supervised Node sidecar
-(see ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK — the SDK is
-TypeScript-only and there is no public HTTP message API, so a sidecar is
-unavoidable.
-
-Inbound:
- The SDK's ``app.messages`` is a long-lived **gRPC** stream. The sidecar
- serializes each message to a normalized JSON event and streams it to this
- adapter over a loopback ``GET /inbound`` (NDJSON). A background task here
- consumes that stream, dedupes on ``messageId``, and dispatches a
- ``MessageEvent`` to the gateway via ``BasePlatformAdapter.handle_message``.
- No webhook, no public URL, no signing secret.
-
-Outbound:
- ``send`` / ``send_typing`` are loopback POSTs to the sidecar's control
- endpoints, authenticated with a shared bearer token. Outbound media
- (images, voice notes, video, documents) goes through spectrum-ts'
- ``attachment()`` / ``voice()`` content builders via the sidecar's
- ``/send-attachment`` endpoint.
-"""
-from __future__ import annotations
-
-import asyncio
-import base64
-import json
-import logging
-import os
-import re
-import secrets
-import shutil
-import signal
-import subprocess
-import sys
-import time
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Dict, List, Optional
-
-if TYPE_CHECKING:
- # Type checkers see ``httpx`` as the always-imported module, so every use
- # site type-checks cleanly. The runtime fallback below keeps the optional
- # dependency truly optional (each use site is guarded by HTTPX_AVAILABLE).
- import httpx
- HTTPX_AVAILABLE = True
-else:
- try:
- import httpx
- HTTPX_AVAILABLE = True
- except ImportError: # pragma: no cover - httpx is already a Hermes dep
- HTTPX_AVAILABLE = False
- httpx = None
-
-from gateway.config import Platform, PlatformConfig
-from gateway.platforms.base import (
- BasePlatformAdapter,
- MessageEvent,
- MessageType,
- ProcessingOutcome,
- SendResult,
-)
-from gateway.platforms.helpers import strip_markdown
-
-from .auth import load_project_credentials
-
-logger = logging.getLogger(__name__)
-
-# ---------------------------------------------------------------------------
-# Constants
-
-_DEFAULT_SIDECAR_PORT = 8789
-_DEFAULT_SIDECAR_BIND = "127.0.0.1"
-
-# Photon iMessage messages from the SDK side have no documented hard
-# limit, but the underlying iMessage protocol limits practical message
-# size to ~16 KB. Keep a conservative cap that matches BlueBubbles.
-_MAX_MESSAGE_LENGTH = 8000
-
-# Dedup parameters — the gRPC stream is at-least-once, and a sidecar
-# reconnect can replay, so keep at least 1k ids for ~48h.
-_DEDUP_MAX_SIZE = 4000
-_DEDUP_WINDOW_SECONDS = 48 * 3600
-
-_SIDECAR_DIR = Path(__file__).parent / "sidecar"
-
-# Group-chat mention wake words. When ``require_mention`` is enabled, group
-# messages are ignored unless they match one of these patterns — same
-# behavior and defaults as the BlueBubbles iMessage channel so the two
-# iMessage adapters gate group chats identically.
-_DEFAULT_MENTION_PATTERNS = [
- r"(? int:
- try:
- return int(value)
- except (TypeError, ValueError):
- return default
-
-
-def check_requirements() -> bool:
- """Return True when both Python deps and the Node sidecar are available."""
- if not HTTPX_AVAILABLE:
- return False
- if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
- return False
- if not (_SIDECAR_DIR / "node_modules").exists():
- # spectrum-ts not installed yet — `hermes photon setup` will
- # install it. check_fn still returns False so the gateway
- # surfaces the missing-deps state in `hermes setup` / status.
- return False
- return True
-
-
-def validate_config(cfg: PlatformConfig) -> bool:
- extra = cfg.extra or {}
- project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID")
- project_secret = extra.get("project_secret") or os.getenv("PHOTON_PROJECT_SECRET")
- if not project_id or not project_secret:
- # Fall back to auth.json
- stored_id, stored_sec = load_project_credentials()
- return bool(stored_id and stored_sec)
- return True
-
-
-def is_connected(cfg: PlatformConfig) -> bool:
- return validate_config(cfg)
-
-
-def _env_enablement() -> Optional[dict]:
- """Seed PlatformConfig.extra from env so env-only setups appear in status.
-
- The special ``home_channel`` key is handled by the core plugin hook and
- becomes a proper ``HomeChannel`` on ``PlatformConfig``.
- """
- project_id, project_secret = load_project_credentials()
- if not (project_id and project_secret):
- return None
- seed: dict = {"project_id": project_id, "project_secret": project_secret}
- home = os.getenv("PHOTON_HOME_CHANNEL", "").strip()
- if home:
- seed["home_channel"] = {
- "chat_id": home,
- "name": os.getenv("PHOTON_HOME_CHANNEL_NAME", "Home"),
- }
- return seed
-
-
-def _markdown_enabled() -> bool:
- """Send agent replies as markdown (spectrum-ts ``markdown()`` builder).
-
- iMessage renders it natively; other Spectrum platforms degrade to
- readable plain text. On-device rendering can't be unit-tested, so
- ``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain
- text without a release.
- """
- return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in {
- "false", "0", "no",
- }
-
-
-# ---------------------------------------------------------------------------
-# Adapter
-
-class PhotonAdapter(BasePlatformAdapter):
- """Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar.
-
- Inbound: consume the sidecar's ``/inbound`` gRPC stream.
- Outbound: loopback POSTs to the sidecar's control channel.
- """
-
- MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
-
- def __init__(self, config: PlatformConfig):
- super().__init__(config, Platform("photon"))
- extra = config.extra or {}
-
- # Project credentials (env wins, then config.extra, then auth.json).
- # ``project_id`` here is the project's spectrumProjectId — the value
- # the spectrum-ts SDK authenticates with.
- stored_id, stored_sec = load_project_credentials()
- self._project_id: str = (
- os.getenv("PHOTON_PROJECT_ID")
- or extra.get("project_id")
- or stored_id
- or ""
- )
- self._project_secret: str = (
- os.getenv("PHOTON_PROJECT_SECRET")
- or extra.get("project_secret")
- or stored_sec
- or ""
- )
-
- # Sidecar
- self._sidecar_port = _coerce_port(
- extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
- _DEFAULT_SIDECAR_PORT,
- )
- self._sidecar_bind = _DEFAULT_SIDECAR_BIND
- self._sidecar_token = (
- os.getenv("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16)
- )
- self._autostart_sidecar = str(
- os.getenv("PHOTON_SIDECAR_AUTOSTART", "true")
- ).lower() not in ("0", "false", "no")
- self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
-
- # With markdown on, format_message preserves fences and the sidecar's
- # markdown() builder renders them (or degrades them readably).
- self.supports_code_blocks = _markdown_enabled()
-
- # Runtime state
- self._sidecar_proc: Optional[subprocess.Popen] = None
- self._sidecar_supervisor_task: Optional[asyncio.Task] = None
- self._inbound_task: Optional[asyncio.Task] = None
- self._inbound_running = False
- self._http_client: Optional["httpx.AsyncClient"] = None
- # Lightweight in-memory dedup. The gRPC stream is at-least-once, so we
- # may see the same messageId more than once (e.g. after a reconnect).
- self._seen_messages: Dict[str, float] = {}
- # Ids of messages WE sent (bounded, insertion-order eviction). Inbound
- # reaction events are only routed to the agent when they target one of
- # these — a tapback on a human↔human message is not addressed to us.
- self._sent_message_ids: Dict[str, float] = {}
- # Latest inbound message id per chat (bounded). Lets the agent-facing
- # react action default to "the message that triggered me" without
- # requiring the model to thread message ids through tool calls.
- self._last_inbound_by_chat: Dict[str, str] = {}
-
- # Group-chat mention gating (parity with BlueBubbles). When enabled,
- # group messages are ignored unless they match a wake word; DMs are
- # always processed. Config key wins, then env var.
- _require_mention = extra.get("require_mention")
- if _require_mention is None:
- _require_mention = os.getenv("PHOTON_REQUIRE_MENTION")
- self.require_mention = str(_require_mention).strip().lower() in {
- "true", "1", "yes", "on",
- }
- self._mention_patterns = self._compile_mention_patterns(
- extra["mention_patterns"]
- if "mention_patterns" in extra
- else os.getenv("PHOTON_MENTION_PATTERNS")
- )
-
- # -- Group-mention gating (parity with BlueBubbles) -------------------
-
- @staticmethod
- def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]":
- """Compile group-mention wake words from config/env.
-
- ``raw`` is a list (config or env JSON), a string (env var: JSON
- list, or comma/newline-separated), or None (use Hermes defaults).
- Mirrors the BlueBubbles implementation so both iMessage channels
- accept the same configuration shapes.
- """
- if raw is None:
- patterns = list(_DEFAULT_MENTION_PATTERNS)
- elif isinstance(raw, str):
- text = raw.strip()
- try:
- loaded = json.loads(text) if text else []
- except Exception:
- loaded = None
- patterns = loaded if isinstance(loaded, list) else [
- part.strip()
- for line in text.splitlines()
- for part in line.split(",")
- ]
- elif isinstance(raw, list):
- patterns = raw
- else:
- patterns = [raw]
-
- compiled: "list[re.Pattern]" = []
- for pattern in patterns:
- text = str(pattern).strip()
- if not text:
- continue
- try:
- compiled.append(re.compile(text, re.IGNORECASE))
- except re.error as exc:
- logger.warning("[photon] Invalid mention pattern %r: %s", text, exc)
- return compiled
-
- def _message_matches_mention_patterns(self, text: str) -> bool:
- if not text or not self._mention_patterns:
- return False
- return any(pattern.search(text) for pattern in self._mention_patterns)
-
- def _clean_mention_text(self, text: str) -> str:
- """Strip a leading wake word before dispatch.
-
- Custom mention patterns are regexes, so we only strip a leading
- match to avoid deleting ordinary words later in the prompt.
- """
- if not text:
- return text
- for pattern in self._mention_patterns:
- match = pattern.match(text.lstrip())
- if match:
- cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
- return cleaned or text
- return text
-
- # -- Connection lifecycle ---------------------------------------------
-
- async def connect(self) -> bool:
- if not HTTPX_AVAILABLE:
- self._set_fatal_error(
- "MISSING_DEP", "httpx not installed", retryable=False
- )
- return False
- if not self._project_id or not self._project_secret:
- self._set_fatal_error(
- "MISSING_CREDENTIALS",
- "PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. "
- "Run: hermes photon setup",
- retryable=False,
- )
- return False
-
- client = httpx.AsyncClient(timeout=30.0)
- self._http_client = client
-
- # The sidecar holds the gRPC stream for BOTH directions, so it is
- # required now (not just for outbound).
- if self._autostart_sidecar:
- try:
- await self._start_sidecar()
- except Exception as e:
- self._set_fatal_error(
- "SIDECAR_FAILED",
- f"failed to start Photon sidecar: {e}",
- retryable=True,
- )
- await client.aclose()
- self._http_client = None
- return False
- else:
- logger.warning(
- "[photon] sidecar autostart disabled — inbound + outbound will fail"
- )
-
- # Start consuming the inbound gRPC stream from the sidecar.
- self._inbound_running = True
- self._inbound_task = asyncio.get_event_loop().create_task(
- self._inbound_loop()
- )
-
- self._mark_connected()
- logger.info(
- "[photon] connected — sidecar on %s:%d, streaming inbound over gRPC",
- self._sidecar_bind, self._sidecar_port,
- )
- return True
-
- async def disconnect(self) -> None:
- self._inbound_running = False
- if self._inbound_task is not None:
- self._inbound_task.cancel()
- try:
- await self._inbound_task
- except asyncio.CancelledError:
- pass
- except Exception:
- pass
- self._inbound_task = None
- await self._stop_sidecar()
- if self._http_client is not None:
- try:
- await self._http_client.aclose()
- except Exception:
- pass
- self._http_client = None
- self._mark_disconnected()
-
- # -- Inbound stream consumer ------------------------------------------
-
- async def _inbound_loop(self) -> None:
- """Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect.
-
- The sidecar owns the gRPC reconnect/heartbeat to Photon; this loop
- only has to re-open the loopback HTTP stream if it drops (e.g. the
- sidecar restarts).
- """
- client = self._http_client
- if client is None:
- return
- url = f"http://{self._sidecar_bind}:{self._sidecar_port}/inbound"
- headers = {"X-Hermes-Sidecar-Token": self._sidecar_token}
- backoff = 1.0
- while self._inbound_running:
- try:
- async with client.stream(
- "GET", url, headers=headers, timeout=None,
- ) as resp:
- if resp.status_code != 200:
- raise RuntimeError(f"/inbound returned {resp.status_code}")
- backoff = 1.0 # reset on a successful connect
- async for line in resp.aiter_lines():
- if not self._inbound_running:
- break
- line = line.strip()
- if not line:
- continue # heartbeat
- await self._on_inbound_line(line)
- except asyncio.CancelledError:
- raise
- except Exception as e:
- if not self._inbound_running:
- break
- logger.warning(
- "[photon] inbound stream dropped (%s); reconnecting in %.1fs",
- e, backoff,
- )
- await asyncio.sleep(backoff)
- backoff = min(backoff * 2, 30.0)
-
- async def _on_inbound_line(self, line: str) -> None:
- try:
- event = json.loads(line)
- except json.JSONDecodeError:
- logger.debug("[photon] skipping non-JSON inbound line")
- return
- msg_id = event.get("messageId")
- if msg_id and self._is_duplicate(msg_id):
- return
- try:
- await self._dispatch_inbound(event)
- except Exception:
- logger.exception("[photon] inbound dispatch failed")
-
- def _is_duplicate(self, msg_id: str) -> bool:
- now = time.time()
- seen = self._seen_messages
- t = seen.get(msg_id)
- if t is not None and now - t < _DEDUP_WINDOW_SECONDS:
- return True # seen, unexpired
- # New or expired: record and enforce a HARD size bound (evict oldest,
- # insertion-order) so a burst of unique ids within the window can't grow
- # the dict without limit — not just the expired-only prune.
- if msg_id in seen:
- del seen[msg_id] # refresh insertion order
- seen[msg_id] = now
- if len(seen) > _DEDUP_MAX_SIZE:
- for old in list(seen.keys())[: len(seen) - _DEDUP_MAX_SIZE]:
- del seen[old]
- return False
-
- async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
- """Normalize a sidecar inbound event and dispatch it to the gateway.
-
- Event shape (from ``sidecar/index.mjs``)::
-
- {
- "messageId": "...",
- "platform": "iMessage",
- "space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
- "sender": {"id": "+E164"},
- "content": {"type": "text", "text": "..."}
- | {"type": "attachment"|"voice", "id", "name",
- "mimeType", "size", "duration"?, "data"?,
- "encoding"?}
- | {"type": "reaction", "emoji": "❤️",
- "targetMessageId": "..." | null,
- "targetDirection": "inbound"|"outbound" | null},
- "timestamp": "2026-05-14T19:06:32.000Z"
-
- Attachment and voice content carry the bytes inline as base64 ``data``
- (with ``encoding == "base64"``) when the sidecar could read them
- within its size cap; otherwise only metadata is present and we surface
- a marker.
- }
- """
- space = event.get("space") or {}
- sender = event.get("sender") or {}
- content = event.get("content") or {}
-
- space_id = space.get("id") or ""
- if not space_id:
- logger.warning("[photon] inbound missing space.id")
- return
-
- # iMessage spaces carry their type directly — no id string-sniffing.
- chat_type = "group" if space.get("type") == "group" else "dm"
- sender_id = sender.get("id") or space.get("phone") or space_id
-
- ts_str = event.get("timestamp") or ""
- try:
- timestamp = (
- datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
- if ts_str
- else datetime.now(tz=timezone.utc)
- )
- except ValueError:
- timestamp = datetime.now(tz=timezone.utc)
-
- # Media attachments (local cached paths) handed to the agent via the
- # gateway's image-routing path, exactly like the BlueBubbles channel.
- media_urls: List[str] = []
- media_types: List[str] = []
-
- ctype = content.get("type")
- if ctype == "reaction":
- # Route only tapbacks on messages WE sent — those are implicitly
- # addressed to the bot (feishu precedent: synthetic text event).
- # Reactions on human↔human messages are not for us. Checked before
- # the mention gate: a tapback never carries a wake word.
- target_id = content.get("targetMessageId")
- is_ours = content.get("targetDirection") == "outbound" or (
- target_id and target_id in self._sent_message_ids
- )
- if not is_ours:
- logger.debug(
- "[photon] ignoring reaction on a message we didn't send"
- )
- return
- emoji = content.get("emoji") or ""
- source = self.build_source(
- chat_id=space_id,
- chat_name=space_id,
- chat_type=chat_type,
- user_id=sender_id,
- user_name=sender_id or None,
- )
- await self.handle_message(
- MessageEvent(
- text=f"reaction:added:{emoji}",
- message_type=MessageType.TEXT,
- source=source,
- message_id=event.get("messageId"),
- raw_message=event,
- timestamp=timestamp,
- )
- )
- return
- # Anything past here is a real (reactable) message — remember it as
- # the chat's latest inbound so `add_reaction` can target it when the
- # caller doesn't pass an explicit message id. Recorded before the
- # mention gate: a reaction to a non-wake-word group message is valid.
- self._record_last_inbound(space_id, event.get("messageId"))
- if ctype == "text":
- text = content.get("text") or ""
- mtype = MessageType.TEXT
- elif ctype in {"attachment", "voice"}:
- is_voice = ctype == "voice"
- name = content.get("name") or ("voice" if is_voice else "(unnamed)")
- mime = content.get("mimeType") or ""
- mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
- cached = _cache_inbound_attachment(
- content, name, mime, force_audio=is_voice
- )
- if cached:
- media_urls.append(cached)
- media_types.append(
- mime or ("audio/mp4" if is_voice else "application/octet-stream")
- )
- # The real bytes are attached, so the agent sees the media
- # itself — a short marker is enough text, and it keeps group
- # mention-gating consistent with plain messages.
- text = "(voice)" if is_voice else "(attachment)"
- else:
- # No bytes (over the sidecar cap, a failed read, or a caching
- # failure) — fall back to a metadata marker so the agent still
- # knows something arrived.
- label = "voice" if is_voice else "attachment"
- duration = content.get("duration")
- duration_text = (
- f", duration: {duration}s"
- if isinstance(duration, (int, float))
- else ""
- )
- text = (
- f"[Photon {label} received: {name} "
- f"({mime or 'unknown MIME'}{duration_text})]"
- )
- else:
- text = f"[Photon content type not handled: {ctype}]"
- mtype = MessageType.TEXT
-
- # Group-mention gating (parity with BlueBubbles). In group chats with
- # require_mention enabled, drop messages that don't hit a wake word;
- # strip the leading wake word from the ones that do. DMs are never
- # gated.
- if chat_type == "group" and self.require_mention:
- if not self._message_matches_mention_patterns(text):
- logger.debug(
- "[photon] ignoring group message "
- "(require_mention=true, no mention pattern matched)"
- )
- return
- text = self._clean_mention_text(text)
-
- source = self.build_source(
- chat_id=space_id,
- chat_name=space_id,
- chat_type=chat_type,
- user_id=sender_id,
- user_name=sender_id or None,
- )
- message_event = MessageEvent(
- text=text,
- message_type=mtype,
- source=source,
- message_id=event.get("messageId"),
- raw_message=event,
- timestamp=timestamp,
- media_urls=media_urls,
- media_types=media_types,
- )
- await self.handle_message(message_event)
-
- # -- Sidecar lifecycle -------------------------------------------------
-
- @staticmethod
- def _find_listener_pids(port: int) -> List[int]:
- """PIDs listening on a local TCP port (empty if none/undeterminable)."""
- try:
- out = subprocess.run( # noqa: S603, S607
- ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
- capture_output=True, text=True, timeout=5.0, check=False,
- )
- except (OSError, subprocess.TimeoutExpired):
- return []
- return [int(tok) for tok in out.stdout.split() if tok.strip().isdigit()]
-
- @staticmethod
- def _pid_is_sidecar(pid: int) -> bool:
- """True if ``pid``'s command line is a Photon sidecar process."""
- try:
- out = subprocess.run( # noqa: S603, S607
- ["ps", "-p", str(pid), "-o", "command="],
- capture_output=True, text=True, timeout=5.0, check=False,
- )
- except (OSError, subprocess.TimeoutExpired):
- return False
- # Checkout-agnostic: any Hermes checkout's sidecar entry point.
- return "photon/sidecar/index.mjs" in out.stdout
-
- @staticmethod
- def _pid_alive(pid: int) -> bool:
- try:
- os.kill(pid, 0) # windows-footgun: ok — only called from _reap_stale_sidecar which win32-guards early
- return True
- except OSError:
- return False
-
- async def _reap_stale_sidecar(self) -> None:
- """Kill an orphaned sidecar squatting our port before spawning ours.
-
- A hard gateway exit (crash, SIGKILL, supervisor restart) used to leave
- the detached sidecar running with a token the new gateway doesn't
- know, so it can't be told to ``/shutdown`` — and every replacement
- spawn died on EADDRINUSE, failing each reconnect attempt. The
- stdin-EOF watch prevents new orphans; this reclaims the port from
- orphans that predate it (or survived it). Listeners are verified by
- command line before being signalled.
- """
- if sys.platform == "win32": # lsof/ps; orphaning is a POSIX-only path
- return
- try:
- async with httpx.AsyncClient(timeout=2.0) as client:
- await client.post(
- f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz",
- headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
- )
- except httpx.RequestError:
- return # nothing listening — the normal case
- pids = self._find_listener_pids(self._sidecar_port)
- stale = [pid for pid in pids if self._pid_is_sidecar(pid)]
- foreign = [pid for pid in pids if pid not in stale]
- if not stale:
- raise RuntimeError(
- f"port {self._sidecar_port} is in use by another process "
- f"(pids: {foreign or 'unknown'}, not a Photon sidecar) — "
- f"free it or set PHOTON_SIDECAR_PORT to a different port"
- )
- for pid in stale:
- logger.warning(
- "[photon] reaping orphaned sidecar (pid %d) on port %d",
- pid, self._sidecar_port,
- )
- try:
- os.kill(pid, signal.SIGTERM)
- except OSError:
- pass
- deadline = time.time() + 3.0
- while time.time() < deadline and any(self._pid_alive(p) for p in stale):
- await asyncio.sleep(0.1)
- for pid in stale:
- if self._pid_alive(pid):
- try:
- os.kill(pid, signal.SIGKILL) # windows-footgun: ok — unreachable on win32 (early return above)
- except OSError:
- pass
- # Give the OS a beat to release the listening socket.
- await asyncio.sleep(0.2)
- if foreign:
- raise RuntimeError(
- f"port {self._sidecar_port} is also held by non-sidecar "
- f"processes (pids: {foreign}) — free it or set "
- f"PHOTON_SIDECAR_PORT to a different port"
- )
-
- async def _start_sidecar(self) -> None:
- if not (_SIDECAR_DIR / "node_modules").exists():
- raise RuntimeError(
- f"Photon sidecar deps not installed. Run: "
- f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)"
- )
- await self._reap_stale_sidecar()
-
- env = os.environ.copy()
- env["PHOTON_PROJECT_ID"] = self._project_id
- env["PHOTON_PROJECT_SECRET"] = self._project_secret
- env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port)
- env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind
- env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token
- # The sidecar exits when its stdin (the pipe below) hits EOF, so a
- # gateway death of ANY kind — including SIGKILL, where disconnect()
- # never runs — can't leave it orphaned on the port.
- env["PHOTON_SIDECAR_WATCH_STDIN"] = "1"
-
- self._sidecar_proc = subprocess.Popen( # noqa: S603
- [self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- env=env,
- start_new_session=(sys.platform != "win32"),
- )
-
- # Pump sidecar stderr/stdout into our logger so users see crashes.
- loop = asyncio.get_event_loop()
- self._sidecar_supervisor_task = loop.create_task(
- self._supervise_sidecar(self._sidecar_proc)
- )
-
- # Wait for /healthz to come up — give it up to 15s on cold start.
- deadline = time.time() + 15.0
- last_err: Optional[Exception] = None
- async with httpx.AsyncClient(timeout=2.0) as client:
- while time.time() < deadline:
- if self._sidecar_proc.poll() is not None:
- raise RuntimeError(
- f"Photon sidecar exited with code "
- f"{self._sidecar_proc.returncode} before becoming ready"
- )
- try:
- resp = await client.post(
- f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz",
- headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
- )
- if resp.status_code == 200:
- return
- except httpx.RequestError as e:
- last_err = e
- await asyncio.sleep(0.2)
- raise RuntimeError(
- f"Photon sidecar did not become ready within 15s: {last_err}"
- )
-
- async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
- """Pump the sidecar's stdout/stderr into our logger."""
- if proc.stdout is None: # subprocess was launched without stdout=PIPE
- return
- stdout = proc.stdout
- loop = asyncio.get_event_loop()
- try:
- while True:
- line = await loop.run_in_executor(None, stdout.readline)
- if not line:
- break
- logger.info("[photon-sidecar] %s", line.decode("utf-8", "replace").rstrip())
- except Exception as e: # pragma: no cover - defensive
- logger.warning("[photon-sidecar] supervisor exited: %s", e)
-
- async def _stop_sidecar(self) -> None:
- proc = self._sidecar_proc
- if proc is None:
- return
- try:
- # Closing our end of the stdin pipe is itself a shutdown signal
- # (the sidecar watches for EOF), and covers the case where the
- # HTTP call below can't get through.
- if proc.stdin is not None:
- try:
- proc.stdin.close()
- except Exception:
- pass
- # Polite shutdown first.
- if self._http_client is not None:
- try:
- await self._http_client.post(
- f"http://{self._sidecar_bind}:{self._sidecar_port}/shutdown",
- headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
- timeout=2.0,
- )
- except Exception:
- pass
- try:
- proc.wait(timeout=3.0)
- except subprocess.TimeoutExpired:
- if sys.platform != "win32":
- try:
- os.killpg(os.getpgid(proc.pid), signal.SIGTERM) # windows-footgun: ok
- except (ProcessLookupError, PermissionError):
- proc.terminate()
- else:
- proc.terminate()
- try:
- proc.wait(timeout=2.0)
- except subprocess.TimeoutExpired:
- proc.kill()
- finally:
- self._sidecar_proc = None
- if self._sidecar_supervisor_task is not None:
- self._sidecar_supervisor_task.cancel()
- self._sidecar_supervisor_task = None
-
- # -- Outbound ----------------------------------------------------------
-
- async def send(
- self,
- chat_id: str,
- content: str,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- ) -> SendResult:
- return await self._sidecar_send(chat_id, self.format_message(content))
-
- # -- Outbound media (parity with the BlueBubbles iMessage channel) -----
- #
- # Photon ships outbound attachments via spectrum-ts' `attachment()` /
- # `voice()` content builders. The sidecar's `/send-attachment` endpoint
- # wraps `space.send(attachment(path, {...}))`. These overrides mirror
- # BlueBubbles: URL-based helpers cache to a local path first, file-based
- # helpers pass the path straight through.
-
- async def send_image(
- self,
- chat_id: str,
- image_url: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- ) -> SendResult:
- try:
- from gateway.platforms.base import cache_image_from_url
-
- local_path = await cache_image_from_url(image_url)
- except Exception:
- # Couldn't fetch the URL — fall back to sending it as text.
- return await super().send_image(chat_id, image_url, caption, reply_to)
- return await self._sidecar_send_attachment(
- chat_id, local_path, caption=caption,
- )
-
- async def send_image_file(
- self,
- chat_id: str,
- image_path: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- return await self._sidecar_send_attachment(
- chat_id, image_path, caption=caption,
- )
-
- async def send_voice(
- self,
- chat_id: str,
- audio_path: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- return await self._sidecar_send_attachment(
- chat_id, audio_path, caption=caption, kind="voice",
- )
-
- async def send_video(
- self,
- chat_id: str,
- video_path: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- return await self._sidecar_send_attachment(
- chat_id, video_path, caption=caption,
- )
-
- async def send_document(
- self,
- chat_id: str,
- file_path: str,
- caption: Optional[str] = None,
- file_name: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- **kwargs,
- ) -> SendResult:
- return await self._sidecar_send_attachment(
- chat_id, file_path, name=file_name, caption=caption,
- )
-
- async def send_animation(
- self,
- chat_id: str,
- animation_url: str,
- caption: Optional[str] = None,
- reply_to: Optional[str] = None,
- metadata: Optional[Dict[str, Any]] = None,
- ) -> SendResult:
- # iMessage renders GIFs inline as ordinary image attachments.
- return await self.send_image(
- chat_id, animation_url, caption, reply_to, metadata,
- )
-
- async def send_typing(self, chat_id: str, metadata=None) -> None:
- try:
- await self._sidecar_call(
- "/typing", {"spaceId": chat_id, "state": "start"}
- )
- except Exception as e:
- logger.debug("[photon] send_typing failed: %s", e)
-
- async def stop_typing(self, chat_id: str) -> None:
- try:
- await self._sidecar_call(
- "/typing", {"spaceId": chat_id, "state": "stop"}
- )
- except Exception as e:
- logger.debug("[photon] stop_typing failed: %s", e)
-
- # -- Reactions (tapbacks) -----------------------------------------------
- #
- # Same lifecycle-hook pattern as Telegram/Discord: 👀 while processing,
- # swapped for 👍/👎 on completion. Opt-in via PHOTON_REACTIONS — iMessage
- # is a personal-texting channel, and a tapback on every text is noisy.
-
- _SENT_IDS_MAX = 1000
- _LAST_INBOUND_CHATS_MAX = 200
-
- def _record_sent_message(self, message_id: Optional[str]) -> None:
- if not message_id:
- return
- sent = self._sent_message_ids
- if message_id in sent:
- del sent[message_id] # refresh insertion order
- sent[message_id] = time.time()
- if len(sent) > self._SENT_IDS_MAX:
- for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]:
- del sent[old]
-
- # A DM space is addressable two ways — the chat GUID (`any;-;+1555...`)
- # that inbound events carry, and the bare E.164 phone that home-channel
- # config typically uses. The sidecar's resolveSpace treats them as the
- # same space; normalize to the bare phone so the last-inbound tracker
- # does too (mirrors phoneTargetFromSpaceId in sidecar/index.mjs).
- _DM_CHAT_GUID_RE = re.compile(r"^any;-;(\+\d{6,})$")
-
- @classmethod
- def _normalize_chat_key(cls, chat_id: str) -> str:
- match = cls._DM_CHAT_GUID_RE.match(chat_id)
- return match.group(1) if match else chat_id
-
- def _record_last_inbound(
- self, chat_id: Optional[str], message_id: Optional[str]
- ) -> None:
- if not chat_id or not message_id:
- return
- key = self._normalize_chat_key(chat_id)
- last = self._last_inbound_by_chat
- if key in last:
- del last[key] # refresh insertion order
- last[key] = message_id
- if len(last) > self._LAST_INBOUND_CHATS_MAX:
- for old in list(last.keys())[
- : len(last) - self._LAST_INBOUND_CHATS_MAX
- ]:
- del last[old]
-
- def _reactions_enabled(self) -> bool:
- return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in {
- "true", "1", "yes", "on",
- }
-
- async def _add_reaction(
- self, chat_id: str, message_id: str, emoji: str
- ) -> bool:
- """Tapback ``emoji`` onto a message. Soft-fails (False), never raises."""
- try:
- await self._sidecar_call(
- "/react",
- {"spaceId": chat_id, "messageId": message_id, "emoji": emoji},
- )
- return True
- except Exception as e:
- logger.debug("[photon] add_reaction failed: %s", e)
- return False
-
- async def _remove_reaction(self, chat_id: str, message_id: str) -> bool:
- """Retract our tapback from a message. Soft-fails (False), never raises.
-
- The sidecar tracks one reaction handle per target message; after a
- sidecar restart the handle is gone and removal is best-effort (the
- stale tapback self-heals when the next reaction replaces it).
- """
- try:
- await self._sidecar_call(
- "/unreact", {"spaceId": chat_id, "messageId": message_id},
- )
- return True
- except Exception as e:
- logger.debug("[photon] remove_reaction failed: %s", e)
- return False
-
- # -- Agent-facing reactions (send_message action="react") ---------------
- #
- # Unlike the lifecycle hooks below, these are deliberate agent intents,
- # so they are NOT gated by PHOTON_REACTIONS (that env var exists to mute
- # the automatic per-message tapback noise, not explicit requests).
-
- async def add_reaction(
- self,
- chat_id: str,
- emoji: str,
- message_id: Optional[str] = None,
- ) -> Dict[str, Any]:
- """Tapback ``emoji`` onto a message in ``chat_id``.
-
- Without ``message_id``, targets the chat's most recent inbound
- message (typically the one the agent is responding to). iMessage
- maps ❤️👍👎😂‼️❓ to native tapbacks; anything else uses Apple's
- custom-emoji reaction.
- """
- target = message_id or self._last_inbound_by_chat.get(
- self._normalize_chat_key(chat_id)
- )
- if not target:
- return {
- "success": False,
- "error": "no message to react to — pass message_id (no "
- "inbound message seen in this chat since the gateway started)",
- }
- ok = await self._add_reaction(chat_id, target, emoji)
- if not ok:
- return {
- "success": False,
- "error": "reaction failed (see gateway debug log)",
- }
- return {"success": True, "message_id": target}
-
- async def remove_reaction(
- self, chat_id: str, message_id: Optional[str] = None
- ) -> Dict[str, Any]:
- """Retract our tapback from a message (best-effort)."""
- target = message_id or self._last_inbound_by_chat.get(
- self._normalize_chat_key(chat_id)
- )
- if not target:
- return {
- "success": False,
- "error": "no message to unreact — pass message_id",
- }
- ok = await self._remove_reaction(chat_id, target)
- if not ok:
- return {
- "success": False,
- "error": "unreact failed (see gateway debug log)",
- }
- return {"success": True, "message_id": target}
-
- async def on_processing_start(self, event: MessageEvent) -> None:
- """Tapback 👀 on the triggering message while the agent works."""
- if not self._reactions_enabled():
- return
- chat_id = getattr(event.source, "chat_id", None)
- message_id = getattr(event, "message_id", None)
- if chat_id and message_id:
- await self._add_reaction(chat_id, message_id, "\U0001f440")
-
- async def on_processing_complete(
- self, event: MessageEvent, outcome: ProcessingOutcome
- ) -> None:
- """Swap the 👀 progress tapback for a 👍/👎 result.
-
- Remove-then-add rather than a bare replace: deterministic whether the
- platform replaces a sender's previous tapback or stacks them, and it
- keeps the sidecar's reaction-handle slot coherent.
- """
- if not self._reactions_enabled():
- return
- chat_id = getattr(event.source, "chat_id", None)
- message_id = getattr(event, "message_id", None)
- if not chat_id or not message_id:
- return
- await self._remove_reaction(chat_id, message_id)
- if outcome == ProcessingOutcome.SUCCESS:
- await self._add_reaction(chat_id, message_id, "\U0001f44d")
- elif outcome == ProcessingOutcome.FAILURE:
- await self._add_reaction(chat_id, message_id, "\U0001f44e")
- # CANCELLED: leave the message unreacted.
-
- async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
- """Return whatever we know about a Spectrum space id.
-
- Photon's ``space.id`` is opaque; the inbound event also carries the
- DM/group type, but here we only have the id, so infer conservatively.
- """
- return {"name": chat_id, "type": "dm", "id": chat_id}
-
- def format_message(self, content: str) -> str:
- # Markdown is passed through verbatim — the sidecar sends it with the
- # markdown() builder and iMessage renders it. The strip path remains
- # as the PHOTON_MARKDOWN=false kill-switch.
- if _markdown_enabled():
- return content
- return strip_markdown(content)
-
- async def _send_with_retry(
- self,
- chat_id: str,
- content: str,
- reply_to: Optional[str] = None,
- metadata: Any = None,
- max_retries: int = 2,
- base_delay: float = 2.0,
- ) -> SendResult:
- """Retry sends without the generic Markdown banner.
-
- Photon replies are markdown (rendered by iMessage) or stripped plain
- text under ``PHOTON_MARKDOWN=false`` — either way the gateway's
- generic banner never applies.
- """
- text = self.format_message(content)
- result = await self.send(
- chat_id=chat_id,
- content=text,
- reply_to=reply_to,
- metadata=metadata,
- )
- if result.success:
- return result
-
- error_str = result.error or ""
- is_network = result.retryable or self._is_retryable_error(error_str)
- if not is_network and self._is_timeout_error(error_str):
- return result
-
- if is_network:
- for attempt in range(1, max_retries + 1):
- delay = base_delay * (2 ** (attempt - 1))
- logger.warning(
- "[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s",
- attempt, max_retries, delay, error_str,
- )
- await asyncio.sleep(delay)
- result = await self.send(
- chat_id=chat_id,
- content=text,
- reply_to=reply_to,
- metadata=metadata,
- )
- if result.success:
- return result
- error_str = result.error or ""
- if not (result.retryable or self._is_retryable_error(error_str)):
- break
- else:
- logger.error(
- "[photon] Failed to deliver response after %d retries: %s",
- max_retries, error_str,
- )
- return result
-
- logger.warning(
- "[photon] Send failed: %s - retrying plain-text message",
- error_str,
- )
- fallback_result = await self.send(
- chat_id=chat_id,
- content=text[: self.MAX_MESSAGE_LENGTH],
- reply_to=reply_to,
- metadata=metadata,
- )
- if not fallback_result.success:
- logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
- return fallback_result
-
- async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
- if len(text) > self.MAX_MESSAGE_LENGTH:
- logger.warning(
- "[photon] truncating outbound from %d to %d chars",
- len(text), self.MAX_MESSAGE_LENGTH,
- )
- text = text[: self.MAX_MESSAGE_LENGTH]
- body: Dict[str, Any] = {"spaceId": space_id, "text": text}
- # Omit the key when disabled so an older sidecar (pre-`format`)
- # keeps accepting the body during a half-upgraded restart.
- if _markdown_enabled():
- body["format"] = "markdown"
- try:
- data = await self._sidecar_call("/send", body)
- except Exception as e:
- return SendResult(success=False, error=str(e))
- self._record_sent_message(data.get("messageId"))
- return SendResult(success=True, message_id=data.get("messageId"))
-
- async def _sidecar_send_attachment(
- self,
- space_id: str,
- path: str,
- *,
- name: Optional[str] = None,
- mime_type: Optional[str] = None,
- caption: Optional[str] = None,
- kind: str = "attachment",
- ) -> SendResult:
- """POST a local file to the sidecar's ``/send-attachment`` endpoint.
-
- ``kind`` is ``"voice"`` for audio sent as a voice note (downgrades
- to a plain audio attachment on platforms without voice notes),
- otherwise ``"attachment"``. spectrum-ts infers ``name`` and
- ``mimeType`` from the file extension; we only pass overrides when
- Hermes supplied them.
- """
- # Defense-in-depth: re-validate the path before handing it to the
- # Node sidecar. The gateway already filters MEDIA paths, but
- # send_*_file / cron callers may pass arbitrary strings.
- safe_path = self.validate_media_delivery_path(str(path))
- if not safe_path:
- return SendResult(
- success=False, error=f"unsafe or missing attachment path: {path}"
- )
- if not mime_type:
- import mimetypes
-
- guessed, _ = mimetypes.guess_type(safe_path)
- mime_type = guessed or None
- body: Dict[str, Any] = {
- "spaceId": space_id,
- "path": safe_path,
- "kind": "voice" if kind == "voice" else "attachment",
- }
- if name:
- body["name"] = name
- if mime_type:
- body["mimeType"] = mime_type
- if caption:
- body["caption"] = caption
- try:
- data = await self._sidecar_call("/send-attachment", body)
- except Exception as e:
- return SendResult(success=False, error=str(e))
- self._record_sent_message(data.get("messageId"))
- return SendResult(success=True, message_id=data.get("messageId"))
-
- async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
- # Guard: adapter not yet connected (no sidecar address known).
- if self._http_client is None:
- raise RuntimeError("Photon adapter not connected")
- # Use a fresh client per call so this method is safe when invoked from
- # a worker thread that owns a different event loop than the one the
- # persistent _http_client was created on (e.g. via _run_async in
- # send_message_tool). The inbound streaming loop continues to use
- # _http_client directly — it always runs on the gateway's loop.
- url = f"http://{self._sidecar_bind}:{self._sidecar_port}{path}"
- headers = {"X-Hermes-Sidecar-Token": self._sidecar_token}
- async with httpx.AsyncClient(timeout=30.0) as client:
- resp = await client.post(url, json=body, headers=headers)
- if resp.status_code != 200:
- raise RuntimeError(
- f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}"
- )
- data = resp.json() or {}
- if not data.get("ok"):
- raise RuntimeError(
- f"Photon sidecar {path} reported error: {data.get('error')}"
- )
- return data
-
-
-# ---------------------------------------------------------------------------
-# Helpers
-
-def _attachment_message_type(mime: str) -> MessageType:
- mime = (mime or "").lower()
- if mime.startswith("image/"):
- return MessageType.PHOTO
- if mime.startswith("video/"):
- return MessageType.VIDEO
- if mime.startswith("audio/"):
- return MessageType.AUDIO
- if mime.startswith("application/"):
- return MessageType.DOCUMENT
- return MessageType.DOCUMENT
-
-
-# MIME → file-extension maps for caching inbound attachment bytes. These mirror
-# the BlueBubbles iMessage channel so both adapters name cached media the same.
-_IMAGE_EXT_BY_MIME = {
- "image/jpeg": ".jpg",
- "image/png": ".png",
- "image/gif": ".gif",
- "image/webp": ".webp",
- "image/heic": ".jpg",
- "image/heif": ".jpg",
- "image/tiff": ".jpg",
-}
-_AUDIO_EXT_BY_MIME = {
- "audio/mp3": ".mp3",
- "audio/mpeg": ".mp3",
- "audio/ogg": ".ogg",
- "audio/wav": ".wav",
- "audio/x-caf": ".mp3",
- "audio/mp4": ".m4a",
- "audio/aac": ".m4a",
-}
-
-
-def _cache_inbound_attachment(
- content: Dict[str, Any],
- name: str,
- mime: str,
- *,
- force_audio: bool = False,
-) -> Optional[str]:
- """Decode a base64-inlined inbound attachment and cache it locally.
-
- The sidecar inlines the attachment bytes as ``content["data"]`` (base64).
- We decode them and route to the shared media cache by MIME type, returning
- the cached absolute path so the caller can populate ``media_urls`` (which
- the gateway then hands to the model). Returns ``None`` when there are no
- bytes (over the sidecar's inline cap or a failed read) or when caching
- fails, so the caller can fall back to a text marker.
- """
- data_b64 = content.get("data")
- if not data_b64:
- return None
- try:
- raw = base64.b64decode(data_b64)
- except (ValueError, TypeError) as exc:
- logger.warning("[photon] failed to decode inbound attachment bytes: %s", exc)
- return None
-
- from gateway.platforms.base import (
- cache_audio_from_bytes,
- cache_document_from_bytes,
- cache_image_from_bytes,
- )
-
- mime = (mime or "").lower()
- # Prefer the real extension from the filename; fall back to the MIME map.
- suffix = Path(name).suffix if name else ""
- try:
- if mime.startswith("image/"):
- ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg")
- try:
- return cache_image_from_bytes(raw, ext)
- except ValueError:
- # Bytes don't look like a supported image (e.g. HEIC magic) —
- # still deliver them as a document rather than dropping them.
- return cache_document_from_bytes(raw, name)
- if force_audio or mime.startswith("audio/"):
- ext = suffix or _AUDIO_EXT_BY_MIME.get(
- mime, ".m4a" if force_audio else ".mp3"
- )
- return cache_audio_from_bytes(raw, ext)
- # Video, application/*, and everything else → document cache.
- return cache_document_from_bytes(raw, name)
- except Exception as exc:
- logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc)
- return None
-
-
-# ---------------------------------------------------------------------------
-# Standalone (out-of-process) send for cron deliveries when the gateway
-# is not co-resident. Reuses a live sidecar already listening on the
-# configured port (cron processes cannot spawn the sidecar themselves).
-
-async def _standalone_send(
- pconfig: PlatformConfig,
- chat_id: str,
- message: str,
- *,
- thread_id: Optional[str] = None, # noqa: ARG001 — Spectrum has no threads yet
- media_files: Optional[list] = None,
- force_document: bool = False, # noqa: ARG001 — iMessage auto-detects file kind
-) -> Dict[str, Any]:
- if not HTTPX_AVAILABLE:
- return {"error": "httpx not installed"}
- port = _coerce_port(
- (pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
- _DEFAULT_SIDECAR_PORT,
- )
- token = os.getenv("PHOTON_SIDECAR_TOKEN")
- if not token:
- return {
- "error": (
- "Photon standalone send requires a running sidecar with "
- "PHOTON_SIDECAR_TOKEN set in the environment. Cron processes "
- "cannot spawn the sidecar themselves."
- )
- }
- base = f"http://{_DEFAULT_SIDECAR_BIND}:{port}"
- headers = {"X-Hermes-Sidecar-Token": token}
- last_message_id: Optional[str] = None
- try:
- async with httpx.AsyncClient(timeout=30.0) as client:
- # 1. Text body first (if any), so it leads the conversation.
- if message:
- send_body: Dict[str, Any] = {
- "spaceId": chat_id,
- "text": message[:_MAX_MESSAGE_LENGTH],
- }
- if _markdown_enabled():
- send_body["format"] = "markdown"
- resp = await client.post(
- f"{base}/send", json=send_body, headers=headers,
- )
- if resp.status_code != 200:
- return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
- data = resp.json() or {}
- if not data.get("ok"):
- return {"error": data.get("error") or "sidecar reported failure"}
- last_message_id = data.get("messageId")
-
- # 2. Each attachment as a separate /send-attachment call.
- # media_files is List[Tuple[path, is_voice]] (see
- # BasePlatformAdapter.filter_media_delivery_paths).
- import mimetypes
-
- for media_path, is_voice in media_files or []:
- safe_path = BasePlatformAdapter.validate_media_delivery_path(str(media_path))
- if not safe_path:
- logger.warning("[photon] standalone send skipping unsafe path")
- continue
- guessed, _ = mimetypes.guess_type(safe_path)
- att_body: Dict[str, Any] = {
- "spaceId": chat_id,
- "path": safe_path,
- "kind": "voice" if is_voice else "attachment",
- }
- if guessed:
- att_body["mimeType"] = guessed
- resp = await client.post(
- f"{base}/send-attachment", json=att_body, headers=headers,
- )
- if resp.status_code != 200:
- return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
- data = resp.json() or {}
- if not data.get("ok"):
- return {"error": data.get("error") or "sidecar reported failure"}
- last_message_id = data.get("messageId") or last_message_id
-
- return {"success": True, "message_id": last_message_id}
- except Exception as e:
- return {"error": f"Photon standalone send failed: {e}"}
-
-
-# ---------------------------------------------------------------------------
-# Plugin entry point
-
-def register(ctx) -> None:
- """Called by the Hermes plugin loader at startup."""
- # Local import to avoid argparse work at module load; reused for both the
- # gateway-setup hook and the `hermes photon` CLI command below.
- from . import cli as _cli
-
- ctx.register_platform(
- name="photon",
- label="iMessage via Photon",
- adapter_factory=lambda cfg: PhotonAdapter(cfg),
- check_fn=check_requirements,
- validate_config=validate_config,
- is_connected=is_connected,
- required_env=["PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET"],
- install_hint=(
- "Run: hermes photon setup (logs in via device flow, creates a "
- "Spectrum project, links your phone number, installs the "
- "spectrum-ts sidecar)."
- ),
- # Surfaces Photon in `hermes gateway setup` alongside every other
- # channel — same unified onboarding wizard, no Photon-only detour.
- setup_fn=_cli.gateway_setup,
- env_enablement_fn=_env_enablement,
- cron_deliver_env_var="PHOTON_HOME_CHANNEL",
- standalone_sender_fn=_standalone_send,
- allowed_users_env="PHOTON_ALLOWED_USERS",
- allow_all_env="PHOTON_ALLOW_ALL_USERS",
- max_message_length=_MAX_MESSAGE_LENGTH,
- emoji="📱",
- # iMessage carries E.164 phone numbers — treat session descriptions
- # as PII-sensitive so they get redacted before reaching the LLM
- # (matches the BlueBubbles iMessage channel in _PII_SAFE_PLATFORMS).
- pii_safe=True,
- allow_update_command=True,
- platform_hint=(
- "You are communicating via Photon Spectrum (iMessage). "
- "Treat replies like regular text messages — short and friendly. "
- "Markdown is rendered (bold, italics, lists, code), but keep "
- "formatting light and conversational. Recipient identifiers are "
- "E.164 phone numbers; never expose them in responses unless the "
- "user asked. Attachments arrive as metadata only."
- ),
- )
-
- # Register CLI subcommands — `hermes photon ...`
- ctx.register_cli_command(
- name="photon",
- help="Set up and manage the Photon iMessage integration",
- setup_fn=_cli.register_cli,
- handler_fn=_cli.dispatch,
- )
+"""
+Photon Spectrum (iMessage) platform adapter for Hermes Agent.
+
+Both directions of traffic flow through a small supervised Node sidecar
+(see ``sidecar/index.mjs``) that runs the ``spectrum-ts`` SDK — the SDK is
+TypeScript-only and there is no public HTTP message API, so a sidecar is
+unavoidable.
+
+Inbound:
+ The SDK's ``app.messages`` is a long-lived **gRPC** stream. The sidecar
+ serializes each message to a normalized JSON event and streams it to this
+ adapter over a loopback ``GET /inbound`` (NDJSON). A background task here
+ consumes that stream, dedupes on ``messageId``, and dispatches a
+ ``MessageEvent`` to the gateway via ``BasePlatformAdapter.handle_message``.
+ No webhook, no public URL, no signing secret.
+
+Outbound:
+ ``send`` / ``send_typing`` are loopback POSTs to the sidecar's control
+ endpoints, authenticated with a shared bearer token. Outbound media
+ (images, voice notes, video, documents) goes through spectrum-ts'
+ ``attachment()`` / ``voice()`` content builders via the sidecar's
+ ``/send-attachment`` endpoint.
+"""
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import logging
+import os
+import re
+import secrets
+import shutil
+import signal
+import subprocess
+import sys
+import time
+
+if sys.platform == "win32":
+ from hermes_cli._subprocess_compat import windows_hide_flags as _windows_hide_flags
+else:
+ def _windows_hide_flags() -> int: # type: ignore[misc]
+ return 0
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
+
+if TYPE_CHECKING:
+ # Type checkers see ``httpx`` as the always-imported module, so every use
+ # site type-checks cleanly. The runtime fallback below keeps the optional
+ # dependency truly optional (each use site is guarded by HTTPX_AVAILABLE).
+ import httpx
+ HTTPX_AVAILABLE = True
+else:
+ try:
+ import httpx
+ HTTPX_AVAILABLE = True
+ except ImportError: # pragma: no cover - httpx is already a Hermes dep
+ HTTPX_AVAILABLE = False
+ httpx = None
+
+from gateway.config import Platform, PlatformConfig
+from gateway.platforms.base import (
+ BasePlatformAdapter,
+ MessageEvent,
+ MessageType,
+ ProcessingOutcome,
+ SendResult,
+)
+from gateway.platforms.helpers import strip_markdown
+
+from .auth import load_project_credentials
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+
+_DEFAULT_SIDECAR_PORT = 8789
+_DEFAULT_SIDECAR_BIND = "127.0.0.1"
+
+# Photon iMessage messages from the SDK side have no documented hard
+# limit, but the underlying iMessage protocol limits practical message
+# size to ~16 KB. Keep a conservative cap that matches BlueBubbles.
+_MAX_MESSAGE_LENGTH = 8000
+
+# Dedup parameters — the gRPC stream is at-least-once, and a sidecar
+# reconnect can replay, so keep at least 1k ids for ~48h.
+_DEDUP_MAX_SIZE = 4000
+_DEDUP_WINDOW_SECONDS = 48 * 3600
+
+_SIDECAR_DIR = Path(__file__).parent / "sidecar"
+
+# Group-chat mention wake words. When ``require_mention`` is enabled, group
+# messages are ignored unless they match one of these patterns — same
+# behavior and defaults as the BlueBubbles iMessage channel so the two
+# iMessage adapters gate group chats identically.
+_DEFAULT_MENTION_PATTERNS = [
+ r"(? int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def check_requirements() -> bool:
+ """Return True when both Python deps and the Node sidecar are available."""
+ if not HTTPX_AVAILABLE:
+ return False
+ if not shutil.which(os.getenv("PHOTON_NODE_BIN") or "node"):
+ return False
+ if not (_SIDECAR_DIR / "node_modules").exists():
+ # spectrum-ts not installed yet — `hermes photon setup` will
+ # install it. check_fn still returns False so the gateway
+ # surfaces the missing-deps state in `hermes setup` / status.
+ return False
+ return True
+
+
+def validate_config(cfg: PlatformConfig) -> bool:
+ extra = cfg.extra or {}
+ project_id = extra.get("project_id") or os.getenv("PHOTON_PROJECT_ID")
+ project_secret = extra.get("project_secret") or os.getenv("PHOTON_PROJECT_SECRET")
+ if not project_id or not project_secret:
+ # Fall back to auth.json
+ stored_id, stored_sec = load_project_credentials()
+ return bool(stored_id and stored_sec)
+ return True
+
+
+def is_connected(cfg: PlatformConfig) -> bool:
+ return validate_config(cfg)
+
+
+def _env_enablement() -> Optional[dict]:
+ """Seed PlatformConfig.extra from env so env-only setups appear in status.
+
+ The special ``home_channel`` key is handled by the core plugin hook and
+ becomes a proper ``HomeChannel`` on ``PlatformConfig``.
+ """
+ project_id, project_secret = load_project_credentials()
+ if not (project_id and project_secret):
+ return None
+ seed: dict = {"project_id": project_id, "project_secret": project_secret}
+ home = os.getenv("PHOTON_HOME_CHANNEL", "").strip()
+ if home:
+ seed["home_channel"] = {
+ "chat_id": home,
+ "name": os.getenv("PHOTON_HOME_CHANNEL_NAME", "Home"),
+ }
+ return seed
+
+
+def _markdown_enabled() -> bool:
+ """Send agent replies as markdown (spectrum-ts ``markdown()`` builder).
+
+ iMessage renders it natively; other Spectrum platforms degrade to
+ readable plain text. On-device rendering can't be unit-tested, so
+ ``PHOTON_MARKDOWN=false`` is the kill-switch back to stripped plain
+ text without a release.
+ """
+ return os.getenv("PHOTON_MARKDOWN", "true").strip().lower() not in {
+ "false", "0", "no",
+ }
+
+
+# ---------------------------------------------------------------------------
+# Adapter
+
+class PhotonAdapter(BasePlatformAdapter):
+ """Bidirectional bridge to Photon Spectrum via the Node spectrum-ts sidecar.
+
+ Inbound: consume the sidecar's ``/inbound`` gRPC stream.
+ Outbound: loopback POSTs to the sidecar's control channel.
+ """
+
+ MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
+
+ def __init__(self, config: PlatformConfig):
+ super().__init__(config, Platform("photon"))
+ extra = config.extra or {}
+
+ # Project credentials (env wins, then config.extra, then auth.json).
+ # ``project_id`` here is the project's spectrumProjectId — the value
+ # the spectrum-ts SDK authenticates with.
+ stored_id, stored_sec = load_project_credentials()
+ self._project_id: str = (
+ os.getenv("PHOTON_PROJECT_ID")
+ or extra.get("project_id")
+ or stored_id
+ or ""
+ )
+ self._project_secret: str = (
+ os.getenv("PHOTON_PROJECT_SECRET")
+ or extra.get("project_secret")
+ or stored_sec
+ or ""
+ )
+
+ # Sidecar
+ self._sidecar_port = _coerce_port(
+ extra.get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
+ _DEFAULT_SIDECAR_PORT,
+ )
+ self._sidecar_bind = _DEFAULT_SIDECAR_BIND
+ self._sidecar_token = (
+ os.getenv("PHOTON_SIDECAR_TOKEN") or secrets.token_hex(16)
+ )
+ self._autostart_sidecar = str(
+ os.getenv("PHOTON_SIDECAR_AUTOSTART", "true")
+ ).lower() not in ("0", "false", "no")
+ self._node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node") or "node"
+
+ # With markdown on, format_message preserves fences and the sidecar's
+ # markdown() builder renders them (or degrades them readably).
+ self.supports_code_blocks = _markdown_enabled()
+
+ # Runtime state
+ self._sidecar_proc: Optional[subprocess.Popen] = None
+ self._sidecar_supervisor_task: Optional[asyncio.Task] = None
+ self._inbound_task: Optional[asyncio.Task] = None
+ self._inbound_running = False
+ self._http_client: Optional["httpx.AsyncClient"] = None
+ # Lightweight in-memory dedup. The gRPC stream is at-least-once, so we
+ # may see the same messageId more than once (e.g. after a reconnect).
+ self._seen_messages: Dict[str, float] = {}
+ # Ids of messages WE sent (bounded, insertion-order eviction). Inbound
+ # reaction events are only routed to the agent when they target one of
+ # these — a tapback on a human↔human message is not addressed to us.
+ self._sent_message_ids: Dict[str, float] = {}
+ # Latest inbound message id per chat (bounded). Lets the agent-facing
+ # react action default to "the message that triggered me" without
+ # requiring the model to thread message ids through tool calls.
+ self._last_inbound_by_chat: Dict[str, str] = {}
+
+ # Group-chat mention gating (parity with BlueBubbles). When enabled,
+ # group messages are ignored unless they match a wake word; DMs are
+ # always processed. Config key wins, then env var.
+ _require_mention = extra.get("require_mention")
+ if _require_mention is None:
+ _require_mention = os.getenv("PHOTON_REQUIRE_MENTION")
+ self.require_mention = str(_require_mention).strip().lower() in {
+ "true", "1", "yes", "on",
+ }
+ self._mention_patterns = self._compile_mention_patterns(
+ extra["mention_patterns"]
+ if "mention_patterns" in extra
+ else os.getenv("PHOTON_MENTION_PATTERNS")
+ )
+
+ # -- Group-mention gating (parity with BlueBubbles) -------------------
+
+ @staticmethod
+ def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]":
+ """Compile group-mention wake words from config/env.
+
+ ``raw`` is a list (config or env JSON), a string (env var: JSON
+ list, or comma/newline-separated), or None (use Hermes defaults).
+ Mirrors the BlueBubbles implementation so both iMessage channels
+ accept the same configuration shapes.
+ """
+ if raw is None:
+ patterns = list(_DEFAULT_MENTION_PATTERNS)
+ elif isinstance(raw, str):
+ text = raw.strip()
+ try:
+ loaded = json.loads(text) if text else []
+ except Exception:
+ loaded = None
+ patterns = loaded if isinstance(loaded, list) else [
+ part.strip()
+ for line in text.splitlines()
+ for part in line.split(",")
+ ]
+ elif isinstance(raw, list):
+ patterns = raw
+ else:
+ patterns = [raw]
+
+ compiled: "list[re.Pattern]" = []
+ for pattern in patterns:
+ text = str(pattern).strip()
+ if not text:
+ continue
+ try:
+ compiled.append(re.compile(text, re.IGNORECASE))
+ except re.error as exc:
+ logger.warning("[photon] Invalid mention pattern %r: %s", text, exc)
+ return compiled
+
+ def _message_matches_mention_patterns(self, text: str) -> bool:
+ if not text or not self._mention_patterns:
+ return False
+ return any(pattern.search(text) for pattern in self._mention_patterns)
+
+ def _clean_mention_text(self, text: str) -> str:
+ """Strip a leading wake word before dispatch.
+
+ Custom mention patterns are regexes, so we only strip a leading
+ match to avoid deleting ordinary words later in the prompt.
+ """
+ if not text:
+ return text
+ for pattern in self._mention_patterns:
+ match = pattern.match(text.lstrip())
+ if match:
+ cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
+ return cleaned or text
+ return text
+
+ # -- Connection lifecycle ---------------------------------------------
+
+ async def connect(self) -> bool:
+ if not HTTPX_AVAILABLE:
+ self._set_fatal_error(
+ "MISSING_DEP", "httpx not installed", retryable=False
+ )
+ return False
+ if not self._project_id or not self._project_secret:
+ self._set_fatal_error(
+ "MISSING_CREDENTIALS",
+ "PHOTON_PROJECT_ID and PHOTON_PROJECT_SECRET are required. "
+ "Run: hermes photon setup",
+ retryable=False,
+ )
+ return False
+
+ client = httpx.AsyncClient(timeout=30.0)
+ self._http_client = client
+
+ # The sidecar holds the gRPC stream for BOTH directions, so it is
+ # required now (not just for outbound).
+ if self._autostart_sidecar:
+ try:
+ await self._start_sidecar()
+ except Exception as e:
+ self._set_fatal_error(
+ "SIDECAR_FAILED",
+ f"failed to start Photon sidecar: {e}",
+ retryable=True,
+ )
+ await client.aclose()
+ self._http_client = None
+ return False
+ else:
+ logger.warning(
+ "[photon] sidecar autostart disabled — inbound + outbound will fail"
+ )
+
+ # Start consuming the inbound gRPC stream from the sidecar.
+ self._inbound_running = True
+ self._inbound_task = asyncio.get_event_loop().create_task(
+ self._inbound_loop()
+ )
+
+ self._mark_connected()
+ logger.info(
+ "[photon] connected — sidecar on %s:%d, streaming inbound over gRPC",
+ self._sidecar_bind, self._sidecar_port,
+ )
+ return True
+
+ async def disconnect(self) -> None:
+ self._inbound_running = False
+ if self._inbound_task is not None:
+ self._inbound_task.cancel()
+ try:
+ await self._inbound_task
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ pass
+ self._inbound_task = None
+ await self._stop_sidecar()
+ if self._http_client is not None:
+ try:
+ await self._http_client.aclose()
+ except Exception:
+ pass
+ self._http_client = None
+ self._mark_disconnected()
+
+ # -- Inbound stream consumer ------------------------------------------
+
+ async def _inbound_loop(self) -> None:
+ """Consume the sidecar's ``/inbound`` NDJSON stream, with reconnect.
+
+ The sidecar owns the gRPC reconnect/heartbeat to Photon; this loop
+ only has to re-open the loopback HTTP stream if it drops (e.g. the
+ sidecar restarts).
+ """
+ client = self._http_client
+ if client is None:
+ return
+ url = f"http://{self._sidecar_bind}:{self._sidecar_port}/inbound"
+ headers = {"X-Hermes-Sidecar-Token": self._sidecar_token}
+ backoff = 1.0
+ while self._inbound_running:
+ try:
+ async with client.stream(
+ "GET", url, headers=headers, timeout=None,
+ ) as resp:
+ if resp.status_code != 200:
+ raise RuntimeError(f"/inbound returned {resp.status_code}")
+ backoff = 1.0 # reset on a successful connect
+ async for line in resp.aiter_lines():
+ if not self._inbound_running:
+ break
+ line = line.strip()
+ if not line:
+ continue # heartbeat
+ await self._on_inbound_line(line)
+ except asyncio.CancelledError:
+ raise
+ except Exception as e:
+ if not self._inbound_running:
+ break
+ logger.warning(
+ "[photon] inbound stream dropped (%s); reconnecting in %.1fs",
+ e, backoff,
+ )
+ await asyncio.sleep(backoff)
+ backoff = min(backoff * 2, 30.0)
+
+ async def _on_inbound_line(self, line: str) -> None:
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError:
+ logger.debug("[photon] skipping non-JSON inbound line")
+ return
+ msg_id = event.get("messageId")
+ if msg_id and self._is_duplicate(msg_id):
+ return
+ try:
+ await self._dispatch_inbound(event)
+ except Exception:
+ logger.exception("[photon] inbound dispatch failed")
+
+ def _is_duplicate(self, msg_id: str) -> bool:
+ now = time.time()
+ seen = self._seen_messages
+ t = seen.get(msg_id)
+ if t is not None and now - t < _DEDUP_WINDOW_SECONDS:
+ return True # seen, unexpired
+ # New or expired: record and enforce a HARD size bound (evict oldest,
+ # insertion-order) so a burst of unique ids within the window can't grow
+ # the dict without limit — not just the expired-only prune.
+ if msg_id in seen:
+ del seen[msg_id] # refresh insertion order
+ seen[msg_id] = now
+ if len(seen) > _DEDUP_MAX_SIZE:
+ for old in list(seen.keys())[: len(seen) - _DEDUP_MAX_SIZE]:
+ del seen[old]
+ return False
+
+ async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
+ """Normalize a sidecar inbound event and dispatch it to the gateway.
+
+ Event shape (from ``sidecar/index.mjs``)::
+
+ {
+ "messageId": "...",
+ "platform": "iMessage",
+ "space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
+ "sender": {"id": "+E164"},
+ "content": {"type": "text", "text": "..."}
+ | {"type": "attachment"|"voice", "id", "name",
+ "mimeType", "size", "duration"?, "data"?,
+ "encoding"?}
+ | {"type": "reaction", "emoji": "❤️",
+ "targetMessageId": "..." | null,
+ "targetDirection": "inbound"|"outbound" | null},
+ "timestamp": "2026-05-14T19:06:32.000Z"
+
+ Attachment and voice content carry the bytes inline as base64 ``data``
+ (with ``encoding == "base64"``) when the sidecar could read them
+ within its size cap; otherwise only metadata is present and we surface
+ a marker.
+ }
+ """
+ space = event.get("space") or {}
+ sender = event.get("sender") or {}
+ content = event.get("content") or {}
+
+ space_id = space.get("id") or ""
+ if not space_id:
+ logger.warning("[photon] inbound missing space.id")
+ return
+
+ # iMessage spaces carry their type directly — no id string-sniffing.
+ chat_type = "group" if space.get("type") == "group" else "dm"
+ sender_id = sender.get("id") or space.get("phone") or space_id
+
+ ts_str = event.get("timestamp") or ""
+ try:
+ timestamp = (
+ datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
+ if ts_str
+ else datetime.now(tz=timezone.utc)
+ )
+ except ValueError:
+ timestamp = datetime.now(tz=timezone.utc)
+
+ # Media attachments (local cached paths) handed to the agent via the
+ # gateway's image-routing path, exactly like the BlueBubbles channel.
+ media_urls: List[str] = []
+ media_types: List[str] = []
+
+ ctype = content.get("type")
+ if ctype == "reaction":
+ # Route only tapbacks on messages WE sent — those are implicitly
+ # addressed to the bot (feishu precedent: synthetic text event).
+ # Reactions on human↔human messages are not for us. Checked before
+ # the mention gate: a tapback never carries a wake word.
+ target_id = content.get("targetMessageId")
+ is_ours = content.get("targetDirection") == "outbound" or (
+ target_id and target_id in self._sent_message_ids
+ )
+ if not is_ours:
+ logger.debug(
+ "[photon] ignoring reaction on a message we didn't send"
+ )
+ return
+ emoji = content.get("emoji") or ""
+ source = self.build_source(
+ chat_id=space_id,
+ chat_name=space_id,
+ chat_type=chat_type,
+ user_id=sender_id,
+ user_name=sender_id or None,
+ )
+ await self.handle_message(
+ MessageEvent(
+ text=f"reaction:added:{emoji}",
+ message_type=MessageType.TEXT,
+ source=source,
+ message_id=event.get("messageId"),
+ raw_message=event,
+ timestamp=timestamp,
+ )
+ )
+ return
+ # Anything past here is a real (reactable) message — remember it as
+ # the chat's latest inbound so `add_reaction` can target it when the
+ # caller doesn't pass an explicit message id. Recorded before the
+ # mention gate: a reaction to a non-wake-word group message is valid.
+ self._record_last_inbound(space_id, event.get("messageId"))
+ if ctype == "text":
+ text = content.get("text") or ""
+ mtype = MessageType.TEXT
+ elif ctype in {"attachment", "voice"}:
+ is_voice = ctype == "voice"
+ name = content.get("name") or ("voice" if is_voice else "(unnamed)")
+ mime = content.get("mimeType") or ""
+ mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
+ cached = _cache_inbound_attachment(
+ content, name, mime, force_audio=is_voice
+ )
+ if cached:
+ media_urls.append(cached)
+ media_types.append(
+ mime or ("audio/mp4" if is_voice else "application/octet-stream")
+ )
+ # The real bytes are attached, so the agent sees the media
+ # itself — a short marker is enough text, and it keeps group
+ # mention-gating consistent with plain messages.
+ text = "(voice)" if is_voice else "(attachment)"
+ else:
+ # No bytes (over the sidecar cap, a failed read, or a caching
+ # failure) — fall back to a metadata marker so the agent still
+ # knows something arrived.
+ label = "voice" if is_voice else "attachment"
+ duration = content.get("duration")
+ duration_text = (
+ f", duration: {duration}s"
+ if isinstance(duration, (int, float))
+ else ""
+ )
+ text = (
+ f"[Photon {label} received: {name} "
+ f"({mime or 'unknown MIME'}{duration_text})]"
+ )
+ else:
+ text = f"[Photon content type not handled: {ctype}]"
+ mtype = MessageType.TEXT
+
+ # Group-mention gating (parity with BlueBubbles). In group chats with
+ # require_mention enabled, drop messages that don't hit a wake word;
+ # strip the leading wake word from the ones that do. DMs are never
+ # gated.
+ if chat_type == "group" and self.require_mention:
+ if not self._message_matches_mention_patterns(text):
+ logger.debug(
+ "[photon] ignoring group message "
+ "(require_mention=true, no mention pattern matched)"
+ )
+ return
+ text = self._clean_mention_text(text)
+
+ source = self.build_source(
+ chat_id=space_id,
+ chat_name=space_id,
+ chat_type=chat_type,
+ user_id=sender_id,
+ user_name=sender_id or None,
+ )
+ message_event = MessageEvent(
+ text=text,
+ message_type=mtype,
+ source=source,
+ message_id=event.get("messageId"),
+ raw_message=event,
+ timestamp=timestamp,
+ media_urls=media_urls,
+ media_types=media_types,
+ )
+ await self.handle_message(message_event)
+
+ # -- Sidecar lifecycle -------------------------------------------------
+
+ @staticmethod
+ def _find_listener_pids(port: int) -> List[int]:
+ """PIDs listening on a local TCP port (empty if none/undeterminable)."""
+ try:
+ out = subprocess.run( # noqa: S603, S607
+ ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
+ capture_output=True, text=True, timeout=5.0, check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return []
+ return [int(tok) for tok in out.stdout.split() if tok.strip().isdigit()]
+
+ @staticmethod
+ def _pid_is_sidecar(pid: int) -> bool:
+ """True if ``pid``'s command line is a Photon sidecar process."""
+ try:
+ out = subprocess.run( # noqa: S603, S607
+ ["ps", "-p", str(pid), "-o", "command="],
+ capture_output=True, text=True, timeout=5.0, check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+ # Checkout-agnostic: any Hermes checkout's sidecar entry point.
+ return "photon/sidecar/index.mjs" in out.stdout
+
+ @staticmethod
+ def _pid_alive(pid: int) -> bool:
+ try:
+ os.kill(pid, 0) # windows-footgun: ok — only called from _reap_stale_sidecar which win32-guards early
+ return True
+ except OSError:
+ return False
+
+ async def _reap_stale_sidecar(self) -> None:
+ """Kill an orphaned sidecar squatting our port before spawning ours.
+
+ A hard gateway exit (crash, SIGKILL, supervisor restart) used to leave
+ the detached sidecar running with a token the new gateway doesn't
+ know, so it can't be told to ``/shutdown`` — and every replacement
+ spawn died on EADDRINUSE, failing each reconnect attempt. The
+ stdin-EOF watch prevents new orphans; this reclaims the port from
+ orphans that predate it (or survived it). Listeners are verified by
+ command line before being signalled.
+ """
+ if sys.platform == "win32": # lsof/ps; orphaning is a POSIX-only path
+ return
+ try:
+ async with httpx.AsyncClient(timeout=2.0) as client:
+ await client.post(
+ f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz",
+ headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
+ )
+ except httpx.RequestError:
+ return # nothing listening — the normal case
+ pids = self._find_listener_pids(self._sidecar_port)
+ stale = [pid for pid in pids if self._pid_is_sidecar(pid)]
+ foreign = [pid for pid in pids if pid not in stale]
+ if not stale:
+ raise RuntimeError(
+ f"port {self._sidecar_port} is in use by another process "
+ f"(pids: {foreign or 'unknown'}, not a Photon sidecar) — "
+ f"free it or set PHOTON_SIDECAR_PORT to a different port"
+ )
+ for pid in stale:
+ logger.warning(
+ "[photon] reaping orphaned sidecar (pid %d) on port %d",
+ pid, self._sidecar_port,
+ )
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except OSError:
+ pass
+ deadline = time.time() + 3.0
+ while time.time() < deadline and any(self._pid_alive(p) for p in stale):
+ await asyncio.sleep(0.1)
+ for pid in stale:
+ if self._pid_alive(pid):
+ try:
+ os.kill(pid, signal.SIGKILL) # windows-footgun: ok — unreachable on win32 (early return above)
+ except OSError:
+ pass
+ # Give the OS a beat to release the listening socket.
+ await asyncio.sleep(0.2)
+ if foreign:
+ raise RuntimeError(
+ f"port {self._sidecar_port} is also held by non-sidecar "
+ f"processes (pids: {foreign}) — free it or set "
+ f"PHOTON_SIDECAR_PORT to a different port"
+ )
+
+ async def _start_sidecar(self) -> None:
+ if not (_SIDECAR_DIR / "node_modules").exists():
+ raise RuntimeError(
+ f"Photon sidecar deps not installed. Run: "
+ f"cd {_SIDECAR_DIR} && npm install (or `hermes photon setup`)"
+ )
+ await self._reap_stale_sidecar()
+
+ env = os.environ.copy()
+ env["PHOTON_PROJECT_ID"] = self._project_id
+ env["PHOTON_PROJECT_SECRET"] = self._project_secret
+ env["PHOTON_SIDECAR_PORT"] = str(self._sidecar_port)
+ env["PHOTON_SIDECAR_BIND"] = self._sidecar_bind
+ env["PHOTON_SIDECAR_TOKEN"] = self._sidecar_token
+ # The sidecar exits when its stdin (the pipe below) hits EOF, so a
+ # gateway death of ANY kind — including SIGKILL, where disconnect()
+ # never runs — can't leave it orphaned on the port.
+ env["PHOTON_SIDECAR_WATCH_STDIN"] = "1"
+
+ self._sidecar_proc = subprocess.Popen( # noqa: S603
+ [self._node_bin, str(_SIDECAR_DIR / "index.mjs")],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ env=env,
+ start_new_session=(sys.platform != "win32"),
+ **({"creationflags": _windows_hide_flags()} if sys.platform == "win32" else {}),
+ )
+
+ # Pump sidecar stderr/stdout into our logger so users see crashes.
+ loop = asyncio.get_event_loop()
+ self._sidecar_supervisor_task = loop.create_task(
+ self._supervise_sidecar(self._sidecar_proc)
+ )
+
+ # Wait for /healthz to come up — give it up to 15s on cold start.
+ deadline = time.time() + 15.0
+ last_err: Optional[Exception] = None
+ async with httpx.AsyncClient(timeout=2.0) as client:
+ while time.time() < deadline:
+ if self._sidecar_proc.poll() is not None:
+ raise RuntimeError(
+ f"Photon sidecar exited with code "
+ f"{self._sidecar_proc.returncode} before becoming ready"
+ )
+ try:
+ resp = await client.post(
+ f"http://{self._sidecar_bind}:{self._sidecar_port}/healthz",
+ headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
+ )
+ if resp.status_code == 200:
+ return
+ except httpx.RequestError as e:
+ last_err = e
+ await asyncio.sleep(0.2)
+ raise RuntimeError(
+ f"Photon sidecar did not become ready within 15s: {last_err}"
+ )
+
+ async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
+ """Pump the sidecar's stdout/stderr into our logger."""
+ if proc.stdout is None: # subprocess was launched without stdout=PIPE
+ return
+ stdout = proc.stdout
+ loop = asyncio.get_event_loop()
+ try:
+ while True:
+ line = await loop.run_in_executor(None, stdout.readline)
+ if not line:
+ break
+ logger.info("[photon-sidecar] %s", line.decode("utf-8", "replace").rstrip())
+ except Exception as e: # pragma: no cover - defensive
+ logger.warning("[photon-sidecar] supervisor exited: %s", e)
+
+ async def _stop_sidecar(self) -> None:
+ proc = self._sidecar_proc
+ if proc is None:
+ return
+ try:
+ # Closing our end of the stdin pipe is itself a shutdown signal
+ # (the sidecar watches for EOF), and covers the case where the
+ # HTTP call below can't get through.
+ if proc.stdin is not None:
+ try:
+ proc.stdin.close()
+ except Exception:
+ pass
+ # Polite shutdown first.
+ if self._http_client is not None:
+ try:
+ await self._http_client.post(
+ f"http://{self._sidecar_bind}:{self._sidecar_port}/shutdown",
+ headers={"X-Hermes-Sidecar-Token": self._sidecar_token},
+ timeout=2.0,
+ )
+ except Exception:
+ pass
+ try:
+ proc.wait(timeout=3.0)
+ except subprocess.TimeoutExpired:
+ if sys.platform != "win32":
+ try:
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM) # windows-footgun: ok
+ except (ProcessLookupError, PermissionError):
+ proc.terminate()
+ else:
+ proc.terminate()
+ try:
+ proc.wait(timeout=2.0)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ finally:
+ self._sidecar_proc = None
+ if self._sidecar_supervisor_task is not None:
+ self._sidecar_supervisor_task.cancel()
+ self._sidecar_supervisor_task = None
+
+ # -- Outbound ----------------------------------------------------------
+
+ async def send(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ return await self._sidecar_send(chat_id, self.format_message(content))
+
+ # -- Outbound media (parity with the BlueBubbles iMessage channel) -----
+ #
+ # Photon ships outbound attachments via spectrum-ts' `attachment()` /
+ # `voice()` content builders. The sidecar's `/send-attachment` endpoint
+ # wraps `space.send(attachment(path, {...}))`. These overrides mirror
+ # BlueBubbles: URL-based helpers cache to a local path first, file-based
+ # helpers pass the path straight through.
+
+ async def send_image(
+ self,
+ chat_id: str,
+ image_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ try:
+ from gateway.platforms.base import cache_image_from_url
+
+ local_path = await cache_image_from_url(image_url)
+ except Exception:
+ # Couldn't fetch the URL — fall back to sending it as text.
+ return await super().send_image(chat_id, image_url, caption, reply_to)
+ return await self._sidecar_send_attachment(
+ chat_id, local_path, caption=caption,
+ )
+
+ async def send_image_file(
+ self,
+ chat_id: str,
+ image_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ return await self._sidecar_send_attachment(
+ chat_id, image_path, caption=caption,
+ )
+
+ async def send_voice(
+ self,
+ chat_id: str,
+ audio_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ return await self._sidecar_send_attachment(
+ chat_id, audio_path, caption=caption, kind="voice",
+ )
+
+ async def send_video(
+ self,
+ chat_id: str,
+ video_path: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ return await self._sidecar_send_attachment(
+ chat_id, video_path, caption=caption,
+ )
+
+ async def send_document(
+ self,
+ chat_id: str,
+ file_path: str,
+ caption: Optional[str] = None,
+ file_name: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ **kwargs,
+ ) -> SendResult:
+ return await self._sidecar_send_attachment(
+ chat_id, file_path, name=file_name, caption=caption,
+ )
+
+ async def send_animation(
+ self,
+ chat_id: str,
+ animation_url: str,
+ caption: Optional[str] = None,
+ reply_to: Optional[str] = None,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> SendResult:
+ # iMessage renders GIFs inline as ordinary image attachments.
+ return await self.send_image(
+ chat_id, animation_url, caption, reply_to, metadata,
+ )
+
+ async def send_typing(self, chat_id: str, metadata=None) -> None:
+ try:
+ await self._sidecar_call(
+ "/typing", {"spaceId": chat_id, "state": "start"}
+ )
+ except Exception as e:
+ logger.debug("[photon] send_typing failed: %s", e)
+
+ async def stop_typing(self, chat_id: str) -> None:
+ try:
+ await self._sidecar_call(
+ "/typing", {"spaceId": chat_id, "state": "stop"}
+ )
+ except Exception as e:
+ logger.debug("[photon] stop_typing failed: %s", e)
+
+ # -- Reactions (tapbacks) -----------------------------------------------
+ #
+ # Same lifecycle-hook pattern as Telegram/Discord: 👀 while processing,
+ # swapped for 👍/👎 on completion. Opt-in via PHOTON_REACTIONS — iMessage
+ # is a personal-texting channel, and a tapback on every text is noisy.
+
+ _SENT_IDS_MAX = 1000
+ _LAST_INBOUND_CHATS_MAX = 200
+
+ def _record_sent_message(self, message_id: Optional[str]) -> None:
+ if not message_id:
+ return
+ sent = self._sent_message_ids
+ if message_id in sent:
+ del sent[message_id] # refresh insertion order
+ sent[message_id] = time.time()
+ if len(sent) > self._SENT_IDS_MAX:
+ for old in list(sent.keys())[: len(sent) - self._SENT_IDS_MAX]:
+ del sent[old]
+
+ # A DM space is addressable two ways — the chat GUID (`any;-;+1555...`)
+ # that inbound events carry, and the bare E.164 phone that home-channel
+ # config typically uses. The sidecar's resolveSpace treats them as the
+ # same space; normalize to the bare phone so the last-inbound tracker
+ # does too (mirrors phoneTargetFromSpaceId in sidecar/index.mjs).
+ _DM_CHAT_GUID_RE = re.compile(r"^any;-;(\+\d{6,})$")
+
+ @classmethod
+ def _normalize_chat_key(cls, chat_id: str) -> str:
+ match = cls._DM_CHAT_GUID_RE.match(chat_id)
+ return match.group(1) if match else chat_id
+
+ def _record_last_inbound(
+ self, chat_id: Optional[str], message_id: Optional[str]
+ ) -> None:
+ if not chat_id or not message_id:
+ return
+ key = self._normalize_chat_key(chat_id)
+ last = self._last_inbound_by_chat
+ if key in last:
+ del last[key] # refresh insertion order
+ last[key] = message_id
+ if len(last) > self._LAST_INBOUND_CHATS_MAX:
+ for old in list(last.keys())[
+ : len(last) - self._LAST_INBOUND_CHATS_MAX
+ ]:
+ del last[old]
+
+ def _reactions_enabled(self) -> bool:
+ return os.getenv("PHOTON_REACTIONS", "false").strip().lower() in {
+ "true", "1", "yes", "on",
+ }
+
+ async def _add_reaction(
+ self, chat_id: str, message_id: str, emoji: str
+ ) -> bool:
+ """Tapback ``emoji`` onto a message. Soft-fails (False), never raises."""
+ try:
+ await self._sidecar_call(
+ "/react",
+ {"spaceId": chat_id, "messageId": message_id, "emoji": emoji},
+ )
+ return True
+ except Exception as e:
+ logger.debug("[photon] add_reaction failed: %s", e)
+ return False
+
+ async def _remove_reaction(self, chat_id: str, message_id: str) -> bool:
+ """Retract our tapback from a message. Soft-fails (False), never raises.
+
+ The sidecar tracks one reaction handle per target message; after a
+ sidecar restart the handle is gone and removal is best-effort (the
+ stale tapback self-heals when the next reaction replaces it).
+ """
+ try:
+ await self._sidecar_call(
+ "/unreact", {"spaceId": chat_id, "messageId": message_id},
+ )
+ return True
+ except Exception as e:
+ logger.debug("[photon] remove_reaction failed: %s", e)
+ return False
+
+ # -- Agent-facing reactions (send_message action="react") ---------------
+ #
+ # Unlike the lifecycle hooks below, these are deliberate agent intents,
+ # so they are NOT gated by PHOTON_REACTIONS (that env var exists to mute
+ # the automatic per-message tapback noise, not explicit requests).
+
+ async def add_reaction(
+ self,
+ chat_id: str,
+ emoji: str,
+ message_id: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """Tapback ``emoji`` onto a message in ``chat_id``.
+
+ Without ``message_id``, targets the chat's most recent inbound
+ message (typically the one the agent is responding to). iMessage
+ maps ❤️👍👎😂‼️❓ to native tapbacks; anything else uses Apple's
+ custom-emoji reaction.
+ """
+ target = message_id or self._last_inbound_by_chat.get(
+ self._normalize_chat_key(chat_id)
+ )
+ if not target:
+ return {
+ "success": False,
+ "error": "no message to react to — pass message_id (no "
+ "inbound message seen in this chat since the gateway started)",
+ }
+ ok = await self._add_reaction(chat_id, target, emoji)
+ if not ok:
+ return {
+ "success": False,
+ "error": "reaction failed (see gateway debug log)",
+ }
+ return {"success": True, "message_id": target}
+
+ async def remove_reaction(
+ self, chat_id: str, message_id: Optional[str] = None
+ ) -> Dict[str, Any]:
+ """Retract our tapback from a message (best-effort)."""
+ target = message_id or self._last_inbound_by_chat.get(
+ self._normalize_chat_key(chat_id)
+ )
+ if not target:
+ return {
+ "success": False,
+ "error": "no message to unreact — pass message_id",
+ }
+ ok = await self._remove_reaction(chat_id, target)
+ if not ok:
+ return {
+ "success": False,
+ "error": "unreact failed (see gateway debug log)",
+ }
+ return {"success": True, "message_id": target}
+
+ async def on_processing_start(self, event: MessageEvent) -> None:
+ """Tapback 👀 on the triggering message while the agent works."""
+ if not self._reactions_enabled():
+ return
+ chat_id = getattr(event.source, "chat_id", None)
+ message_id = getattr(event, "message_id", None)
+ if chat_id and message_id:
+ await self._add_reaction(chat_id, message_id, "\U0001f440")
+
+ async def on_processing_complete(
+ self, event: MessageEvent, outcome: ProcessingOutcome
+ ) -> None:
+ """Swap the 👀 progress tapback for a 👍/👎 result.
+
+ Remove-then-add rather than a bare replace: deterministic whether the
+ platform replaces a sender's previous tapback or stacks them, and it
+ keeps the sidecar's reaction-handle slot coherent.
+ """
+ if not self._reactions_enabled():
+ return
+ chat_id = getattr(event.source, "chat_id", None)
+ message_id = getattr(event, "message_id", None)
+ if not chat_id or not message_id:
+ return
+ await self._remove_reaction(chat_id, message_id)
+ if outcome == ProcessingOutcome.SUCCESS:
+ await self._add_reaction(chat_id, message_id, "\U0001f44d")
+ elif outcome == ProcessingOutcome.FAILURE:
+ await self._add_reaction(chat_id, message_id, "\U0001f44e")
+ # CANCELLED: leave the message unreacted.
+
+ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
+ """Return whatever we know about a Spectrum space id.
+
+ Photon's ``space.id`` is opaque; the inbound event also carries the
+ DM/group type, but here we only have the id, so infer conservatively.
+ """
+ return {"name": chat_id, "type": "dm", "id": chat_id}
+
+ def format_message(self, content: str) -> str:
+ # Markdown is passed through verbatim — the sidecar sends it with the
+ # markdown() builder and iMessage renders it. The strip path remains
+ # as the PHOTON_MARKDOWN=false kill-switch.
+ if _markdown_enabled():
+ return content
+ return strip_markdown(content)
+
+ async def _send_with_retry(
+ self,
+ chat_id: str,
+ content: str,
+ reply_to: Optional[str] = None,
+ metadata: Any = None,
+ max_retries: int = 2,
+ base_delay: float = 2.0,
+ ) -> SendResult:
+ """Retry sends without the generic Markdown banner.
+
+ Photon replies are markdown (rendered by iMessage) or stripped plain
+ text under ``PHOTON_MARKDOWN=false`` — either way the gateway's
+ generic banner never applies.
+ """
+ text = self.format_message(content)
+ result = await self.send(
+ chat_id=chat_id,
+ content=text,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if result.success:
+ return result
+
+ error_str = result.error or ""
+ is_network = result.retryable or self._is_retryable_error(error_str)
+ if not is_network and self._is_timeout_error(error_str):
+ return result
+
+ if is_network:
+ for attempt in range(1, max_retries + 1):
+ delay = base_delay * (2 ** (attempt - 1))
+ logger.warning(
+ "[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s",
+ attempt, max_retries, delay, error_str,
+ )
+ await asyncio.sleep(delay)
+ result = await self.send(
+ chat_id=chat_id,
+ content=text,
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if result.success:
+ return result
+ error_str = result.error or ""
+ if not (result.retryable or self._is_retryable_error(error_str)):
+ break
+ else:
+ logger.error(
+ "[photon] Failed to deliver response after %d retries: %s",
+ max_retries, error_str,
+ )
+ return result
+
+ logger.warning(
+ "[photon] Send failed: %s - retrying plain-text message",
+ error_str,
+ )
+ fallback_result = await self.send(
+ chat_id=chat_id,
+ content=text[: self.MAX_MESSAGE_LENGTH],
+ reply_to=reply_to,
+ metadata=metadata,
+ )
+ if not fallback_result.success:
+ logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
+ return fallback_result
+
+ async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
+ if len(text) > self.MAX_MESSAGE_LENGTH:
+ logger.warning(
+ "[photon] truncating outbound from %d to %d chars",
+ len(text), self.MAX_MESSAGE_LENGTH,
+ )
+ text = text[: self.MAX_MESSAGE_LENGTH]
+ body: Dict[str, Any] = {"spaceId": space_id, "text": text}
+ # Omit the key when disabled so an older sidecar (pre-`format`)
+ # keeps accepting the body during a half-upgraded restart.
+ if _markdown_enabled():
+ body["format"] = "markdown"
+ try:
+ data = await self._sidecar_call("/send", body)
+ except Exception as e:
+ return SendResult(success=False, error=str(e))
+ self._record_sent_message(data.get("messageId"))
+ return SendResult(success=True, message_id=data.get("messageId"))
+
+ async def _sidecar_send_attachment(
+ self,
+ space_id: str,
+ path: str,
+ *,
+ name: Optional[str] = None,
+ mime_type: Optional[str] = None,
+ caption: Optional[str] = None,
+ kind: str = "attachment",
+ ) -> SendResult:
+ """POST a local file to the sidecar's ``/send-attachment`` endpoint.
+
+ ``kind`` is ``"voice"`` for audio sent as a voice note (downgrades
+ to a plain audio attachment on platforms without voice notes),
+ otherwise ``"attachment"``. spectrum-ts infers ``name`` and
+ ``mimeType`` from the file extension; we only pass overrides when
+ Hermes supplied them.
+ """
+ # Defense-in-depth: re-validate the path before handing it to the
+ # Node sidecar. The gateway already filters MEDIA paths, but
+ # send_*_file / cron callers may pass arbitrary strings.
+ safe_path = self.validate_media_delivery_path(str(path))
+ if not safe_path:
+ return SendResult(
+ success=False, error=f"unsafe or missing attachment path: {path}"
+ )
+ if not mime_type:
+ import mimetypes
+
+ guessed, _ = mimetypes.guess_type(safe_path)
+ mime_type = guessed or None
+ body: Dict[str, Any] = {
+ "spaceId": space_id,
+ "path": safe_path,
+ "kind": "voice" if kind == "voice" else "attachment",
+ }
+ if name:
+ body["name"] = name
+ if mime_type:
+ body["mimeType"] = mime_type
+ if caption:
+ body["caption"] = caption
+ try:
+ data = await self._sidecar_call("/send-attachment", body)
+ except Exception as e:
+ return SendResult(success=False, error=str(e))
+ self._record_sent_message(data.get("messageId"))
+ return SendResult(success=True, message_id=data.get("messageId"))
+
+ async def _sidecar_call(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
+ # Guard: adapter not yet connected (no sidecar address known).
+ if self._http_client is None:
+ raise RuntimeError("Photon adapter not connected")
+ # Use a fresh client per call so this method is safe when invoked from
+ # a worker thread that owns a different event loop than the one the
+ # persistent _http_client was created on (e.g. via _run_async in
+ # send_message_tool). The inbound streaming loop continues to use
+ # _http_client directly — it always runs on the gateway's loop.
+ url = f"http://{self._sidecar_bind}:{self._sidecar_port}{path}"
+ headers = {"X-Hermes-Sidecar-Token": self._sidecar_token}
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(url, json=body, headers=headers)
+ if resp.status_code != 200:
+ raise RuntimeError(
+ f"Photon sidecar {path} returned {resp.status_code}: {resp.text[:200]}"
+ )
+ data = resp.json() or {}
+ if not data.get("ok"):
+ raise RuntimeError(
+ f"Photon sidecar {path} reported error: {data.get('error')}"
+ )
+ return data
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+
+def _attachment_message_type(mime: str) -> MessageType:
+ mime = (mime or "").lower()
+ if mime.startswith("image/"):
+ return MessageType.PHOTO
+ if mime.startswith("video/"):
+ return MessageType.VIDEO
+ if mime.startswith("audio/"):
+ return MessageType.AUDIO
+ if mime.startswith("application/"):
+ return MessageType.DOCUMENT
+ return MessageType.DOCUMENT
+
+
+# MIME → file-extension maps for caching inbound attachment bytes. These mirror
+# the BlueBubbles iMessage channel so both adapters name cached media the same.
+_IMAGE_EXT_BY_MIME = {
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/heic": ".jpg",
+ "image/heif": ".jpg",
+ "image/tiff": ".jpg",
+}
+_AUDIO_EXT_BY_MIME = {
+ "audio/mp3": ".mp3",
+ "audio/mpeg": ".mp3",
+ "audio/ogg": ".ogg",
+ "audio/wav": ".wav",
+ "audio/x-caf": ".mp3",
+ "audio/mp4": ".m4a",
+ "audio/aac": ".m4a",
+}
+
+
+def _cache_inbound_attachment(
+ content: Dict[str, Any],
+ name: str,
+ mime: str,
+ *,
+ force_audio: bool = False,
+) -> Optional[str]:
+ """Decode a base64-inlined inbound attachment and cache it locally.
+
+ The sidecar inlines the attachment bytes as ``content["data"]`` (base64).
+ We decode them and route to the shared media cache by MIME type, returning
+ the cached absolute path so the caller can populate ``media_urls`` (which
+ the gateway then hands to the model). Returns ``None`` when there are no
+ bytes (over the sidecar's inline cap or a failed read) or when caching
+ fails, so the caller can fall back to a text marker.
+ """
+ data_b64 = content.get("data")
+ if not data_b64:
+ return None
+ try:
+ raw = base64.b64decode(data_b64)
+ except (ValueError, TypeError) as exc:
+ logger.warning("[photon] failed to decode inbound attachment bytes: %s", exc)
+ return None
+
+ from gateway.platforms.base import (
+ cache_audio_from_bytes,
+ cache_document_from_bytes,
+ cache_image_from_bytes,
+ )
+
+ mime = (mime or "").lower()
+ # Prefer the real extension from the filename; fall back to the MIME map.
+ suffix = Path(name).suffix if name else ""
+ try:
+ if mime.startswith("image/"):
+ ext = suffix or _IMAGE_EXT_BY_MIME.get(mime, ".jpg")
+ try:
+ return cache_image_from_bytes(raw, ext)
+ except ValueError:
+ # Bytes don't look like a supported image (e.g. HEIC magic) —
+ # still deliver them as a document rather than dropping them.
+ return cache_document_from_bytes(raw, name)
+ if force_audio or mime.startswith("audio/"):
+ ext = suffix or _AUDIO_EXT_BY_MIME.get(
+ mime, ".m4a" if force_audio else ".mp3"
+ )
+ return cache_audio_from_bytes(raw, ext)
+ # Video, application/*, and everything else → document cache.
+ return cache_document_from_bytes(raw, name)
+ except Exception as exc:
+ logger.warning("[photon] failed to cache inbound attachment %s: %s", name, exc)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Standalone (out-of-process) send for cron deliveries when the gateway
+# is not co-resident. Reuses a live sidecar already listening on the
+# configured port (cron processes cannot spawn the sidecar themselves).
+
+async def _standalone_send(
+ pconfig: PlatformConfig,
+ chat_id: str,
+ message: str,
+ *,
+ thread_id: Optional[str] = None, # noqa: ARG001 — Spectrum has no threads yet
+ media_files: Optional[list] = None,
+ force_document: bool = False, # noqa: ARG001 — iMessage auto-detects file kind
+) -> Dict[str, Any]:
+ if not HTTPX_AVAILABLE:
+ return {"error": "httpx not installed"}
+ port = _coerce_port(
+ (pconfig.extra or {}).get("sidecar_port") or os.getenv("PHOTON_SIDECAR_PORT"),
+ _DEFAULT_SIDECAR_PORT,
+ )
+ token = os.getenv("PHOTON_SIDECAR_TOKEN")
+ if not token:
+ return {
+ "error": (
+ "Photon standalone send requires a running sidecar with "
+ "PHOTON_SIDECAR_TOKEN set in the environment. Cron processes "
+ "cannot spawn the sidecar themselves."
+ )
+ }
+ base = f"http://{_DEFAULT_SIDECAR_BIND}:{port}"
+ headers = {"X-Hermes-Sidecar-Token": token}
+ last_message_id: Optional[str] = None
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ # 1. Text body first (if any), so it leads the conversation.
+ if message:
+ send_body: Dict[str, Any] = {
+ "spaceId": chat_id,
+ "text": message[:_MAX_MESSAGE_LENGTH],
+ }
+ if _markdown_enabled():
+ send_body["format"] = "markdown"
+ resp = await client.post(
+ f"{base}/send", json=send_body, headers=headers,
+ )
+ if resp.status_code != 200:
+ return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
+ data = resp.json() or {}
+ if not data.get("ok"):
+ return {"error": data.get("error") or "sidecar reported failure"}
+ last_message_id = data.get("messageId")
+
+ # 2. Each attachment as a separate /send-attachment call.
+ # media_files is List[Tuple[path, is_voice]] (see
+ # BasePlatformAdapter.filter_media_delivery_paths).
+ import mimetypes
+
+ for media_path, is_voice in media_files or []:
+ safe_path = BasePlatformAdapter.validate_media_delivery_path(str(media_path))
+ if not safe_path:
+ logger.warning("[photon] standalone send skipping unsafe path")
+ continue
+ guessed, _ = mimetypes.guess_type(safe_path)
+ att_body: Dict[str, Any] = {
+ "spaceId": chat_id,
+ "path": safe_path,
+ "kind": "voice" if is_voice else "attachment",
+ }
+ if guessed:
+ att_body["mimeType"] = guessed
+ resp = await client.post(
+ f"{base}/send-attachment", json=att_body, headers=headers,
+ )
+ if resp.status_code != 200:
+ return {"error": f"sidecar returned {resp.status_code}: {resp.text[:200]}"}
+ data = resp.json() or {}
+ if not data.get("ok"):
+ return {"error": data.get("error") or "sidecar reported failure"}
+ last_message_id = data.get("messageId") or last_message_id
+
+ return {"success": True, "message_id": last_message_id}
+ except Exception as e:
+ return {"error": f"Photon standalone send failed: {e}"}
+
+
+# ---------------------------------------------------------------------------
+# Plugin entry point
+
+def register(ctx) -> None:
+ """Called by the Hermes plugin loader at startup."""
+ # Local import to avoid argparse work at module load; reused for both the
+ # gateway-setup hook and the `hermes photon` CLI command below.
+ from . import cli as _cli
+
+ ctx.register_platform(
+ name="photon",
+ label="iMessage via Photon",
+ adapter_factory=lambda cfg: PhotonAdapter(cfg),
+ check_fn=check_requirements,
+ validate_config=validate_config,
+ is_connected=is_connected,
+ required_env=["PHOTON_PROJECT_ID", "PHOTON_PROJECT_SECRET"],
+ install_hint=(
+ "Run: hermes photon setup (logs in via device flow, creates a "
+ "Spectrum project, links your phone number, installs the "
+ "spectrum-ts sidecar)."
+ ),
+ # Surfaces Photon in `hermes gateway setup` alongside every other
+ # channel — same unified onboarding wizard, no Photon-only detour.
+ setup_fn=_cli.gateway_setup,
+ env_enablement_fn=_env_enablement,
+ cron_deliver_env_var="PHOTON_HOME_CHANNEL",
+ standalone_sender_fn=_standalone_send,
+ allowed_users_env="PHOTON_ALLOWED_USERS",
+ allow_all_env="PHOTON_ALLOW_ALL_USERS",
+ max_message_length=_MAX_MESSAGE_LENGTH,
+ emoji="📱",
+ # iMessage carries E.164 phone numbers — treat session descriptions
+ # as PII-sensitive so they get redacted before reaching the LLM
+ # (matches the BlueBubbles iMessage channel in _PII_SAFE_PLATFORMS).
+ pii_safe=True,
+ allow_update_command=True,
+ platform_hint=(
+ "You are communicating via Photon Spectrum (iMessage). "
+ "Treat replies like regular text messages — short and friendly. "
+ "Markdown is rendered (bold, italics, lists, code), but keep "
+ "formatting light and conversational. Recipient identifiers are "
+ "E.164 phone numbers; never expose them in responses unless the "
+ "user asked. Attachments arrive as metadata only."
+ ),
+ )
+
+ # Register CLI subcommands — `hermes photon ...`
+ ctx.register_cli_command(
+ name="photon",
+ help="Set up and manage the Photon iMessage integration",
+ setup_fn=_cli.register_cli,
+ handler_fn=_cli.dispatch,
+ )