mosscode 0.1.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 (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,216 @@
1
+ """Long-lived agent teams of one project, stored under ~/.mosscode/projects/<key>/teams/<team>/."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Callable
8
+ from dataclasses import asdict, dataclass, field, fields
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from ..paths import project_dir
13
+ from ..worktree.meta import process_alive
14
+ from ..worktree.names import RESERVED
15
+ from .board import TaskBoard
16
+ from .lock import TeamError, atomic_write, file_lock, now, read_text
17
+ from .mailbox import BROADCAST, LEAD, Mailbox
18
+
19
+ __all__ = [
20
+ "BROADCAST",
21
+ "LEAD",
22
+ "Member",
23
+ "Team",
24
+ "TeamError",
25
+ "TeamStore",
26
+ "list_teams",
27
+ "teams_root",
28
+ "validate_member_name",
29
+ "validate_team_name",
30
+ ]
31
+
32
+ NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,31}")
33
+ VERSION = 1
34
+
35
+
36
+ def _validate(name: str, what: str) -> str | None:
37
+ """None when usable, otherwise the reason."""
38
+ if not NAME.fullmatch(name or ""):
39
+ return f"{what}只能含小写字母、数字和 -,1–32 个字符,以字母或数字开头"
40
+ if name in RESERVED:
41
+ return f"{name} 是 Windows 保留的设备名"
42
+ return None
43
+
44
+
45
+ def validate_team_name(name: str) -> str | None:
46
+ return _validate(name, "团队名")
47
+
48
+
49
+ def validate_member_name(name: str) -> str | None:
50
+ if name in {LEAD, BROADCAST}:
51
+ return f"成员名不能是 {name}"
52
+ return _validate(name, "成员名")
53
+
54
+
55
+ def teams_root(project: Path, home: Path | None = None) -> Path:
56
+ """Next to the project's sessions, so the same key finds both."""
57
+ return project_dir(project, home) / "teams"
58
+
59
+
60
+ @dataclass
61
+ class Member:
62
+ name: str
63
+ agent_type: str
64
+ backend: Literal["in-process", "windows-terminal", "tmux"] = "in-process"
65
+ backend_reason: str = ""
66
+ status: Literal["running", "idle", "stopped"] = "stopped"
67
+ require_plan_approval: bool = False
68
+ approved: bool = False # Its plan was approved; a revived member keeps it.
69
+ mode: str = "default" # Written by the lead, read by a pane member each turn.
70
+ model: str = ""
71
+ worktree: dict | None = None # {"name", "path", "branch"}; None works in the project directory.
72
+ pid: int | None = None
73
+ heartbeat: str | None = None
74
+ pane: str | None = None # tmux pane id.
75
+ transcript: str = "" # Relative to the team directory.
76
+ prompt: str = ""
77
+ usage: dict = field(default_factory=dict)
78
+ created: str = field(default_factory=now)
79
+ error: str = "" # Written by a pane member that could not start.
80
+ last_tool: str = "" # Written by a pane member while it runs.
81
+ window: str = "" # The Windows Terminal team window its tab is in.
82
+ approvals: list = field(default_factory=list) # A pane member's requests waiting for an answer.
83
+
84
+ def agent_id(self, team: str) -> str:
85
+ return f"{self.name}@{team}"
86
+
87
+ @classmethod
88
+ def from_dict(cls, data: dict) -> Member:
89
+ known = {item.name for item in fields(cls)}
90
+ return cls(**{key: value for key, value in data.items() if key in known})
91
+
92
+
93
+ @dataclass
94
+ class Team:
95
+ name: str
96
+ description: str = ""
97
+ created: str = field(default_factory=now)
98
+ lead: dict | None = None # {"pid", "session", "since"} of the process holding it.
99
+ members: list[Member] = field(default_factory=list)
100
+ project: str = "" # The main project directory; a pane member loads its rules and agents there.
101
+
102
+ def member(self, name: str) -> Member | None:
103
+ return next((member for member in self.members if member.name == name), None)
104
+
105
+ def names(self) -> tuple[str, ...]:
106
+ return tuple(member.name for member in self.members)
107
+
108
+ def to_dict(self) -> dict:
109
+ data = asdict(self)
110
+ data["v"] = VERSION
111
+ for item, member in zip(data["members"], self.members):
112
+ item["agent_id"] = member.agent_id(self.name)
113
+ return data
114
+
115
+ @classmethod
116
+ def from_dict(cls, data: dict) -> Team:
117
+ members = [Member.from_dict(item) for item in data.get("members", []) if isinstance(item, dict)]
118
+ return cls(
119
+ name=str(data["name"]),
120
+ description=str(data.get("description", "")),
121
+ created=str(data.get("created", "")),
122
+ lead=data.get("lead") or None,
123
+ members=members,
124
+ project=str(data.get("project", "")),
125
+ )
126
+
127
+
128
+ def _valid(data) -> bool:
129
+ return isinstance(data, dict) and isinstance(data.get("name"), str) and isinstance(data.get("members", []), list)
130
+
131
+
132
+ class TeamStore:
133
+ """One team directory: config.json, tasks.json, mailbox/, transcripts/."""
134
+
135
+ def __init__(self, directory: Path, on_notice: Callable[[str], None] | None = None):
136
+ self.directory = directory
137
+ self.name = directory.name
138
+ self.on_notice = on_notice
139
+ self.config = directory / "config.json"
140
+ self.mailbox = Mailbox(directory / "mailbox", on_notice)
141
+ self.board = TaskBoard(directory / "tasks.json", on_notice)
142
+ self.transcripts = directory / "transcripts"
143
+
144
+ @classmethod
145
+ def at(cls, root: Path, name: str, on_notice: Callable[[str], None] | None = None) -> TeamStore:
146
+ problem = validate_team_name(name)
147
+ if problem:
148
+ raise TeamError("invalid_name", f"团队名 {name} 不可用:{problem}。")
149
+ return cls(root / name, on_notice)
150
+
151
+ def exists(self) -> bool:
152
+ return self.config.exists()
153
+
154
+ def create(self, description: str = "") -> Team:
155
+ """A new team; team_exists when the directory already has one (the caller opens it instead)."""
156
+ with file_lock(self.config):
157
+ if self.config.exists():
158
+ raise TeamError("team_exists", f"团队 {self.name} 已存在。")
159
+ team = Team(self.name, description.strip())
160
+ self.transcripts.mkdir(parents=True, exist_ok=True)
161
+ self.mailbox.directory.mkdir(parents=True, exist_ok=True)
162
+ atomic_write(self.config, team.to_dict())
163
+ return team
164
+
165
+ def _read(self) -> Team:
166
+ """Unlike a mailbox, a broken config is left where it is, not replaced: the roster would be lost."""
167
+ try:
168
+ data = json.loads(read_text(self.config))
169
+ except FileNotFoundError:
170
+ raise TeamError("no_team", f"团队 {self.name} 不存在。") from None
171
+ except (OSError, ValueError) as exc:
172
+ raise TeamError("team_corrupt", f"团队 {self.name} 的记录无法读取({self.config}:{exc})。") from None
173
+ if not _valid(data):
174
+ raise TeamError("team_corrupt", f"团队 {self.name} 的记录格式不对({self.config})。")
175
+ return Team.from_dict(data)
176
+
177
+ def load(self) -> Team:
178
+ return self._read()
179
+
180
+ def modify(self, change: Callable[[Team], object]) -> Team:
181
+ """Re-read, change, write, all under the lock; another process's writes are never lost."""
182
+ with file_lock(self.config):
183
+ team = self._read()
184
+ change(team)
185
+ atomic_write(self.config, team.to_dict())
186
+ return team
187
+
188
+ def acquire(self, pid: int, session: str = "", project: str = "") -> Team:
189
+ """The lead process takes the team; team_held while another live process has it."""
190
+
191
+ def take(team: Team) -> None:
192
+ holder = team.lead or {}
193
+ other = int(holder.get("pid") or 0)
194
+ if other and other != pid and process_alive(other):
195
+ raise TeamError(
196
+ "team_held", f"团队 {team.name} 正由另一个 MossCode 进程(PID {other})作为 Lead 使用。"
197
+ )
198
+ team.lead = {"pid": pid, "session": session, "since": now()}
199
+ if project:
200
+ team.project = project
201
+
202
+ return self.modify(take)
203
+
204
+ def release(self, pid: int) -> Team:
205
+ def drop(team: Team) -> None:
206
+ if team.lead and int(team.lead.get("pid") or 0) == pid:
207
+ team.lead = None
208
+
209
+ return self.modify(drop)
210
+
211
+
212
+ def list_teams(root: Path) -> list[TeamStore]:
213
+ """Every directory with a config.json, by name."""
214
+ if not root.is_dir():
215
+ return []
216
+ return [TeamStore(path) for path in sorted(root.iterdir()) if (path / "config.json").is_file()]
@@ -0,0 +1,259 @@
1
+ """Where a member runs — in the lead's process, or in a terminal pane of its own.
2
+
3
+ A pane member is another MossCode process (`--team-dir/--team-member`). The lead starts it, waits for
4
+ it to write its pid into the team record, and later reads its status and heartbeat from there.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import shlex
12
+ import shutil
13
+ import signal
14
+ import subprocess
15
+ import sys
16
+ from collections.abc import Callable, Mapping
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from ..worktree.meta import process_alive
22
+ from .lock import TeamError
23
+
24
+ IN_PROCESS, WINDOWS_TERMINAL, TMUX = "in-process", "windows-terminal", "tmux"
25
+ # A pane member is a tab of its team's window, or a tmux window of its own.
26
+ LABELS = {IN_PROCESS: "进程内", WINDOWS_TERMINAL: "Windows Terminal 标签页", TMUX: "tmux 窗口"}
27
+ IN_PROCESS_REASON = "进程内运行,与 Lead 共用这个窗口"
28
+ START_TIMEOUT = 20.0 # Seconds for a pane member to write its pid.
29
+ HEARTBEAT = 5.0 # Seconds between a pane member's heartbeats.
30
+ FRESH = 15.0 # A heartbeat at most this old means the member is still at work.
31
+ LAUNCH_TIMEOUT = 15.0 # Seconds for wt/tmux itself to return.
32
+
33
+ Run = Callable[..., subprocess.CompletedProcess]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Choice:
38
+ backend: str
39
+ reason: str
40
+
41
+
42
+ def _tmux_missing(env: Mapping[str, str], which) -> str:
43
+ if not env.get("TMUX"):
44
+ return "MossCode 不在 tmux 会话中(环境变量 TMUX 为空)"
45
+ if not which("tmux"):
46
+ return "找不到 tmux 命令"
47
+ return ""
48
+
49
+
50
+ def _wt_missing(env: Mapping[str, str], which) -> str:
51
+ if not env.get("WT_SESSION"):
52
+ return "MossCode 不在 Windows Terminal 中运行(环境变量 WT_SESSION 为空)"
53
+ if not which("wt"):
54
+ return "找不到 wt.exe"
55
+ return ""
56
+
57
+
58
+ def _but(missing: str) -> str:
59
+ return ("但 " if missing[:1].isascii() else "但") + missing # 但 MossCode…, 但找不到…
60
+
61
+
62
+ def detect_backend(mode: str, env: Mapping[str, str] | None = None, which=shutil.which) -> Choice:
63
+ """Auto picks tmux, then Windows Terminal, then in-process; an explicit pane backend that
64
+ cannot run is an error, never a quiet fallback."""
65
+ env = os.environ if env is None else env
66
+ if mode == IN_PROCESS:
67
+ return Choice(IN_PROCESS, "teammate_mode: in-process;" + IN_PROCESS_REASON)
68
+ if mode == TMUX:
69
+ missing = _tmux_missing(env, which)
70
+ if missing:
71
+ raise TeamError(
72
+ "backend_unavailable",
73
+ f"teammate_mode 是 tmux,{_but(missing)};没有改用其他后端。"
74
+ "在 tmux 中启动 MossCode,或把 teammate_mode 改为 auto / in-process。",
75
+ )
76
+ return Choice(TMUX, "teammate_mode: tmux")
77
+ if mode == WINDOWS_TERMINAL:
78
+ missing = _wt_missing(env, which)
79
+ if missing:
80
+ raise TeamError(
81
+ "backend_unavailable",
82
+ f"teammate_mode 是 windows-terminal,{_but(missing)};没有改用其他后端。"
83
+ "在 Windows Terminal 中启动 MossCode,或把 teammate_mode 改为 auto / in-process。",
84
+ )
85
+ return Choice(WINDOWS_TERMINAL, "teammate_mode: windows-terminal")
86
+ if not _tmux_missing(env, which):
87
+ return Choice(TMUX, "auto:检测到 tmux")
88
+ if not _wt_missing(env, which):
89
+ return Choice(WINDOWS_TERMINAL, "auto:检测到 Windows Terminal")
90
+ return Choice(IN_PROCESS, "auto:不在 tmux 或 Windows Terminal 中;" + IN_PROCESS_REASON)
91
+
92
+
93
+ def member_arguments(
94
+ team_dir: Path, name: str, config: str | Path | None = None, provider: str | None = None
95
+ ) -> list[str]:
96
+ """What follows `python -m mosscode`; the configuration by path, never a key."""
97
+ arguments = ["--team-dir", str(team_dir), "--team-member", name]
98
+ if config:
99
+ arguments += ["--config", str(Path(config).resolve())]
100
+ if provider:
101
+ arguments += ["--provider", provider]
102
+ return arguments
103
+
104
+
105
+ def team_window(team_dir: Path) -> str:
106
+ """One Windows Terminal window per team; the digest keeps same-named teams of other projects apart."""
107
+ digest = hashlib.sha1(os.path.normcase(str(Path(team_dir).resolve())).encode("utf-8")).hexdigest()[:6]
108
+ return f"mosscode-{Path(team_dir).name}-{digest}"
109
+
110
+
111
+ def heartbeat_age(stamp: str | None) -> float | None:
112
+ if not stamp:
113
+ return None
114
+ try:
115
+ return (datetime.now().astimezone() - datetime.fromisoformat(stamp)).total_seconds()
116
+ except (TypeError, ValueError):
117
+ return None
118
+
119
+
120
+ def member_alive(pid: int | None, heartbeat: str | None, fresh: float = FRESH) -> bool:
121
+ """Its process runs and it reported recently."""
122
+ age = heartbeat_age(heartbeat)
123
+ return bool(pid) and process_alive(int(pid)) and age is not None and age <= fresh
124
+
125
+
126
+ def kill_tree(pid: int, run: Run = subprocess.run) -> None:
127
+ """The member and whatever it started."""
128
+ if not pid or pid == os.getpid():
129
+ return
130
+ if os.name == "nt":
131
+ run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, timeout=LAUNCH_TIMEOUT)
132
+ return
133
+ try:
134
+ group = os.getpgid(pid)
135
+ except OSError:
136
+ return
137
+ try:
138
+ if group != os.getpgid(0):
139
+ os.killpg(group, signal.SIGKILL)
140
+ else:
141
+ os.kill(pid, signal.SIGKILL)
142
+ except OSError:
143
+ pass
144
+
145
+
146
+ class PaneBackend:
147
+ name = ""
148
+
149
+ def __init__(self, run: Run = subprocess.run, which=shutil.which, python: str | None = None):
150
+ self.run, self.which = run, which
151
+ self.python = python or sys.executable
152
+
153
+ def command(
154
+ self, cwd: Path, arguments: list[str], *, member: str = "", team: str = "", window: str = ""
155
+ ) -> list[str]:
156
+ raise NotImplementedError
157
+
158
+ def launch(
159
+ self, cwd: Path, arguments: list[str], *, member: str = "", team: str = "", window: str = ""
160
+ ) -> str | None:
161
+ """Start the member's pane; the pane id when the terminal reports one."""
162
+ command = self.command(cwd, arguments, member=member, team=team, window=window)
163
+ try:
164
+ done = self.run(command, capture_output=True, text=True, timeout=LAUNCH_TIMEOUT)
165
+ except (OSError, subprocess.SubprocessError) as exc:
166
+ raise TeamError("backend_failed", f"无法启动{LABELS[self.name]}:{exc}") from None
167
+ if done.returncode != 0:
168
+ output = ((done.stderr or "") + (done.stdout or "")).strip()
169
+ raise TeamError(
170
+ "backend_failed",
171
+ f"{LABELS[self.name]}启动失败(退出码 {done.returncode})" + (f":{output}" if output else "。"),
172
+ )
173
+ return self.pane(done.stdout or "")
174
+
175
+ def pane(self, output: str) -> str | None:
176
+ return None
177
+
178
+ def kill(self, pid: int | None, pane: str | None) -> None:
179
+ if pid:
180
+ try:
181
+ kill_tree(int(pid), self.run)
182
+ except (OSError, subprocess.SubprocessError):
183
+ pass
184
+
185
+
186
+ class WindowsTerminalBackend(PaneBackend):
187
+ """A tab of the team's own window (`-w <name>` makes it the first time), so
188
+ the lead's window is never split. The tab runs mosscode.teams.pane, which always exits 0, so it
189
+ closes after a normal exit and after taskkill alike; the window closes with its last tab."""
190
+
191
+ name = WINDOWS_TERMINAL
192
+
193
+ def command(
194
+ self, cwd: Path, arguments: list[str], *, member: str = "", team: str = "", window: str = ""
195
+ ) -> list[str]:
196
+ wt = self.which("wt") or "wt"
197
+ title = ["--title", f"{member} · {team}", "--suppressApplicationTitle"] if member else []
198
+ rest = [
199
+ "-w",
200
+ window or "0",
201
+ "new-tab",
202
+ *title,
203
+ "-d",
204
+ str(cwd),
205
+ "--",
206
+ self.python,
207
+ "-m",
208
+ "mosscode.teams.pane",
209
+ *arguments,
210
+ ]
211
+ return [wt, *(escape_wt(item) for item in rest)]
212
+
213
+
214
+ def escape_wt(argument: str) -> str:
215
+ """wt splits its command line at every `;` (a new subcommand); `\\;` is a literal one."""
216
+ return argument.replace(";", "\\;")
217
+
218
+
219
+ class TmuxBackend(PaneBackend):
220
+ """A window of its own in the current tmux session, not switched to; kill-pane closes it."""
221
+
222
+ name = TMUX
223
+
224
+ def command(
225
+ self, cwd: Path, arguments: list[str], *, member: str = "", team: str = "", window: str = ""
226
+ ) -> list[str]:
227
+ tmux = self.which("tmux") or "tmux"
228
+ named = ["-n", member] if member else []
229
+ return [
230
+ tmux,
231
+ "new-window",
232
+ "-d",
233
+ *named,
234
+ "-P",
235
+ "-F",
236
+ "#{pane_id} #{pane_pid}",
237
+ "-c",
238
+ str(cwd),
239
+ shlex.join([self.python, "-m", "mosscode", *arguments]),
240
+ ]
241
+
242
+ def pane(self, output: str) -> str | None:
243
+ parts = output.strip().split()
244
+ return parts[0] if parts else None
245
+
246
+ def kill(self, pid: int | None, pane: str | None) -> None:
247
+ super().kill(pid, pane)
248
+ if pane:
249
+ try:
250
+ self.run(
251
+ [self.which("tmux") or "tmux", "kill-pane", "-t", pane], capture_output=True, timeout=LAUNCH_TIMEOUT
252
+ )
253
+ except (OSError, subprocess.SubprocessError):
254
+ pass
255
+
256
+
257
+ def pane_backend(name: str, run: Run = subprocess.run, which=shutil.which, python: str | None = None) -> PaneBackend:
258
+ backend = {WINDOWS_TERMINAL: WindowsTerminalBackend, TMUX: TmuxBackend}[name]
259
+ return backend(run, which, python)