claude-team-mcp 0.6.1__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. claude_team/__init__.py +11 -0
  2. claude_team/events.py +501 -0
  3. claude_team/idle_detection.py +173 -0
  4. claude_team/poller.py +245 -0
  5. claude_team_mcp/cli_backends/__init__.py +4 -2
  6. claude_team_mcp/cli_backends/claude.py +45 -5
  7. claude_team_mcp/cli_backends/codex.py +44 -3
  8. claude_team_mcp/config.py +350 -0
  9. claude_team_mcp/config_cli.py +263 -0
  10. claude_team_mcp/idle_detection.py +16 -3
  11. claude_team_mcp/issue_tracker/__init__.py +68 -3
  12. claude_team_mcp/iterm_utils.py +5 -73
  13. claude_team_mcp/registry.py +43 -26
  14. claude_team_mcp/server.py +164 -61
  15. claude_team_mcp/session_state.py +364 -2
  16. claude_team_mcp/terminal_backends/__init__.py +49 -0
  17. claude_team_mcp/terminal_backends/base.py +106 -0
  18. claude_team_mcp/terminal_backends/iterm.py +251 -0
  19. claude_team_mcp/terminal_backends/tmux.py +683 -0
  20. claude_team_mcp/tools/__init__.py +4 -2
  21. claude_team_mcp/tools/adopt_worker.py +89 -32
  22. claude_team_mcp/tools/close_workers.py +39 -10
  23. claude_team_mcp/tools/discover_workers.py +176 -32
  24. claude_team_mcp/tools/list_workers.py +29 -0
  25. claude_team_mcp/tools/message_workers.py +35 -5
  26. claude_team_mcp/tools/poll_worker_changes.py +227 -0
  27. claude_team_mcp/tools/spawn_workers.py +254 -153
  28. claude_team_mcp/tools/wait_idle_workers.py +1 -0
  29. claude_team_mcp/utils/errors.py +7 -3
  30. claude_team_mcp/worktree.py +73 -12
  31. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
  32. claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
  33. claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
  34. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
  35. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -17,22 +17,13 @@ if TYPE_CHECKING:
17
17
  from ..server import AppContext
18
18
 
19
19
  from ..cli_backends import get_cli_backend
20
+ from ..config import ConfigError, default_config, load_config
20
21
  from ..colors import generate_tab_color
21
22
  from ..formatting import format_badge_text, format_session_title
22
- from ..iterm_utils import (
23
- MAX_PANES_PER_TAB,
24
- create_multi_pane_layout,
25
- find_available_window,
26
- get_window_for_session,
27
- send_prompt,
28
- send_prompt_for_agent,
29
- split_pane,
30
- start_agent_in_session,
31
- start_claude_in_session,
32
- )
33
23
  from ..names import pick_names_for_count
34
24
  from ..profile import apply_appearance_colors
35
25
  from ..registry import SessionStatus
26
+ from ..terminal_backends import ItermBackend, MAX_PANES_PER_TAB
36
27
  from ..utils import HINTS, error_response, get_worktree_tracker_dir
37
28
  from ..worker_prompt import generate_worker_prompt, get_coordinator_guidance
38
29
  from ..worktree import WorktreeError, create_local_worktree
@@ -40,6 +31,13 @@ from ..worktree import WorktreeError, create_local_worktree
40
31
  logger = logging.getLogger("claude-team-mcp")
41
32
 
42
33
 
34
+ class WorktreeConfig(TypedDict, total=False):
35
+ """Configuration for worktree creation."""
36
+
37
+ branch: str # Optional: Branch name for the worktree
38
+ base: str # Optional: Base ref/branch for the new branch
39
+
40
+
43
41
  class WorkerConfig(TypedDict, total=False):
44
42
  """Configuration for a single worker."""
45
43
 
@@ -51,6 +49,7 @@ class WorkerConfig(TypedDict, total=False):
51
49
  prompt: str # Optional: Custom prompt (None = standard worker prompt)
52
50
  skip_permissions: bool # Optional: Default False
53
51
  use_worktree: bool # Optional: Create isolated worktree (default True)
52
+ worktree: WorktreeConfig # Optional: Worktree settings (branch/base)
54
53
 
55
54
 
56
55
  def register_tools(mcp: FastMCP, ensure_connection) -> None:
