claude-team-mcp 0.3.2__py3-none-any.whl → 0.5.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.
@@ -8,7 +8,7 @@ import logging
8
8
  import os
9
9
  import uuid
10
10
  from pathlib import Path
11
- from typing import TYPE_CHECKING, Literal, Optional, Required, TypedDict
11
+ from typing import TYPE_CHECKING, Literal, Required, TypedDict
12
12
 
13
13
  from mcp.server.fastmcp import Context, FastMCP
14
14
  from mcp.server.session import ServerSession
@@ -16,25 +16,23 @@ from mcp.server.session import ServerSession
16
16
  if TYPE_CHECKING:
17
17
  from ..server import AppContext
18
18
 
19
+ from ..cli_backends import get_cli_backend
19
20
  from ..colors import generate_tab_color
20
21
  from ..formatting import format_badge_text, format_session_title
21
22
  from ..iterm_utils import (
22
- LAYOUT_PANE_NAMES,
23
23
  MAX_PANES_PER_TAB,
24
24
  create_multi_pane_layout,
25
25
  find_available_window,
26
26
  send_prompt,
27
+ send_prompt_for_agent,
27
28
  split_pane,
29
+ start_agent_in_session,
28
30
  start_claude_in_session,
29
31
  )
30
32
  from ..names import pick_names_for_count
31
- from ..profile import (
32
- PROFILE_NAME,
33
- apply_appearance_colors,
34
- get_or_create_profile,
35
- )
33
+ from ..profile import apply_appearance_colors
36
34
  from ..registry import SessionStatus
37
- from ..utils import BEADS_HELP_TEXT, HINTS, error_response, get_worktree_beads_dir
35
+ from ..utils import HINTS, error_response, get_worktree_beads_dir
38
36
  from ..worker_prompt import generate_worker_prompt, get_coordinator_guidance
39
37
  from ..worktree import WorktreeError, create_local_worktree
40
38
 
@@ -45,6 +43,7 @@ class WorkerConfig(TypedDict, total=False):
45
43
  """Configuration for a single worker."""
46
44
 
47
45
  project_path: Required[str] # Required: Path to repo, or "auto" to use env var
46
+ agent_type: str # Optional: "claude" (default) or "codex"
48
47
  name: str # Optional: Worker name override. None = auto-pick from themed sets.
49
48
  annotation: str # Optional: Task description (badge, branch, worker annotation)
50
49
  bead: str # Optional: Beads issue ID (for badge, branch naming)
@@ -100,6 +99,9 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
100
99
  }
101
100
  }
