claude-team-mcp 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_team_mcp/__init__.py +24 -0
- claude_team_mcp/__main__.py +8 -0
- claude_team_mcp/cli_backends/__init__.py +44 -0
- claude_team_mcp/cli_backends/base.py +132 -0
- claude_team_mcp/cli_backends/claude.py +110 -0
- claude_team_mcp/cli_backends/codex.py +110 -0
- claude_team_mcp/colors.py +108 -0
- claude_team_mcp/formatting.py +120 -0
- claude_team_mcp/idle_detection.py +488 -0
- claude_team_mcp/iterm_utils.py +1119 -0
- claude_team_mcp/names.py +427 -0
- claude_team_mcp/profile.py +364 -0
- claude_team_mcp/registry.py +426 -0
- claude_team_mcp/schemas/__init__.py +5 -0
- claude_team_mcp/schemas/codex.py +267 -0
- claude_team_mcp/server.py +390 -0
- claude_team_mcp/session_state.py +1058 -0
- claude_team_mcp/subprocess_cache.py +119 -0
- claude_team_mcp/tools/__init__.py +52 -0
- claude_team_mcp/tools/adopt_worker.py +122 -0
- claude_team_mcp/tools/annotate_worker.py +57 -0
- claude_team_mcp/tools/bd_help.py +42 -0
- claude_team_mcp/tools/check_idle_workers.py +98 -0
- claude_team_mcp/tools/close_workers.py +194 -0
- claude_team_mcp/tools/discover_workers.py +129 -0
- claude_team_mcp/tools/examine_worker.py +56 -0
- claude_team_mcp/tools/list_workers.py +76 -0
- claude_team_mcp/tools/list_worktrees.py +106 -0
- claude_team_mcp/tools/message_workers.py +311 -0
- claude_team_mcp/tools/read_worker_logs.py +158 -0
- claude_team_mcp/tools/spawn_workers.py +634 -0
- claude_team_mcp/tools/wait_idle_workers.py +148 -0
- claude_team_mcp/utils/__init__.py +17 -0
- claude_team_mcp/utils/constants.py +87 -0
- claude_team_mcp/utils/errors.py +87 -0
- claude_team_mcp/utils/worktree_detection.py +79 -0
- claude_team_mcp/worker_prompt.py +350 -0
- claude_team_mcp/worktree.py +532 -0
- claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
- claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
- claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
- claude_team_mcp-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Idle Detection for Claude Team Workers
|
|
3
|
+
|
|
4
|
+
Supports two detection modes:
|
|
5
|
+
|
|
6
|
+
1. Claude Code (Stop Hook Detection):
|
|
7
|
+
Workers are spawned with a Stop hook that fires when Claude finishes responding.
|
|
8
|
+
The hook embeds a session ID marker in the JSONL - if it fired with no subsequent
|
|
9
|
+
messages, the worker is idle.
|
|
10
|
+
Binary state model:
|
|
11
|
+
- Idle: Stop hook fired, no messages after it
|
|
12
|
+
- Working: Either no stop hook yet, or messages exist after the last one
|
|
13
|
+
|
|
14
|
+
2. Codex (Session File Polling):
|
|
15
|
+
Codex writes session files to ~/.codex/sessions/YYYY/MM/DD/.
|
|
16
|
+
We poll these files for agent_message events which indicate the agent
|
|
17
|
+
has finished responding. The session file name contains the thread_id.
|
|
18
|
+
Binary state model:
|
|
19
|
+
- Idle: Last response_item with agent_message type exists, no subsequent user_message
|
|
20
|
+
- Working: No agent_message yet, or user_message exists after the last agent_message
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import msgspec
|
|
31
|
+
|
|
32
|
+
from .schemas.codex import ThreadStarted, TurnCompleted, TurnFailed, TurnStarted, decode_event
|
|
33
|
+
from .session_state import is_session_stopped
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("claude-team-mcp")
|
|
36
|
+
|
|
37
|
+
# Path to Codex session files
|
|
38
|
+
CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_codex_session_file(
|
|
42
|
+
thread_id: str | None = None,
|
|
43
|
+
max_age_seconds: int = 300,
|
|
44
|
+
) -> Path | None:
|
|
45
|
+
"""
|
|
46
|
+
Find a Codex session file in ~/.codex/sessions/.
|
|
47
|
+
|
|
48
|
+
Searches for session files matching the given thread_id, or returns
|
|
49
|
+
the most recent session file if no thread_id is specified.
|
|
50
|
+
|
|
51
|
+
Session files are named: rollout-YYYY-MM-DDTHH-MM-SS-<thread_id>.jsonl
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
thread_id: Optional thread ID to search for. If None, returns most recent.
|
|
55
|
+
max_age_seconds: Only consider files modified within this time (default 5 min)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Path to the matching session file, or None if not found
|
|
59
|
+
"""
|
|
60
|
+
if not CODEX_SESSIONS_DIR.exists():
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
now = time.time()
|
|
64
|
+
cutoff = now - max_age_seconds
|
|
65
|
+
|
|
66
|
+
# Search recent date directories (today and yesterday)
|
|
67
|
+
date_dirs: list[Path] = []
|
|
68
|
+
for year_dir in sorted(CODEX_SESSIONS_DIR.iterdir(), reverse=True):
|
|
69
|
+
if not year_dir.is_dir():
|
|
70
|
+
continue
|
|
71
|
+
for month_dir in sorted(year_dir.iterdir(), reverse=True):
|
|
72
|
+
if not month_dir.is_dir():
|
|
73
|
+
continue
|
|
74
|
+
for day_dir in sorted(month_dir.iterdir(), reverse=True):
|
|
75
|
+
if not day_dir.is_dir():
|
|
76
|
+
continue
|
|
77
|
+
date_dirs.append(day_dir)
|
|
78
|
+
# Limit to recent 3 days of directories
|
|
79
|
+
if len(date_dirs) >= 3:
|
|
80
|
+
break
|
|
81
|
+
if len(date_dirs) >= 3:
|
|
82
|
+
break
|
|
83
|
+
if len(date_dirs) >= 3:
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
candidates: list[tuple[float, Path]] = []
|
|
87
|
+
|
|
88
|
+
for date_dir in date_dirs:
|
|
89
|
+
for jsonl_file in date_dir.glob("rollout-*.jsonl"):
|
|
90
|
+
# Check file age
|
|
91
|
+
try:
|
|
92
|
+
mtime = jsonl_file.stat().st_mtime
|
|
93
|
+
if mtime < cutoff:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# If thread_id specified, check if it's in the filename
|
|
97
|
+
if thread_id:
|
|
98
|
+
if thread_id in jsonl_file.name:
|
|
99
|
+
return jsonl_file
|
|
100
|
+
else:
|
|
101
|
+
candidates.append((mtime, jsonl_file))
|
|
102
|
+
|
|
103
|
+
except OSError:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# If no thread_id specified, return most recent file
|
|
107
|
+
if candidates and not thread_id:
|
|
108
|
+
candidates.sort(reverse=True) # Sort by mtime descending
|
|
109
|
+
return candidates[0][1]
|
|
110
|
+
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_codex_thread_id_from_session_file(jsonl_path: Path) -> str | None:
|
|
115
|
+
"""
|
|
116
|
+
Extract the thread_id from a Codex session file name.
|
|
117
|
+
|
|
118
|
+
Session files are named: rollout-YYYY-MM-DDTHH-MM-SS-<thread_id>.jsonl
|
|
119
|
+
The thread_id is the last component before .jsonl.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
jsonl_path: Path to the Codex session file
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The thread_id string if found, None otherwise
|
|
126
|
+
"""
|
|
127
|
+
name = jsonl_path.stem # Remove .jsonl
|
|
128
|
+
# Format: rollout-2026-01-11T15-14-58-019baf57-64cb-7cc1-96bf-1d41751e40fc
|
|
129
|
+
# The thread_id is a UUID after the timestamp
|
|
130
|
+
parts = name.split("-")
|
|
131
|
+
if len(parts) >= 8:
|
|
132
|
+
# Thread ID is the last 5 parts (UUID format: 8-4-4-4-12 hex chars)
|
|
133
|
+
# But looking at actual names, it appears to be everything after the timestamp
|
|
134
|
+
# rollout-YYYY-MM-DDTHH-MM-SS-<thread_id>
|
|
135
|
+
# We can extract from session_meta in the file instead
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# More reliable: extract from session_meta in the file
|
|
139
|
+
try:
|
|
140
|
+
with open(jsonl_path, "rb") as f:
|
|
141
|
+
first_line = f.readline()
|
|
142
|
+
if first_line:
|
|
143
|
+
data = json.loads(first_line)
|
|
144
|
+
if data.get("type") == "session_meta":
|
|
145
|
+
return data.get("payload", {}).get("id")
|
|
146
|
+
except (OSError, json.JSONDecodeError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_codex_thread_id(jsonl_path: Path) -> str | None:
|
|
153
|
+
"""
|
|
154
|
+
Extract the thread_id from a Codex session's JSONL output.
|
|
155
|
+
|
|
156
|
+
Parses the JSONL file looking for a ThreadStarted event, which contains
|
|
157
|
+
the thread_id needed for session resume commands.
|
|
158
|
+
|
|
159
|
+
The ThreadStarted event is typically near the beginning of the file,
|
|
160
|
+
but we read from the start to find it reliably.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
jsonl_path: Path to the Codex JSONL output file
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The thread_id string if found, None otherwise
|
|
167
|
+
"""
|
|
168
|
+
if not jsonl_path.exists():
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Read the first portion of the file (ThreadStarted is near the beginning)
|
|
173
|
+
# Limit read to first 10KB to avoid loading huge files
|
|
174
|
+
with open(jsonl_path, "rb") as f:
|
|
175
|
+
content = f.read(10000)
|
|
176
|
+
|
|
177
|
+
# Parse lines looking for ThreadStarted
|
|
178
|
+
lines = content.strip().split(b"\n")
|
|
179
|
+
|
|
180
|
+
for line in lines:
|
|
181
|
+
if not line.strip():
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
event = decode_event(line)
|
|
186
|
+
|
|
187
|
+
# Check for ThreadStarted which contains thread_id
|
|
188
|
+
if isinstance(event, ThreadStarted):
|
|
189
|
+
return event.thread_id
|
|
190
|
+
|
|
191
|
+
except Exception:
|
|
192
|
+
# Skip malformed lines (could be partial at end of read)
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
# No ThreadStarted found
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
except (OSError, IOError) as e:
|
|
199
|
+
logger.warning(f"Error reading Codex JSONL {jsonl_path}: {e}")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
# Default timeout for waiting operations (10 minutes)
|
|
203
|
+
DEFAULT_TIMEOUT = 600.0
|
|
204
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def is_idle(jsonl_path: Path, session_id: str) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Check if a Claude Code session is idle (finished responding).
|
|
210
|
+
|
|
211
|
+
A session is idle if its Stop hook has fired and no messages
|
|
212
|
+
have been sent after it.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
jsonl_path: Path to the session JSONL file
|
|
216
|
+
session_id: The session ID (matches marker in Stop hook)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
True if idle, False if working or file not found
|
|
220
|
+
"""
|
|
221
|
+
if not jsonl_path.exists():
|
|
222
|
+
return False
|
|
223
|
+
return is_session_stopped(jsonl_path, session_id)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def is_codex_idle(jsonl_path: Path) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Check if a Codex session is idle by parsing the session JSONL file.
|
|
229
|
+
|
|
230
|
+
For interactive mode session files (in ~/.codex/sessions/), idle detection
|
|
231
|
+
works by checking the last events in the file:
|
|
232
|
+
- If the last response_item has type "message" with role "assistant" or
|
|
233
|
+
has payload.type "agent_message", the agent has responded and is idle
|
|
234
|
+
- If there's a user_message after the last agent response, still working
|
|
235
|
+
|
|
236
|
+
For exec mode (legacy capture files), checks for TurnCompleted/TurnFailed events.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
jsonl_path: Path to the Codex JSONL session file
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
True if idle (agent responded), False if working or file not found
|
|
243
|
+
"""
|
|
244
|
+
if not jsonl_path.exists():
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
file_size = jsonl_path.stat().st_size
|
|
249
|
+
if file_size == 0:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
# Read last 50KB for efficiency
|
|
253
|
+
read_size = min(file_size, 50000)
|
|
254
|
+
with open(jsonl_path, "rb") as f:
|
|
255
|
+
if file_size > read_size:
|
|
256
|
+
f.seek(file_size - read_size)
|
|
257
|
+
f.readline() # Skip partial first line
|
|
258
|
+
content = f.read()
|
|
259
|
+
|
|
260
|
+
lines = content.strip().split(b"\n")
|
|
261
|
+
|
|
262
|
+
# Track the last significant events
|
|
263
|
+
last_agent_response_idx = -1
|
|
264
|
+
last_user_message_idx = -1
|
|
265
|
+
|
|
266
|
+
for i, line in enumerate(lines):
|
|
267
|
+
if not line.strip():
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
data = json.loads(line)
|
|
272
|
+
|
|
273
|
+
# Check for interactive mode format (wrapped events)
|
|
274
|
+
if data.get("type") == "event_msg":
|
|
275
|
+
payload = data.get("payload", {})
|
|
276
|
+
payload_type = payload.get("type")
|
|
277
|
+
# agent_message indicates agent finished responding
|
|
278
|
+
if payload_type == "agent_message":
|
|
279
|
+
last_agent_response_idx = i
|
|
280
|
+
# user_message indicates new input
|
|
281
|
+
elif payload_type == "user_message":
|
|
282
|
+
last_user_message_idx = i
|
|
283
|
+
|
|
284
|
+
elif data.get("type") == "response_item":
|
|
285
|
+
payload = data.get("payload", {})
|
|
286
|
+
payload_type = payload.get("type")
|
|
287
|
+
role = payload.get("role")
|
|
288
|
+
# message with role=assistant indicates agent response
|
|
289
|
+
if payload_type == "message" and role == "assistant":
|
|
290
|
+
last_agent_response_idx = i
|
|
291
|
+
# message with role=user indicates user input
|
|
292
|
+
elif payload_type == "message" and role == "user":
|
|
293
|
+
last_user_message_idx = i
|
|
294
|
+
# agent_message type in payload
|
|
295
|
+
elif payload_type == "agent_message":
|
|
296
|
+
last_agent_response_idx = i
|
|
297
|
+
|
|
298
|
+
# Check for exec mode format (direct events)
|
|
299
|
+
else:
|
|
300
|
+
event_type = data.get("type")
|
|
301
|
+
if event_type in ("turn.completed", "turn.failed"):
|
|
302
|
+
last_agent_response_idx = i
|
|
303
|
+
elif event_type == "turn.started":
|
|
304
|
+
last_user_message_idx = i
|
|
305
|
+
|
|
306
|
+
except json.JSONDecodeError:
|
|
307
|
+
# Try msgspec for exec mode format
|
|
308
|
+
try:
|
|
309
|
+
event = decode_event(line)
|
|
310
|
+
if isinstance(event, (TurnCompleted, TurnFailed)):
|
|
311
|
+
last_agent_response_idx = i
|
|
312
|
+
elif isinstance(event, TurnStarted):
|
|
313
|
+
last_user_message_idx = i
|
|
314
|
+
except msgspec.DecodeError:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
# Idle if we have an agent response and no user message after it
|
|
318
|
+
if last_agent_response_idx >= 0:
|
|
319
|
+
return last_user_message_idx < last_agent_response_idx
|
|
320
|
+
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
except (OSError, IOError) as e:
|
|
324
|
+
logger.warning(f"Error reading Codex JSONL {jsonl_path}: {e}")
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def wait_for_idle(
|
|
329
|
+
jsonl_path: Path,
|
|
330
|
+
session_id: str,
|
|
331
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
332
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
333
|
+
) -> dict:
|
|
334
|
+
"""
|
|
335
|
+
Wait for a session to become idle.
|
|
336
|
+
|
|
337
|
+
Polls until the Stop hook fires or timeout is reached.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
jsonl_path: Path to session JSONL file
|
|
341
|
+
session_id: The session ID to check
|
|
342
|
+
timeout: Maximum seconds to wait (default 600s / 10 min)
|
|
343
|
+
poll_interval: Seconds between checks
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Dict with {idle: bool, session_id: str, waited_seconds: float, timed_out: bool}
|
|
347
|
+
"""
|
|
348
|
+
start = time.time()
|
|
349
|
+
|
|
350
|
+
while time.time() - start < timeout:
|
|
351
|
+
if is_idle(jsonl_path, session_id):
|
|
352
|
+
return {
|
|
353
|
+
"idle": True,
|
|
354
|
+
"session_id": session_id,
|
|
355
|
+
"waited_seconds": time.time() - start,
|
|
356
|
+
"timed_out": False,
|
|
357
|
+
}
|
|
358
|
+
await asyncio.sleep(poll_interval)
|
|
359
|
+
|
|
360
|
+
# Timeout
|
|
361
|
+
return {
|
|
362
|
+
"idle": False,
|
|
363
|
+
"session_id": session_id,
|
|
364
|
+
"waited_seconds": timeout,
|
|
365
|
+
"timed_out": True,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class SessionInfo:
|
|
371
|
+
"""Info needed to check a session's idle state."""
|
|
372
|
+
|
|
373
|
+
jsonl_path: Path
|
|
374
|
+
session_id: str
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def wait_for_any_idle(
|
|
378
|
+
sessions: list[SessionInfo],
|
|
379
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
380
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
381
|
+
) -> dict:
|
|
382
|
+
"""
|
|
383
|
+
Wait for ANY session to become idle.
|
|
384
|
+
|
|
385
|
+
Returns as soon as the first session becomes idle.
|
|
386
|
+
Useful for pipeline patterns where you want to process results
|
|
387
|
+
as they become available.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
sessions: List of SessionInfo to monitor
|
|
391
|
+
timeout: Maximum seconds to wait
|
|
392
|
+
poll_interval: Seconds between checks
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Dict with {
|
|
396
|
+
idle_session_id: str | None, # First session to become idle
|
|
397
|
+
idle: bool, # True if any session became idle
|
|
398
|
+
waited_seconds: float,
|
|
399
|
+
timed_out: bool,
|
|
400
|
+
}
|
|
401
|
+
"""
|
|
402
|
+
start = time.time()
|
|
403
|
+
|
|
404
|
+
while time.time() - start < timeout:
|
|
405
|
+
for session in sessions:
|
|
406
|
+
if is_idle(session.jsonl_path, session.session_id):
|
|
407
|
+
return {
|
|
408
|
+
"idle_session_id": session.session_id,
|
|
409
|
+
"idle": True,
|
|
410
|
+
"waited_seconds": time.time() - start,
|
|
411
|
+
"timed_out": False,
|
|
412
|
+
}
|
|
413
|
+
await asyncio.sleep(poll_interval)
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
"idle_session_id": None,
|
|
417
|
+
"idle": False,
|
|
418
|
+
"waited_seconds": timeout,
|
|
419
|
+
"timed_out": True,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def wait_for_all_idle(
|
|
424
|
+
sessions: list[SessionInfo],
|
|
425
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
426
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
427
|
+
) -> dict:
|
|
428
|
+
"""
|
|
429
|
+
Wait for ALL sessions to become idle.
|
|
430
|
+
|
|
431
|
+
Returns when every session has become idle, or on timeout.
|
|
432
|
+
Useful for fan-out/fan-in patterns where you need all results
|
|
433
|
+
before proceeding.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
sessions: List of SessionInfo to monitor
|
|
437
|
+
timeout: Maximum seconds to wait
|
|
438
|
+
poll_interval: Seconds between checks
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Dict with {
|
|
442
|
+
idle_session_ids: list[str], # Sessions that are idle
|
|
443
|
+
all_idle: bool, # True if all sessions became idle
|
|
444
|
+
waiting_on: list[str], # Sessions still working (if timed out)
|
|
445
|
+
waited_seconds: float,
|
|
446
|
+
timed_out: bool,
|
|
447
|
+
}
|
|
448
|
+
"""
|
|
449
|
+
start = time.time()
|
|
450
|
+
|
|
451
|
+
while time.time() - start < timeout:
|
|
452
|
+
idle_sessions = []
|
|
453
|
+
working_sessions = []
|
|
454
|
+
|
|
455
|
+
for session in sessions:
|
|
456
|
+
if is_idle(session.jsonl_path, session.session_id):
|
|
457
|
+
idle_sessions.append(session.session_id)
|
|
458
|
+
else:
|
|
459
|
+
working_sessions.append(session.session_id)
|
|
460
|
+
|
|
461
|
+
if not working_sessions:
|
|
462
|
+
# All idle!
|
|
463
|
+
return {
|
|
464
|
+
"idle_session_ids": idle_sessions,
|
|
465
|
+
"all_idle": True,
|
|
466
|
+
"waiting_on": [],
|
|
467
|
+
"waited_seconds": time.time() - start,
|
|
468
|
+
"timed_out": False,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await asyncio.sleep(poll_interval)
|
|
472
|
+
|
|
473
|
+
# Timeout - return final state
|
|
474
|
+
idle_sessions = []
|
|
475
|
+
working_sessions = []
|
|
476
|
+
for session in sessions:
|
|
477
|
+
if is_idle(session.jsonl_path, session.session_id):
|
|
478
|
+
idle_sessions.append(session.session_id)
|
|
479
|
+
else:
|
|
480
|
+
working_sessions.append(session.session_id)
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
"idle_session_ids": idle_sessions,
|
|
484
|
+
"all_idle": False,
|
|
485
|
+
"waiting_on": working_sessions,
|
|
486
|
+
"waited_seconds": timeout,
|
|
487
|
+
"timed_out": True,
|
|
488
|
+
}
|