Skip to content

Commit a73fffd

Browse files
committed
feat(opencode): add experimental lead notify via push to OpenCode session
Behind CLAUDE_TEAMS_EXPERIMENTAL_LEAD_NOTIFY env var, discover the lead agent's OpenCode session by matching cwd against active sessions at initialization. When enabled, push messages addressed to team-lead directly into their session and skip long-poll blocking in poll_inbox (which stalls the single-threaded OpenCode agent).
1 parent 3cf781f commit a73fffd

4 files changed

Lines changed: 320 additions & 39 deletions

File tree

src/claude_teams/opencode_client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,37 @@ def get_session_status(server_url: str, session_id: str) -> str:
163163
except json.JSONDecodeError:
164164
raise OpenCodeAPIError("Opencode returned invalid JSON from /session/status")
165165
return data.get(session_id, "unknown")
166+
167+
168+
def list_active_sessions(server_url: str) -> dict:
169+
"""Return mapping of session_id -> status from GET /session/status.
170+
171+
Only returns currently active/busy sessions, not historical ones.
172+
"""
173+
raw = _request("GET", f"{server_url}/session/status")
174+
try:
175+
data = json.loads(raw)
176+
except json.JSONDecodeError:
177+
raise OpenCodeAPIError("Opencode returned invalid JSON from /session/status")
178+
if not isinstance(data, dict):
179+
return {}
180+
return data
181+
182+
183+
def get_session(server_url: str, session_id: str) -> dict:
184+
"""Return full session metadata from GET /session/{id}.
185+
186+
Includes id, slug, directory, title, projectID, etc.
187+
"""
188+
raw = _request("GET", f"{server_url}/session/{session_id}")
189+
try:
190+
data = json.loads(raw)
191+
except json.JSONDecodeError:
192+
raise OpenCodeAPIError(
193+
f"Opencode returned invalid JSON from /session/{session_id}"
194+
)
195+
if not isinstance(data, dict):
196+
raise OpenCodeAPIError(
197+
f"Opencode returned non-object from /session/{session_id}"
198+
)
199+
return data

src/claude_teams/server.py

Lines changed: 126 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import time
66
import uuid
7+
from pathlib import Path
78
from types import SimpleNamespace
89
from typing import Any, Literal
910

@@ -55,10 +56,21 @@
5556
_VALID_BACKENDS = frozenset(KNOWN_CLIENTS.values())
5657

5758

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+
5864
def _parse_backends_env(raw: str) -> list[str]:
5965
if not raw:
6066
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+
)
6274

6375

6476
_SPAWN_TOOL_BASE_DESCRIPTION = (
@@ -116,6 +128,46 @@ def _update_spawn_tool(tool, enabled: list[str], state: dict[str, Any]) -> None:
116128
)
117129

118130

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+
119171
@lifespan
120172
async def app_lifespan(server):
121173
global _spawn_tool
@@ -148,33 +200,43 @@ async def app_lifespan(server):
148200
_spawn_tool = tool
149201

150202
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+
)
158214
else:
159215
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,
162221
)
163222

164223
session_id = str(uuid.uuid4())
165224
_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+
)
178240
yield _lifespan_state
179241

180242

@@ -203,13 +265,26 @@ async def on_initialize(self, context, call_next):
203265
if not enabled:
204266
if _lifespan_state.get("claude_binary"):
205267
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+
):
207271
enabled.append("opencode")
208272

209273
_lifespan_state["enabled_backends"] = enabled
210274
_lifespan_state["client_name"] = client_name
211275
_lifespan_state["client_version"] = client_version
212276

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+
213288
if _spawn_tool:
214289
_update_spawn_tool(_spawn_tool, enabled, _lifespan_state)
215290

@@ -338,6 +413,16 @@ def _push_to_opencode_session(
338413
)
339414

340415

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+
341426
def _cleanup_opencode_session(server_url: str | None, session_id: str | None) -> None:
342427
"""Abort and delete an opencode session. Best-effort, errors are logged."""
343428
if not server_url or not session_id:
@@ -428,6 +513,14 @@ def send_message(
428513
)
429514
if target_member and oc_url:
430515
_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+
431524
return SendMessageResult(
432525
success=True,
433526
message=f"Message sent to {recipient}",
@@ -723,6 +816,13 @@ async def poll_inbox(
723816
)
724817
if msgs:
725818
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+
726826
deadline = time.time() + timeout_ms / 1000.0
727827
while time.time() < deadline:
728828
await asyncio.sleep(0.5)
@@ -816,7 +916,9 @@ def peek_teammate(
816916

817917

818918
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+
)
820922
mcp.run()
821923

822924

0 commit comments

Comments
 (0)