claude-team-mcp 0.6.1__py3-none-any.whl → 0.7.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.
@@ -0,0 +1,646 @@
1
+ """
2
+ Tmux terminal backend adapter.
3
+
4
+ Provides a TerminalBackend implementation backed by tmux CLI commands.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import re
11
+ import subprocess
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Any, TYPE_CHECKING
15
+
16
+ from .base import TerminalBackend, TerminalSession
17
+
18
+ if TYPE_CHECKING:
19
+ from ..cli_backends import AgentCLI
20
+
21
+
22
+ KEY_MAP: dict[str, str] = {
23
+ "enter": "C-m",
24
+ "return": "C-m",
25
+ "newline": "C-j",
26
+ "escape": "Escape",
27
+ "tab": "Tab",
28
+ "backspace": "BSpace",
29
+ "delete": "DC",
30
+ "up": "Up",
31
+ "down": "Down",
32
+ "right": "Right",
33
+ "left": "Left",
34
+ "home": "Home",
35
+ "end": "End",
36
+ "ctrl-c": "C-c",
37
+ "ctrl-d": "C-d",
38
+ "ctrl-u": "C-u",
39
+ "ctrl-l": "C-l",
40
+ "ctrl-z": "C-z",
41
+ }
42
+
43
+ ISSUE_ID_PATTERN = re.compile(r"\b[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]*\d[A-Za-z0-9]*\b")
44
+
45
+ SHELL_READY_MARKER = "CLAUDE_TEAM_READY_7f3a9c"
46
+ CODEX_PRE_ENTER_DELAY = 0.5
47
+ TMUX_SESSION_NAME = "claude-team"
48
+
49
+ LAYOUT_PANE_NAMES = {
50
+ "single": ["main"],
51
+ "vertical": ["left", "right"],
52
+ "triple_vertical": ["left", "middle", "right"],
53
+ "horizontal": ["top", "bottom"],
54
+ "quad": ["top_left", "top_right", "bottom_left", "bottom_right"],
55
+ }
56
+
57
+ LAYOUT_SELECT = {
58
+ "vertical": "even-horizontal",
59
+ "triple_vertical": "even-horizontal",
60
+ "horizontal": "even-vertical",
61
+ "quad": "tiled",
62
+ }
63
+
64
+
65
+ class TmuxBackend(TerminalBackend):
66
+ """Terminal backend adapter for tmux."""
67
+
68
+ backend_id = "tmux"
69
+
70
+ def __init__(self, socket_path: str | None = None) -> None:
71
+ """Initialize the backend with an optional tmux socket path."""
72
+ self._socket_path = socket_path
73
+
74
+ def wrap_session(self, handle: Any) -> TerminalSession:
75
+ """Wrap a tmux pane id in a TerminalSession."""
76
+ pane_id = str(handle)
77
+ return TerminalSession(
78
+ backend_id=self.backend_id,
79
+ native_id=pane_id,
80
+ handle=pane_id,
81
+ )
82
+
83
+ def unwrap_session(self, session: TerminalSession) -> str:
84
+ """Extract the tmux pane id from a TerminalSession."""
85
+ return str(session.handle)
86
+
87
+ async def create_session(
88
+ self,
89
+ name: str | None = None,
90
+ *,
91
+ project_path: str | None = None,
92
+ issue_id: str | None = None,
93
+ coordinator_annotation: str | None = None,
94
+ profile: str | None = None,
95
+ profile_customizations: Any | None = None,
96
+ ) -> TerminalSession:
97
+ """Create a worker window in the claude-team tmux session."""
98
+ if profile or profile_customizations:
99
+ raise ValueError("tmux backend does not support profiles")
100
+
101
+ base_name = name or self._generate_window_name()
102
+ project_name = self._project_name_from_path(project_path)
103
+ resolved_issue_id = self._resolve_issue_id(issue_id, coordinator_annotation)
104
+ window_name = self._format_window_name(base_name, project_name, resolved_issue_id)
105
+
106
+ # Ensure the dedicated session exists, then create a new window for this worker.
107
+ try:
108
+ await self._run_tmux(["has-session", "-t", TMUX_SESSION_NAME])
109
+ output = await self._run_tmux(
110
+ [
111
+ "new-window",
112
+ "-t",
113
+ TMUX_SESSION_NAME,
114
+ "-n",
115
+ window_name,
116
+ "-P",
117
+ "-F",
118
+ "#{pane_id}\t#{window_id}\t#{window_index}",
119
+ ]
120
+ )
121
+ except subprocess.CalledProcessError:
122
+ output = await self._run_tmux(
123
+ [
124
+ "new-session",
125
+ "-d",
126
+ "-s",
127
+ TMUX_SESSION_NAME,
128
+ "-n",
129
+ window_name,
130
+ "-P",
131
+ "-F",
132
+ "#{pane_id}\t#{window_id}\t#{window_index}",
133
+ ]
134
+ )
135
+
136
+ pane_id, window_id, window_index = self._parse_window_output(output)
137
+ if not pane_id:
138
+ raise RuntimeError("Failed to determine tmux pane id for new window")
139
+
140
+ metadata = {
141
+ "session_name": TMUX_SESSION_NAME,
142
+ "window_id": window_id,
143
+ "window_index": window_index,
144
+ "window_name": window_name,
145
+ }
146
+ if project_name:
147
+ metadata["project_name"] = project_name
148
+ if resolved_issue_id:
149
+ metadata["issue_id"] = resolved_issue_id
150
+
151
+ return TerminalSession(
152
+ backend_id=self.backend_id,
153
+ native_id=pane_id,
154
+ handle=pane_id,
155
+ metadata=metadata,
156
+ )
157
+
158
+ async def send_text(self, session: TerminalSession, text: str) -> None:
159
+ """Send raw text to a tmux pane."""
160
+ pane_id = self.unwrap_session(session)
161
+ await self._run_tmux(["send-keys", "-t", pane_id, "-l", text])
162
+
163
+ async def send_key(self, session: TerminalSession, key: str) -> None:
164
+ """Send a special key to a tmux pane."""
165
+ pane_id = self.unwrap_session(session)
166
+ tmux_key = KEY_MAP.get(key.lower())
167
+ if tmux_key is None:
168
+ raise ValueError(f"Unknown key: {key}. Available: {list(KEY_MAP.keys())}")
169
+ await self._run_tmux(["send-keys", "-t", pane_id, tmux_key])
170
+
171
+ async def send_prompt(
172
+ self, session: TerminalSession, text: str, submit: bool = True
173
+ ) -> None:
174
+ """Send a prompt to a tmux pane, optionally submitting it."""
175
+ await self.send_text(session, text)
176
+ if not submit:
177
+ return
178
+ # Delay to allow tmux to finish pasting before sending Enter.
179
+ delay = self._compute_paste_delay(text)
180
+ await asyncio.sleep(delay)
181
+ await self.send_key(session, "enter")
182
+
183
+ async def send_prompt_for_agent(
184
+ self,
185
+ session: TerminalSession,
186
+ text: str,
187
+ agent_type: str = "claude",
188
+ submit: bool = True,
189
+ ) -> None:
190
+ """Send a prompt with agent-specific handling (Claude vs Codex)."""
191
+ await self.send_text(session, text)
192
+ if not submit:
193
+ return
194
+ # Codex needs a longer pre-Enter delay; use the max of paste vs minimum.
195
+ delay = self._compute_paste_delay(text)
196
+ if agent_type == "codex":
197
+ delay = max(CODEX_PRE_ENTER_DELAY, delay)
198
+ await asyncio.sleep(delay)
199
+ await self.send_key(session, "enter")
200
+
201
+ async def read_screen_text(self, session: TerminalSession) -> str:
202
+ """Read visible screen content from a tmux pane."""
203
+ pane_id = self.unwrap_session(session)
204
+ return await self._run_tmux(["capture-pane", "-p", "-t", pane_id])
205
+
206
+ async def split_pane(
207
+ self,
208
+ session: TerminalSession,
209
+ *,
210
+ vertical: bool = True,
211
+ before: bool = False,
212
+ profile: str | None = None,
213
+ profile_customizations: Any | None = None,
214
+ ) -> TerminalSession:
215
+ """Split a tmux pane and return the new pane."""
216
+ if profile or profile_customizations:
217
+ raise ValueError("tmux backend does not support profiles")
218
+
219
+ pane_id = self.unwrap_session(session)
220
+ args = ["split-window", "-t", pane_id]
221
+ args.append("-h" if vertical else "-v")
222
+ if before:
223
+ args.append("-b")
224
+ # -P prints the new pane id, -F controls the output format.
225
+ args.extend(["-P", "-F", "#{pane_id}"])
226
+
227
+ output = await self._run_tmux(args)
228
+ new_pane_id = self._first_non_empty_line(output)
229
+ if not new_pane_id:
230
+ raise RuntimeError("Failed to determine tmux pane id for split")
231
+
232
+ metadata = dict(session.metadata) if session.metadata else {}
233
+ return TerminalSession(
234
+ backend_id=self.backend_id,
235
+ native_id=new_pane_id,
236
+ handle=new_pane_id,
237
+ metadata=metadata,
238
+ )
239
+
240
+ async def close_session(self, session: TerminalSession, force: bool = False) -> None:
241
+ """Close a tmux window (or its pane) for this worker."""
242
+ pane_id = self.unwrap_session(session)
243
+ _ = force
244
+ window_id = session.metadata.get("window_id")
245
+ if not window_id:
246
+ window_id = await self._window_id_for_pane(pane_id)
247
+ if window_id:
248
+ await self._run_tmux(["kill-window", "-t", window_id])
249
+ else:
250
+ await self._run_tmux(["kill-pane", "-t", pane_id])
251
+
252
+ async def create_multi_pane_layout(
253
+ self,
254
+ layout: str,
255
+ *,
256
+ profile: str | None = None,
257
+ profile_customizations: dict[str, Any] | None = None,
258
+ ) -> dict[str, TerminalSession]:
259
+ """Create a multi-pane layout in a new tmux window."""
260
+ if profile or profile_customizations:
261
+ raise ValueError("tmux backend does not support profiles")
262
+ if layout not in LAYOUT_PANE_NAMES:
263
+ raise ValueError(f"Unknown layout: {layout}. Valid: {list(LAYOUT_PANE_NAMES.keys())}")
264
+
265
+ # Start a new window for this layout within the dedicated session.
266
+ initial = await self.create_session()
267
+ session_name = initial.metadata.get("session_name")
268
+ window_id = initial.metadata.get("window_id")
269
+
270
+ panes: dict[str, TerminalSession] = {}
271
+
272
+ if layout == "single":
273
+ panes["main"] = initial
274
+ elif layout == "vertical":
275
+ panes["left"] = initial
276
+ panes["right"] = await self.split_pane(initial, vertical=True)
277
+ elif layout == "triple_vertical":
278
+ panes["left"] = initial
279
+ panes["middle"] = await self.split_pane(initial, vertical=True)
280
+ panes["right"] = await self.split_pane(panes["middle"], vertical=True)
281
+ elif layout == "horizontal":
282
+ panes["top"] = initial
283
+ panes["bottom"] = await self.split_pane(initial, vertical=False)
284
+ elif layout == "quad":
285
+ panes["top_left"] = initial
286
+ panes["top_right"] = await self.split_pane(initial, vertical=True)
287
+ panes["bottom_left"] = await self.split_pane(initial, vertical=False)
288
+ panes["bottom_right"] = await self.split_pane(panes["top_right"], vertical=False)
289
+
290
+ if layout in LAYOUT_SELECT:
291
+ target = window_id or session_name
292
+ if target:
293
+ await self._run_tmux(["select-layout", "-t", target, LAYOUT_SELECT[layout]])
294
+
295
+ return panes
296
+
297
+ async def list_sessions(self) -> list[TerminalSession]:
298
+ """List all tmux panes in the claude-team session."""
299
+ try:
300
+ output = await self._run_tmux(
301
+ [
302
+ "list-panes",
303
+ "-t",
304
+ TMUX_SESSION_NAME,
305
+ "-F",
306
+ "#{session_name}\t#{window_id}\t#{window_name}\t#{window_index}\t#{pane_index}\t#{pane_id}",
307
+ ]
308
+ )
309
+ except subprocess.CalledProcessError:
310
+ return []
311
+
312
+ sessions: list[TerminalSession] = []
313
+
314
+ # Each line includes session/window/pane metadata and pane id.
315
+ for line in output.splitlines():
316
+ line = line.strip()
317
+ if not line:
318
+ continue
319
+ parts = line.split("\t")
320
+ if len(parts) != 6:
321
+ continue
322
+ session_name, window_id, window_name, window_index, pane_index, pane_id = parts
323
+ sessions.append(
324
+ TerminalSession(
325
+ backend_id=self.backend_id,
326
+ native_id=pane_id,
327
+ handle=pane_id,
328
+ metadata={
329
+ "session_name": session_name,
330
+ "window_id": window_id,
331
+ "window_name": window_name,
332
+ "window_index": window_index,
333
+ "pane_index": pane_index,
334
+ },
335
+ )
336
+ )
337
+
338
+ return sessions
339
+
340
+ async def find_available_window(
341
+ self,
342
+ max_panes: int = 4,
343
+ managed_session_ids: set[str] | None = None,
344
+ ) -> tuple[str, str, TerminalSession] | None:
345
+ """Find a tmux window with space for additional panes."""
346
+ # Query panes across all tmux sessions/windows with enough metadata
347
+ # to group panes and select a reasonable split target.
348
+ try:
349
+ output = await self._run_tmux(
350
+ [
351
+ "list-panes",
352
+ "-t",
353
+ TMUX_SESSION_NAME,
354
+ "-F",
355
+ "#{session_name}\t#{window_id}\t#{window_index}\t#{pane_index}\t#{pane_active}\t#{pane_id}",
356
+ ]
357
+ )
358
+ except subprocess.CalledProcessError:
359
+ return None
360
+
361
+ panes_by_window: dict[tuple[str, str, str], list[dict[str, str]]] = {}
362
+
363
+ for line in output.splitlines():
364
+ line = line.strip()
365
+ if not line:
366
+ continue
367
+ parts = line.split("\t")
368
+ if len(parts) != 6:
369
+ continue
370
+ session_name, window_id, window_index, pane_index, pane_active, pane_id = parts
371
+ panes_by_window.setdefault((session_name, window_id, window_index), []).append(
372
+ {
373
+ "pane_id": pane_id,
374
+ "pane_index": pane_index,
375
+ "pane_active": pane_active,
376
+ }
377
+ )
378
+
379
+ for (session_name, window_id, window_index), panes in panes_by_window.items():
380
+ # Respect the managed-session filter when provided (including empty set).
381
+ if managed_session_ids is not None:
382
+ if not any(p["pane_id"] in managed_session_ids for p in panes):
383
+ continue
384
+
385
+ # Only consider windows that have room for more panes.
386
+ if len(panes) >= max_panes:
387
+ continue
388
+
389
+ # Prefer the active pane as the split target when available.
390
+ target = next((p for p in panes if p["pane_active"] == "1"), panes[0])
391
+ return (
392
+ session_name,
393
+ window_index,
394
+ TerminalSession(
395
+ backend_id=self.backend_id,
396
+ native_id=target["pane_id"],
397
+ handle=target["pane_id"],
398
+ metadata={
399
+ "session_name": session_name,
400
+ "window_id": window_id,
401
+ "window_index": window_index,
402
+ "pane_index": target["pane_index"],
403
+ },
404
+ ),
405
+ )
406
+
407
+ return None
408
+
409
+ async def start_agent_in_session(
410
+ self,
411
+ handle: TerminalSession,
412
+ cli: "AgentCLI",
413
+ project_path: str,
414
+ dangerously_skip_permissions: bool = False,
415
+ env: dict[str, str] | None = None,
416
+ shell_ready_timeout: float = 10.0,
417
+ agent_ready_timeout: float = 30.0,
418
+ stop_hook_marker_id: str | None = None,
419
+ output_capture_path: str | None = None,
420
+ ) -> None:
421
+ """Start a CLI agent in an existing tmux pane."""
422
+ # Ensure the shell is responsive before we send the launch command.
423
+ shell_ready = await self._wait_for_shell_ready(
424
+ handle, timeout_seconds=shell_ready_timeout
425
+ )
426
+ if not shell_ready:
427
+ raise RuntimeError(
428
+ f"Shell not ready after {shell_ready_timeout}s in {project_path}. "
429
+ "Terminal may still be initializing."
430
+ )
431
+
432
+ # Optionally inject a Stop hook using a settings file (Claude only).
433
+ settings_file = None
434
+ if stop_hook_marker_id and cli.supports_settings_file():
435
+ from ..iterm_utils import build_stop_hook_settings_file
436
+
437
+ settings_file = build_stop_hook_settings_file(stop_hook_marker_id)
438
+
439
+ # Build the CLI command (with env vars and settings) for this agent.
440
+ agent_cmd = cli.build_full_command(
441
+ dangerously_skip_permissions=dangerously_skip_permissions,
442
+ settings_file=settings_file,
443
+ env_vars=env,
444
+ )
445
+
446
+ # Capture stdout/stderr if requested (useful for JSONL idle detection).
447
+ if output_capture_path:
448
+ agent_cmd = f"{agent_cmd} 2>&1 | tee {output_capture_path}"
449
+
450
+ # Launch in one atomic command to avoid races between cd and exec.
451
+ cmd = f"cd {project_path} && {agent_cmd}"
452
+ await self.send_prompt(handle, cmd, submit=True)
453
+
454
+ # Wait for the agent to become ready before returning.
455
+ agent_ready = await self._wait_for_agent_ready(
456
+ handle,
457
+ cli,
458
+ timeout_seconds=agent_ready_timeout,
459
+ )
460
+ if not agent_ready:
461
+ raise RuntimeError(
462
+ f"{cli.engine_id} failed to start in {project_path} within "
463
+ f"{agent_ready_timeout}s. Check that '{cli.command()}' is "
464
+ "available and authentication is configured."
465
+ )
466
+
467
+ async def start_claude_in_session(
468
+ self,
469
+ handle: TerminalSession,
470
+ project_path: str,
471
+ dangerously_skip_permissions: bool = False,
472
+ env: dict[str, str] | None = None,
473
+ shell_ready_timeout: float = 10.0,
474
+ claude_ready_timeout: float = 30.0,
475
+ stop_hook_marker_id: str | None = None,
476
+ ) -> None:
477
+ """Start Claude Code in an existing tmux pane."""
478
+ from ..cli_backends import claude_cli
479
+
480
+ await self.start_agent_in_session(
481
+ handle=handle,
482
+ cli=claude_cli,
483
+ project_path=project_path,
484
+ dangerously_skip_permissions=dangerously_skip_permissions,
485
+ env=env,
486
+ shell_ready_timeout=shell_ready_timeout,
487
+ agent_ready_timeout=claude_ready_timeout,
488
+ stop_hook_marker_id=stop_hook_marker_id,
489
+ )
490
+
491
+ async def _run_tmux(self, args: list[str]) -> str:
492
+ """Run a tmux command and return stdout."""
493
+ cmd = ["tmux"]
494
+ if self._socket_path:
495
+ cmd.extend(["-S", self._socket_path])
496
+ cmd.extend(args)
497
+
498
+ def _run() -> subprocess.CompletedProcess[str]:
499
+ return subprocess.run(
500
+ cmd,
501
+ check=True,
502
+ capture_output=True,
503
+ text=True,
504
+ )
505
+
506
+ result = await asyncio.to_thread(_run)
507
+ return result.stdout.strip()
508
+
509
+ def _compute_paste_delay(self, text: str) -> float:
510
+ """Compute a delay to let tmux process pasted text before Enter."""
511
+ # Match the iTerm delay heuristics for consistent cross-backend timing.
512
+ line_count = text.count("\n")
513
+ char_count = len(text)
514
+ if line_count > 0:
515
+ return min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
516
+ return 0.05
517
+
518
+ async def _wait_for_shell_ready(
519
+ self,
520
+ session: TerminalSession,
521
+ *,
522
+ timeout_seconds: float = 10.0,
523
+ poll_interval: float = 0.1,
524
+ ) -> bool:
525
+ """Wait for the shell to accept input by echoing a marker."""
526
+ import time
527
+
528
+ # Kick off the marker echo and then look for the echoed line.
529
+ await self.send_prompt(session, f'echo "{SHELL_READY_MARKER}"', submit=True)
530
+
531
+ start_time = time.monotonic()
532
+ while (time.monotonic() - start_time) < timeout_seconds:
533
+ # Scan visible pane content for the marker on its own line.
534
+ content = await self.read_screen_text(session)
535
+ for line in content.splitlines():
536
+ if line.strip() == SHELL_READY_MARKER:
537
+ return True
538
+ await asyncio.sleep(poll_interval)
539
+
540
+ return False
541
+
542
+ async def _wait_for_agent_ready(
543
+ self,
544
+ session: TerminalSession,
545
+ cli: "AgentCLI",
546
+ *,
547
+ timeout_seconds: float = 15.0,
548
+ poll_interval: float = 0.2,
549
+ stable_count: int = 2,
550
+ ) -> bool:
551
+ """Wait for an agent CLI to show its ready patterns."""
552
+ import time
553
+
554
+ patterns = cli.ready_patterns()
555
+ start_time = time.monotonic()
556
+ last_content = None
557
+ stable_reads = 0
558
+
559
+ while (time.monotonic() - start_time) < timeout_seconds:
560
+ # Read the pane content and only check once output stabilizes.
561
+ content = await self.read_screen_text(session)
562
+ if content == last_content:
563
+ stable_reads += 1
564
+ else:
565
+ stable_reads = 0
566
+ last_content = content
567
+
568
+ if stable_reads >= stable_count:
569
+ for line in content.splitlines():
570
+ stripped = line.strip()
571
+ for pattern in patterns:
572
+ if pattern in stripped:
573
+ return True
574
+
575
+ await asyncio.sleep(poll_interval)
576
+
577
+ return False
578
+
579
+ # Extract a display name for the project from a project path.
580
+ def _project_name_from_path(self, project_path: str | None) -> str | None:
581
+ if not project_path:
582
+ return None
583
+ path = Path(project_path)
584
+ parts = path.parts
585
+ if ".worktrees" in parts:
586
+ worktrees_index = parts.index(".worktrees")
587
+ if worktrees_index > 0:
588
+ return parts[worktrees_index - 1]
589
+ return path.name
590
+
591
+ # Resolve an issue id from explicit input or a coordinator annotation.
592
+ def _resolve_issue_id(
593
+ self,
594
+ issue_id: str | None,
595
+ coordinator_annotation: str | None,
596
+ ) -> str | None:
597
+ if issue_id:
598
+ return issue_id
599
+ if not coordinator_annotation:
600
+ return None
601
+ match = ISSUE_ID_PATTERN.search(coordinator_annotation)
602
+ if not match:
603
+ return None
604
+ return match.group(0)
605
+
606
+ # Build the final tmux window name for a worker.
607
+ def _format_window_name(
608
+ self,
609
+ name: str,
610
+ project_name: str | None,
611
+ issue_id: str | None,
612
+ ) -> str:
613
+ window_name = f"{name} | {project_name}" if project_name else name
614
+ if issue_id:
615
+ return f"{window_name} [{issue_id}]"
616
+ return window_name
617
+
618
+ # Generate a default tmux window name.
619
+ def _generate_window_name(self) -> str:
620
+ return f"worker-{uuid.uuid4().hex[:8]}"
621
+
622
+ # Parse tmux output that includes pane and window ids.
623
+ @staticmethod
624
+ def _parse_window_output(text: str) -> tuple[str | None, str | None, str | None]:
625
+ line = next((line for line in text.splitlines() if line.strip()), "")
626
+ parts = [part.strip() for part in line.split("\t")]
627
+ if len(parts) < 3:
628
+ return None, None, None
629
+ pane_id, window_id, window_index = parts[0], parts[1], parts[2]
630
+ return pane_id, window_id, window_index
631
+
632
+ # Resolve the window id that owns a given pane id.
633
+ async def _window_id_for_pane(self, pane_id: str) -> str | None:
634
+ output = await self._run_tmux(
635
+ ["display-message", "-p", "-t", pane_id, "#{window_id}"]
636
+ )
637
+ return output.strip() or None
638
+
639
+ @staticmethod
640
+ def _first_non_empty_line(text: str) -> str | None:
641
+ """Return the first non-empty line from text, if any."""
642
+ for line in text.splitlines():
643
+ line = line.strip()
644
+ if line:
645
+ return line
646
+ return None
@@ -16,6 +16,7 @@ from . import examine_worker
16
16
  from . import list_workers
17
17
  from . import list_worktrees
18
18
  from . import message_workers
19
+ from . import poll_worker_changes
19
20
  from . import read_worker_logs
20
21
  from . import spawn_workers
21
22
  from . import wait_idle_workers
@@ -27,7 +28,7 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None:
27
28
 
28
29
  Args:
29
30
  mcp: The FastMCP server instance
30
- ensure_connection: Function to ensure iTerm2 connection is alive
31
+ ensure_connection: Function to ensure terminal backend is alive
31
32
  """
32
33
  # Tools that don't need ensure_connection
33
34
  annotate_worker.register_tools(mcp)
@@ -38,10 +39,11 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None:
38
39
  list_workers.register_tools(mcp)
39
40
  list_worktrees.register_tools(mcp)
40
41
  message_workers.register_tools(mcp)
42
+ poll_worker_changes.register_tools(mcp)
41
43
  read_worker_logs.register_tools(mcp)
42
44
  wait_idle_workers.register_tools(mcp)
43
45
 
44
- # Tools that need ensure_connection for iTerm2 operations
46
+ # Tools that need ensure_connection for terminal backend operations
45
47
  adopt_worker.register_tools(mcp, ensure_connection)
46
48
  discover_workers.register_tools(mcp, ensure_connection)
47
49
  spawn_workers.register_tools(mcp, ensure_connection)