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.
Files changed (42) hide show
  1. claude_team_mcp/__init__.py +24 -0
  2. claude_team_mcp/__main__.py +8 -0
  3. claude_team_mcp/cli_backends/__init__.py +44 -0
  4. claude_team_mcp/cli_backends/base.py +132 -0
  5. claude_team_mcp/cli_backends/claude.py +110 -0
  6. claude_team_mcp/cli_backends/codex.py +110 -0
  7. claude_team_mcp/colors.py +108 -0
  8. claude_team_mcp/formatting.py +120 -0
  9. claude_team_mcp/idle_detection.py +488 -0
  10. claude_team_mcp/iterm_utils.py +1119 -0
  11. claude_team_mcp/names.py +427 -0
  12. claude_team_mcp/profile.py +364 -0
  13. claude_team_mcp/registry.py +426 -0
  14. claude_team_mcp/schemas/__init__.py +5 -0
  15. claude_team_mcp/schemas/codex.py +267 -0
  16. claude_team_mcp/server.py +390 -0
  17. claude_team_mcp/session_state.py +1058 -0
  18. claude_team_mcp/subprocess_cache.py +119 -0
  19. claude_team_mcp/tools/__init__.py +52 -0
  20. claude_team_mcp/tools/adopt_worker.py +122 -0
  21. claude_team_mcp/tools/annotate_worker.py +57 -0
  22. claude_team_mcp/tools/bd_help.py +42 -0
  23. claude_team_mcp/tools/check_idle_workers.py +98 -0
  24. claude_team_mcp/tools/close_workers.py +194 -0
  25. claude_team_mcp/tools/discover_workers.py +129 -0
  26. claude_team_mcp/tools/examine_worker.py +56 -0
  27. claude_team_mcp/tools/list_workers.py +76 -0
  28. claude_team_mcp/tools/list_worktrees.py +106 -0
  29. claude_team_mcp/tools/message_workers.py +311 -0
  30. claude_team_mcp/tools/read_worker_logs.py +158 -0
  31. claude_team_mcp/tools/spawn_workers.py +634 -0
  32. claude_team_mcp/tools/wait_idle_workers.py +148 -0
  33. claude_team_mcp/utils/__init__.py +17 -0
  34. claude_team_mcp/utils/constants.py +87 -0
  35. claude_team_mcp/utils/errors.py +87 -0
  36. claude_team_mcp/utils/worktree_detection.py +79 -0
  37. claude_team_mcp/worker_prompt.py +350 -0
  38. claude_team_mcp/worktree.py +532 -0
  39. claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
  40. claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
  41. claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
  42. 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
+