@@ -60,14 +59,15 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
60
59
  async def spawn_workers(
61
60
  ctx: Context[ServerSession, "AppContext"],
62
61
  workers: list[WorkerConfig],
63
- layout: Literal["auto", "new"] = "auto",
62
+ layout: Literal["auto", "new"] | None = None,
64
63
  ) -> dict:
65
64
  """
66
65
  Spawn Claude Code worker sessions.
67
66
 
68
- Creates worker sessions in iTerm2, each with its own pane, Claude instance,
69
- and optional worktree. Workers can be spawned into existing windows (layout="auto")
70
- or a fresh window (layout="new").
67
+ Creates worker sessions in the active terminal backend, each with its own pane
68
+ (iTerm) or window (tmux), Claude instance, and optional worktree. Workers can be
69
+ spawned into existing
70
+ windows (layout="auto") or a fresh window (layout="new").
71
71
 
72
72
  **Layout Modes:**
73
73
 
@@ -83,6 +83,10 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
83
83
  - 3 workers: triple vertical (left/middle/right)
84
84
  - 4 workers: quad layout (2x2 grid)
85
85
 
86
+ **tmux note:**
87
+ - Workers get their own tmux window in a per-project claude-team session.
88
+ - layout is ignored for tmux.
89
+
86
90
  **WorkerConfig fields:**
87
91
  project_path: Required. Path to the repository.
88
92
  - Explicit path: Use this repo (e.g., "/path/to/repo")
@@ -107,6 +111,9 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
107
111
  - True: Creates worktree at <repo>/.worktrees/<bead>-<annotation>
108
112
  or <repo>/.worktrees/<name>-<uuid>-<annotation>
109
113
  - False: Worker uses the repo directory directly (no isolation)
114
+ worktree: Optional worktree configuration.
115
+ - branch: Branch name for the worktree
116
+ - base: Base ref/branch for the new branch
110
117
  name: Optional worker name override. Leaving this empty allows us to auto-pick names
111
118
  from themed sets (Beatles, Marx Brothers, etc.) which aids visual identification.
112
119
  annotation: Optional task description. Shown on badge second line, used in
@@ -180,13 +187,23 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
180
187
  # Then immediately:
181
188
  message_workers(session_ids=["Groucho"], message="Your task is...")
182
189
  """
183
- from iterm2.profile import LocalWriteOnlyProfile
184
-
185
190
  from ..session_state import await_marker_in_jsonl, generate_marker_message
186
191
 
187
192
  app_ctx = ctx.request_context.lifespan_context
188
193
  registry = app_ctx.registry
189
194
 
195
+ # Load config and apply defaults
196
+ try:
197
+ config = load_config()
198
+ except ConfigError as exc:
199
+ logger.warning("Invalid config file; using defaults: %s", exc)
200
+ config = default_config()
201
+ defaults = config.defaults
202
+
203
+ # Resolve layout from config if not explicitly provided
204
+ if layout is None:
205
+ layout = defaults.layout
206
+
190
207
  # Validate worker count
191
208
  if not workers:
192
209
  return error_response("At least one worker is required")
@@ -201,8 +218,8 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
201
218
  if "project_path" not in w:
202
219
  return error_response(f"Worker {i} missing required 'project_path'")
203
220
 
204
- # Ensure we have a fresh connection
205
- connection, app = await ensure_connection(app_ctx)
221
+ # Ensure we have a fresh backend connection/state
222
+ backend = await ensure_connection(app_ctx)
206
223
 
207
224
  try:
208
225
  # Get base session index for color generation
@@ -233,16 +250,37 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
233
250
  resolved_paths: list[str] = []
234
251
  worktree_paths: dict[int, Path] = {} # index -> worktree path
235
252
  main_repo_paths: dict[int, Path] = {} # index -> main repo
253
+ worktree_warnings: list[str] = []
236
254
 
237
255
  # Get CLAUDE_TEAM_PROJECT_DIR for "auto" paths
238
256
  env_project_dir = os.environ.get("CLAUDE_TEAM_PROJECT_DIR")
239
257
 
240
258
  for i, (w, name) in enumerate(zip(workers, resolved_names)):
241
259
  project_path = w["project_path"]
