claude-team-mcp 0.6.1__py3-none-any.whl → 0.8.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 +501 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/cli_backends/__init__.py +4 -2
- claude_team_mcp/cli_backends/claude.py +45 -5
- claude_team_mcp/cli_backends/codex.py +44 -3
- claude_team_mcp/config.py +350 -0
- claude_team_mcp/config_cli.py +263 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/issue_tracker/__init__.py +68 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +164 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +49 -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 +683 -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 +254 -153
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +73 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tmux terminal backend adapter.
|
|
3
|
+
|
|
4
|
+
Provides a TerminalBackend implementation backed by tmux CLI commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from .base import TerminalBackend, TerminalSession
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from ..cli_backends import AgentCLI
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
KEY_MAP: dict[str, str] = {
|
|
24
|
+
"enter": "C-m",
|
|
25
|
+
"return": "C-m",
|
|
26
|
+
"newline": "C-j",
|
|
27
|
+
"escape": "Escape",
|
|
28
|
+
"tab": "Tab",
|
|
29
|
+
"backspace": "BSpace",
|
|
30
|
+
"delete": "DC",
|
|
31
|
+
"up": "Up",
|
|
32
|
+
"down": "Down",
|
|
33
|
+
"right": "Right",
|
|
34
|
+
"left": "Left",
|
|
35
|
+
"home": "Home",
|
|
36
|
+
"end": "End",
|
|
37
|
+
"ctrl-c": "C-c",
|
|
38
|
+
"ctrl-d": "C-d",
|
|
39
|
+
"ctrl-u": "C-u",
|
|
40
|
+
"ctrl-l": "C-l",
|
|
41
|
+
"ctrl-z": "C-z",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ISSUE_ID_PATTERN = re.compile(r"\b[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]*\d[A-Za-z0-9]*\b")
|
|
45
|
+
|
|
46
|
+
SHELL_READY_MARKER = "CLAUDE_TEAM_READY_7f3a9c"
|
|
47
|
+
CODEX_PRE_ENTER_DELAY = 0.5
|
|
48
|
+
TMUX_SESSION_PREFIX = "claude-team"
|
|
49
|
+
TMUX_SESSION_HASH_LEN = 8
|
|
50
|
+
TMUX_SESSION_SLUG_MAX = 32
|
|
51
|
+
TMUX_SESSION_FALLBACK = "project"
|
|
52
|
+
TMUX_SESSION_PREFIXED = f"{TMUX_SESSION_PREFIX}-"
|
|
53
|
+
|
|
54
|
+
LAYOUT_PANE_NAMES = {
|
|
55
|
+
"single": ["main"],
|
|
56
|
+
"vertical": ["left", "right"],
|
|
57
|
+
"triple_vertical": ["left", "middle", "right"],
|
|
58
|
+
"horizontal": ["top", "bottom"],
|
|
59
|
+
"quad": ["top_left", "top_right", "bottom_left", "bottom_right"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
LAYOUT_SELECT = {
|
|
63
|
+
"vertical": "even-horizontal",
|
|
64
|
+
"triple_vertical": "even-horizontal",
|
|
65
|
+
"horizontal": "even-vertical",
|
|
66
|
+
"quad": "tiled",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Normalize a project name into a tmux-safe slug.
|
|
71
|
+
def _tmux_safe_slug(value: str) -> str:
|
|
72
|
+
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", value.strip())
|
|
73
|
+
slug = slug.strip("-_")
|
|
74
|
+
if not slug:
|
|
75
|
+
return TMUX_SESSION_FALLBACK
|
|
76
|
+
if len(slug) > TMUX_SESSION_SLUG_MAX:
|
|
77
|
+
slug = slug[:TMUX_SESSION_SLUG_MAX].rstrip("-_")
|
|
78
|
+
return slug or TMUX_SESSION_FALLBACK
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def project_name_from_path(project_path: str | None) -> str | None:
|
|
82
|
+
"""Return a display name for a project path, handling worktree paths."""
|
|
83
|
+
if not project_path:
|
|
84
|
+
return None
|
|
85
|
+
path = Path(project_path)
|
|
86
|
+
parts = path.parts
|
|
87
|
+
if ".worktrees" in parts:
|
|
88
|
+
worktrees_index = parts.index(".worktrees")
|
|
89
|
+
if worktrees_index > 0:
|
|
90
|
+
return parts[worktrees_index - 1]
|
|
91
|
+
return path.name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def tmux_session_name_for_project(project_path: str | None) -> str:
|
|
95
|
+
"""Return the per-project tmux session name for a given project path."""
|
|
96
|
+
project_name = project_name_from_path(project_path) or TMUX_SESSION_FALLBACK
|
|
97
|
+
slug = _tmux_safe_slug(project_name)
|
|
98
|
+
if project_path:
|
|
99
|
+
digest_source = project_path
|
|
100
|
+
else:
|
|
101
|
+
digest_source = uuid.uuid4().hex
|
|
102
|
+
digest = hashlib.sha1(digest_source.encode("utf-8")).hexdigest()[:TMUX_SESSION_HASH_LEN]
|
|
103
|
+
return f"{TMUX_SESSION_PREFIXED}{slug}-{digest}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# Determine whether a tmux session is managed by claude-team.
|
|
107
|
+
def _is_managed_session_name(session_name: str) -> bool:
|
|
108
|
+
return session_name.startswith(TMUX_SESSION_PREFIXED)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TmuxBackend(TerminalBackend):
|
|
112
|
+
"""Terminal backend adapter for tmux."""
|
|
113
|
+
|
|
114
|
+
backend_id = "tmux"
|
|
115
|
+
|
|
116
|
+
def __init__(self, socket_path: str | None = None) -> None:
|
|
117
|
+
"""Initialize the backend with an optional tmux socket path."""
|
|
118
|
+
self._socket_path = socket_path
|
|
119
|
+
|
|
120
|
+
def wrap_session(self, handle: Any) -> TerminalSession:
|
|
121
|
+
"""Wrap a tmux pane id in a TerminalSession."""
|
|
122
|
+
pane_id = str(handle)
|
|
123
|
+
return TerminalSession(
|
|
124
|
+
backend_id=self.backend_id,
|
|
125
|
+
native_id=pane_id,
|
|
126
|
+
handle=pane_id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def unwrap_session(self, session: TerminalSession) -> str:
|
|
130
|
+
"""Extract the tmux pane id from a TerminalSession."""
|
|
131
|
+
return str(session.handle)
|
|
132
|
+
|
|
133
|
+
async def create_session(
|
|
134
|
+
self,
|
|
135
|
+
name: str | None = None,
|
|
136
|
+
*,
|
|
137
|
+
project_path: str | None = None,
|
|
138
|
+
issue_id: str | None = None,
|
|
139
|
+
coordinator_annotation: str | None = None,
|
|
140
|
+
profile: str | None = None,
|
|
141
|
+
profile_customizations: Any | None = None,
|
|
142
|
+
) -> TerminalSession:
|
|
143
|
+
"""Create a worker window in a per-project tmux session."""
|
|
144
|
+
if profile or profile_customizations:
|
|
145
|
+
raise ValueError("tmux backend does not support profiles")
|
|
146
|
+
|
|
147
|
+
base_name = name or self._generate_window_name()
|
|
148
|
+
project_name = project_name_from_path(project_path)
|
|
149
|
+
resolved_issue_id = self._resolve_issue_id(issue_id, coordinator_annotation)
|
|
150
|
+
window_name = self._format_window_name(base_name, project_name, resolved_issue_id)
|
|
151
|
+
session_name = tmux_session_name_for_project(project_path)
|
|
152
|
+
|
|
153
|
+
# Ensure the dedicated session exists, then create a new window for this worker.
|
|
154
|
+
try:
|
|
155
|
+
await self._run_tmux(["has-session", "-t", session_name])
|
|
156
|
+
output = await self._run_tmux(
|
|
157
|
+
[
|
|
158
|
+
"new-window",
|
|
159
|
+
"-t",
|
|
160
|
+
session_name,
|
|
161
|
+
"-n",
|
|
162
|
+
window_name,
|
|
163
|
+
"-P",
|
|
164
|
+
"-F",
|
|
165
|
+
"#{pane_id}\t#{window_id}\t#{window_index}",
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
except subprocess.CalledProcessError:
|
|
169
|
+
output = await self._run_tmux(
|
|
170
|
+
[
|
|
171
|
+
"new-session",
|
|
172
|
+
"-d",
|
|
173
|
+
"-s",
|
|
174
|
+
session_name,
|
|
175
|
+
"-n",
|
|
176
|
+
window_name,
|
|
177
|
+
"-P",
|
|
178
|
+
"-F",
|
|
179
|
+
"#{pane_id}\t#{window_id}\t#{window_index}",
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
pane_id, window_id, window_index = self._parse_window_output(output)
|
|
184
|
+
if not pane_id:
|
|
185
|
+
raise RuntimeError("Failed to determine tmux pane id for new window")
|
|
186
|
+
|
|
187
|
+
metadata = {
|
|
188
|
+
"session_name": session_name,
|
|
189
|
+
"window_id": window_id,
|
|
190
|
+
"window_index": window_index,
|
|
191
|
+
"window_name": window_name,
|
|
192
|
+
}
|
|
193
|
+
if project_name:
|
|
194
|
+
metadata["project_name"] = project_name
|
|
195
|
+
if resolved_issue_id:
|
|
196
|
+
metadata["issue_id"] = resolved_issue_id
|
|
197
|
+
|
|
198
|
+
return TerminalSession(
|
|
199
|
+
backend_id=self.backend_id,
|
|
200
|
+
native_id=pane_id,
|
|
201
|
+
handle=pane_id,
|
|
202
|
+
metadata=metadata,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def send_text(self, session: TerminalSession, text: str) -> None:
|
|
206
|
+
"""Send raw text to a tmux pane."""
|
|
207
|
+
pane_id = self.unwrap_session(session)
|
|
208
|
+
await self._run_tmux(["send-keys", "-t", pane_id, "-l", text])
|
|
209
|
+
|
|
210
|
+
async def send_key(self, session: TerminalSession, key: str) -> None:
|
|
211
|
+
"""Send a special key to a tmux pane."""
|
|
212
|
+
pane_id = self.unwrap_session(session)
|
|
213
|
+
tmux_key = KEY_MAP.get(key.lower())
|
|
214
|
+
if tmux_key is None:
|
|
215
|
+
raise ValueError(f"Unknown key: {key}. Available: {list(KEY_MAP.keys())}")
|
|
216
|
+
await self._run_tmux(["send-keys", "-t", pane_id, tmux_key])
|
|
217
|
+
|
|
218
|
+
async def send_prompt(
|
|
219
|
+
self, session: TerminalSession, text: str, submit: bool = True
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Send a prompt to a tmux pane, optionally submitting it."""
|
|
222
|
+
await self.send_text(session, text)
|
|
223
|
+
if not submit:
|
|
224
|
+
return
|
|
225
|
+
# Delay to allow tmux to finish pasting before sending Enter.
|
|
226
|
+
delay = self._compute_paste_delay(text)
|
|
227
|
+
await asyncio.sleep(delay)
|
|
228
|
+
await self.send_key(session, "enter")
|
|
229
|
+
|
|
230
|
+
async def send_prompt_for_agent(
|
|
231
|
+
self,
|
|
232
|
+
session: TerminalSession,
|
|
233
|
+
text: str,
|
|
234
|
+
agent_type: str = "claude",
|
|
235
|
+
submit: bool = True,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Send a prompt with agent-specific handling (Claude vs Codex)."""
|
|
238
|
+
await self.send_text(session, text)
|
|
239
|
+
if not submit:
|
|
240
|
+
return
|
|
241
|
+
# Codex needs a longer pre-Enter delay; use the max of paste vs minimum.
|
|
242
|
+
delay = self._compute_paste_delay(text)
|
|
243
|
+
if agent_type == "codex":
|
|
244
|
+
delay = max(CODEX_PRE_ENTER_DELAY, delay)
|
|
245
|
+
await asyncio.sleep(delay)
|
|
246
|
+
await self.send_key(session, "enter")
|
|
247
|
+
|
|
248
|
+
async def read_screen_text(self, session: TerminalSession) -> str:
|
|
249
|
+
"""Read visible screen content from a tmux pane."""
|
|
250
|
+
pane_id = self.unwrap_session(session)
|
|
251
|
+
return await self._run_tmux(["capture-pane", "-p", "-t", pane_id])
|
|
252
|
+
|
|
253
|
+
async def split_pane(
|
|
254
|
+
self,
|
|
255
|
+
session: TerminalSession,
|
|
256
|
+
*,
|
|
257
|
+
vertical: bool = True,
|
|
258
|
+
before: bool = False,
|
|
259
|
+
profile: str | None = None,
|
|
260
|
+
profile_customizations: Any | None = None,
|
|
261
|
+
) -> TerminalSession:
|
|
262
|
+
"""Split a tmux pane and return the new pane."""
|
|
263
|
+
if profile or profile_customizations:
|
|
264
|
+
raise ValueError("tmux backend does not support profiles")
|
|
265
|
+
|
|
266
|
+
pane_id = self.unwrap_session(session)
|
|
267
|
+
args = ["split-window", "-t", pane_id]
|
|
268
|
+
args.append("-h" if vertical else "-v")
|
|
269
|
+
if before:
|
|
270
|
+
args.append("-b")
|
|
271
|
+
# -P prints the new pane id, -F controls the output format.
|
|
272
|
+
args.extend(["-P", "-F", "#{pane_id}"])
|
|
273
|
+
|
|
274
|
+
output = await self._run_tmux(args)
|
|
275
|
+
new_pane_id = self._first_non_empty_line(output)
|
|
276
|
+
if not new_pane_id:
|
|
277
|
+
raise RuntimeError("Failed to determine tmux pane id for split")
|
|
278
|
+
|
|
279
|
+
metadata = dict(session.metadata) if session.metadata else {}
|
|
280
|
+
return TerminalSession(
|
|
281
|
+
backend_id=self.backend_id,
|
|
282
|
+
native_id=new_pane_id,
|
|
283
|
+
handle=new_pane_id,
|
|
284
|
+
metadata=metadata,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
async def close_session(self, session: TerminalSession, force: bool = False) -> None:
|
|
288
|
+
"""Close a tmux window (or its pane) for this worker."""
|
|
289
|
+
pane_id = self.unwrap_session(session)
|
|
290
|
+
_ = force
|
|
291
|
+
window_id = session.metadata.get("window_id")
|
|
292
|
+
if not window_id:
|
|
293
|
+
window_id = await self._window_id_for_pane(pane_id)
|
|
294
|
+
if window_id:
|
|
295
|
+
await self._run_tmux(["kill-window", "-t", window_id])
|
|
296
|
+
else:
|
|
297
|
+
await self._run_tmux(["kill-pane", "-t", pane_id])
|
|
298
|
+
|
|
299
|
+
async def create_multi_pane_layout(
|
|
300
|
+
self,
|
|
301
|
+
layout: str,
|
|
302
|
+
*,
|
|
303
|
+
profile: str | None = None,
|
|
304
|
+
profile_customizations: dict[str, Any] | None = None,
|
|
305
|
+
) -> dict[str, TerminalSession]:
|
|
306
|
+
"""Create a multi-pane layout in a new tmux window."""
|
|
307
|
+
if profile or profile_customizations:
|
|
308
|
+
raise ValueError("tmux backend does not support profiles")
|
|
309
|
+
if layout not in LAYOUT_PANE_NAMES:
|
|
310
|
+
raise ValueError(f"Unknown layout: {layout}. Valid: {list(LAYOUT_PANE_NAMES.keys())}")
|
|
311
|
+
|
|
312
|
+
# Start a new window for this layout within the dedicated session.
|
|
313
|
+
initial = await self.create_session()
|
|
314
|
+
session_name = initial.metadata.get("session_name")
|
|
315
|
+
window_id = initial.metadata.get("window_id")
|
|
316
|
+
|
|
317
|
+
panes: dict[str, TerminalSession] = {}
|
|
318
|
+
|
|
319
|
+
if layout == "single":
|
|
320
|
+
panes["main"] = initial
|
|
321
|
+
elif layout == "vertical":
|
|
322
|
+
panes["left"] = initial
|
|
323
|
+
panes["right"] = await self.split_pane(initial, vertical=True)
|
|
324
|
+
elif layout == "triple_vertical":
|
|
325
|
+
panes["left"] = initial
|
|
326
|
+
panes["middle"] = await self.split_pane(initial, vertical=True)
|
|
327
|
+
panes["right"] = await self.split_pane(panes["middle"], vertical=True)
|
|
328
|
+
elif layout == "horizontal":
|
|
329
|
+
panes["top"] = initial
|
|
330
|
+
panes["bottom"] = await self.split_pane(initial, vertical=False)
|
|
331
|
+
elif layout == "quad":
|
|
332
|
+
panes["top_left"] = initial
|
|
333
|
+
panes["top_right"] = await self.split_pane(initial, vertical=True)
|
|
334
|
+
panes["bottom_left"] = await self.split_pane(initial, vertical=False)
|
|
335
|
+
panes["bottom_right"] = await self.split_pane(panes["top_right"], vertical=False)
|
|
336
|
+
|
|
337
|
+
if layout in LAYOUT_SELECT:
|
|
338
|
+
target = window_id or session_name
|
|
339
|
+
if target:
|
|
340
|
+
await self._run_tmux(["select-layout", "-t", target, LAYOUT_SELECT[layout]])
|
|
341
|
+
|
|
342
|
+
return panes
|
|
343
|
+
|
|
344
|
+
async def list_sessions(self) -> list[TerminalSession]:
|
|
345
|
+
"""List all tmux panes in claude-team-managed sessions."""
|
|
346
|
+
try:
|
|
347
|
+
output = await self._run_tmux(
|
|
348
|
+
[
|
|
349
|
+
"list-panes",
|
|
350
|
+
"-a",
|
|
351
|
+
"-F",
|
|
352
|
+
"#{session_name}\t#{window_id}\t#{window_name}\t#{window_index}\t#{pane_index}\t#{pane_id}",
|
|
353
|
+
]
|
|
354
|
+
)
|
|
355
|
+
except subprocess.CalledProcessError:
|
|
356
|
+
return []
|
|
357
|
+
|
|
358
|
+
sessions: list[TerminalSession] = []
|
|
359
|
+
|
|
360
|
+
# Each line includes session/window/pane metadata and pane id.
|
|
361
|
+
for line in output.splitlines():
|
|
362
|
+
line = line.strip()
|
|
363
|
+
if not line:
|
|
364
|
+
continue
|
|
365
|
+
parts = line.split("\t")
|
|
366
|
+
if len(parts) != 6:
|
|
367
|
+
continue
|
|
368
|
+
session_name, window_id, window_name, window_index, pane_index, pane_id = parts
|
|
369
|
+
if not _is_managed_session_name(session_name):
|
|
370
|
+
continue
|
|
371
|
+
sessions.append(
|
|
372
|
+
TerminalSession(
|
|
373
|
+
backend_id=self.backend_id,
|
|
374
|
+
native_id=pane_id,
|
|
375
|
+
handle=pane_id,
|
|
376
|
+
metadata={
|
|
377
|
+
"session_name": session_name,
|
|
378
|
+
"window_id": window_id,
|
|
379
|
+
"window_name": window_name,
|
|
380
|
+
"window_index": window_index,
|
|
381
|
+
"pane_index": pane_index,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return sessions
|
|
387
|
+
|
|
388
|
+
async def find_available_window(
|
|
389
|
+
self,
|
|
390
|
+
max_panes: int = 4,
|
|
391
|
+
managed_session_ids: set[str] | None = None,
|
|
392
|
+
) -> tuple[str, str, TerminalSession] | None:
|
|
393
|
+
"""Find a tmux window with space for additional panes."""
|
|
394
|
+
# Query panes across all tmux sessions/windows with enough metadata
|
|
395
|
+
# to group panes and select a reasonable split target.
|
|
396
|
+
try:
|
|
397
|
+
output = await self._run_tmux(
|
|
398
|
+
[
|
|
399
|
+
"list-panes",
|
|
400
|
+
"-a",
|
|
401
|
+
"-F",
|
|
402
|
+
"#{session_name}\t#{window_id}\t#{window_index}\t#{pane_index}\t#{pane_active}\t#{pane_id}",
|
|
403
|
+
]
|
|
404
|
+
)
|
|
405
|
+
except subprocess.CalledProcessError:
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
panes_by_window: dict[tuple[str, str, str], list[dict[str, str]]] = {}
|
|
409
|
+
|
|
410
|
+
for line in output.splitlines():
|
|
411
|
+
line = line.strip()
|
|
412
|
+
if not line:
|
|
413
|
+
continue
|
|
414
|
+
parts = line.split("\t")
|
|
415
|
+
if len(parts) != 6:
|
|
416
|
+
continue
|
|
417
|
+
session_name, window_id, window_index, pane_index, pane_active, pane_id = parts
|
|
418
|
+
if not _is_managed_session_name(session_name):
|
|
419
|
+
continue
|
|
420
|
+
panes_by_window.setdefault((session_name, window_id, window_index), []).append(
|
|
421
|
+
{
|
|
422
|
+
"pane_id": pane_id,
|
|
423
|
+
"pane_index": pane_index,
|
|
424
|
+
"pane_active": pane_active,
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
for (session_name, window_id, window_index), panes in panes_by_window.items():
|
|
429
|
+
# Respect the managed-session filter when provided (including empty set).
|
|
430
|
+
if managed_session_ids is not None:
|
|
431
|
+
if not any(p["pane_id"] in managed_session_ids for p in panes):
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# Only consider windows that have room for more panes.
|
|
435
|
+
if len(panes) >= max_panes:
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
# Prefer the active pane as the split target when available.
|
|
439
|
+
target = next((p for p in panes if p["pane_active"] == "1"), panes[0])
|
|
440
|
+
return (
|
|
441
|
+
session_name,
|
|
442
|
+
window_index,
|
|
443
|
+
TerminalSession(
|
|
444
|
+
backend_id=self.backend_id,
|
|
445
|
+
native_id=target["pane_id"],
|
|
446
|
+
handle=target["pane_id"],
|
|
447
|
+
metadata={
|
|
448
|
+
"session_name": session_name,
|
|
449
|
+
"window_id": window_id,
|
|
450
|
+
"window_index": window_index,
|
|
451
|
+
"pane_index": target["pane_index"],
|
|
452
|
+
},
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
async def start_agent_in_session(
|
|
459
|
+
self,
|
|
460
|
+
handle: TerminalSession,
|
|
461
|
+
cli: "AgentCLI",
|
|
462
|
+
project_path: str,
|
|
463
|
+
dangerously_skip_permissions: bool = False,
|
|
464
|
+
env: dict[str, str] | None = None,
|
|
465
|
+
shell_ready_timeout: float = 10.0,
|
|
466
|
+
agent_ready_timeout: float = 30.0,
|
|
467
|
+
stop_hook_marker_id: str | None = None,
|
|
468
|
+
output_capture_path: str | None = None,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Start a CLI agent in an existing tmux pane."""
|
|
471
|
+
# Ensure the shell is responsive before we send the launch command.
|
|
472
|
+
shell_ready = await self._wait_for_shell_ready(
|
|
473
|
+
handle, timeout_seconds=shell_ready_timeout
|
|
474
|
+
)
|
|
475
|
+
if not shell_ready:
|
|
476
|
+
raise RuntimeError(
|
|
477
|
+
f"Shell not ready after {shell_ready_timeout}s in {project_path}. "
|
|
478
|
+
"Terminal may still be initializing."
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
# Optionally inject a Stop hook using a settings file (Claude only).
|
|
482
|
+
settings_file = None
|
|
483
|
+
if stop_hook_marker_id and cli.supports_settings_file():
|
|
484
|
+
from ..iterm_utils import build_stop_hook_settings_file
|
|
485
|
+
|
|
486
|
+
settings_file = build_stop_hook_settings_file(stop_hook_marker_id)
|
|
487
|
+
|
|
488
|
+
# Build the CLI command (with env vars and settings) for this agent.
|
|
489
|
+
agent_cmd = cli.build_full_command(
|
|
490
|
+
dangerously_skip_permissions=dangerously_skip_permissions,
|
|
491
|
+
settings_file=settings_file,
|
|
492
|
+
env_vars=env,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Capture stdout/stderr if requested (useful for JSONL idle detection).
|
|
496
|
+
if output_capture_path:
|
|
497
|
+
agent_cmd = f"{agent_cmd} 2>&1 | tee {output_capture_path}"
|
|
498
|
+
|
|
499
|
+
# Launch in one atomic command to avoid races between cd and exec.
|
|
500
|
+
cmd = f"cd {project_path} && {agent_cmd}"
|
|
501
|
+
await self.send_prompt(handle, cmd, submit=True)
|
|
502
|
+
|
|
503
|
+
# Wait for the agent to become ready before returning.
|
|
504
|
+
agent_ready = await self._wait_for_agent_ready(
|
|
505
|
+
handle,
|
|
506
|
+
cli,
|
|
507
|
+
timeout_seconds=agent_ready_timeout,
|
|
508
|
+
)
|
|
509
|
+
if not agent_ready:
|
|
510
|
+
raise RuntimeError(
|
|
511
|
+
f"{cli.engine_id} failed to start in {project_path} within "
|
|
512
|
+
f"{agent_ready_timeout}s. Check that '{cli.command()}' is "
|
|
513
|
+
"available and authentication is configured."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
async def start_claude_in_session(
|
|
517
|
+
self,
|
|
518
|
+
handle: TerminalSession,
|
|
519
|
+
project_path: str,
|
|
520
|
+
dangerously_skip_permissions: bool = False,
|
|
521
|
+
env: dict[str, str] | None = None,
|
|
522
|
+
shell_ready_timeout: float = 10.0,
|
|
523
|
+
claude_ready_timeout: float = 30.0,
|
|
524
|
+
stop_hook_marker_id: str | None = None,
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Start Claude Code in an existing tmux pane."""
|
|
527
|
+
from ..cli_backends import claude_cli
|
|
528
|
+
|
|
529
|
+
await self.start_agent_in_session(
|
|
530
|
+
handle=handle,
|
|
531
|
+
cli=claude_cli,
|
|
532
|
+
project_path=project_path,
|
|
533
|
+
dangerously_skip_permissions=dangerously_skip_permissions,
|
|
534
|
+
env=env,
|
|
535
|
+
shell_ready_timeout=shell_ready_timeout,
|
|
536
|
+
agent_ready_timeout=claude_ready_timeout,
|
|
537
|
+
stop_hook_marker_id=stop_hook_marker_id,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
async def _run_tmux(self, args: list[str]) -> str:
|
|
541
|
+
"""Run a tmux command and return stdout."""
|
|
542
|
+
cmd = ["tmux"]
|
|
543
|
+
if self._socket_path:
|
|
544
|
+
cmd.extend(["-S", self._socket_path])
|
|
545
|
+
cmd.extend(args)
|
|
546
|
+
|
|
547
|
+
def _run() -> subprocess.CompletedProcess[str]:
|
|
548
|
+
return subprocess.run(
|
|
549
|
+
cmd,
|
|
550
|
+
check=True,
|
|
551
|
+
capture_output=True,
|
|
552
|
+
text=True,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
result = await asyncio.to_thread(_run)
|
|
556
|
+
return result.stdout.strip()
|
|
557
|
+
|
|
558
|
+
def _compute_paste_delay(self, text: str) -> float:
|
|
559
|
+
"""Compute a delay to let tmux process pasted text before Enter."""
|
|
560
|
+
# Match the iTerm delay heuristics for consistent cross-backend timing.
|
|
561
|
+
line_count = text.count("\n")
|
|
562
|
+
char_count = len(text)
|
|
563
|
+
if line_count > 0:
|
|
564
|
+
return min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
|
|
565
|
+
return 0.05
|
|
566
|
+
|
|
567
|
+
async def _wait_for_shell_ready(
|
|
568
|
+
self,
|
|
569
|
+
session: TerminalSession,
|
|
570
|
+
*,
|
|
571
|
+
timeout_seconds: float = 10.0,
|
|
572
|
+
poll_interval: float = 0.1,
|
|
573
|
+
) -> bool:
|
|
574
|
+
"""Wait for the shell to accept input by echoing a marker."""
|
|
575
|
+
import time
|
|
576
|
+
|
|
577
|
+
# Kick off the marker echo and then look for the echoed line.
|
|
578
|
+
await self.send_prompt(session, f'echo "{SHELL_READY_MARKER}"', submit=True)
|
|
579
|
+
|
|
580
|
+
start_time = time.monotonic()
|
|
581
|
+
while (time.monotonic() - start_time) < timeout_seconds:
|
|
582
|
+
# Scan visible pane content for the marker on its own line.
|
|
583
|
+
content = await self.read_screen_text(session)
|
|
584
|
+
for line in content.splitlines():
|
|
585
|
+
if line.strip() == SHELL_READY_MARKER:
|
|
586
|
+
return True
|
|
587
|
+
await asyncio.sleep(poll_interval)
|
|
588
|
+
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
async def _wait_for_agent_ready(
|
|
592
|
+
self,
|
|
593
|
+
session: TerminalSession,
|
|
594
|
+
cli: "AgentCLI",
|
|
595
|
+
*,
|
|
596
|
+
timeout_seconds: float = 15.0,
|
|
597
|
+
poll_interval: float = 0.2,
|
|
598
|
+
stable_count: int = 2,
|
|
599
|
+
) -> bool:
|
|
600
|
+
"""Wait for an agent CLI to show its ready patterns."""
|
|
601
|
+
import time
|
|
602
|
+
|
|
603
|
+
patterns = cli.ready_patterns()
|
|
604
|
+
start_time = time.monotonic()
|
|
605
|
+
last_content = None
|
|
606
|
+
stable_reads = 0
|
|
607
|
+
|
|
608
|
+
while (time.monotonic() - start_time) < timeout_seconds:
|
|
609
|
+
# Read the pane content and only check once output stabilizes.
|
|
610
|
+
content = await self.read_screen_text(session)
|
|
611
|
+
if content == last_content:
|
|
612
|
+
stable_reads += 1
|
|
613
|
+
else:
|
|
614
|
+
stable_reads = 0
|
|
615
|
+
last_content = content
|
|
616
|
+
|
|
617
|
+
if stable_reads >= stable_count:
|
|
618
|
+
for line in content.splitlines():
|
|
619
|
+
stripped = line.strip()
|
|
620
|
+
for pattern in patterns:
|
|
621
|
+
if pattern in stripped:
|
|
622
|
+
return True
|
|
623
|
+
|
|
624
|
+
await asyncio.sleep(poll_interval)
|
|
625
|
+
|
|
626
|
+
return False
|
|
627
|
+
|
|
628
|
+
# Resolve an issue id from explicit input or a coordinator annotation.
|
|
629
|
+
def _resolve_issue_id(
|
|
630
|
+
self,
|
|
631
|
+
issue_id: str | None,
|
|
632
|
+
coordinator_annotation: str | None,
|
|
633
|
+
) -> str | None:
|
|
634
|
+
if issue_id:
|
|
635
|
+
return issue_id
|
|
636
|
+
if not coordinator_annotation:
|
|
637
|
+
return None
|
|
638
|
+
match = ISSUE_ID_PATTERN.search(coordinator_annotation)
|
|
639
|
+
if not match:
|
|
640
|
+
return None
|
|
641
|
+
return match.group(0)
|
|
642
|
+
|
|
643
|
+
# Build the final tmux window name for a worker.
|
|
644
|
+
def _format_window_name(
|
|
645
|
+
self,
|
|
646
|
+
name: str,
|
|
647
|
+
project_name: str | None,
|
|
648
|
+
issue_id: str | None,
|
|
649
|
+
) -> str:
|
|
650
|
+
window_name = f"{name} | {project_name}" if project_name else name
|
|
651
|
+
if issue_id:
|
|
652
|
+
return f"{window_name} [{issue_id}]"
|
|
653
|
+
return window_name
|
|
654
|
+
|
|
655
|
+
# Generate a default tmux window name.
|
|
656
|
+
def _generate_window_name(self) -> str:
|
|
657
|
+
return f"worker-{uuid.uuid4().hex[:8]}"
|
|
658
|
+
|
|
659
|
+
# Parse tmux output that includes pane and window ids.
|
|
660
|
+
@staticmethod
|
|
661
|
+
def _parse_window_output(text: str) -> tuple[str | None, str | None, str | None]:
|
|
662
|
+
line = next((line for line in text.splitlines() if line.strip()), "")
|
|
663
|
+
parts = [part.strip() for part in line.split("\t")]
|
|
664
|
+
if len(parts) < 3:
|
|
665
|
+
return None, None, None
|
|
666
|
+
pane_id, window_id, window_index = parts[0], parts[1], parts[2]
|
|
667
|
+
return pane_id, window_id, window_index
|
|
668
|
+
|
|
669
|
+
# Resolve the window id that owns a given pane id.
|
|
670
|
+
async def _window_id_for_pane(self, pane_id: str) -> str | None:
|
|
671
|
+
output = await self._run_tmux(
|
|
672
|
+
["display-message", "-p", "-t", pane_id, "#{window_id}"]
|
|
673
|
+
)
|
|
674
|
+
return output.strip() or None
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
def _first_non_empty_line(text: str) -> str | None:
|
|
678
|
+
"""Return the first non-empty line from text, if any."""
|
|
679
|
+
for line in text.splitlines():
|
|
680
|
+
line = line.strip()
|
|
681
|
+
if line:
|
|
682
|
+
return line
|
|
683
|
+
return None
|