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,1119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
iTerm2 Utilities for Claude Team MCP
|
|
3
|
+
|
|
4
|
+
Low-level primitives for iTerm2 terminal control, extracted and adapted
|
|
5
|
+
from the original primitives.py for use in the MCP server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING, Optional
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from iterm2.app import App as ItermApp
|
|
14
|
+
from iterm2.connection import Connection as ItermConnection
|
|
15
|
+
from iterm2.profile import LocalWriteOnlyProfile as ItermLocalWriteOnlyProfile
|
|
16
|
+
from iterm2.session import Session as ItermSession
|
|
17
|
+
from iterm2.tab import Tab as ItermTab
|
|
18
|
+
from iterm2.window import Window as ItermWindow
|
|
19
|
+
|
|
20
|
+
from .cli_backends import AgentCLI
|
|
21
|
+
|
|
22
|
+
from .subprocess_cache import cached_system_profiler
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("claude-team-mcp.iterm_utils")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Key Codes
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
# Key codes for iTerm2 async_send_text()
|
|
32
|
+
# IMPORTANT: Use \x0d (Ctrl+M/carriage return) for Enter, NOT \n
|
|
33
|
+
KEYS = {
|
|
34
|
+
"enter": "\x0d", # Carriage return - the actual Enter key
|
|
35
|
+
"return": "\x0d",
|
|
36
|
+
"newline": "\n", # Line feed - creates newline in text, doesn't submit
|
|
37
|
+
"escape": "\x1b",
|
|
38
|
+
"tab": "\t",
|
|
39
|
+
"backspace": "\x7f",
|
|
40
|
+
"delete": "\x1b[3~",
|
|
41
|
+
"up": "\x1b[A",
|
|
42
|
+
"down": "\x1b[B",
|
|
43
|
+
"right": "\x1b[C",
|
|
44
|
+
"left": "\x1b[D",
|
|
45
|
+
"home": "\x1b[H",
|
|
46
|
+
"end": "\x1b[F",
|
|
47
|
+
"ctrl-c": "\x03", # Interrupt
|
|
48
|
+
"ctrl-d": "\x04", # EOF
|
|
49
|
+
"ctrl-u": "\x15", # Clear line
|
|
50
|
+
"ctrl-l": "\x0c", # Clear screen
|
|
51
|
+
"ctrl-z": "\x1a", # Suspend
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# =============================================================================
|
|
56
|
+
# Terminal Control
|
|
57
|
+
# =============================================================================
|
|
58
|
+
|
|
59
|
+
async def send_text(session: "ItermSession", text: str) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Send raw text to an iTerm2 session.
|
|
62
|
+
|
|
63
|
+
Note: This sends characters as-is. Use send_key() for special keys.
|
|
64
|
+
"""
|
|
65
|
+
await session.async_send_text(text)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def send_key(session: "ItermSession", key: str) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Send a special key to an iTerm2 session.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
session: iTerm2 session object
|
|
74
|
+
key: Key name (enter, escape, tab, backspace, up, down, left, right,
|
|
75
|
+
ctrl-c, ctrl-u, ctrl-d, etc.)
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If key name is not recognized
|
|
79
|
+
"""
|
|
80
|
+
key_code = KEYS.get(key.lower())
|
|
81
|
+
if key_code is None:
|
|
82
|
+
raise ValueError(f"Unknown key: {key}. Available: {list(KEYS.keys())}")
|
|
83
|
+
await session.async_send_text(key_code)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def send_prompt(session: "ItermSession", text: str, submit: bool = True) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Send a prompt to an iTerm2 session, optionally submitting it.
|
|
89
|
+
|
|
90
|
+
IMPORTANT: Uses \\x0d (Ctrl+M) for Enter, not \\n.
|
|
91
|
+
iTerm2 interprets \\x0d as the actual Enter keypress.
|
|
92
|
+
|
|
93
|
+
For multi-line text, iTerm2 uses bracketed paste mode which wraps the
|
|
94
|
+
content in escape sequences. A delay is needed after pasting multi-line
|
|
95
|
+
content before sending Enter to ensure the paste operation completes.
|
|
96
|
+
The delay scales with text length since longer pastes take more time
|
|
97
|
+
for the terminal to process.
|
|
98
|
+
|
|
99
|
+
Note: This function is primarily for Claude Code. For Codex, use
|
|
100
|
+
send_prompt_for_agent() which provides the longer pre-Enter delay
|
|
101
|
+
that Codex requires.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session: iTerm2 session object
|
|
105
|
+
text: The text to send
|
|
106
|
+
submit: If True, press Enter after sending text
|
|
107
|
+
"""
|
|
108
|
+
import asyncio
|
|
109
|
+
|
|
110
|
+
await session.async_send_text(text)
|
|
111
|
+
if submit:
|
|
112
|
+
# Calculate delay based on text characteristics. Longer text and more
|
|
113
|
+
# lines require more time for iTerm2's bracketed paste mode to process.
|
|
114
|
+
# Without adequate delay, the Enter key arrives before paste completes,
|
|
115
|
+
# resulting in the prompt not being submitted.
|
|
116
|
+
line_count = text.count("\n")
|
|
117
|
+
char_count = len(text)
|
|
118
|
+
|
|
119
|
+
if line_count > 0:
|
|
120
|
+
# Multi-line text: base delay + scaling factors for lines and chars.
|
|
121
|
+
# - Base: 0.1s minimum for bracketed paste mode overhead
|
|
122
|
+
# - Per line: 0.01s to account for line processing
|
|
123
|
+
# - Per 1000 chars: 0.05s for large text buffers
|
|
124
|
+
# Capped at 2.0s to avoid excessive waits on huge pastes.
|
|
125
|
+
delay = min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
|
|
126
|
+
else:
|
|
127
|
+
# Single-line text: minimal delay, just enough for event loop sync
|
|
128
|
+
delay = 0.05
|
|
129
|
+
|
|
130
|
+
await asyncio.sleep(delay)
|
|
131
|
+
await session.async_send_text(KEYS["enter"])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Pre-Enter delay for Codex (in seconds).
|
|
135
|
+
# Codex uses crossterm in raw mode - batch text sending works fine,
|
|
136
|
+
# but it needs a longer delay before Enter than Claude does.
|
|
137
|
+
# Testing showed 200ms is reliable; 50ms (Claude's default) is too short.
|
|
138
|
+
CODEX_PRE_ENTER_DELAY = 0.5 # 500ms minimum for Codex input processing
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def send_prompt_for_agent(
|
|
142
|
+
session: "ItermSession",
|
|
143
|
+
text: str,
|
|
144
|
+
agent_type: str = "claude",
|
|
145
|
+
submit: bool = True,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Send a prompt to an iTerm2 session, with agent-specific input handling.
|
|
149
|
+
|
|
150
|
+
Both Claude and Codex handle batch/burst text input correctly. The key
|
|
151
|
+
difference is the delay needed before pressing Enter:
|
|
152
|
+
|
|
153
|
+
- **Claude Code**: 50ms delay before Enter is sufficient.
|
|
154
|
+
- **Codex**: Needs ~250ms delay before Enter for reliable input processing.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
session: iTerm2 session object
|
|
158
|
+
text: The text to send
|
|
159
|
+
agent_type: The agent type identifier ("claude" or "codex")
|
|
160
|
+
submit: If True, press Enter after sending text
|
|
161
|
+
"""
|
|
162
|
+
import asyncio
|
|
163
|
+
|
|
164
|
+
if agent_type == "codex":
|
|
165
|
+
# Codex: batch send text, but use longer pre-Enter delay.
|
|
166
|
+
# For long/multi-line prompts, iTerm2's bracketed paste mode needs
|
|
167
|
+
# time to complete before Enter is sent. Use the same delay
|
|
168
|
+
# calculation as send_prompt() but ensure at least CODEX_PRE_ENTER_DELAY.
|
|
169
|
+
await session.async_send_text(text)
|
|
170
|
+
if submit:
|
|
171
|
+
# Calculate delay based on text length (same as send_prompt)
|
|
172
|
+
line_count = text.count("\n")
|
|
173
|
+
char_count = len(text)
|
|
174
|
+
if line_count > 0:
|
|
175
|
+
paste_delay = min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
|
|
176
|
+
else:
|
|
177
|
+
paste_delay = 0.05
|
|
178
|
+
# Use whichever is larger: paste delay or Codex minimum
|
|
179
|
+
delay = max(CODEX_PRE_ENTER_DELAY, paste_delay)
|
|
180
|
+
logger.debug(
|
|
181
|
+
"send_prompt_for_agent: codex chars=%d lines=%d delay=%.3fs",
|
|
182
|
+
char_count, line_count, delay
|
|
183
|
+
)
|
|
184
|
+
await asyncio.sleep(delay)
|
|
185
|
+
await session.async_send_text(KEYS["enter"])
|
|
186
|
+
else:
|
|
187
|
+
# Claude Code and other agents: use standard send_prompt
|
|
188
|
+
await send_prompt(session, text, submit=submit)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def read_screen(session: "ItermSession") -> list[str]:
|
|
192
|
+
"""
|
|
193
|
+
Read all lines from an iTerm2 session's screen.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
session: iTerm2 session object
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of strings, one per line
|
|
200
|
+
"""
|
|
201
|
+
screen = await session.async_get_screen_contents()
|
|
202
|
+
return [screen.line(i).string for i in range(screen.number_of_lines)]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def read_screen_text(session: "ItermSession") -> str:
|
|
206
|
+
"""
|
|
207
|
+
Read screen content as a single string.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
session: iTerm2 session object
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Screen content as newline-separated string
|
|
214
|
+
"""
|
|
215
|
+
lines = await read_screen(session)
|
|
216
|
+
return "\n".join(lines)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# =============================================================================
|
|
220
|
+
# Window Management
|
|
221
|
+
# =============================================================================
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _calculate_screen_frame() -> tuple[float, float, float, float]:
|
|
225
|
+
"""
|
|
226
|
+
Calculate a screen-filling window frame that avoids macOS fullscreen.
|
|
227
|
+
|
|
228
|
+
Returns dimensions slightly smaller than full screen to ensure the window
|
|
229
|
+
stays in the current Space rather than entering macOS fullscreen mode.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (x, y, width, height) in points for the window frame.
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
# Use cached system_profiler to avoid repeated slow calls
|
|
236
|
+
stdout = cached_system_profiler("SPDisplaysDataType")
|
|
237
|
+
if stdout is None:
|
|
238
|
+
logger.warning("system_profiler failed, using default frame")
|
|
239
|
+
return (0.0, 25.0, 1400.0, 900.0)
|
|
240
|
+
|
|
241
|
+
# Parse resolution from output like "Resolution: 3840 x 2160"
|
|
242
|
+
match = re.search(r"Resolution: (\d+) x (\d+)", stdout)
|
|
243
|
+
if not match:
|
|
244
|
+
logger.warning("Could not parse screen resolution, using defaults")
|
|
245
|
+
return (0.0, 25.0, 1400.0, 900.0)
|
|
246
|
+
|
|
247
|
+
screen_w, screen_h = int(match.group(1)), int(match.group(2))
|
|
248
|
+
|
|
249
|
+
# Detect Retina display (2x scale factor)
|
|
250
|
+
scale = 2 if "Retina" in stdout else 1
|
|
251
|
+
logical_w = screen_w // scale
|
|
252
|
+
logical_h = screen_h // scale
|
|
253
|
+
|
|
254
|
+
# Leave space for menu bar (25px) and dock (~70px), plus small margins
|
|
255
|
+
# to ensure we don't trigger fullscreen mode
|
|
256
|
+
x = 0.0
|
|
257
|
+
y = 25.0 # Below menu bar
|
|
258
|
+
width = float(logical_w) - 10 # Small margin on right
|
|
259
|
+
height = float(logical_h) - 100 # Space for menu bar and dock
|
|
260
|
+
|
|
261
|
+
logger.debug(
|
|
262
|
+
f"Screen {screen_w}x{screen_h} (scale {scale}) -> "
|
|
263
|
+
f"window frame ({x}, {y}, {width}, {height})"
|
|
264
|
+
)
|
|
265
|
+
return (x, y, width, height)
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.warning(f"Failed to calculate screen frame: {e}")
|
|
269
|
+
return (0.0, 25.0, 1400.0, 900.0)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def create_window(
|
|
273
|
+
connection: "ItermConnection",
|
|
274
|
+
profile: Optional[str] = None,
|
|
275
|
+
profile_customizations: Optional["ItermLocalWriteOnlyProfile"] = None,
|
|
276
|
+
) -> "ItermWindow":
|
|
277
|
+
"""
|
|
278
|
+
Create a new iTerm2 window with screen-filling dimensions.
|
|
279
|
+
|
|
280
|
+
Creates the window, exits fullscreen if needed, and sets its frame to
|
|
281
|
+
fill the screen without entering macOS fullscreen mode (staying in the
|
|
282
|
+
current Space).
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
connection: iTerm2 connection object
|
|
286
|
+
profile: Optional profile name to use for the window's initial session
|
|
287
|
+
profile_customizations: Optional LocalWriteOnlyProfile with per-session
|
|
288
|
+
customizations (tab color, badge, etc.) to apply to the initial session
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
New window object
|
|
292
|
+
"""
|
|
293
|
+
from iterm2.util import Frame, Point, Size
|
|
294
|
+
from iterm2.window import Window
|
|
295
|
+
|
|
296
|
+
# Create the window
|
|
297
|
+
# Build kwargs conditionally - only include profile if explicitly set,
|
|
298
|
+
# otherwise let iTerm2 use its default. Empty string causes INVALID_PROFILE_NAME.
|
|
299
|
+
kwargs: dict = {}
|
|
300
|
+
if profile is not None:
|
|
301
|
+
kwargs["profile"] = profile
|
|
302
|
+
if profile_customizations is not None:
|
|
303
|
+
kwargs["profile_customizations"] = profile_customizations
|
|
304
|
+
|
|
305
|
+
window = await Window.async_create(connection, **kwargs)
|
|
306
|
+
|
|
307
|
+
if window is None:
|
|
308
|
+
raise RuntimeError("Failed to create iTerm2 window")
|
|
309
|
+
|
|
310
|
+
# Exit fullscreen mode if the window opened in fullscreen
|
|
311
|
+
# (can happen if user's default profile or iTerm2 settings use fullscreen)
|
|
312
|
+
is_fullscreen = await window.async_get_fullscreen()
|
|
313
|
+
if is_fullscreen:
|
|
314
|
+
logger.info("Window opened in fullscreen, exiting fullscreen mode")
|
|
315
|
+
await window.async_set_fullscreen(False)
|
|
316
|
+
# Give macOS time to animate out of fullscreen (animation is ~0.2s)
|
|
317
|
+
import asyncio
|
|
318
|
+
await asyncio.sleep(0.2)
|
|
319
|
+
|
|
320
|
+
# Set window frame to fill screen without triggering fullscreen mode
|
|
321
|
+
x, y, width, height = _calculate_screen_frame()
|
|
322
|
+
frame = Frame(
|
|
323
|
+
origin=Point(int(x), int(y)),
|
|
324
|
+
size=Size(int(width), int(height)),
|
|
325
|
+
)
|
|
326
|
+
await window.async_set_frame(frame)
|
|
327
|
+
|
|
328
|
+
# Bring window to focus
|
|
329
|
+
await window.async_activate()
|
|
330
|
+
|
|
331
|
+
return window
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def split_pane(
|
|
335
|
+
session: "ItermSession",
|
|
336
|
+
vertical: bool = True,
|
|
337
|
+
before: bool = False,
|
|
338
|
+
profile: Optional[str] = None,
|
|
339
|
+
profile_customizations: Optional["ItermLocalWriteOnlyProfile"] = None,
|
|
340
|
+
) -> "ItermSession":
|
|
341
|
+
"""
|
|
342
|
+
Split an iTerm2 session into two panes.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
session: The session to split
|
|
346
|
+
vertical: If True, split vertically (side by side). If False, horizontal (stacked).
|
|
347
|
+
before: If True, new pane appears before/above. If False, after/below.
|
|
348
|
+
profile: Optional profile name to use for the new pane
|
|
349
|
+
profile_customizations: Optional LocalWriteOnlyProfile with per-session
|
|
350
|
+
customizations (tab color, badge, etc.) to apply to the new pane
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
The new session created in the split pane.
|
|
354
|
+
"""
|
|
355
|
+
# Build kwargs conditionally - only include profile if explicitly set
|
|
356
|
+
kwargs: dict = {"vertical": vertical, "before": before}
|
|
357
|
+
if profile is not None:
|
|
358
|
+
kwargs["profile"] = profile
|
|
359
|
+
if profile_customizations is not None:
|
|
360
|
+
kwargs["profile_customizations"] = profile_customizations
|
|
361
|
+
|
|
362
|
+
return await session.async_split_pane(**kwargs)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def close_pane(session: "ItermSession", force: bool = False) -> bool:
|
|
366
|
+
"""
|
|
367
|
+
Close an iTerm2 session/pane.
|
|
368
|
+
|
|
369
|
+
Uses the iTerm2 async_close() API to terminate the pane. If the pane is the
|
|
370
|
+
last one in a tab/window, the tab/window will also close.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
session: The iTerm2 session to close
|
|
374
|
+
force: If True, forcefully close even if processes are running
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
True if the pane was closed successfully
|
|
378
|
+
"""
|
|
379
|
+
await session.async_close(force=force)
|
|
380
|
+
return True
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# =============================================================================
|
|
384
|
+
# Shell Readiness Detection
|
|
385
|
+
# =============================================================================
|
|
386
|
+
|
|
387
|
+
# Marker used to detect shell readiness - must be unique enough not to appear randomly
|
|
388
|
+
SHELL_READY_MARKER = "CLAUDE_TEAM_READY_7f3a9c"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def wait_for_shell_ready(
|
|
392
|
+
session: "ItermSession",
|
|
393
|
+
timeout_seconds: float = 10.0,
|
|
394
|
+
poll_interval: float = 0.1,
|
|
395
|
+
) -> bool:
|
|
396
|
+
"""
|
|
397
|
+
Wait for the shell to be ready to accept input.
|
|
398
|
+
|
|
399
|
+
Sends an echo command with a unique marker and waits for it to appear
|
|
400
|
+
in the terminal output. This proves the shell is accepting and executing
|
|
401
|
+
commands, regardless of prompt style.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
session: iTerm2 session to monitor
|
|
405
|
+
timeout_seconds: Maximum time to wait for shell readiness
|
|
406
|
+
poll_interval: Time between screen content checks
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
True if shell became ready, False if timeout was reached
|
|
410
|
+
"""
|
|
411
|
+
import asyncio
|
|
412
|
+
import time
|
|
413
|
+
|
|
414
|
+
# Send the marker command
|
|
415
|
+
await send_prompt(session, f'echo "{SHELL_READY_MARKER}"')
|
|
416
|
+
|
|
417
|
+
# Wait for marker to appear in output (not in the command itself)
|
|
418
|
+
# We look for the marker at the start of a line, which indicates the echo
|
|
419
|
+
# actually executed and produced output, not just that the command was displayed
|
|
420
|
+
start_time = time.monotonic()
|
|
421
|
+
while (time.monotonic() - start_time) < timeout_seconds:
|
|
422
|
+
try:
|
|
423
|
+
content = await read_screen_text(session)
|
|
424
|
+
# Check each line - the output will be the marker on its own line
|
|
425
|
+
# (not preceded by 'echo "' which would be the command)
|
|
426
|
+
for line in content.split('\n'):
|
|
427
|
+
stripped = line.strip()
|
|
428
|
+
if stripped == SHELL_READY_MARKER:
|
|
429
|
+
return True
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
await asyncio.sleep(poll_interval)
|
|
433
|
+
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# =============================================================================
|
|
438
|
+
# Claude Readiness Detection
|
|
439
|
+
# =============================================================================
|
|
440
|
+
|
|
441
|
+
# Patterns that indicate Claude Code has started and is ready for input.
|
|
442
|
+
# These appear in Claude's startup banner (the ASCII robot art).
|
|
443
|
+
CLAUDE_READY_PATTERNS = [
|
|
444
|
+
"Claude Code v", # Version line in banner
|
|
445
|
+
"▐▛███▜▌", # Top of robot head
|
|
446
|
+
"▝▜█████▛▘", # Middle of robot
|
|
447
|
+
]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def wait_for_claude_ready(
|
|
451
|
+
session: "ItermSession",
|
|
452
|
+
timeout_seconds: float = 15.0,
|
|
453
|
+
poll_interval: float = 0.2,
|
|
454
|
+
stable_count: int = 2,
|
|
455
|
+
) -> bool:
|
|
456
|
+
"""
|
|
457
|
+
Wait for Claude Code's TUI to be ready to accept input.
|
|
458
|
+
|
|
459
|
+
Polls the screen content and waits for Claude's prompt to appear.
|
|
460
|
+
Claude is considered ready when the screen shows either:
|
|
461
|
+
- A line starting with '>' (Claude's input prompt)
|
|
462
|
+
- A status line containing 'tokens' (bottom status bar)
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
session: iTerm2 session to monitor
|
|
466
|
+
timeout_seconds: Maximum time to wait for Claude readiness
|
|
467
|
+
poll_interval: Time between screen content checks
|
|
468
|
+
stable_count: Number of consecutive stable reads before considering ready
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
True if Claude became ready, False if timeout was reached
|
|
472
|
+
"""
|
|
473
|
+
import asyncio
|
|
474
|
+
import time
|
|
475
|
+
|
|
476
|
+
start_time = time.monotonic()
|
|
477
|
+
last_content = None
|
|
478
|
+
stable_reads = 0
|
|
479
|
+
|
|
480
|
+
while (time.monotonic() - start_time) < timeout_seconds:
|
|
481
|
+
try:
|
|
482
|
+
content = await read_screen_text(session)
|
|
483
|
+
lines = content.split('\n')
|
|
484
|
+
|
|
485
|
+
# Check if content is stable (same as last read)
|
|
486
|
+
if content == last_content:
|
|
487
|
+
stable_reads += 1
|
|
488
|
+
else:
|
|
489
|
+
stable_reads = 0
|
|
490
|
+
last_content = content
|
|
491
|
+
|
|
492
|
+
# Only check for Claude readiness after content has stabilized
|
|
493
|
+
if stable_reads >= stable_count:
|
|
494
|
+
for line in lines:
|
|
495
|
+
stripped = line.strip()
|
|
496
|
+
# Check for Claude's input prompt (starts with >)
|
|
497
|
+
if stripped.startswith('>'):
|
|
498
|
+
logger.debug("Claude ready: found '>' prompt")
|
|
499
|
+
return True
|
|
500
|
+
# Check for status bar (contains 'tokens')
|
|
501
|
+
if 'tokens' in stripped:
|
|
502
|
+
logger.debug("Claude ready: found status bar with 'tokens'")
|
|
503
|
+
return True
|
|
504
|
+
|
|
505
|
+
except Exception as e:
|
|
506
|
+
# Screen read failed, retry
|
|
507
|
+
logger.debug(f"Screen read failed during Claude ready check: {e}")
|
|
508
|
+
|
|
509
|
+
await asyncio.sleep(poll_interval)
|
|
510
|
+
|
|
511
|
+
logger.warning(f"Timeout waiting for Claude TUI readiness ({timeout_seconds}s)")
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
async def wait_for_agent_ready(
|
|
516
|
+
session: "ItermSession",
|
|
517
|
+
cli: "AgentCLI",
|
|
518
|
+
timeout_seconds: float = 15.0,
|
|
519
|
+
poll_interval: float = 0.2,
|
|
520
|
+
stable_count: int = 2,
|
|
521
|
+
) -> bool:
|
|
522
|
+
"""
|
|
523
|
+
Wait for an agent CLI to be ready to accept input.
|
|
524
|
+
|
|
525
|
+
Generic version of wait_for_claude_ready() that uses the CLI's ready_patterns.
|
|
526
|
+
Polls the screen content and waits for any of the CLI's ready patterns to appear.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
session: iTerm2 session to monitor
|
|
530
|
+
cli: The AgentCLI instance providing ready_patterns
|
|
531
|
+
timeout_seconds: Maximum time to wait for readiness
|
|
532
|
+
poll_interval: Time between screen content checks
|
|
533
|
+
stable_count: Number of consecutive stable reads before considering ready
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
True if agent became ready, False if timeout was reached
|
|
537
|
+
"""
|
|
538
|
+
import asyncio
|
|
539
|
+
import time
|
|
540
|
+
|
|
541
|
+
patterns = cli.ready_patterns()
|
|
542
|
+
start_time = time.monotonic()
|
|
543
|
+
last_content = None
|
|
544
|
+
stable_reads = 0
|
|
545
|
+
|
|
546
|
+
while (time.monotonic() - start_time) < timeout_seconds:
|
|
547
|
+
try:
|
|
548
|
+
content = await read_screen_text(session)
|
|
549
|
+
lines = content.split('\n')
|
|
550
|
+
|
|
551
|
+
# Check if content is stable (same as last read)
|
|
552
|
+
if content == last_content:
|
|
553
|
+
stable_reads += 1
|
|
554
|
+
else:
|
|
555
|
+
stable_reads = 0
|
|
556
|
+
last_content = content
|
|
557
|
+
|
|
558
|
+
# Only check for readiness after content has stabilized
|
|
559
|
+
if stable_reads >= stable_count:
|
|
560
|
+
for line in lines:
|
|
561
|
+
stripped = line.strip()
|
|
562
|
+
for pattern in patterns:
|
|
563
|
+
if pattern in stripped:
|
|
564
|
+
logger.debug(
|
|
565
|
+
f"Agent ready: found pattern '{pattern}' in line"
|
|
566
|
+
)
|
|
567
|
+
return True
|
|
568
|
+
|
|
569
|
+
except Exception as e:
|
|
570
|
+
# Screen read failed, retry
|
|
571
|
+
logger.debug(f"Screen read failed during agent ready check: {e}")
|
|
572
|
+
|
|
573
|
+
await asyncio.sleep(poll_interval)
|
|
574
|
+
|
|
575
|
+
logger.warning(
|
|
576
|
+
f"Timeout waiting for {cli.engine_id} readiness ({timeout_seconds}s)"
|
|
577
|
+
)
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# =============================================================================
|
|
582
|
+
# Agent Session Control
|
|
583
|
+
# =============================================================================
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def build_stop_hook_settings_file(marker_id: str) -> str:
|
|
587
|
+
"""
|
|
588
|
+
Build a settings file for Stop hook injection.
|
|
589
|
+
|
|
590
|
+
The hook embeds a marker in the command text itself, which gets logged
|
|
591
|
+
to the JSONL in the stop_hook_summary's hookInfos array. This provides
|
|
592
|
+
reliable completion detection without needing stderr or exit code hacks.
|
|
593
|
+
|
|
594
|
+
We write to a file instead of passing JSON inline due to a bug in Claude Code
|
|
595
|
+
v2.0.72+ where inline JSON causes the file watcher to incorrectly watch the
|
|
596
|
+
temp directory, crashing on Unix sockets. See:
|
|
597
|
+
https://github.com/anthropics/claude-code/issues/14438
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
marker_id: Unique ID to embed in the marker (typically session_id)
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Path to the settings file (suitable for --settings flag)
|
|
604
|
+
"""
|
|
605
|
+
import json
|
|
606
|
+
from pathlib import Path
|
|
607
|
+
|
|
608
|
+
# Use a stable directory that won't have Unix sockets
|
|
609
|
+
settings_dir = Path.home() / ".claude" / "claude-team-settings"
|
|
610
|
+
settings_dir.mkdir(parents=True, exist_ok=True)
|
|
611
|
+
|
|
612
|
+
settings = {
|
|
613
|
+
"hooks": {
|
|
614
|
+
"Stop": [{
|
|
615
|
+
"hooks": [{
|
|
616
|
+
"type": "command",
|
|
617
|
+
"command": f"echo [worker-done:{marker_id}]"
|
|
618
|
+
}]
|
|
619
|
+
}]
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
# Use marker_id as filename for deterministic, reusable files
|
|
624
|
+
settings_file = settings_dir / f"worker-{marker_id}.json"
|
|
625
|
+
settings_file.write_text(json.dumps(settings, indent=2))
|
|
626
|
+
|
|
627
|
+
return str(settings_file)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
async def start_agent_in_session(
|
|
631
|
+
session: "ItermSession",
|
|
632
|
+
cli: "AgentCLI",
|
|
633
|
+
project_path: str,
|
|
634
|
+
dangerously_skip_permissions: bool = False,
|
|
635
|
+
env: Optional[dict[str, str]] = None,
|
|
636
|
+
shell_ready_timeout: float = 10.0,
|
|
637
|
+
agent_ready_timeout: float = 30.0,
|
|
638
|
+
stop_hook_marker_id: Optional[str] = None,
|
|
639
|
+
output_capture_path: Optional[str] = None,
|
|
640
|
+
) -> None:
|
|
641
|
+
"""
|
|
642
|
+
Start an agent CLI in an existing iTerm2 session.
|
|
643
|
+
|
|
644
|
+
Changes to the project directory and launches the agent in a single
|
|
645
|
+
atomic command (cd && <agent>). Waits for shell readiness before sending
|
|
646
|
+
the command, then waits for the agent's ready patterns to appear.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
session: iTerm2 session to use
|
|
650
|
+
cli: AgentCLI instance defining command and arguments
|
|
651
|
+
project_path: Directory to run the agent in
|
|
652
|
+
dangerously_skip_permissions: If True, add skip-permissions flag
|
|
653
|
+
env: Optional dict of environment variables to set before running agent
|
|
654
|
+
shell_ready_timeout: Max seconds to wait for shell prompt
|
|
655
|
+
agent_ready_timeout: Max seconds to wait for agent to start
|
|
656
|
+
stop_hook_marker_id: If provided, inject a Stop hook for completion detection
|
|
657
|
+
(only used if cli.supports_settings_file() returns True)
|
|
658
|
+
output_capture_path: If provided, capture agent's stdout/stderr to this file
|
|
659
|
+
using tee. Useful for agents that output JSONL for idle detection.
|
|
660
|
+
|
|
661
|
+
Raises:
|
|
662
|
+
RuntimeError: If shell not ready or agent fails to start within timeout
|
|
663
|
+
"""
|
|
664
|
+
# Wait for shell to be ready
|
|
665
|
+
shell_ready = await wait_for_shell_ready(session, timeout_seconds=shell_ready_timeout)
|
|
666
|
+
if not shell_ready:
|
|
667
|
+
raise RuntimeError(
|
|
668
|
+
f"Shell not ready after {shell_ready_timeout}s in {project_path}. "
|
|
669
|
+
"Terminal may still be initializing."
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Build settings file for Stop hook injection if supported
|
|
673
|
+
settings_file = None
|
|
674
|
+
if stop_hook_marker_id and cli.supports_settings_file():
|
|
675
|
+
settings_file = build_stop_hook_settings_file(stop_hook_marker_id)
|
|
676
|
+
|
|
677
|
+
# Build the full command using the AgentCLI abstraction
|
|
678
|
+
agent_cmd = cli.build_full_command(
|
|
679
|
+
dangerously_skip_permissions=dangerously_skip_permissions,
|
|
680
|
+
settings_file=settings_file,
|
|
681
|
+
env_vars=env,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
# Add output capture via tee if requested
|
|
685
|
+
# This pipes stdout/stderr to both the terminal and a file (for JSONL parsing)
|
|
686
|
+
if output_capture_path:
|
|
687
|
+
agent_cmd = f"{agent_cmd} 2>&1 | tee {output_capture_path}"
|
|
688
|
+
|
|
689
|
+
# Combine cd and agent into atomic command to avoid race condition.
|
|
690
|
+
# Shell executes "cd /path && agent" as a unit - if cd fails, agent won't run.
|
|
691
|
+
cmd = f"cd {project_path} && {agent_cmd}"
|
|
692
|
+
|
|
693
|
+
logger.info(f"start_agent_in_session: Running command: {cmd[:200]}...")
|
|
694
|
+
|
|
695
|
+
await send_prompt(session, cmd)
|
|
696
|
+
|
|
697
|
+
# Wait for agent to actually start (detect ready patterns, not blind sleep)
|
|
698
|
+
if not await wait_for_agent_ready(
|
|
699
|
+
session, cli, timeout_seconds=agent_ready_timeout
|
|
700
|
+
):
|
|
701
|
+
raise RuntimeError(
|
|
702
|
+
f"{cli.engine_id} failed to start in {project_path} within "
|
|
703
|
+
f"{agent_ready_timeout}s. Check that '{cli.command()}' command is "
|
|
704
|
+
"available and authentication is configured."
|
|
705
|
+
)
|
|
706
|
+
|
|
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
|
+
# =============================================================================
|
|
778
|
+
# Multi-Pane Layouts
|
|
779
|
+
# =============================================================================
|
|
780
|
+
|
|
781
|
+
# Valid pane names for each layout type
|
|
782
|
+
LAYOUT_PANE_NAMES = {
|
|
783
|
+
"single": ["main"],
|
|
784
|
+
"vertical": ["left", "right"],
|
|
785
|
+
"horizontal": ["top", "bottom"],
|
|
786
|
+
"quad": ["top_left", "top_right", "bottom_left", "bottom_right"],
|
|
787
|
+
"triple_vertical": ["left", "middle", "right"],
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
async def create_multi_pane_layout(
|
|
792
|
+
connection: "ItermConnection",
|
|
793
|
+
layout: str,
|
|
794
|
+
profile: Optional[str] = None,
|
|
795
|
+
profile_customizations: Optional[dict[str, "ItermLocalWriteOnlyProfile"]] = None,
|
|
796
|
+
) -> dict[str, "ItermSession"]:
|
|
797
|
+
"""
|
|
798
|
+
Create a new iTerm2 window with a multi-pane layout.
|
|
799
|
+
|
|
800
|
+
Creates a window and splits it into panes according to the specified layout.
|
|
801
|
+
Returns a mapping of pane names to iTerm2 sessions.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
connection: iTerm2 connection object
|
|
805
|
+
layout: Layout type - one of:
|
|
806
|
+
- "single": 1 pane, full window (main)
|
|
807
|
+
- "vertical": 2 panes side by side (left, right)
|
|
808
|
+
- "horizontal": 2 panes stacked (top, bottom)
|
|
809
|
+
- "quad": 4 panes in 2x2 grid (top_left, top_right, bottom_left, bottom_right)
|
|
810
|
+
- "triple_vertical": 3 panes side by side (left, middle, right)
|
|
811
|
+
profile: Optional profile name to use for all panes
|
|
812
|
+
profile_customizations: Optional dict mapping pane names to LocalWriteOnlyProfile
|
|
813
|
+
objects with per-pane customizations (tab color, badge, etc.)
|
|
814
|
+
|
|
815
|
+
Returns:
|
|
816
|
+
Dict mapping pane names to iTerm2 sessions
|
|
817
|
+
|
|
818
|
+
Raises:
|
|
819
|
+
ValueError: If layout is not recognized
|
|
820
|
+
"""
|
|
821
|
+
if layout not in LAYOUT_PANE_NAMES:
|
|
822
|
+
raise ValueError(
|
|
823
|
+
f"Unknown layout: {layout}. Valid: {list(LAYOUT_PANE_NAMES.keys())}"
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
# Helper to get customizations for a specific pane
|
|
827
|
+
def get_customization(pane_name: str):
|
|
828
|
+
if profile_customizations:
|
|
829
|
+
return profile_customizations.get(pane_name)
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
# Get the first pane name for the initial window
|
|
833
|
+
first_pane = LAYOUT_PANE_NAMES[layout][0]
|
|
834
|
+
|
|
835
|
+
# Create window with initial session (with customizations if provided)
|
|
836
|
+
window = await create_window(
|
|
837
|
+
connection,
|
|
838
|
+
profile=profile,
|
|
839
|
+
profile_customizations=get_customization(first_pane),
|
|
840
|
+
)
|
|
841
|
+
current_tab = window.current_tab
|
|
842
|
+
if current_tab is None:
|
|
843
|
+
raise RuntimeError("Failed to get current tab from new window")
|
|
844
|
+
initial_session = current_tab.current_session
|
|
845
|
+
if initial_session is None:
|
|
846
|
+
raise RuntimeError("Failed to get initial session from new window")
|
|
847
|
+
|
|
848
|
+
panes: dict[str, "ItermSession"] = {}
|
|
849
|
+
|
|
850
|
+
if layout == "single":
|
|
851
|
+
# Single pane - no splitting needed, just use initial session
|
|
852
|
+
panes["main"] = initial_session
|
|
853
|
+
|
|
854
|
+
elif layout == "vertical":
|
|
855
|
+
# Split into left and right
|
|
856
|
+
panes["left"] = initial_session
|
|
857
|
+
panes["right"] = await split_pane(
|
|
858
|
+
initial_session,
|
|
859
|
+
vertical=True,
|
|
860
|
+
profile=profile,
|
|
861
|
+
profile_customizations=get_customization("right"),
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
elif layout == "horizontal":
|
|
865
|
+
# Split into top and bottom
|
|
866
|
+
panes["top"] = initial_session
|
|
867
|
+
panes["bottom"] = await split_pane(
|
|
868
|
+
initial_session,
|
|
869
|
+
vertical=False,
|
|
870
|
+
profile=profile,
|
|
871
|
+
profile_customizations=get_customization("bottom"),
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
elif layout == "quad":
|
|
875
|
+
# Create 2x2 grid:
|
|
876
|
+
# 1. Split vertically: left | right
|
|
877
|
+
# 2. Split left horizontally: top_left / bottom_left
|
|
878
|
+
# 3. Split right horizontally: top_right / bottom_right
|
|
879
|
+
left = initial_session
|
|
880
|
+
right = await split_pane(
|
|
881
|
+
left,
|
|
882
|
+
vertical=True,
|
|
883
|
+
profile=profile,
|
|
884
|
+
profile_customizations=get_customization("top_right"),
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
# Split the left column
|
|
888
|
+
panes["top_left"] = left
|
|
889
|
+
panes["bottom_left"] = await split_pane(
|
|
890
|
+
left,
|
|
891
|
+
vertical=False,
|
|
892
|
+
profile=profile,
|
|
893
|
+
profile_customizations=get_customization("bottom_left"),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
# Split the right column
|
|
897
|
+
panes["top_right"] = right
|
|
898
|
+
panes["bottom_right"] = await split_pane(
|
|
899
|
+
right,
|
|
900
|
+
vertical=False,
|
|
901
|
+
profile=profile,
|
|
902
|
+
profile_customizations=get_customization("bottom_right"),
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
elif layout == "triple_vertical":
|
|
906
|
+
# Create 3 vertical panes: left | middle | right
|
|
907
|
+
# 1. Split initial into 2
|
|
908
|
+
# 2. Split right pane into 2 more
|
|
909
|
+
panes["left"] = initial_session
|
|
910
|
+
right_section = await split_pane(
|
|
911
|
+
initial_session,
|
|
912
|
+
vertical=True,
|
|
913
|
+
profile=profile,
|
|
914
|
+
profile_customizations=get_customization("middle"),
|
|
915
|
+
)
|
|
916
|
+
panes["middle"] = right_section
|
|
917
|
+
panes["right"] = await split_pane(
|
|
918
|
+
right_section,
|
|
919
|
+
vertical=True,
|
|
920
|
+
profile=profile,
|
|
921
|
+
profile_customizations=get_customization("right"),
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
return panes
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
async def create_multi_claude_layout(
|
|
928
|
+
connection: "ItermConnection",
|
|
929
|
+
projects: dict[str, str],
|
|
930
|
+
layout: str,
|
|
931
|
+
skip_permissions: bool = False,
|
|
932
|
+
project_envs: Optional[dict[str, dict[str, str]]] = None,
|
|
933
|
+
profile: Optional[str] = None,
|
|
934
|
+
profile_customizations: Optional[dict[str, "ItermLocalWriteOnlyProfile"]] = None,
|
|
935
|
+
pane_marker_ids: Optional[dict[str, str]] = None,
|
|
936
|
+
) -> dict[str, "ItermSession"]:
|
|
937
|
+
"""
|
|
938
|
+
Create a multi-pane window and start Claude Code in each pane.
|
|
939
|
+
|
|
940
|
+
High-level primitive that combines create_multi_pane_layout with
|
|
941
|
+
starting Claude in each pane.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
connection: iTerm2 connection object
|
|
945
|
+
projects: Dict mapping pane names to project paths. Keys must match
|
|
946
|
+
the expected pane names for the layout (e.g., for 'quad':
|
|
947
|
+
'top_left', 'top_right', 'bottom_left', 'bottom_right')
|
|
948
|
+
layout: Layout type (single, vertical, horizontal, quad, triple_vertical)
|
|
949
|
+
skip_permissions: If True, start Claude with --dangerously-skip-permissions
|
|
950
|
+
project_envs: Optional dict mapping pane names to env var dicts. Each
|
|
951
|
+
pane can have its own environment variables set before starting Claude.
|
|
952
|
+
profile: Optional profile name to use for all panes
|
|
953
|
+
profile_customizations: Optional dict mapping pane names to LocalWriteOnlyProfile
|
|
954
|
+
objects with per-pane customizations (tab color, badge, etc.)
|
|
955
|
+
pane_marker_ids: Optional dict mapping pane names to marker IDs for Stop hook
|
|
956
|
+
injection. Each worker will have a Stop hook that logs its marker ID
|
|
957
|
+
to the JSONL for completion detection.
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
Dict mapping pane names to iTerm2 sessions (after Claude is started)
|
|
961
|
+
|
|
962
|
+
Raises:
|
|
963
|
+
ValueError: If layout is invalid or project keys don't match layout panes
|
|
964
|
+
"""
|
|
965
|
+
import asyncio
|
|
966
|
+
|
|
967
|
+
# Validate pane names match the layout
|
|
968
|
+
expected_panes = set(LAYOUT_PANE_NAMES.get(layout, []))
|
|
969
|
+
provided_panes = set(projects.keys())
|
|
970
|
+
|
|
971
|
+
if not provided_panes.issubset(expected_panes):
|
|
972
|
+
invalid = provided_panes - expected_panes
|
|
973
|
+
raise ValueError(
|
|
974
|
+
f"Invalid pane names for layout '{layout}': {invalid}. "
|
|
975
|
+
f"Valid names: {expected_panes}"
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
# Create the pane layout with profile customizations
|
|
979
|
+
panes = await create_multi_pane_layout(
|
|
980
|
+
connection,
|
|
981
|
+
layout,
|
|
982
|
+
profile=profile,
|
|
983
|
+
profile_customizations=profile_customizations,
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
# Start Claude in all panes in parallel.
|
|
987
|
+
# Each start_claude_in_session call uses wait_for_shell_ready() internally
|
|
988
|
+
# which provides proper readiness detection, so no sleeps between starts needed.
|
|
989
|
+
async def start_claude_for_pane(pane_name: str, project_path: str) -> None:
|
|
990
|
+
session = panes[pane_name]
|
|
991
|
+
pane_env = project_envs.get(pane_name) if project_envs else None
|
|
992
|
+
marker_id = pane_marker_ids.get(pane_name) if pane_marker_ids else None
|
|
993
|
+
await start_claude_in_session(
|
|
994
|
+
session=session,
|
|
995
|
+
project_path=project_path,
|
|
996
|
+
dangerously_skip_permissions=skip_permissions,
|
|
997
|
+
env=pane_env,
|
|
998
|
+
stop_hook_marker_id=marker_id,
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
await asyncio.gather(*[
|
|
1002
|
+
start_claude_for_pane(pane_name, project_path)
|
|
1003
|
+
for pane_name, project_path in projects.items()
|
|
1004
|
+
])
|
|
1005
|
+
|
|
1006
|
+
# Return only the panes that were used
|
|
1007
|
+
return {name: panes[name] for name in projects.keys()}
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
# =============================================================================
|
|
1011
|
+
# Window/Pane Introspection
|
|
1012
|
+
# =============================================================================
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
MAX_PANES_PER_TAB = 4 # Maximum panes before considering tab "full"
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def count_panes_in_tab(tab: "ItermTab") -> int:
|
|
1019
|
+
"""
|
|
1020
|
+
Count the number of panes (sessions) in a tab.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
tab: iTerm2 tab object
|
|
1024
|
+
|
|
1025
|
+
Returns:
|
|
1026
|
+
Number of sessions in the tab
|
|
1027
|
+
"""
|
|
1028
|
+
return len(tab.sessions)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def count_panes_in_window(window: "ItermWindow") -> int:
|
|
1032
|
+
"""
|
|
1033
|
+
Count total panes across all tabs in a window.
|
|
1034
|
+
|
|
1035
|
+
Note: For smart layout purposes, we typically care about individual tabs
|
|
1036
|
+
since panes are split within a tab. Use count_panes_in_tab() for that.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
window: iTerm2 window object
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
Total number of sessions across all tabs in the window
|
|
1043
|
+
"""
|
|
1044
|
+
total = 0
|
|
1045
|
+
for tab in window.tabs:
|
|
1046
|
+
total += len(tab.sessions)
|
|
1047
|
+
return total
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
async def find_available_window(
|
|
1051
|
+
app: "ItermApp",
|
|
1052
|
+
max_panes: int = MAX_PANES_PER_TAB,
|
|
1053
|
+
managed_session_ids: Optional[set[str]] = None,
|
|
1054
|
+
) -> Optional[tuple["ItermWindow", "ItermTab", "ItermSession"]]:
|
|
1055
|
+
"""
|
|
1056
|
+
Find a window with an available tab that has room for more panes.
|
|
1057
|
+
|
|
1058
|
+
Searches terminal windows for a tab with fewer than max_panes sessions.
|
|
1059
|
+
If managed_session_ids is provided, only considers tabs that contain
|
|
1060
|
+
at least one managed session (to avoid splitting into user's unrelated tabs).
|
|
1061
|
+
|
|
1062
|
+
Note: When managed_session_ids is an empty set, no tabs will match (correct
|
|
1063
|
+
behavior - an empty registry means we have no managed sessions to reuse,
|
|
1064
|
+
so a new window should be created).
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
app: iTerm2 app object
|
|
1068
|
+
max_panes: Maximum panes before considering a tab full (default 4)
|
|
1069
|
+
managed_session_ids: Optional set of iTerm2 session IDs that are managed
|
|
1070
|
+
by claude-team. If provided (including empty set), only tabs
|
|
1071
|
+
containing at least one of these sessions will be considered.
|
|
1072
|
+
Pass None to consider all tabs.
|
|
1073
|
+
|
|
1074
|
+
Returns:
|
|
1075
|
+
Tuple of (window, tab, session) if found, None if all tabs are full
|
|
1076
|
+
"""
|
|
1077
|
+
for window in app.terminal_windows:
|
|
1078
|
+
for tab in window.tabs:
|
|
1079
|
+
# If we have managed session IDs filter, check if this tab contains any
|
|
1080
|
+
# Note: empty set is valid (matches nothing) - use `is not None` check
|
|
1081
|
+
if managed_session_ids is not None:
|
|
1082
|
+
tab_has_managed = any(
|
|
1083
|
+
s.session_id in managed_session_ids for s in tab.sessions
|
|
1084
|
+
)
|
|
1085
|
+
if not tab_has_managed:
|
|
1086
|
+
# Skip this tab - it doesn't contain any managed sessions
|
|
1087
|
+
continue
|
|
1088
|
+
|
|
1089
|
+
# Check if this tab has room for more panes
|
|
1090
|
+
if count_panes_in_tab(tab) < max_panes:
|
|
1091
|
+
# Return the current session in this tab as the split target
|
|
1092
|
+
current_session = tab.current_session
|
|
1093
|
+
if current_session:
|
|
1094
|
+
return (window, tab, current_session)
|
|
1095
|
+
return None
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
async def get_window_for_session(
|
|
1099
|
+
app: "ItermApp",
|
|
1100
|
+
session: "ItermSession",
|
|
1101
|
+
) -> Optional["ItermWindow"]:
|
|
1102
|
+
"""
|
|
1103
|
+
Find the window containing a given session.
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
app: iTerm2 app object
|
|
1107
|
+
session: The session to find
|
|
1108
|
+
|
|
1109
|
+
Returns:
|
|
1110
|
+
The window containing the session, or None if not found
|
|
1111
|
+
"""
|
|
1112
|
+
for window in app.terminal_windows:
|
|
1113
|
+
for tab in window.tabs:
|
|
1114
|
+
for s in tab.sessions:
|
|
1115
|
+
if s.session_id == session.session_id:
|
|
1116
|
+
return window
|
|
1117
|
+
return None
|
|
1118
|
+
|
|
1119
|
+
|