242
- use_worktree = w.get("use_worktree", True) # Default True
260
+ # Use config default when not explicitly set
261
+ use_worktree = w.get("use_worktree")
262
+ if use_worktree is None:
263
+ use_worktree = defaults.use_worktree
264
+ worktree_config = w.get("worktree")
265
+ worktree_explicitly_requested = worktree_config is not None
266
+ worktree_branch = None
267
+ worktree_base = None
243
268
  bead = w.get("bead")
244
269
  annotation = w.get("annotation")
245
270
 
271
+ if worktree_config is not None:
272
+ if isinstance(worktree_config, dict):
273
+ worktree_branch = worktree_config.get("branch")
274
+ worktree_base = worktree_config.get("base")
275
+ use_worktree = True
276
+ elif isinstance(worktree_config, bool):
277
+ use_worktree = worktree_config
278
+ else:
279
+ return error_response(
280
+ f"Worker {i} has invalid 'worktree' configuration",
281
+ hint="Expected a dict with optional 'branch'/'base' fields or a boolean.",
282
+ )
283
+
246
284
  # Step 1: Resolve repo path
247
285
  if project_path == "auto":
248
286
  if env_project_dir:
@@ -272,16 +310,28 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
272
310
  worker_name=name,
273
311
  bead_id=bead,
274
312
  annotation=annotation,
313
+ branch=worktree_branch,
314
+ base=worktree_base,
275
315
  )
276
316
  worktree_paths[i] = worktree_path
277
317
  main_repo_paths[i] = repo_path
278
318
  resolved_paths.append(str(worktree_path))
279
319
  logger.info(f"Created local worktree for {name} at {worktree_path}")
280
320
  except WorktreeError as e:
