|
4 | 4 | import os |
5 | 5 | import time |
6 | 6 | import uuid |
| 7 | +from pathlib import Path |
7 | 8 | from types import SimpleNamespace |
8 | 9 | from typing import Any, Literal |
9 | 10 |
|
|
55 | 56 | _VALID_BACKENDS = frozenset(KNOWN_CLIENTS.values()) |
56 | 57 |
|
57 | 58 |
|
| 59 | +def _lead_notify_enabled() -> bool: |
| 60 | + """Check if experimental lead notification feature is enabled.""" |
| 61 | + return os.environ.get("CLAUDE_TEAMS_EXPERIMENTAL_LEAD_NOTIFY") is not None |
| 62 | + |
| 63 | + |
58 | 64 | def _parse_backends_env(raw: str) -> list[str]: |
59 | 65 | if not raw: |
60 | 66 | return [] |
61 | | - return list(dict.fromkeys(b.strip() for b in raw.split(",") if b.strip() and b.strip() in _VALID_BACKENDS)) |
| 67 | + return list( |
| 68 | + dict.fromkeys( |
| 69 | + b.strip() |
| 70 | + for b in raw.split(",") |
| 71 | + if b.strip() and b.strip() in _VALID_BACKENDS |
| 72 | + ) |
| 73 | + ) |
62 | 74 |
|
63 | 75 |
|
64 | 76 | _SPAWN_TOOL_BASE_DESCRIPTION = ( |
@@ -116,6 +128,46 @@ def _update_spawn_tool(tool, enabled: list[str], state: dict[str, Any]) -> None: |
116 | 128 | ) |
117 | 129 |
|
118 | 130 |
|
| 131 | +def _discover_lead_opencode_session(server_url: str) -> str | None: |
| 132 | + """Discover the lead agent's OpenCode session ID. |
| 133 | +
|
| 134 | + Queries active sessions on the OpenCode server, filters by current |
| 135 | + working directory. Returns session ID if exactly one match, else None. |
| 136 | + """ |
| 137 | + try: |
| 138 | + active = opencode_client.list_active_sessions(server_url) |
| 139 | + except OpenCodeAPIError: |
| 140 | + logger.warning("Lead notify: failed to list active sessions") |
| 141 | + return None |
| 142 | + |
| 143 | + if not active: |
| 144 | + return None |
| 145 | + |
| 146 | + cwd = str(Path.cwd()) |
| 147 | + candidates = [] |
| 148 | + for session_id in active: |
| 149 | + try: |
| 150 | + session = opencode_client.get_session(server_url, session_id) |
| 151 | + except OpenCodeAPIError: |
| 152 | + continue |
| 153 | + if session.get("directory") == cwd: |
| 154 | + candidates.append(session_id) |
| 155 | + |
| 156 | + if len(candidates) == 1: |
| 157 | + logger.info("Lead notify: discovered lead session %s", candidates[0]) |
| 158 | + return candidates[0] |
| 159 | + |
| 160 | + if len(candidates) > 1: |
| 161 | + logger.warning( |
| 162 | + "Lead notify: %d active sessions in cwd, cannot determine lead", |
| 163 | + len(candidates), |
| 164 | + ) |
| 165 | + else: |
| 166 | + logger.info("Lead notify: no active sessions found in cwd") |
| 167 | + |
| 168 | + return None |
| 169 | + |
| 170 | + |
119 | 171 | @lifespan |
120 | 172 | async def app_lifespan(server): |
121 | 173 | global _spawn_tool |
@@ -148,33 +200,43 @@ async def app_lifespan(server): |
148 | 200 | _spawn_tool = tool |
149 | 201 |
|
150 | 202 | if enabled_backends: |
151 | | - _update_spawn_tool(tool, enabled_backends, { |
152 | | - "claude_binary": claude_binary, |
153 | | - "opencode_binary": opencode_binary, |
154 | | - "opencode_models": opencode_models, |
155 | | - "opencode_server_url": opencode_server_url, |
156 | | - "opencode_agents": opencode_agents, |
157 | | - }) |
| 203 | + _update_spawn_tool( |
| 204 | + tool, |
| 205 | + enabled_backends, |
| 206 | + { |
| 207 | + "claude_binary": claude_binary, |
| 208 | + "opencode_binary": opencode_binary, |
| 209 | + "opencode_models": opencode_models, |
| 210 | + "opencode_server_url": opencode_server_url, |
| 211 | + "opencode_agents": opencode_agents, |
| 212 | + }, |
| 213 | + ) |
158 | 214 | else: |
159 | 215 | tool.description = _build_spawn_description( |
160 | | - claude_binary, opencode_binary, opencode_models, |
161 | | - opencode_server_url, opencode_agents, |
| 216 | + claude_binary, |
| 217 | + opencode_binary, |
| 218 | + opencode_models, |
| 219 | + opencode_server_url, |
| 220 | + opencode_agents, |
162 | 221 | ) |
163 | 222 |
|
164 | 223 | session_id = str(uuid.uuid4()) |
165 | 224 | _lifespan_state.clear() |
166 | | - _lifespan_state.update({ |
167 | | - "claude_binary": claude_binary, |
168 | | - "opencode_binary": opencode_binary, |
169 | | - "opencode_server_url": opencode_server_url, |
170 | | - "opencode_agents": opencode_agents, |
171 | | - "opencode_models": opencode_models, |
172 | | - "enabled_backends": enabled_backends, |
173 | | - "session_id": session_id, |
174 | | - "active_team": None, |
175 | | - "client_name": "unknown", |
176 | | - "client_version": "unknown", |
177 | | - }) |
| 225 | + _lifespan_state.update( |
| 226 | + { |
| 227 | + "claude_binary": claude_binary, |
| 228 | + "opencode_binary": opencode_binary, |
| 229 | + "opencode_server_url": opencode_server_url, |
| 230 | + "opencode_agents": opencode_agents, |
| 231 | + "opencode_models": opencode_models, |
| 232 | + "enabled_backends": enabled_backends, |
| 233 | + "session_id": session_id, |
| 234 | + "active_team": None, |
| 235 | + "client_name": "unknown", |
| 236 | + "client_version": "unknown", |
| 237 | + "lead_opencode_session_id": None, |
| 238 | + } |
| 239 | + ) |
178 | 240 | yield _lifespan_state |
179 | 241 |
|
180 | 242 |
|
@@ -203,13 +265,26 @@ async def on_initialize(self, context, call_next): |
203 | 265 | if not enabled: |
204 | 266 | if _lifespan_state.get("claude_binary"): |
205 | 267 | enabled.append("claude") |
206 | | - if _lifespan_state.get("opencode_binary") and _lifespan_state.get("opencode_server_url"): |
| 268 | + if _lifespan_state.get("opencode_binary") and _lifespan_state.get( |
| 269 | + "opencode_server_url" |
| 270 | + ): |
207 | 271 | enabled.append("opencode") |
208 | 272 |
|
209 | 273 | _lifespan_state["enabled_backends"] = enabled |
210 | 274 | _lifespan_state["client_name"] = client_name |
211 | 275 | _lifespan_state["client_version"] = client_version |
212 | 276 |
|
| 277 | + # Experimental: discover lead's OpenCode session for push notifications |
| 278 | + if ( |
| 279 | + _lead_notify_enabled() |
| 280 | + and client_name == "opencode" |
| 281 | + and _lifespan_state.get("opencode_server_url") |
| 282 | + ): |
| 283 | + lead_session = _discover_lead_opencode_session( |
| 284 | + _lifespan_state["opencode_server_url"] |
| 285 | + ) |
| 286 | + _lifespan_state["lead_opencode_session_id"] = lead_session |
| 287 | + |
213 | 288 | if _spawn_tool: |
214 | 289 | _update_spawn_tool(_spawn_tool, enabled, _lifespan_state) |
215 | 290 |
|
@@ -338,6 +413,16 @@ def _push_to_opencode_session( |
338 | 413 | ) |
339 | 414 |
|
340 | 415 |
|
| 416 | +def _push_to_lead(server_url: str, lead_session_id: str, text: str) -> None: |
| 417 | + """Push a message into the lead's OpenCode session. Best-effort.""" |
| 418 | + try: |
| 419 | + opencode_client.send_prompt_async(server_url, lead_session_id, text) |
| 420 | + except OpenCodeAPIError: |
| 421 | + logger.warning( |
| 422 | + "Lead notify: failed to push to lead session %s", lead_session_id |
| 423 | + ) |
| 424 | + |
| 425 | + |
341 | 426 | def _cleanup_opencode_session(server_url: str | None, session_id: str | None) -> None: |
342 | 427 | """Abort and delete an opencode session. Best-effort, errors are logged.""" |
343 | 428 | if not server_url or not session_id: |
@@ -428,6 +513,14 @@ def send_message( |
428 | 513 | ) |
429 | 514 | if target_member and oc_url: |
430 | 515 | _push_to_opencode_session(oc_url, target_member, content) |
| 516 | + |
| 517 | + # Experimental: push to lead's OpenCode session |
| 518 | + if recipient == "team-lead" and _lead_notify_enabled(): |
| 519 | + ls = _get_lifespan(ctx) |
| 520 | + lead_sid = ls.get("lead_opencode_session_id") |
| 521 | + if lead_sid and oc_url: |
| 522 | + _push_to_lead(oc_url, lead_sid, content) |
| 523 | + |
431 | 524 | return SendMessageResult( |
432 | 525 | success=True, |
433 | 526 | message=f"Message sent to {recipient}", |
@@ -723,6 +816,13 @@ async def poll_inbox( |
723 | 816 | ) |
724 | 817 | if msgs: |
725 | 818 | return [m.model_dump(by_alias=True, exclude_none=True) for m in msgs] |
| 819 | + |
| 820 | + # When lead notify is enabled and client is OpenCode, return immediately. |
| 821 | + # Messages are pushed to the lead's session via send_prompt_async, |
| 822 | + # so blocking here is unnecessary and harmful (single-threaded agent). |
| 823 | + if _lead_notify_enabled() and _lifespan_state.get("client_name") == "opencode": |
| 824 | + return [] |
| 825 | + |
726 | 826 | deadline = time.time() + timeout_ms / 1000.0 |
727 | 827 | while time.time() < deadline: |
728 | 828 | await asyncio.sleep(0.5) |
@@ -816,7 +916,9 @@ def peek_teammate( |
816 | 916 |
|
817 | 917 |
|
818 | 918 | def main(): |
819 | | - logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") |
| 919 | + logging.basicConfig( |
| 920 | + level=logging.INFO, format="%(levelname)s %(name)s: %(message)s" |
| 921 | + ) |
820 | 922 | mcp.run() |
821 | 923 |
|
822 | 924 |
|
|
0 commit comments