claude-team-mcp 0.6.1__py3-none-any.whl → 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_team/__init__.py +11 -0
- claude_team/events.py +501 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/cli_backends/__init__.py +4 -2
- claude_team_mcp/cli_backends/claude.py +45 -5
- claude_team_mcp/cli_backends/codex.py +44 -3
- claude_team_mcp/config.py +350 -0
- claude_team_mcp/config_cli.py +263 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/issue_tracker/__init__.py +68 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +164 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +49 -0
- claude_team_mcp/terminal_backends/base.py +106 -0
- claude_team_mcp/terminal_backends/iterm.py +251 -0
- claude_team_mcp/terminal_backends/tmux.py +683 -0
- claude_team_mcp/tools/__init__.py +4 -2
- claude_team_mcp/tools/adopt_worker.py +89 -32
- claude_team_mcp/tools/close_workers.py +39 -10
- claude_team_mcp/tools/discover_workers.py +176 -32
- claude_team_mcp/tools/list_workers.py +29 -0
- claude_team_mcp/tools/message_workers.py +35 -5
- claude_team_mcp/tools/poll_worker_changes.py +227 -0
- claude_team_mcp/tools/spawn_workers.py +254 -153
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +73 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -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"] =
|
|
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
|
|
69
|
-
and optional worktree. Workers can be
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
358
|
+
profile_customizations = []
|
|
359
|
+
for i, (w, name) in enumerate(zip(workers, resolved_names)):
|
|
360
|
+
customization = LocalWriteOnlyProfile()
|
|
305
361
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
383
|
+
# Apply current appearance mode colors
|
|
384
|
+
await apply_appearance_colors(customization, backend.connection)
|
|
324
385
|
|
|
325
|
-
|
|
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
|
-
|
|
404
|
+
reuse_window = False
|
|
329
405
|
initial_pane_count = 0
|
|
330
|
-
first_session = None #
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
355
|
-
coordinator_window = await
|
|
356
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
if
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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.
|
|
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=
|
|
461
|
+
managed_session_ids=managed_session_ids,
|
|
381
462
|
)
|
|
382
463
|
|
|
383
464
|
if result:
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
|
398
|
-
# Reuse existing window - track pane count locally
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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(
|
|
650
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|