102
101
  ```
102
+ agent_type: Which agent CLI to use (default "claude").
103
+ - "claude": Claude Code CLI (Stop hook idle detection)
104
+ - "codex": OpenAI Codex CLI (JSONL streaming idle detection)
103
105
  use_worktree: Whether to create an isolated worktree (default True).
104
106
  - True: Creates worktree at <repo>/.worktrees/<bead>-<annotation>
105
107
  or <repo>/.worktrees/<name>-<uuid>-<annotation>
@@ -202,9 +204,6 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
202
204
  connection, app = await ensure_connection(app_ctx)
203
205
 
204
206
  try:
205
- # Ensure the claude-team profile exists
206
- await get_or_create_profile(connection)
207
-
208
207
  # Get base session index for color generation
209
208
  base_index = registry.count()
210
209
 
@@ -297,6 +296,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
297
296
 
298
297
  bead = w.get("bead")
299
298
  annotation = w.get("annotation")
299
+ agent_type = w.get("agent_type", "claude")
300
300
 
301
301
  # Tab title
302
302
  tab_title = format_session_title(name, issue_id=bead, annotation=annotation)
@@ -307,8 +307,10 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
307
307
  customization.set_tab_color(color)
308
308
  customization.set_use_tab_color(True)
309
309
 
310
- # Badge (multi-line with bead/name and annotation)
311
- badge_text = format_badge_text(name, bead=bead, annotation=annotation)
310
+ # Badge (multi-line with bead/name, annotation, and agent type indicator)
311
+ badge_text = format_badge_text(
312
+ name, bead=bead, annotation=annotation, agent_type=agent_type
313
+ )
312
314
  customization.set_badge_text(badge_text)
313
315
 
314
316
  # Apply current appearance mode colors
@@ -368,7 +370,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
368
370
  first_session,
369
371
  vertical=True,
370
372
  before=False,
371
- profile=PROFILE_NAME,
373
+ profile=None,
372
374
  profile_customizations=profile_customizations[i],
373
375
  )
374
376
  elif local_pane_count == 2:
@@ -378,7 +380,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
378
380
  first_session,
379
381
  vertical=False,
380
382
  before=False,
381
- profile=PROFILE_NAME,
383
+ profile=None,
382
384
  profile_customizations=profile_customizations[i],
383
385
  )
384
386
  else: # local_pane_count == 3
@@ -389,7 +391,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
389
391
  tr_session,
390
392
  vertical=False,
391
393
  before=False,
392
- profile=PROFILE_NAME,
394
+ profile=None,
393
395
  profile_customizations=profile_customizations[i],
394
396
  )
395
397
 
@@ -422,7 +424,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
422
424
  panes = await create_multi_pane_layout(
423
425
  connection,
424
426
  window_layout,
425
- profile=PROFILE_NAME,
427
+ profile=None,
426
428
  profile_customizations=customizations_dict,
427
429
  )
428
430
 
@@ -451,34 +453,54 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
451
453
  panes = await create_multi_pane_layout(
452
454
  connection,
453
455
  window_layout,
454
- profile=PROFILE_NAME,
456
+ profile=None,
455
457
  profile_customizations=customizations_dict,
456
458
  )
457
459
 
458
460
  pane_sessions = [panes[name] for name in pane_names[:worker_count]]
459
461
 
460
- # Start Claude in all panes
462
+ # Pre-calculate agent types for each worker
461
463
  import asyncio
462
464
 
463
- async def start_claude_for_worker(index: int) -> None:
465
+ agent_types: list[str] = []
466
+
467
+ for i, w in enumerate(workers):
468
+ agent_type = w.get("agent_type", "claude")
469
+ agent_types.append(agent_type)
470
+
471
+ # Start agent in all panes (both Claude and Codex)
472
+ async def start_agent_for_worker(index: int) -> None:
464
473
  session = pane_sessions[index]
465
474
  project_path = resolved_paths[index]
466
475
  worker_config = workers[index]
467
476
  marker_id = session_ids[index]
477
+ agent_type = agent_types[index]
468
478
 
469
479
  # Check for worktree and set BEADS_DIR if needed
470
480
  beads_dir = get_worktree_beads_dir(project_path)
471
481
  env = {"BEADS_DIR": beads_dir} if beads_dir else None
472
482
 
473
- await start_claude_in_session(
474
- session=session,
475
- project_path=project_path,
476
- dangerously_skip_permissions=worker_config.get("skip_permissions", False),
477
- env=env,
478
- stop_hook_marker_id=marker_id,
479
- )
483
+ if agent_type == "codex":
484
+ # Start Codex in interactive mode using start_agent_in_session
485
+ cli = get_cli_backend("codex")
486
+ await start_agent_in_session(
487
+ session=session,
488
+ cli=cli,
489
+ project_path=project_path,
490
+ dangerously_skip_permissions=worker_config.get("skip_permissions", False),
491
+ env=env,
492
+ )
493
+ else:
494
+ # For Claude: use start_claude_in_session (convenience wrapper)
495
+ await start_claude_in_session(
496
+ session=session,
497
+ project_path=project_path,
498
+ dangerously_skip_permissions=worker_config.get("skip_permissions", False),
499
+ env=env,
500
+ stop_hook_marker_id=marker_id,
501
+ )
480
502
 
481
- await asyncio.gather(*[start_claude_for_worker(i) for i in range(worker_count)])
503
+ await asyncio.gather(*[start_agent_for_worker(i) for i in range(worker_count)])
482
504
 
483
505
  # Register all sessions
484
506
  managed_sessions = []
@@ -491,35 +513,40 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
491
513
  )
492
514
  # Set annotation from worker config (if provided)
493
515
  managed.coordinator_annotation = workers[i].get("annotation")
516
+ # Set agent type
517
+ managed.agent_type = agent_types[i]
494
518
  # Store worktree info if applicable
495
519
  if i in worktree_paths:
496
520
  managed.worktree_path = worktree_paths[i]
497
521
  managed.main_repo_path = main_repo_paths[i]
498
522
  managed_sessions.append(managed)
499
523
 
500
- # Send marker messages for JSONL correlation
524
+ # Send marker messages for JSONL correlation (Claude only)
525
+ # Codex doesn't use JSONL markers for session tracking
501
526
  for i, managed in enumerate(managed_sessions):
502
- marker_message = generate_marker_message(
503
- managed.session_id,
504
- iterm_session_id=managed.iterm_session.session_id,
505
- )
506
- await send_prompt(pane_sessions[i], marker_message, submit=True)
527
+ if managed.agent_type == "claude":
528
+ marker_message = generate_marker_message(
529
+ managed.session_id,
530
+ iterm_session_id=managed.iterm_session.session_id,
531
+ )
532
+ await send_prompt(pane_sessions[i], marker_message, submit=True)
507
533
 
508
- # Wait for markers to appear in JSONL
534
+ # Wait for markers to appear in JSONL (Claude only)
509
535
  for i, managed in enumerate(managed_sessions):
510
- claude_session_id = await await_marker_in_jsonl(
511
- managed.project_path,
512
- managed.session_id,
513
- timeout=30.0,
514
- poll_interval=0.1,
515
- )
516
- if claude_session_id:
517
- managed.claude_session_id = claude_session_id
518
- else:
519
- logger.warning(
520
- f"Marker polling timed out for {managed.session_id}, "
521
- "JSONL correlation unavailable"
536
+ if managed.agent_type == "claude":
537
+ claude_session_id = await await_marker_in_jsonl(
538
+ managed.project_path,
539
+ managed.session_id,
540
+ timeout=30.0,
541
+ poll_interval=0.1,
522
542
  )
543
+ if claude_session_id:
544
+ managed.claude_session_id = claude_session_id
545
+ else:
546
+ logger.warning(
547
+ f"Marker polling timed out for {managed.session_id}, "
548
+ "JSONL correlation unavailable"
549
+ )
523
550
 
524
551
  # Send worker prompts - always use generate_worker_prompt with bead/custom_prompt
525
552
  workers_awaiting_task: list[str] = [] # Workers with no bead and no prompt
@@ -536,12 +563,17 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
536
563
  worker_prompt = generate_worker_prompt(
537
564
  managed.session_id,
538
565
  resolved_names[i],
566
+ agent_type=managed.agent_type,
539
567
  use_worktree=use_worktree,
540
568
  bead=bead,
541
569
  custom_prompt=custom_prompt,
542
570
  )
543
571
 
544
- await send_prompt(pane_sessions[i], worker_prompt, submit=True)
572
+ # Send prompt to the already-running agent (both Claude and Codex)
573
+ # Use agent-specific timing (Codex needs longer delay before Enter)
574
+ logger.info(f"Sending prompt to {managed.name} (agent_type={managed.agent_type}, chars={len(worker_prompt)})")
575
+ await send_prompt_for_agent(pane_sessions[i], worker_prompt, agent_type=managed.agent_type)
576
+ logger.info(f"Prompt sent to {managed.name}")
545
577
 
546
578
  # Mark sessions ready
547
579
  result_sessions = {}
@@ -571,6 +603,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
571
603
 
572
604
  worker_summaries.append({
573
605
  "name": name,
606
+ "agent_type": agent_types[i],
574
607
  "bead": bead,
575
608
  "custom_prompt": custom_prompt,
576
609
  "awaiting_task": awaiting,
@@ -2,9 +2,15 @@
2
2
  Shared constants for Claude Team MCP tools.
3
3
  """
4
4
 
5
+ from pathlib import Path
6
+
5
7
  # Default page size for conversation history pagination
6
8
  CONVERSATION_PAGE_SIZE = 5
7
9
 
10
+ # Directory for Codex JSONL output capture
11
+ # Codex streams JSONL to stdout; we pipe it through tee to this directory
12
+ CODEX_JSONL_DIR = Path.home() / ".claude-team" / "codex"
13
+
8
14
  # Hint appended to messages sent to workers
9
15
  WORKER_MESSAGE_HINT = "\n\n---\n(Note: Use the `bd_help` tool for guidance on using beads to track progress and add comments.)"
10
16
 
@@ -1,11 +1,16 @@
1
1
  """Worker pre-prompt generation for coordinated team sessions."""
2
2
 
3
- from typing import Optional
3
+ from typing import Literal, Optional
4
+
5
+ # Valid agent types for prompt generation
6
+ AgentType = Literal["claude", "codex"]
4
7
 
5
8
 
6
9
  def generate_worker_prompt(
7
10
  session_id: str,
8
11
  name: str,
12
+ *,
13
+ agent_type: AgentType = "claude",
9
14
  use_worktree: bool = False,
10
15
  bead: Optional[str] = None,
11
16
  custom_prompt: Optional[str] = None,
@@ -15,6 +20,7 @@ def generate_worker_prompt(
15
20
  Args:
16
21
  session_id: The unique identifier for this worker session
17
22
  name: The friendly name assigned to this worker
23
+ agent_type: The type of agent CLI ("claude" or "codex")
18
24
  use_worktree: Whether this worker is in an isolated worktree
19
25
  bead: Optional beads issue ID (if provided, this is the assignment)
20
26
  custom_prompt: Optional additional instructions from the coordinator
@@ -25,7 +31,50 @@ def generate_worker_prompt(
25
31
  Note:
26
32
  The iTerm-specific marker for session recovery is emitted separately
27
33
  via generate_marker_message() in session_state.py, which is called
28
- before the worker prompt is sent.
34
+ before the worker prompt is sent. This marker is only used for Claude
35
+ workers since Codex doesn't parse JSONL markers.
36
+ """
37
+ if agent_type == "codex":
38
+ return _generate_codex_worker_prompt(
39
+ session_id=session_id,
40
+ name=name,
41
+ use_worktree=use_worktree,
42
+ bead=bead,
43
+ custom_prompt=custom_prompt,
44
+ )
45
+ # Default to Claude prompt for unknown agent types to maintain backward compatibility
46
+ return _generate_claude_worker_prompt(
47
+ session_id=session_id,
48
+ name=name,
49
+ use_worktree=use_worktree,
50
+ bead=bead,
51
+ custom_prompt=custom_prompt,
52
+ )
53
+
54
+
55
+ def _generate_claude_worker_prompt(
56
+ session_id: str,
57
+ name: str,
58
+ use_worktree: bool = False,
59
+ bead: Optional[str] = None,
60
+ custom_prompt: Optional[str] = None,
61
+ ) -> str:
62
+ """Generate the pre-prompt for a Claude Code worker session.
63
+
64
+ Claude Code workers have access to:
65
+ - claude-team MCP markers for session recovery
66
+ - Stop hook idle detection
67
+ - Full MCP tool ecosystem
68
+
69
+ Args:
70
+ session_id: The unique identifier for this worker session
71
+ name: The friendly name assigned to this worker
72
+ use_worktree: Whether this worker is in an isolated worktree
73
+ bead: Optional beads issue ID
74
+ custom_prompt: Optional additional instructions
75
+
76
+ Returns:
77
+ Formatted pre-prompt for Claude worker
29
78
  """
30
79
  # Build optional sections with dynamic numbering
31
80
  next_step = 4
@@ -107,6 +156,111 @@ your session as idle to the coordinator so they can respond.
107
156
  '''
108
157
 
109
158
 
159
+ def _generate_codex_worker_prompt(
160
+ session_id: str,
161
+ name: str,
162
+ use_worktree: bool = False,
163
+ bead: Optional[str] = None,
164
+ custom_prompt: Optional[str] = None,
165
+ ) -> str:
166
+ """Generate the pre-prompt for an OpenAI Codex worker session.
167
+
168
+ Codex workers differ from Claude:
169
+ - No claude-team MCP markers (Codex doesn't parse JSONL markers)
170
+ - No Stop hook idle detection (uses output pattern matching or timeouts)
171
+ - Runs with --full-auto instead of --dangerously-skip-permissions
172
+
173
+ Args:
174
+ session_id: The unique identifier for this worker session
175
+ name: The friendly name assigned to this worker
176
+ use_worktree: Whether this worker is in an isolated worktree
177
+ bead: Optional beads issue ID
178
+ custom_prompt: Optional additional instructions
179
+
180
+ Returns:
181
+ Formatted pre-prompt for Codex worker
182
+ """
183
+ # Build optional sections with dynamic numbering
184
+ next_step = 4
185
+ extra_sections = ""
186
+
187
+ # Beads section (if bead provided) - same workflow as Claude
188
+ if bead:
189
+ beads_section = f"""
190
+ {next_step}. **Beads workflow.** You're working on `{bead}`. Follow this workflow:
191
+ - Mark in progress: `bd --no-db update {bead} --status in_progress`
192
+ - Implement the changes
193
+ - Close issue: `bd --no-db close {bead}`
194
+ - Commit with issue reference: `git add -A && git commit -m "{bead}: <summary>"`
195
+
196
+ Use `bd --no-db` for all beads commands (required in worktrees).
197
+ """
198
+ extra_sections += beads_section
199
+ next_step += 1
200
+
201
+ # Commit section (if worktree but beads section didn't already cover commit)
202
+ if use_worktree and not bead:
203
+ commit_section = f"""
204
+ {next_step}. **Commit when done.** You're in an isolated worktree branch — commit your
205
+ completed work so it can be easily cherry-picked or merged. Use a clear
206
+ commit message summarizing what you did. Don't push; the coordinator
207
+ handles that.
208
+ """
209
+ extra_sections += commit_section
210
+
211
+ # Closing/assignment section - 4 cases based on bead and custom_prompt
212
+ if bead and custom_prompt:
213
+ closing = f"""=== YOUR ASSIGNMENT ===
214
+
215
+ The coordinator assigned you `{bead}` (use `bd show {bead}` for details) and included
216
+ the following instructions:
217
+
218
+ {custom_prompt}
219
+
220
+ Get to work!"""
221
+ elif bead:
222
+ closing = f"""=== YOUR ASSIGNMENT ===
223
+
224
+ Your assignment is `{bead}`. Use `bd show {bead}` for details. Get to work!"""
225
+ elif custom_prompt:
226
+ closing = f"""=== YOUR ASSIGNMENT ===
227
+
228
+ The coordinator assigned you the following task:
229
+
230
+ {custom_prompt}
231
+
232
+ Get to work!"""
233
+ else:
234
+ closing = "Alright, you're all set. The coordinator will send your first task shortly."
235
+
236
+ # Codex prompt differs from Claude in key ways:
237
+ # - No reference to claude-team MCP markers
238
+ # - No "automatically report your session as idle" - Codex doesn't use stop hooks
239
+ # - Simpler coordination model (output-based status checking)
240
+ return f'''Hey {name}! Welcome to the team.
241
+
242
+ You're part of a coordinated multi-agent team. Your coordinator has tasks for you.
243
+ Do your best to complete the work you've been assigned autonomously.
244
+
245
+ If you have questions or concerns, clearly state them at the end of your response
246
+ and wait for further instructions. The coordinator will check your progress periodically.
247
+
248
+ === THE DEAL ===
249
+
250
+ 1. **Do the work fully.** Either complete it or explain what's blocking you.
251
+ The coordinator reads your output to understand what happened.
252
+
253
+ 2. **When you're done,** leave a clear summary of what you accomplished.
254
+ End your response with "COMPLETED" or "BLOCKED: <reason>" so the coordinator
255
+ can easily assess your status.
256
+
257
+ 3. **If blocked,** explain what you need. The coordinator will read your output
258
+ and address it.
259
+ {extra_sections}
260
+ {closing}
261
+ '''
262
+
263
+
110
264
  def get_coordinator_guidance(
111
265
  worker_summaries: list[dict],
112
266
  ) -> str:
@@ -115,6 +269,7 @@ def get_coordinator_guidance(
115
269
  Args:
116
270
  worker_summaries: List of dicts with keys:
117
271
  - name: Worker name
272
+ - agent_type: Agent type ("claude" or "codex")
118
273
  - bead: Optional bead ID
119
274
  - custom_prompt: Optional custom instructions (truncated for display)
120
275
  - awaiting_task: True if worker has no bead and no prompt
@@ -122,43 +277,74 @@ def get_coordinator_guidance(
122
277
  Returns:
123
278
  Formatted coordinator guidance string
124
279
  """
280
+ # Check if we have a mixed team
281
+ agent_types = {w.get("agent_type", "claude") for w in worker_summaries}
282
+ is_mixed_team = len(agent_types) > 1
283
+
125
284
  # Build per-worker summary lines
126
285
  worker_lines = []
127
286
  for w in worker_summaries:
128
287
  name = w["name"]
288
+ agent_type = w.get("agent_type", "claude")
129
289
  bead = w.get("bead")
130
290
  custom_prompt = w.get("custom_prompt")
131
291
  awaiting = w.get("awaiting_task", False)
132
292
 
293
+ # Add agent type indicator if mixed team
294
+ type_indicator = f" [{agent_type}]" if is_mixed_team else ""
295
+
133
296
  if awaiting:
134
- worker_lines.append(f"- **{name}**: ⚠️ AWAITING TASK — send them instructions now")
297
+ worker_lines.append(
298
+ f"- **{name}**{type_indicator}: "
299
+ "AWAITING TASK - send them instructions now"
300
+ )
135
301
  elif bead and custom_prompt:
136
302
  # Truncate custom prompt for display
137
- short_prompt = custom_prompt[:50] + "..." if len(custom_prompt) > 50 else custom_prompt
138
- worker_lines.append(f"- **{name}**: `{bead}` + custom instructions: \"{short_prompt}\"")
303
+ short_prompt = (
304
+ custom_prompt[:50] + "..." if len(custom_prompt) > 50 else custom_prompt
305
+ )
306
+ worker_lines.append(
307
+ f"- **{name}**{type_indicator}: `{bead}` + custom: \"{short_prompt}\""
308
+ )
139
309
  elif bead:
140
- worker_lines.append(f"- **{name}**: `{bead}` (beads workflow: mark in_progress → implement → close → commit)")
310
+ worker_lines.append(
311
+ f"- **{name}**{type_indicator}: `{bead}` "
312
+ "(beads workflow: mark in_progress -> implement -> close -> commit)"
313
+ )
141
314
  elif custom_prompt:
142
- short_prompt = custom_prompt[:50] + "..." if len(custom_prompt) > 50 else custom_prompt
143
- worker_lines.append(f"- **{name}**: custom task: \"{short_prompt}\"")
315
+ short_prompt = (
316
+ custom_prompt[:50] + "..." if len(custom_prompt) > 50 else custom_prompt
317
+ )
318
+ worker_lines.append(
319
+ f"- **{name}**{type_indicator}: custom task: \"{short_prompt}\""
320
+ )
144
321
 
145
322
  workers_section = "\n".join(worker_lines)
146
323
 
324
+ # Build mixed team guidance if applicable
325
+ mixed_team_section = ""
326
+ if is_mixed_team:
327
+ mixed_team_section = """
328
+ **Mixed team note:** You have both Claude and Codex workers:
329
+ - **Claude workers**: Idle detection via Stop hooks (automatic)
330
+ - **Codex workers**: Check status by reading their output for "COMPLETED" or "BLOCKED"
331
+ """
332
+
147
333
  return f"""=== TEAM DISPATCHED ===
148
334
 
149
335
  {workers_section}
150
-
336
+ {mixed_team_section}
151
337
  Workers will do the work and explain their output. If blocked, they'll say so.
152
338
  You review everything before it's considered done.
153
339
 
154
340
  **Coordination style reminder:** Match your approach to the task. Hands-off for exploratory
155
341
  work (check in when asked), autonomous for pipelines (wait for completion, read logs, continue).
156
342
 
157
- ⚠️ **WORKTREE LIFECYCLE** — Workers with worktrees commit to ephemeral branches.
158
- BEFORE closing workers:
159
- 1. Review their code changes (read_worker_logs or inspect worktree directly)
160
- 2. Merge or cherry-pick their commits to a persistent branch
161
- 3. THEN close workers
343
+ **WORKTREE LIFECYCLE** — Workers with worktrees commit to ephemeral branches.
344
+ When you close workers:
345
+ 1. Worktree directories are removed, but branches (and commits) are preserved
346
+ 2. Review commits on worker branches before merging
347
+ 3. Merge or cherry-pick to main, then delete the worker branch
162
348
 
163
- Closing workers with worktrees DESTROYS their branches. Unmerged commits will be orphaned.
349
+ Branches persist until explicitly deleted with `git branch -d <branch>`.
164
350
  """
@@ -240,6 +240,10 @@ def create_local_worktree(
240
240
  The branch name matches the worktree directory name for consistency.
241
241
  Automatically adds .worktrees to .gitignore if not present.
242
242
 
243
+ If a worktree path or branch already exists, appends an incrementing
244
+ suffix (-1, -2, etc.) until an available name is found. This allows
245
+ multiple workers to work on the same bead in parallel.
246
+
243
247
  Args:
244
248
  repo_path: Path to the main repository
245
249
  worker_name: Name of the worker (used in fallback naming)
@@ -262,6 +266,9 @@ def create_local_worktree(
262
266
  )
263
267
  # Returns: Path("/path/to/repo/.worktrees/cic-abc-add-local-worktrees")
264
268
 
269
+ # If called again with same bead/annotation:
270
+ # Returns: Path("/path/to/repo/.worktrees/cic-abc-add-local-worktrees-1")
271
+
265
272
  # Without bead ID
266
273
  path = create_local_worktree(
267
274
  repo_path=Path("/path/to/repo"),
@@ -290,7 +297,6 @@ def create_local_worktree(
290
297
 
291
298
  # Worktree path inside the repo
292
299
  worktrees_dir = repo_path / LOCAL_WORKTREE_DIR
293
- worktree_path = worktrees_dir / dir_name
294
300
 
295
301
  # Ensure .worktrees is in .gitignore
296
302
  ensure_gitignore_entry(repo_path, LOCAL_WORKTREE_DIR)
@@ -298,29 +304,32 @@ def create_local_worktree(
298
304
  # Ensure .worktrees directory exists
299
305
  worktrees_dir.mkdir(parents=True, exist_ok=True)
300
306
 
301
- # Check if worktree already exists
302
- if worktree_path.exists():
303
- raise WorktreeError(f"Worktree already exists at {worktree_path}")
307
+ # Find an available name, handling collisions with incrementing suffix.
308
+ # Check both path existence and branch existence (git won't allow the same
309
+ # branch checked out in multiple worktrees).
310
+ def branch_exists(name: str) -> bool:
311
+ result = subprocess.run(
312
+ ["git", "-C", str(repo_path), "rev-parse", "--verify", f"refs/heads/{name}"],
313
+ capture_output=True,
314
+ text=True,
315
+ )
316
+ return result.returncode == 0
304
317
 
305
- # Branch name matches directory name for clarity
306
- branch_name = dir_name
318
+ base_dir_name = dir_name
319
+ worktree_path = worktrees_dir / dir_name
320
+ suffix = 0
307
321
 
308
- # Build the git worktree add command
309
- cmd = ["git", "-C", str(repo_path), "worktree", "add"]
322
+ while worktree_path.exists() or branch_exists(dir_name):
323
+ suffix += 1
324
+ dir_name = f"{base_dir_name}-{suffix}"
325
+ worktree_path = worktrees_dir / dir_name
310
326
 
311
- # Check if branch exists
312
- branch_check = subprocess.run(
313
- ["git", "-C", str(repo_path), "rev-parse", "--verify", f"refs/heads/{branch_name}"],
314
- capture_output=True,
315
- text=True,
316
- )
327
+ # Branch name matches directory name for clarity
328
+ branch_name = dir_name
317
329
 
318
- if branch_check.returncode == 0:
319
- # Branch exists, check it out
320
- cmd.extend([str(worktree_path), branch_name])
321
- else:
322
- # Branch doesn't exist, create it with -b
323
- cmd.extend(["-b", branch_name, str(worktree_path)])
330
+ # Build the git worktree add command.
331
+ # Branch is guaranteed not to exist (collision loop checked for it).
332
+ cmd = ["git", "-C", str(repo_path), "worktree", "add", "-b", branch_name, str(worktree_path)]
324
333
 
325
334
  result = subprocess.run(cmd, capture_output=True, text=True)
326
335