281
- logger.warning(
321
+ warning_message = (
282
322
  f"Failed to create worktree for {name}: {e}. "
283
323
  "Using repo directly."
284
324
  )
325
+ if worktree_explicitly_requested:
326
+ return error_response(
327
+ warning_message,
328
+ hint=(
329
+ "Verify the worktree branch/base settings and "
330
+ "that the repository is in a clean state."
331
+ ),
332
+ )
333
+ logger.warning(warning_message)
334
+ worktree_warnings.append(warning_message)
285
335
  resolved_paths.append(str(repo_path))
286
336
  else:
287
337
  # No worktree - use repo directly
@@ -290,44 +340,82 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
290
340
  # Pre-generate session IDs for Stop hook injection
291
341
  session_ids = [str(uuid.uuid4())[:8] for _ in workers]
292
342
 
293
- # Build profile customizations for each worker
294
- profile_customizations: list[LocalWriteOnlyProfile] = []
295
- for i, (w, name) in enumerate(zip(workers, resolved_names)):
296
- customization = LocalWriteOnlyProfile()
343
+ # Pre-calculate agent types for each worker (needed by profile customizations
344
+ # and agent startup)
345
+ agent_types: list[str] = []
346
+ for w in workers:
347
+ # Use config default when not explicitly set
348
+ agent_type = w.get("agent_type")
349
+ if agent_type is None:
350
+ agent_type = defaults.agent_type
351
+ agent_types.append(agent_type)
297
352
 
298
- bead = w.get("bead")
299
- annotation = w.get("annotation")
300
- agent_type = w.get("agent_type", "claude")
353
+ # Build profile customizations for each worker (iTerm-only)
354
+ profile_customizations: list[object | None] = [None] * worker_count
355
+ if isinstance(backend, ItermBackend):
356
+ from iterm2.profile import LocalWriteOnlyProfile
301
357
 
302
- # Tab title
303
- tab_title = format_session_title(name, issue_id=bead, annotation=annotation)
304
- customization.set_name(tab_title)
358
+ profile_customizations = []
359
+ for i, (w, name) in enumerate(zip(workers, resolved_names)):
360
+ customization = LocalWriteOnlyProfile()
305
361
 
306
- # Tab color (unique per worker)
307
- color = generate_tab_color(base_index + i)
308
- customization.set_tab_color(color)
309
- customization.set_use_tab_color(True)
362
+ bead = w.get("bead")
363
+ annotation = w.get("annotation")
364
+ agent_type = agent_types[i]
310
365
 
311
- # Badge (multi-line with bead/name, annotation, and agent type indicator)
312
- badge_text = format_badge_text(
313
- name, bead=bead, annotation=annotation, agent_type=agent_type
314
- )
315
- customization.set_badge_text(badge_text)
366
+ # Tab title
367
+ tab_title = format_session_title(
368
+ name, issue_id=bead, annotation=annotation
369
+ )
370
+ customization.set_name(tab_title)
316
371
 
317
- # Apply current appearance mode colors
318
- await apply_appearance_colors(customization, connection)
372
+ # Tab color (unique per worker)
373
+ color = generate_tab_color(base_index + i)
374
+ customization.set_tab_color(color)
375
+ customization.set_use_tab_color(True)
319
376
 
320
- profile_customizations.append(customization)
377
+ # Badge (multi-line with bead/name, annotation, and agent type indicator)
378
+ badge_text = format_badge_text(
379
+ name, bead=bead, annotation=annotation, agent_type=agent_type
380
+ )
381
+ customization.set_badge_text(badge_text)
321
382
 
322
- # Create panes based on layout mode
323
- pane_sessions: list = [] # list of iTerm sessions
383
+ # Apply current appearance mode colors
384
+ await apply_appearance_colors(customization, backend.connection)
324
385
 
325
- if layout == "auto":
386
+ profile_customizations.append(customization)
387
+
388
+ # Create panes based on layout mode
389
+ pane_sessions: list = [] # list of terminal handles
390
+
391
+ if backend.backend_id == "tmux":
392
+ for i in range(worker_count):
393
+ pane_sessions.append(
394
+ await backend.create_session(
395
+ name=resolved_names[i],
396
+ project_path=resolved_paths[i],
397
+ issue_id=workers[i].get("bead"),
398
+ coordinator_annotation=workers[i].get("annotation"),
399
+ )
400
+ )
401
+ elif layout == "auto":
326
402
  # Try to find an existing window where the ENTIRE batch fits.
327
403
  # This keeps spawn batches together rather than spreading across windows.
328
- target_tab = None
404
+ reuse_window = False
329
405
  initial_pane_count = 0
330
- first_session = None # Session to split from
406
+ first_session = None # Terminal handle to split from
407
+
408
+ def _count_tmux_panes(target_session, sessions) -> int:
409
+ session_name = target_session.metadata.get("session_name")
410
+ window_index = target_session.metadata.get("window_index")
411
+ if not session_name or window_index is None:
412
+ return 1
413
+ return sum(
414
+ 1
415
+ for session in sessions
416
+ if session.metadata.get("session_name") == session_name
417
+ and session.metadata.get("window_index") == window_index
418
+ )
331
419
 
332
420
  # Prefer the coordinator's window when running inside iTerm2.
333
421
  # ITERM_SESSION_ID format is "wXtYpZ:UUID" - extract just the UUID.
@@ -335,67 +423,65 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
335
423
  coordinator_session_id = None
336
424
  if iterm_session_env and ":" in iterm_session_env:
337
425
  coordinator_session_id = iterm_session_env.split(":", 1)[1]
338
- if coordinator_session_id:
339
- coordinator_session = None
340
- coordinator_tab = None
341
-
342
- for window in app.terminal_windows:
343
- for tab in window.tabs:
344
- for session in tab.sessions:
345
- if session.session_id == coordinator_session_id:
346
- coordinator_session = session
347
- coordinator_tab = tab
348
- break
349
- if coordinator_session:
350
- break
351
- if coordinator_session:
426
+ if coordinator_session_id and isinstance(backend, ItermBackend):
427
+ coordinator_handle = None
428
+ for session_handle in await backend.list_sessions():
429
+ if session_handle.native_id == coordinator_session_id:
430
+ coordinator_handle = session_handle
352
431
  break
353
432
 
354
- if coordinator_session and coordinator_tab:
355
- coordinator_window = await get_window_for_session(
356
- app, coordinator_session
433
+ if coordinator_handle:
434
+ coordinator_window = await backend.get_window_for_handle(
435
+ coordinator_handle
357
436
  )
358
437
  if coordinator_window is not None:
359
- initial_pane_count = len(coordinator_tab.sessions)
360
- available_slots = MAX_PANES_PER_TAB - initial_pane_count
361
- if worker_count <= available_slots:
362
- target_tab = coordinator_tab
363
- first_session = coordinator_session
364
- logger.debug(
365
- "Using coordinator window "
366
- f"({initial_pane_count} panes, {available_slots} slots)"
367
- )
368
-
369
- if target_tab is None:
370
- managed_iterm_ids: set[str] = {
371
- s.iterm_session.session_id
438
+ native_session = backend.unwrap_session(coordinator_handle)
439
+ coordinator_tab = native_session.tab
440
+ if coordinator_tab is not None:
441
+ initial_pane_count = len(coordinator_tab.sessions)
442
+ available_slots = MAX_PANES_PER_TAB - initial_pane_count
443
+ if worker_count <= available_slots:
444
+ reuse_window = True
445
+ first_session = coordinator_handle
446
+ logger.debug(
447
+ "Using coordinator window "
448
+ f"({initial_pane_count} panes, {available_slots} slots)"
449
+ )
450
+
451
+ if not reuse_window:
452
+ managed_session_ids = {
453
+ s.terminal_session.native_id
372
454
  for s in registry.list_all()
373
- if s.iterm_session is not None
455
+ if s.terminal_session.backend_id == backend.backend_id
374
456
  }
375
457
 
376
458
  # Find a window with enough space for ALL workers
377
- result = await find_available_window(
378
- app,
459
+ result = await backend.find_available_window(
379
460
  max_panes=MAX_PANES_PER_TAB,
380
- managed_session_ids=managed_iterm_ids,
461
+ managed_session_ids=managed_session_ids,
381
462
  )
382
463
 
383
464
  if result:
384
- window, tab, existing_session = result
385
- initial_pane_count = len(tab.sessions)
465
+ _, tab_or_window, existing_session = result
466
+ first_session = existing_session
467
+ if isinstance(backend, ItermBackend):
468
+ initial_pane_count = len(tab_or_window.sessions)
469
+ else:
470
+ initial_pane_count = _count_tmux_panes(
471
+ existing_session, await backend.list_sessions()
472
+ )
386
473
  available_slots = MAX_PANES_PER_TAB - initial_pane_count
387
474
 
388
475
  if worker_count <= available_slots:
389
476
  # Entire batch fits in this window
390
- target_tab = tab
391
- first_session = existing_session
477
+ reuse_window = True
392
478
  logger.debug(
393
479
  f"Batch of {worker_count} fits in existing window "
394
480
  f"({initial_pane_count} panes, {available_slots} slots)"
395
481
  )
396
482
 
397
- if target_tab:
398
- # Reuse existing window - track pane count locally (iTerm objects stale)
483
+ if reuse_window and first_session is not None:
484
+ # Reuse existing window - track pane count locally
399
485
  local_pane_count = initial_pane_count
400
486
  final_pane_count = initial_pane_count + worker_count
401
487
  # Track created sessions for splitting
@@ -411,7 +497,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
411
497
  # | worker2
412
498
  if local_pane_count == 1:
413
499
  # First split: vertical from coordinator
414
- new_session = await split_pane(
500
+ new_session = await backend.split_pane(
415
501
  first_session,
416
502
  vertical=True,
417
503
  before=False,
@@ -420,8 +506,11 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
420
506
  )
421
507
  else:
422
508
  # Second split: horizontal from first worker (stack on right)
423
- new_session = await split_pane(
424
- created_sessions[0],
509
+ split_target = (
510
+ created_sessions[0] if created_sessions else first_session
511
+ )
512
+ new_session = await backend.split_pane(
513
+ split_target,
425
514
  vertical=False,
426
515
  before=False,
427
516
  profile=None,
@@ -431,7 +520,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
431
520
  # Quad pattern: TL→TR(vsplit)→BL(hsplit)→BR(hsplit)
432
521
  if local_pane_count == 1:
433
522
  # First split: vertical (left/right)
434
- new_session = await split_pane(
523
+ new_session = await backend.split_pane(
435
524
  first_session,
436
525
  vertical=True,
437
526
  before=False,
@@ -440,7 +529,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
440
529
  )
441
530
  elif local_pane_count == 2:
442
531
  # Second split: horizontal from left pane (bottom-left)
443
- new_session = await split_pane(
532
+ new_session = await backend.split_pane(
444
533
  first_session,
445
534
  vertical=False,
446
535
  before=False,
@@ -452,7 +541,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
452
541
  tr_session = (
453
542
  created_sessions[0] if created_sessions else first_session
454
543
  )
455
- new_session = await split_pane(
544
+ new_session = await backend.split_pane(
456
545
  tr_session,
457
546
  vertical=False,
458
547
  before=False,
@@ -482,12 +571,14 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
482
571
  window_layout = "quad"
483
572
  pane_names = ["top_left", "top_right", "bottom_left", "bottom_right"]
484
573
 
485
- customizations_dict = {
486
- pane_names[i]: profile_customizations[i] for i in range(worker_count)
487
- }
574
+ customizations_dict = None
575
+ if isinstance(backend, ItermBackend):
576
+ customizations_dict = {
577
+ pane_names[i]: profile_customizations[i]
578
+ for i in range(worker_count)
579
+ }
488
580
 
489
- panes = await create_multi_pane_layout(
490
- connection,
581
+ panes = await backend.create_multi_pane_layout(
491
582
  window_layout,
492
583
  profile=None,
493
584
  profile_customizations=customizations_dict,
@@ -511,12 +602,14 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
511
602
  pane_names = ["top_left", "top_right", "bottom_left", "bottom_right"]
512
603
 
513
604
  # Build customizations dict for layout
514
- customizations_dict = {
515
- pane_names[i]: profile_customizations[i] for i in range(worker_count)
516
- }
605
+ customizations_dict = None
606
+ if isinstance(backend, ItermBackend):
607
+ customizations_dict = {
608
+ pane_names[i]: profile_customizations[i]
609
+ for i in range(worker_count)
610
+ }
517
611
 
518
- panes = await create_multi_pane_layout(
519
- connection,
612
+ panes = await backend.create_multi_pane_layout(
520
613
  window_layout,
521
614
  profile=None,
522
615
  profile_customizations=customizations_dict,
@@ -524,16 +617,8 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
524
617
 
525
618
  pane_sessions = [panes[name] for name in pane_names[:worker_count]]
526
619
 
527
- # Pre-calculate agent types for each worker
528
- import asyncio
529
-
530
- agent_types: list[str] = []
531
-
532
- for i, w in enumerate(workers):
533
- agent_type = w.get("agent_type", "claude")
534
- agent_types.append(agent_type)
535
-
536
620
  # Start agent in all panes (both Claude and Codex)
621
+ import asyncio
537
622
  async def start_agent_for_worker(index: int) -> None:
538
623
  session = pane_sessions[index]
539
624
  project_path = resolved_paths[index]
@@ -549,25 +634,20 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
549
634
  else:
550
635
  env = None
551
636
 
552
- if agent_type == "codex":
553
- # Start Codex in interactive mode using start_agent_in_session
554
- cli = get_cli_backend("codex")
555
- await start_agent_in_session(
556
- session=session,
557
- cli=cli,
558
- project_path=project_path,
559
- dangerously_skip_permissions=worker_config.get("skip_permissions", False),
560
- env=env,
561
- )
562
- else:
563
- # For Claude: use start_claude_in_session (convenience wrapper)
564
- await start_claude_in_session(
565
- session=session,
566
- project_path=project_path,
567
- dangerously_skip_permissions=worker_config.get("skip_permissions", False),
568
- env=env,
569
- stop_hook_marker_id=marker_id,
570
- )
637
+ cli = get_cli_backend(agent_type)
638
+ stop_hook_marker_id = marker_id if agent_type == "claude" else None
639
+ # Use config default when not explicitly set
640
+ skip_permissions = worker_config.get("skip_permissions")
641
+ if skip_permissions is None:
642
+ skip_permissions = defaults.skip_permissions
643
+ await backend.start_agent_in_session(
644
+ handle=session,
645
+ cli=cli,
646
+ project_path=project_path,
647
+ dangerously_skip_permissions=skip_permissions,
648
+ env=env,
649
+ stop_hook_marker_id=stop_hook_marker_id,
650
+ )
571
651
 
572
652
  await asyncio.gather(*[start_agent_for_worker(i) for i in range(worker_count)])
573
653
 
@@ -575,7 +655,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
575
655
  managed_sessions = []
576
656
  for i in range(worker_count):
577
657
  managed = registry.add(
578
- iterm_session=pane_sessions[i],
658
+ terminal_session=pane_sessions[i],
579
659
  project_path=resolved_paths[i],
580
660
  name=resolved_names[i],
581
661
  session_id=session_ids[i],
@@ -590,15 +670,28 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
590
670
  managed.main_repo_path = main_repo_paths[i]
591
671
  managed_sessions.append(managed)
592
672
 
593
- # Send marker messages for JSONL correlation (Claude only)
594
- # Codex doesn't use JSONL markers for session tracking
673
+ # Send marker messages for JSONL correlation (Claude + Codex)
595
674
  for i, managed in enumerate(managed_sessions):
596
- if managed.agent_type == "claude":
597
- marker_message = generate_marker_message(
598
- managed.session_id,
599
- iterm_session_id=managed.iterm_session.session_id,
600
- )
601
- await send_prompt(pane_sessions[i], marker_message, submit=True)
675
+ iterm_session_id = None
676
+ tmux_pane_ids = None
677
+ if managed.terminal_session.backend_id == "iterm":
678
+ iterm_session_id = managed.terminal_session.native_id
679
+ elif managed.terminal_session.backend_id == "tmux":
680
+ tmux_pane_ids = [managed.terminal_session.native_id]
681
+ marker_message = generate_marker_message(
682
+ managed.session_id,
683
+ iterm_session_id=iterm_session_id,
684
+ tmux_pane_ids=tmux_pane_ids,
685
+ project_path=(
686
+ managed.project_path if managed.agent_type == "codex" else None
687
+ ),
688
+ )
689
+ await backend.send_prompt_for_agent(
690
+ pane_sessions[i],
691
+ marker_message,
692
+ agent_type=managed.agent_type,
693
+ submit=True,
694
+ )
602
695
 
603
696
  # Wait for markers to appear in JSONL (Claude only)
604
697
  for i, managed in enumerate(managed_sessions):
@@ -646,8 +739,17 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
646
739
 
647
740
  # Send prompt to the already-running agent (both Claude and Codex)
648
741
  # Use agent-specific timing (Codex needs longer delay before Enter)
649
- logger.info(f"Sending prompt to {managed.name} (agent_type={managed.agent_type}, chars={len(worker_prompt)})")
650
- await send_prompt_for_agent(pane_sessions[i], worker_prompt, agent_type=managed.agent_type)
742
+ logger.info(
743
+ "Sending prompt to %s (agent_type=%s, chars=%d)",
744
+ managed.name,
745
+ managed.agent_type,
746
+ len(worker_prompt),
747
+ )
748
+ await backend.send_prompt_for_agent(
749
+ pane_sessions[i],
750
+ worker_prompt,
751
+ agent_type=managed.agent_type,
752
+ )
651
753
  logger.info(f"Prompt sent to {managed.name}")
652
754
 
653
755
  # Mark sessions ready
@@ -657,16 +759,13 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
657
759
  result_sessions[managed.name] = managed.to_dict()
658
760
 
659
761
  # Re-activate the window to bring it to focus
660
- try:
661
- await app.async_activate()
662
- if pane_sessions:
663
- tab = pane_sessions[0].tab
664
- if tab is not None:
665
- window = tab.window
666
- if window is not None:
667
- await window.async_activate()
668
- except Exception as e:
669
- logger.debug(f"Failed to re-activate window: {e}")
762
+ if isinstance(backend, ItermBackend):
763
+ try:
764
+ await backend.activate_app()
765
+ if pane_sessions:
766
+ await backend.activate_window_for_handle(pane_sessions[0])
767
+ except Exception as e:
768
+ logger.debug(f"Failed to re-activate window: {e}")
670
769
 
671
770
  # Build worker summaries for coordinator guidance
672
771
  worker_summaries = []
@@ -695,6 +794,8 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
695
794
  # Add structured warning for programmatic access
696
795
  if workers_awaiting_task:
697
796
  result["workers_awaiting_task"] = workers_awaiting_task
797
+ if worktree_warnings:
798
+ result["warnings"] = worktree_warnings
698
799
 
699
800
  return result
700
801
 
@@ -92,6 +92,7 @@ def register_tools(mcp: FastMCP) -> None:
92
92
  session_infos.append(SessionInfo(
93
93
  jsonl_path=jsonl_path,
94
94
  session_id=session.session_id, # Must use internal ID to match stop hook marker
95
+ agent_type=session.agent_type,
95
96
  ))
96
97
 
97
98
  # Report any missing sessions/files