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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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)
|