claude-team-mcp 0.6.1__py3-none-any.whl → 0.7.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/__init__.py +11 -0
- claude_team/events.py +477 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +95 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +31 -0
- claude_team_mcp/terminal_backends/base.py +106 -0
- claude_team_mcp/terminal_backends/iterm.py +251 -0
- claude_team_mcp/terminal_backends/tmux.py +646 -0
- claude_team_mcp/tools/__init__.py +4 -2
- claude_team_mcp/tools/adopt_worker.py +89 -32
- claude_team_mcp/tools/close_workers.py +39 -10
- claude_team_mcp/tools/discover_workers.py +176 -32
- claude_team_mcp/tools/list_workers.py +29 -0
- claude_team_mcp/tools/message_workers.py +35 -5
- claude_team_mcp/tools/poll_worker_changes.py +227 -0
- claude_team_mcp/tools/spawn_workers.py +221 -142
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +59 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.7.0.dist-info/RECORD +52 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
claude_team/poller.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Background poller for worker state snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal, Protocol
|
|
12
|
+
|
|
13
|
+
from . import events
|
|
14
|
+
from .idle_detection import detect_worker_idle
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("claude-team-poller")
|
|
17
|
+
|
|
18
|
+
WorkerState = Literal["idle", "active"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Minimal registry interface used by WorkerPoller.
|
|
22
|
+
class _RegistryLike(Protocol):
|
|
23
|
+
def list_all(self) -> list["_SessionLike"]:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Minimal session interface used by WorkerPoller.
|
|
28
|
+
class _SessionLike(Protocol):
|
|
29
|
+
session_id: str
|
|
30
|
+
agent_type: Literal["claude", "codex"]
|
|
31
|
+
project_path: str
|
|
32
|
+
claude_session_id: str | None
|
|
33
|
+
output_path: Path | None
|
|
34
|
+
message_count: int | None
|
|
35
|
+
last_message_count: int | None
|
|
36
|
+
last_message_timestamp: float | None
|
|
37
|
+
pid: int | None
|
|
38
|
+
is_idle: bool
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Snapshot of a worker at a point in time.
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _WorkerSnapshot:
|
|
47
|
+
session_id: str
|
|
48
|
+
state: WorkerState
|
|
49
|
+
info: dict
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _isoformat_zulu(value: datetime) -> str:
|
|
53
|
+
# Format timestamps with a Z suffix for UTC.
|
|
54
|
+
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sanitize_for_json(obj: object) -> object:
|
|
58
|
+
# Recursively sanitize an object for JSON serialization.
|
|
59
|
+
# Removes non-serializable types like asyncio Futures, methods, etc.
|
|
60
|
+
if obj is None or isinstance(obj, (bool, int, float, str)):
|
|
61
|
+
return obj
|
|
62
|
+
if callable(obj):
|
|
63
|
+
# Skip methods, functions, lambdas
|
|
64
|
+
return None
|
|
65
|
+
if isinstance(obj, dict):
|
|
66
|
+
return {str(k): _sanitize_for_json(v) for k, v in obj.items()}
|
|
67
|
+
if isinstance(obj, (list, tuple)):
|
|
68
|
+
return [_sanitize_for_json(item) for item in obj]
|
|
69
|
+
if isinstance(obj, Path):
|
|
70
|
+
return str(obj)
|
|
71
|
+
if isinstance(obj, datetime):
|
|
72
|
+
return obj.isoformat()
|
|
73
|
+
# For any other type, try to convert to string, else skip
|
|
74
|
+
try:
|
|
75
|
+
return str(obj)
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_snapshot(registry: _RegistryLike) -> dict[str, _WorkerSnapshot]:
|
|
81
|
+
# Capture current worker states from the registry.
|
|
82
|
+
snapshots: dict[str, _WorkerSnapshot] = {}
|
|
83
|
+
for session in registry.list_all():
|
|
84
|
+
info = _sanitize_for_json(session.to_dict())
|
|
85
|
+
is_idle, _ = detect_worker_idle(session, idle_threshold_seconds=300)
|
|
86
|
+
info["is_idle"] = is_idle
|
|
87
|
+
state: WorkerState = "idle" if is_idle else "active"
|
|
88
|
+
snapshots[session.session_id] = _WorkerSnapshot(session.session_id, state, info)
|
|
89
|
+
return snapshots
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _snapshot_payload(snapshots: dict[str, _WorkerSnapshot]) -> dict:
|
|
93
|
+
# Build a full snapshot payload for persistence.
|
|
94
|
+
workers = []
|
|
95
|
+
for snapshot in snapshots.values():
|
|
96
|
+
payload = dict(snapshot.info)
|
|
97
|
+
payload["state"] = snapshot.state
|
|
98
|
+
workers.append(payload)
|
|
99
|
+
return {"count": len(workers), "workers": workers}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _transition_payload(snapshot: _WorkerSnapshot, previous_state: WorkerState | None) -> dict:
|
|
103
|
+
# Build transition data payload for a worker event.
|
|
104
|
+
payload = dict(snapshot.info)
|
|
105
|
+
payload["state"] = snapshot.state
|
|
106
|
+
payload["previous_state"] = previous_state
|
|
107
|
+
return payload
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _closed_payload(snapshot: _WorkerSnapshot) -> dict:
|
|
111
|
+
# Build payload for a worker_closed event using the last known state.
|
|
112
|
+
payload = dict(snapshot.info)
|
|
113
|
+
payload["state"] = "closed"
|
|
114
|
+
payload["previous_state"] = snapshot.state
|
|
115
|
+
return payload
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_transition_events(
|
|
119
|
+
previous: dict[str, _WorkerSnapshot],
|
|
120
|
+
current: dict[str, _WorkerSnapshot],
|
|
121
|
+
timestamp: str,
|
|
122
|
+
) -> list[events.WorkerEvent]:
|
|
123
|
+
# Compare snapshot sets and emit lifecycle transition events.
|
|
124
|
+
results: list[events.WorkerEvent] = []
|
|
125
|
+
previous_ids = set(previous)
|
|
126
|
+
current_ids = set(current)
|
|
127
|
+
|
|
128
|
+
# New sessions -> worker_started events.
|
|
129
|
+
for session_id in current_ids - previous_ids:
|
|
130
|
+
snapshot = current[session_id]
|
|
131
|
+
results.append(events.WorkerEvent(
|
|
132
|
+
ts=timestamp,
|
|
133
|
+
type="worker_started",
|
|
134
|
+
worker_id=session_id,
|
|
135
|
+
data=_transition_payload(snapshot, None),
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
# Removed sessions -> worker_closed events.
|
|
139
|
+
for session_id in previous_ids - current_ids:
|
|
140
|
+
snapshot = previous[session_id]
|
|
141
|
+
results.append(events.WorkerEvent(
|
|
142
|
+
ts=timestamp,
|
|
143
|
+
type="worker_closed",
|
|
144
|
+
worker_id=session_id,
|
|
145
|
+
data=_closed_payload(snapshot),
|
|
146
|
+
))
|
|
147
|
+
|
|
148
|
+
# Existing sessions -> idle/active transitions.
|
|
149
|
+
for session_id in previous_ids & current_ids:
|
|
150
|
+
before = previous[session_id]
|
|
151
|
+
after = current[session_id]
|
|
152
|
+
if before.state == after.state:
|
|
153
|
+
continue
|
|
154
|
+
event_type = "worker_idle" if after.state == "idle" else "worker_active"
|
|
155
|
+
results.append(events.WorkerEvent(
|
|
156
|
+
ts=timestamp,
|
|
157
|
+
type=event_type,
|
|
158
|
+
worker_id=session_id,
|
|
159
|
+
data=_transition_payload(after, before.state),
|
|
160
|
+
))
|
|
161
|
+
|
|
162
|
+
return results
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class WorkerPoller:
|
|
166
|
+
"""Background poller that snapshots worker state and logs transitions."""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
registry: _RegistryLike,
|
|
171
|
+
poll_interval_seconds: int = 60,
|
|
172
|
+
snapshot_interval_seconds: int = 300,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Initialize the poller with registry and polling cadence."""
|
|
175
|
+
self._registry = registry
|
|
176
|
+
self._poll_interval_seconds = poll_interval_seconds
|
|
177
|
+
self._snapshot_interval_seconds = snapshot_interval_seconds
|
|
178
|
+
self._stop_event = asyncio.Event()
|
|
179
|
+
self._task: asyncio.Task | None = None
|
|
180
|
+
self._last_snapshot: dict[str, _WorkerSnapshot] = {}
|
|
181
|
+
self._last_snapshot_event_at: float | None = None
|
|
182
|
+
|
|
183
|
+
def start(self) -> None:
|
|
184
|
+
"""Start the background polling task."""
|
|
185
|
+
if self._task and not self._task.done():
|
|
186
|
+
return
|
|
187
|
+
self._stop_event.clear()
|
|
188
|
+
self._task = asyncio.create_task(self._run(), name="worker-poller")
|
|
189
|
+
|
|
190
|
+
async def stop(self) -> None:
|
|
191
|
+
"""Stop the background polling task."""
|
|
192
|
+
if not self._task:
|
|
193
|
+
return
|
|
194
|
+
self._stop_event.set()
|
|
195
|
+
await self._task
|
|
196
|
+
self._task = None
|
|
197
|
+
|
|
198
|
+
async def _run(self) -> None:
|
|
199
|
+
# Poll until stop is requested, logging events along the way.
|
|
200
|
+
while not self._stop_event.is_set():
|
|
201
|
+
try:
|
|
202
|
+
self._poll_once()
|
|
203
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
204
|
+
logger.exception("Worker poller failed: %s", exc)
|
|
205
|
+
await self._wait_for_next_tick()
|
|
206
|
+
|
|
207
|
+
async def _wait_for_next_tick(self) -> None:
|
|
208
|
+
# Wait for either the next poll interval or a stop request.
|
|
209
|
+
try:
|
|
210
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=self._poll_interval_seconds)
|
|
211
|
+
except asyncio.TimeoutError:
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
def _poll_once(self) -> None:
|
|
215
|
+
# Capture a snapshot, diff it, and persist any resulting events.
|
|
216
|
+
# Timestamp both for events and snapshot cadence.
|
|
217
|
+
now_iso = _isoformat_zulu(datetime.now(timezone.utc))
|
|
218
|
+
now_monotonic = time.monotonic()
|
|
219
|
+
# Snapshot current registry and compute transitions.
|
|
220
|
+
current_snapshot = _build_snapshot(self._registry)
|
|
221
|
+
transitions = _build_transition_events(self._last_snapshot, current_snapshot, now_iso)
|
|
222
|
+
|
|
223
|
+
# Emit periodic full snapshot for recovery.
|
|
224
|
+
if self._should_emit_snapshot(now_monotonic):
|
|
225
|
+
transitions.append(events.WorkerEvent(
|
|
226
|
+
ts=now_iso,
|
|
227
|
+
type="snapshot",
|
|
228
|
+
worker_id=None,
|
|
229
|
+
data=_snapshot_payload(current_snapshot),
|
|
230
|
+
))
|
|
231
|
+
self._last_snapshot_event_at = now_monotonic
|
|
232
|
+
|
|
233
|
+
# Persist any events in a single batch.
|
|
234
|
+
if transitions:
|
|
235
|
+
events.append_events(transitions)
|
|
236
|
+
|
|
237
|
+
# Update the in-memory snapshot for the next diff.
|
|
238
|
+
self._last_snapshot = current_snapshot
|
|
239
|
+
|
|
240
|
+
def _should_emit_snapshot(self, now_monotonic: float) -> bool:
|
|
241
|
+
# Decide whether it's time to emit a full snapshot event.
|
|
242
|
+
last = self._last_snapshot_event_at
|
|
243
|
+
if last is None:
|
|
244
|
+
return True
|
|
245
|
+
return (now_monotonic - last) >= self._snapshot_interval_seconds
|
|
@@ -372,6 +372,7 @@ class SessionInfo:
|
|
|
372
372
|
|
|
373
373
|
jsonl_path: Path
|
|
374
374
|
session_id: str
|
|
375
|
+
agent_type: str = "claude"
|
|
375
376
|
|
|
376
377
|
|
|
377
378
|
async def wait_for_any_idle(
|
|
@@ -403,7 +404,11 @@ async def wait_for_any_idle(
|
|
|
403
404
|
|
|
404
405
|
while time.time() - start < timeout:
|
|
405
406
|
for session in sessions:
|
|
406
|
-
if
|
|
407
|
+
if session.agent_type == "codex":
|
|
408
|
+
idle = is_codex_idle(session.jsonl_path)
|
|
409
|
+
else:
|
|
410
|
+
idle = is_idle(session.jsonl_path, session.session_id)
|
|
411
|
+
if idle:
|
|
407
412
|
return {
|
|
408
413
|
"idle_session_id": session.session_id,
|
|
409
414
|
"idle": True,
|
|
@@ -453,7 +458,11 @@ async def wait_for_all_idle(
|
|
|
453
458
|
working_sessions = []
|
|
454
459
|
|
|
455
460
|
for session in sessions:
|
|
456
|
-
if
|
|
461
|
+
if session.agent_type == "codex":
|
|
462
|
+
idle = is_codex_idle(session.jsonl_path)
|
|
463
|
+
else:
|
|
464
|
+
idle = is_idle(session.jsonl_path, session.session_id)
|
|
465
|
+
if idle:
|
|
457
466
|
idle_sessions.append(session.session_id)
|
|
458
467
|
else:
|
|
459
468
|
working_sessions.append(session.session_id)
|
|
@@ -474,7 +483,11 @@ async def wait_for_all_idle(
|
|
|
474
483
|
idle_sessions = []
|
|
475
484
|
working_sessions = []
|
|
476
485
|
for session in sessions:
|
|
477
|
-
if
|
|
486
|
+
if session.agent_type == "codex":
|
|
487
|
+
idle = is_codex_idle(session.jsonl_path)
|
|
488
|
+
else:
|
|
489
|
+
idle = is_idle(session.jsonl_path, session.session_id)
|
|
490
|
+
if idle:
|
|
478
491
|
idle_sessions.append(session.session_id)
|
|
479
492
|
else:
|
|
480
493
|
working_sessions.append(session.session_id)
|
claude_team_mcp/iterm_utils.py
CHANGED
|
@@ -705,75 +705,6 @@ async def start_agent_in_session(
|
|
|
705
705
|
)
|
|
706
706
|
|
|
707
707
|
|
|
708
|
-
async def start_claude_in_session(
|
|
709
|
-
session: "ItermSession",
|
|
710
|
-
project_path: str,
|
|
711
|
-
dangerously_skip_permissions: bool = False,
|
|
712
|
-
env: Optional[dict[str, str]] = None,
|
|
713
|
-
shell_ready_timeout: float = 10.0,
|
|
714
|
-
claude_ready_timeout: float = 30.0,
|
|
715
|
-
stop_hook_marker_id: Optional[str] = None,
|
|
716
|
-
) -> None:
|
|
717
|
-
"""
|
|
718
|
-
Start Claude Code in an existing iTerm2 session.
|
|
719
|
-
|
|
720
|
-
Convenience wrapper around start_agent_in_session() for Claude.
|
|
721
|
-
Changes to the project directory and launches Claude Code in a single
|
|
722
|
-
atomic command (cd && claude). Waits for shell readiness before sending
|
|
723
|
-
the command, then waits for Claude's startup banner to appear.
|
|
724
|
-
|
|
725
|
-
The command used to launch Claude Code can be overridden by setting
|
|
726
|
-
the CLAUDE_TEAM_COMMAND environment variable (defaults to "claude").
|
|
727
|
-
This is useful for running alternative Claude CLI implementations
|
|
728
|
-
like "happy" or for testing purposes.
|
|
729
|
-
|
|
730
|
-
Args:
|
|
731
|
-
session: iTerm2 session to use
|
|
732
|
-
project_path: Directory to run Claude in
|
|
733
|
-
dangerously_skip_permissions: If True, start with --dangerously-skip-permissions
|
|
734
|
-
env: Optional dict of environment variables to set before running claude
|
|
735
|
-
shell_ready_timeout: Max seconds to wait for shell prompt
|
|
736
|
-
claude_ready_timeout: Max seconds to wait for Claude to start and show banner
|
|
737
|
-
stop_hook_marker_id: If provided, inject a Stop hook that logs this marker
|
|
738
|
-
to the JSONL for completion detection
|
|
739
|
-
|
|
740
|
-
Raises:
|
|
741
|
-
RuntimeError: If shell not ready or Claude fails to start within timeout
|
|
742
|
-
"""
|
|
743
|
-
from .cli_backends import claude_cli
|
|
744
|
-
|
|
745
|
-
await start_agent_in_session(
|
|
746
|
-
session=session,
|
|
747
|
-
cli=claude_cli,
|
|
748
|
-
project_path=project_path,
|
|
749
|
-
dangerously_skip_permissions=dangerously_skip_permissions,
|
|
750
|
-
env=env,
|
|
751
|
-
shell_ready_timeout=shell_ready_timeout,
|
|
752
|
-
agent_ready_timeout=claude_ready_timeout,
|
|
753
|
-
stop_hook_marker_id=stop_hook_marker_id,
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
# Legacy alias for backward compatibility with Claude-specific code
|
|
758
|
-
# that checks for banner patterns. Uses wait_for_agent_ready with claude_cli.
|
|
759
|
-
async def _wait_for_claude_ready_via_agent(
|
|
760
|
-
session: "ItermSession",
|
|
761
|
-
timeout_seconds: float = 15.0,
|
|
762
|
-
poll_interval: float = 0.2,
|
|
763
|
-
stable_count: int = 2,
|
|
764
|
-
) -> bool:
|
|
765
|
-
"""Internal helper - uses wait_for_agent_ready with Claude CLI."""
|
|
766
|
-
from .cli_backends import claude_cli
|
|
767
|
-
|
|
768
|
-
return await wait_for_agent_ready(
|
|
769
|
-
session=session,
|
|
770
|
-
cli=claude_cli,
|
|
771
|
-
timeout_seconds=timeout_seconds,
|
|
772
|
-
poll_interval=poll_interval,
|
|
773
|
-
stable_count=stable_count,
|
|
774
|
-
)
|
|
775
|
-
|
|
776
|
-
|
|
777
708
|
# =============================================================================
|
|
778
709
|
# Multi-Pane Layouts
|
|
779
710
|
# =============================================================================
|
|
@@ -983,15 +914,17 @@ async def create_multi_claude_layout(
|
|
|
983
914
|
profile_customizations=profile_customizations,
|
|
984
915
|
)
|
|
985
916
|
|
|
917
|
+
from .cli_backends import claude_cli
|
|
918
|
+
|
|
986
919
|
# Start Claude in all panes in parallel.
|
|
987
|
-
#
|
|
988
|
-
# which provides proper readiness detection, so no sleeps between starts needed.
|
|
920
|
+
# start_agent_in_session uses wait_for_shell_ready() internally, so no sleeps needed.
|
|
989
921
|
async def start_claude_for_pane(pane_name: str, project_path: str) -> None:
|
|
990
922
|
session = panes[pane_name]
|
|
991
923
|
pane_env = project_envs.get(pane_name) if project_envs else None
|
|
992
924
|
marker_id = pane_marker_ids.get(pane_name) if pane_marker_ids else None
|
|
993
|
-
await
|
|
925
|
+
await start_agent_in_session(
|
|
994
926
|
session=session,
|
|
927
|
+
cli=claude_cli,
|
|
995
928
|
project_path=project_path,
|
|
996
929
|
dangerously_skip_permissions=skip_permissions,
|
|
997
930
|
env=pane_env,
|
|
@@ -1116,4 +1049,3 @@ async def get_window_for_session(
|
|
|
1116
1049
|
return window
|
|
1117
1050
|
return None
|
|
1118
1051
|
|
|
1119
|
-
|
claude_team_mcp/registry.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Session Registry for Claude Team MCP
|
|
3
3
|
|
|
4
4
|
Tracks all spawned Claude Code sessions, maintaining the mapping between
|
|
5
|
-
our session IDs,
|
|
5
|
+
our session IDs, terminal session handles, and Claude JSONL session IDs.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import uuid
|
|
@@ -10,12 +10,14 @@ from dataclasses import dataclass, field
|
|
|
10
10
|
from datetime import datetime
|
|
11
11
|
from enum import Enum
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import
|
|
13
|
+
from typing import Literal, Optional
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
from .session_state import (
|
|
16
|
+
find_codex_session_by_internal_id,
|
|
17
|
+
get_project_dir,
|
|
18
|
+
parse_session,
|
|
19
|
+
)
|
|
20
|
+
from .terminal_backends import TerminalSession
|
|
19
21
|
|
|
20
22
|
# Type alias for supported agent types
|
|
21
23
|
AgentType = Literal["claude", "codex"]
|
|
@@ -31,16 +33,16 @@ class TerminalId:
|
|
|
31
33
|
tools to accept terminal IDs directly for recovery scenarios.
|
|
32
34
|
|
|
33
35
|
Attributes:
|
|
34
|
-
|
|
36
|
+
backend_id: Terminal backend identifier ("iterm", "tmux", "zed", etc.)
|
|
35
37
|
native_id: Terminal's native session ID (e.g., iTerm's UUID)
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
backend_id: str
|
|
39
41
|
native_id: str
|
|
40
42
|
|
|
41
43
|
def __str__(self) -> str:
|
|
42
44
|
"""For display: 'iterm:DB29DB03-...'"""
|
|
43
|
-
return f"{self.
|
|
45
|
+
return f"{self.backend_id}:{self.native_id}"
|
|
44
46
|
|
|
45
47
|
@classmethod
|
|
46
48
|
def from_string(cls, s: str) -> "TerminalId":
|
|
@@ -50,9 +52,8 @@ class TerminalId:
|
|
|
50
52
|
Falls back to treating bare IDs as iTerm for backwards compatibility.
|
|
51
53
|
"""
|
|
52
54
|
if ":" in s:
|
|
53
|
-
|
|
54
|
-
return cls(
|
|
55
|
-
# Assume bare ID is iTerm for backwards compatibility
|
|
55
|
+
backend_id, native_id = s.split(":", 1)
|
|
56
|
+
return cls(backend_id, native_id)
|
|
56
57
|
return cls("iterm", s)
|
|
57
58
|
|
|
58
59
|
|
|
@@ -69,12 +70,12 @@ class ManagedSession:
|
|
|
69
70
|
"""
|
|
70
71
|
Represents a spawned Claude Code session.
|
|
71
72
|
|
|
72
|
-
Tracks
|
|
73
|
+
Tracks terminal session metadata, project path, and Claude session ID
|
|
73
74
|
discovered from the JSONL file.
|
|
74
75
|
"""
|
|
75
76
|
|
|
76
77
|
session_id: str # Our assigned ID (e.g., "worker-1")
|
|
77
|
-
|
|
78
|
+
terminal_session: TerminalSession
|
|
78
79
|
project_path: str
|
|
79
80
|
claude_session_id: Optional[str] = None # Discovered from JSONL
|
|
80
81
|
name: Optional[str] = None # Optional friendly name
|
|
@@ -87,16 +88,19 @@ class ManagedSession:
|
|
|
87
88
|
worktree_path: Optional[Path] = None # Path to worker's git worktree if any
|
|
88
89
|
main_repo_path: Optional[Path] = None # Path to main git repo (for worktree cleanup)
|
|
89
90
|
|
|
90
|
-
# Terminal-agnostic identifier (auto-populated from
|
|
91
|
+
# Terminal-agnostic identifier (auto-populated from terminal_session if not set)
|
|
91
92
|
terminal_id: Optional[TerminalId] = None
|
|
92
93
|
|
|
93
94
|
# Agent type: "claude" (default) or "codex"
|
|
94
95
|
agent_type: AgentType = "claude"
|
|
95
96
|
|
|
96
97
|
def __post_init__(self):
|
|
97
|
-
"""Auto-populate terminal_id from
|
|
98
|
-
if self.terminal_id is None
|
|
99
|
-
self.terminal_id = TerminalId(
|
|
98
|
+
"""Auto-populate terminal_id from terminal_session if not set."""
|
|
99
|
+
if self.terminal_id is None:
|
|
100
|
+
self.terminal_id = TerminalId(
|
|
101
|
+
self.terminal_session.backend_id,
|
|
102
|
+
self.terminal_session.native_id,
|
|
103
|
+
)
|
|
100
104
|
|
|
101
105
|
def to_dict(self) -> dict:
|
|
102
106
|
"""Convert to dictionary for MCP tool responses."""
|
|
@@ -148,15 +152,21 @@ class ManagedSession:
|
|
|
148
152
|
Get the path to this session's JSONL file.
|
|
149
153
|
|
|
150
154
|
For Claude workers: uses marker-based discovery in ~/.claude/projects/.
|
|
151
|
-
For Codex workers:
|
|
155
|
+
For Codex workers: uses marker-based discovery in ~/.codex/sessions/.
|
|
152
156
|
|
|
153
157
|
Returns:
|
|
154
158
|
Path object, or None if session cannot be discovered
|
|
155
159
|
"""
|
|
156
160
|
if self.agent_type == "codex":
|
|
157
|
-
# For Codex, search the sessions directory
|
|
158
161
|
from .idle_detection import find_codex_session_file
|
|
159
162
|
|
|
163
|
+
# Prefer marker-based match, fall back to most recent for legacy sessions.
|
|
164
|
+
match = find_codex_session_by_internal_id(
|
|
165
|
+
self.session_id,
|
|
166
|
+
max_age_seconds=600,
|
|
167
|
+
)
|
|
168
|
+
if match:
|
|
169
|
+
return match.jsonl_path
|
|
160
170
|
return find_codex_session_file(max_age_seconds=600)
|
|
161
171
|
else:
|
|
162
172
|
# For Claude, use marker-based discovery
|
|
@@ -205,8 +215,14 @@ class ManagedSession:
|
|
|
205
215
|
if self.agent_type == "codex":
|
|
206
216
|
from .idle_detection import find_codex_session_file, is_codex_idle
|
|
207
217
|
|
|
208
|
-
#
|
|
209
|
-
|
|
218
|
+
# Prefer marker-based match, fall back to most recent for legacy sessions.
|
|
219
|
+
match = find_codex_session_by_internal_id(
|
|
220
|
+
self.session_id,
|
|
221
|
+
max_age_seconds=600,
|
|
222
|
+
)
|
|
223
|
+
session_file = match.jsonl_path if match else None
|
|
224
|
+
if not session_file:
|
|
225
|
+
session_file = find_codex_session_file(max_age_seconds=600)
|
|
210
226
|
if not session_file:
|
|
211
227
|
return False
|
|
212
228
|
return is_codex_idle(session_file)
|
|
@@ -269,7 +285,7 @@ class SessionRegistry:
|
|
|
269
285
|
|
|
270
286
|
def add(
|
|
271
287
|
self,
|
|
272
|
-
|
|
288
|
+
terminal_session: TerminalSession,
|
|
273
289
|
project_path: str,
|
|
274
290
|
name: Optional[str] = None,
|
|
275
291
|
session_id: Optional[str] = None,
|
|
@@ -278,7 +294,7 @@ class SessionRegistry:
|
|
|
278
294
|
Add a new session to the registry.
|
|
279
295
|
|
|
280
296
|
Args:
|
|
281
|
-
|
|
297
|
+
terminal_session: Backend-agnostic terminal session handle
|
|
282
298
|
project_path: Directory where Claude is running
|
|
283
299
|
name: Optional friendly name
|
|
284
300
|
session_id: Optional specific ID (auto-generated if not provided)
|
|
@@ -291,7 +307,7 @@ class SessionRegistry:
|
|
|
291
307
|
|
|
292
308
|
session = ManagedSession(
|
|
293
309
|
session_id=session_id,
|
|
294
|
-
|
|
310
|
+
terminal_session=terminal_session,
|
|
295
311
|
project_path=project_path,
|
|
296
312
|
name=name,
|
|
297
313
|
)
|
|
@@ -331,7 +347,8 @@ class SessionRegistry:
|
|
|
331
347
|
|
|
332
348
|
Lookup order (most specific first):
|
|
333
349
|
1. Internal session_id (e.g., "d875b833")
|
|
334
|
-
2. Terminal
|
|
350
|
+
2. Terminal ID with backend prefix (e.g., "iterm:DB29DB03-..."),
|
|
351
|
+
or a bare iTerm ID for backwards compatibility
|
|
335
352
|
3. Session name
|
|
336
353
|
|
|
337
354
|
After MCP restart, internal IDs are lost until import. This method
|