Skip to content

Commit 4392795

Browse files
authored
refactor: replace polling with direct inbox reads and teammate checks (#16)
* feat(introspection): add peek_teammate tool for non-blocking tmux status checks * 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). * refactor(introspection): replace peek_teammate with check_teammate Evolve peek_teammate into check_teammate with richer semantics: - Read and mark-as-read unread messages FROM the checked teammate (via new read_inbox_filtered helper with sender-scoped marking) - Report their_unread_count (messages they haven't consumed) - Output is opt-in (include_output flag, off by default) - Dynamic tool description based on push notification availability - Optional deferred reminder via notify_after_minutes (OpenCode push) - Remove CLAUDE_TEAMS_EXPERIMENTAL_LEAD_NOTIFY env gate; push activation is now automatic when OpenCode session is discovered * refactor(introspection): add dynamic descriptions for poll_inbox and read_inbox * refactor: remove poll_inbox tool in favor of read_inbox and check_teammate
1 parent f79115b commit 4392795

9 files changed

Lines changed: 1083 additions & 131 deletions

File tree

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Claude Code (`.mcp.json`):
2323
"mcpServers": {
2424
"claude-teams": {
2525
"command": "uvx",
26-
"args": ["--from", "git+https://github.com/cs50victor/claude-code-teams-mcp@v0.1.0", "claude-teams"]
26+
"args": ["--from", "git+https://github.com/cs50victor/claude-code-teams-mcp@v0.1.1", "claude-teams"]
2727
}
2828
}
2929
}
@@ -36,7 +36,7 @@ OpenCode (`~/.config/opencode/opencode.json`):
3636
"mcp": {
3737
"claude-teams": {
3838
"type": "local",
39-
"command": ["uvx", "--from", "git+https://github.com/cs50victor/claude-code-teams-mcp@v0.1.0", "claude-teams"],
39+
"command": ["uvx", "--from", "git+https://github.com/cs50victor/claude-code-teams-mcp@v0.1.1", "claude-teams"],
4040
"enabled": true
4141
}
4242
}
@@ -65,7 +65,7 @@ Without `CLAUDE_TEAMS_BACKENDS`, the server auto-detects the connecting client a
6565
"mcpServers": {
6666
"claude-teams": {
6767
"command": "uvx",
68-
"args": ["--from", "git+https://github.com/cs50victor/claude-code-teams-mcp@v0.1.0", "claude-teams"],
68+
"args": ["--from", "git+https://github.com/cs50victor/claude-code-teams-mcp@v0.1.1", "claude-teams"],
6969
"env": {
7070
"CLAUDE_TEAMS_BACKENDS": "claude,opencode",
7171
"OPENCODE_SERVER_URL": "http://localhost:4096"
@@ -84,7 +84,6 @@ Without `CLAUDE_TEAMS_BACKENDS`, the server auto-detects the connecting client a
8484
| `spawn_teammate` | Spawn a teammate in tmux |
8585
| `send_message` | Send DMs, broadcasts (lead only), shutdown/plan responses |
8686
| `read_inbox` | Read messages from an agent's inbox |
87-
| `poll_inbox` | Long-poll inbox for new messages (up to 30s) |
8887
| `read_config` | Read team config and member list |
8988
| `task_create` | Create a task (auto-incrementing ID) |
9089
| `task_update` | Update task status, owner, dependencies, or metadata |

src/claude_teams/messaging.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,71 @@ def read_inbox(
7878
return list(all_msgs)
7979

8080

81+
def read_inbox_filtered(
82+
team_name: str,
83+
agent_name: str,
84+
sender_filter: str,
85+
unread_only: bool = True,
86+
mark_as_read: bool = True,
87+
limit: int | None = None,
88+
base_dir: Path | None = None,
89+
) -> list[InboxMessage]:
90+
"""Read inbox messages filtered by sender.
91+
92+
When mark_as_read=True, only messages matching the sender_filter
93+
(and unread_only criteria) are marked as read. Other messages in
94+
the inbox are left untouched.
95+
96+
Args:
97+
team_name: Team name.
98+
agent_name: Whose inbox to read (e.g. "team-lead").
99+
sender_filter: Only return messages where from_ == sender_filter.
100+
unread_only: If True, skip already-read messages.
101+
mark_as_read: If True, mark returned messages as read on disk.
102+
limit: Max messages to return (newest N if set). Returns chronological order.
103+
base_dir: Override base directory for testing.
104+
"""
105+
path = inbox_path(team_name, agent_name, base_dir)
106+
if not path.exists():
107+
return []
108+
109+
if mark_as_read:
110+
lock_path = path.parent / ".lock"
111+
with file_lock(lock_path):
112+
raw_list = json.loads(path.read_text())
113+
all_msgs = [InboxMessage.model_validate(entry) for entry in raw_list]
114+
115+
selected_indices = []
116+
for i, m in enumerate(all_msgs):
117+
if m.from_ != sender_filter:
118+
continue
119+
if unread_only and m.read:
120+
continue
121+
selected_indices.append(i)
122+
123+
if limit is not None and len(selected_indices) > limit:
124+
selected_indices = selected_indices[-limit:]
125+
126+
result = [all_msgs[i] for i in selected_indices]
127+
if result:
128+
for i in selected_indices:
129+
all_msgs[i].read = True
130+
serialized = [m.model_dump(by_alias=True, exclude_none=True) for m in all_msgs]
131+
path.write_text(json.dumps(serialized))
132+
133+
return result
134+
else:
135+
raw_list = json.loads(path.read_text())
136+
all_msgs = [InboxMessage.model_validate(entry) for entry in raw_list]
137+
138+
filtered = [m for m in all_msgs if m.from_ == sender_filter]
139+
if unread_only:
140+
filtered = [m for m in filtered if not m.read]
141+
if limit is not None and len(filtered) > limit:
142+
filtered = filtered[-limit:]
143+
return filtered
144+
145+
81146
def append_message(
82147
team_name: str,
83148
agent_name: str,

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

0 commit comments

Comments
 (0)