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,1355 @@
1
+ """The lead's side of a team — the current team, its members (in-process or in panes) and the lead's mailbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ..messages import ProviderError, UsageTotals
16
+ from ..permissions import ApprovalRequest
17
+ from ..worktree.meta import process_alive
18
+ from . import LEAD, Member, TeamError, TeamStore, list_teams, teams_root, validate_member_name
19
+ from .backends import (
20
+ IN_PROCESS,
21
+ IN_PROCESS_REASON,
22
+ LABELS,
23
+ START_TIMEOUT,
24
+ WINDOWS_TERMINAL,
25
+ Choice,
26
+ PaneBackend,
27
+ detect_backend,
28
+ member_alive,
29
+ member_arguments,
30
+ pane_backend,
31
+ team_window,
32
+ )
33
+ from .board import STATUS_LABELS
34
+ from .lock import now
35
+ from .mailbox import Message
36
+ from .member import POLL, MemberPolicy, MemberRunner, TeamAccess, addendum, approval_from_record, task_listing
37
+
38
+ if TYPE_CHECKING:
39
+ from ..conversation import Conversation
40
+
41
+ MAX_MEMBERS = 4
42
+ BODY_PREVIEW = 4000 # Characters of a message body in a team update.
43
+ REASON = IN_PROCESS_REASON
44
+ BACKEND_LABELS = LABELS
45
+ STATUS_NAMES = {"running": "运行中", "idle": "空闲", "stopped": "已停止"}
46
+ STOP_WAIT = 10.0 # Seconds a member has to stop after a shutdown_request.
47
+ IDLE_STATES = {"failed": "失败", "interrupted": "被用户中断"} # idle_notification reasons.
48
+ LEAD_GUIDE = """## Agent 团队
49
+
50
+ 你可以作为 lead 组建团队:成员是长期存在的 Agent,各自在独立的 git 工作树中工作,通过消息和共享任务协作。
51
+ - 何时使用:用户的任务能拆成几块可以并行、彼此独立的多文件改动时。一处小改动、一个问题或只需要调查时不要建团队,直接做或用子 Agent。
52
+ - 做法:team_create 建团队;先用 team_task_create 建好任务并写明依赖(blocked_by),再用 team_spawn 创建成员(至多 {max_members} 个),在 prompt 中写清它负责的任务、背景、范围和完成后要提交改动。
53
+ - 创建后不要轮询、不要反复查看任务列表:成员的消息、空闲通知和新就绪的任务会作为 <team-update> 自动送达。需要时用 send_message 回复或指派。
54
+ - 全部成员完成后,用 team_merge 按任务依赖的顺序逐个合并它们的分支;每次合并都要用户确认。
55
+ - 合并冲突时:让该成员在它的工作树中合入主分支、解决冲突并提交,再 team_merge。主目录有未提交的改动、或用户拒绝合并时,停下来问用户,不要自己清理或重试。
56
+ - 最后向用户汇报:每个成员做了什么、合并了哪些分支、还剩什么问题。"""
57
+
58
+
59
+ def remove_tree(path: Path) -> None:
60
+ """Windows: a reader may hold a file for a moment; retry briefly."""
61
+ for attempt in range(20):
62
+ try:
63
+ shutil.rmtree(path)
64
+ return
65
+ except FileNotFoundError:
66
+ return
67
+ except PermissionError:
68
+ if attempt == 19:
69
+ raise
70
+ time.sleep(0.05)
71
+
72
+
73
+ def render_update(team: str, messages: list[Message], ready: list[str]) -> str:
74
+ """One notification for everything that arrived in the lead's mailbox."""
75
+ lines = [f"<team-update>\n团队 {team}:"]
76
+ for message in messages:
77
+ extra, sender = message.extra, message.sender
78
+ if message.type == "text":
79
+ body = message.body
80
+ if len(body) > BODY_PREVIEW:
81
+ body = body[:BODY_PREVIEW] + f"\n(正文共 {len(message.body)} 字,已截断)"
82
+ lines.append(f"[{sender} 的消息] 摘要:{message.summary}\n{body}")
83
+ elif message.type == "plan_approval_request":
84
+ lines.append(
85
+ f"[{sender} 提交了计划,request_id={extra.get('request_id')}]\n{extra.get('plan', '')}\n"
86
+ f"用 send_message(to: {sender},type: plan_approval_response,request_id,approve,feedback)答复。"
87
+ )
88
+ elif message.type == "idle_notification":
89
+ tasks = "、".join(extra.get("completed_tasks") or [])
90
+ state = IDLE_STATES.get(extra.get("reason"), "空闲")
91
+ lines.append(
92
+ f"{sender} {state}"
93
+ + (f",完成任务 {tasks}" if tasks else "")
94
+ + (f"。最后回复:{message.summary}" if message.summary else "。")
95
+ )
96
+ elif message.type == "shutdown_response":
97
+ verdict = "同意停止" if extra.get("approve") else f"拒绝停止:{extra.get('reason') or '未说明'}"
98
+ lines.append(f"{sender} {verdict}。")
99
+ else:
100
+ lines.append(f"[{sender} · {message.type}] {message.body}")
101
+ if ready:
102
+ lines.append("现在就绪的任务:" + "、".join(ready) + "。")
103
+ return "\n".join(lines) + "\n</team-update>\n以上是团队的最新消息;需要时用 send_message 回复成员,不要轮询。"
104
+
105
+
106
+ def note_for(message: Message) -> str:
107
+ """One line in the chat for each update."""
108
+ sender, extra = message.sender, message.extra
109
+ if message.type == "text":
110
+ return f"{sender} → lead:{message.summary}"
111
+ if message.type == "plan_approval_request":
112
+ return f"成员 {sender} 提交了计划,等待 Lead 审批"
113
+ if message.type == "idle_notification":
114
+ tasks = "、".join(extra.get("completed_tasks") or [])
115
+ state = IDLE_STATES.get(extra.get("reason"), "空闲")
116
+ return f"{sender} {state}" + (f":完成任务 {tasks}" if tasks else "")
117
+ if message.type == "shutdown_response":
118
+ return f"{sender} {'已停止' if extra.get('approve') else '拒绝停止'}"
119
+ return f"{sender}:{message.type}"
120
+
121
+
122
+ def label_of(runner) -> str:
123
+ return BACKEND_LABELS.get(getattr(runner, "backend_name", IN_PROCESS), IN_PROCESS)
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class MemberRow:
128
+ """One line of the member panel. Waiting approvals are counted by the interface."""
129
+
130
+ name: str
131
+ status: str
132
+ pane: bool
133
+ since: float | None = None # time.monotonic() when it entered its status; None when stopped.
134
+ last_tool: str = ""
135
+ task: str = "" # The subject of its first in_progress task.
136
+ window: str = ""
137
+
138
+
139
+ class PaneRunner:
140
+ """The lead's view of a member in a pane of its own. It polls its mailbox itself; its
141
+ record in config.json says how it is doing, and its process ending is the member stopping."""
142
+
143
+ def __init__(
144
+ self,
145
+ store: TeamStore,
146
+ name: str,
147
+ backend: PaneBackend,
148
+ pid: int,
149
+ pane: str | None,
150
+ *,
151
+ status: str = "idle",
152
+ poll: float = POLL,
153
+ ):
154
+ self.store, self.name, self.backend = store, name, backend
155
+ self.backend_name = backend.name
156
+ self.pid, self.pane, self.poll = pid, pane, poll
157
+ self.status, self.last_tool = status, ""
158
+ self.since = time.monotonic() # When the lead saw it enter its status.
159
+ self.on_status: Callable[[str, dict], None] | None = None
160
+ # A request it published in its record, and one that left the record (or the member
161
+ # stopped with it still there).
162
+ self.on_request: Callable[[dict], None] | None = None
163
+ self.on_settled: Callable[[str, bool], None] | None = None # (request id, the member ended)
164
+ self.asked: set[str] = set()
165
+ self.job: asyncio.Task | None = None
166
+
167
+ def wake(self) -> None:
168
+ """Nothing to do; it reads its mailbox every POLL seconds."""
169
+
170
+ def start(self) -> asyncio.Task:
171
+ self.job = asyncio.get_running_loop().create_task(self.watch())
172
+ return self.job
173
+
174
+ def _report(self, status: str) -> None:
175
+ if status != self.status:
176
+ self.since = time.monotonic()
177
+ self.status = status
178
+ if self.on_status is not None:
179
+ self.on_status(status, {})
180
+
181
+ async def watch(self) -> None:
182
+ try:
183
+ while process_alive(self.pid):
184
+ await asyncio.sleep(self.poll)
185
+ try:
186
+ team = await asyncio.to_thread(self.store.load)
187
+ except TeamError:
188
+ continue
189
+ member = team.member(self.name)
190
+ if member is None or member.status == "stopped" or (member.pid and member.pid != self.pid):
191
+ return
192
+ self.last_tool = member.last_tool if member.status == "running" else ""
193
+ self._approvals(member.approvals)
194
+ if member.status != self.status:
195
+ self._report(member.status)
196
+ finally:
197
+ self._approvals([], ended=True)
198
+ self._report("stopped")
199
+
200
+ def _approvals(self, items: list, *, ended: bool = False) -> None:
201
+ """New requests in the record go to the lead; the ones gone from it were answered or dropped."""
202
+ listed = {item.get("id") for item in items if isinstance(item, dict) and item.get("id")}
203
+ for item in items:
204
+ if isinstance(item, dict) and item.get("id") and item["id"] not in self.asked:
205
+ self.asked.add(item["id"])
206
+ if self.on_request is not None:
207
+ self.on_request(item)
208
+ for request_id in self.asked - listed:
209
+ self.asked.discard(request_id)
210
+ if self.on_settled is not None:
211
+ self.on_settled(request_id, ended)
212
+
213
+ async def stop(self) -> None:
214
+ """End its process tree (and its tmux pane)."""
215
+ if self.job is not None and not self.job.done():
216
+ self.job.cancel()
217
+ await asyncio.gather(self.job, return_exceptions=True)
218
+ await asyncio.to_thread(self.backend.kill, self.pid if process_alive(self.pid) else None, self.pane)
219
+
220
+ async def close(self) -> None:
221
+ pass
222
+
223
+
224
+ async def build_runner(
225
+ conversation: Conversation,
226
+ store: TeamStore,
227
+ member: Member,
228
+ agent,
229
+ root: Path | None,
230
+ relative: str,
231
+ *,
232
+ wake: Callable[[str], None] | None = None,
233
+ on_approval: Callable[[ApprovalRequest], None] | None = None,
234
+ poll: float = POLL,
235
+ session_mode: Callable[[], str] | None = None,
236
+ ) -> MemberRunner:
237
+ """The definition's prompt plus the team addendum, tools rooted in its worktree. The lead builds
238
+ in-process members with it; a pane member's process builds itself with it (session_mode from its record)."""
239
+ from ..agent import AgentLoop, add_usage
240
+ from ..agents.runtime import GLOBAL_BAN, SubagentLog, effective_mode
241
+ from ..prompts import git_snapshot
242
+ from ..tools.team import member_tools
243
+
244
+ main = conversation._agent
245
+ parent = main.registry.permissions
246
+ access = TeamAccess(store, member.name, wake)
247
+ session_mode = session_mode or (lambda: parent.mode)
248
+ runner = MemberRunner(
249
+ access,
250
+ member.name,
251
+ agent_mode=lambda mode: effective_mode(agent, mode),
252
+ session_mode=session_mode,
253
+ require_plan_approval=member.require_plan_approval,
254
+ on_approval=on_approval,
255
+ poll=poll,
256
+ )
257
+ provider, budget, owned = conversation.subagents._provider(agent.model, f"成员 {member.name} ")
258
+ mode = "plan" if member.require_plan_approval else effective_mode(agent, session_mode())
259
+ policy = MemberPolicy(parent, mode, interactive=parent.interactive, label=f"成员 {member.name}", root=root)
260
+ base = root or parent.root
261
+ extra = member_tools(base, access, runner)
262
+ keep = lambda name: name not in GLOBAL_BAN and agent.allows(name) # noqa: E731
263
+ if root is None:
264
+ registry = main.registry.for_agent(
265
+ agent="teammate", agent_type=agent.name, permissions=policy, keep=keep, extra=extra
266
+ )
267
+ git = main.git
268
+ else:
269
+ from ..tools import create_default_registry
270
+
271
+ shell = getattr(main.registry.get("execute_command"), "shell", None)
272
+ fresh = create_default_registry(root, permissions=policy, shell=shell if shell is not None else "auto")
273
+ registry = fresh.for_agent(agent="teammate", agent_type=agent.name, permissions=policy, keep=keep, extra=extra)
274
+ registry.session, registry.hooks, registry.worktree = main.registry.session, main.registry.hooks, relative
275
+ git = await asyncio.to_thread(git_snapshot, root)
276
+ system = agent.prompt + "\n\n" + addendum(store.name, member.name, member.worktree)
277
+ turns = min(agent.max_turns or main.max_iterations, main.max_iterations)
278
+ loop = AgentLoop(provider, system, registry, max_iterations=turns, unknown_limit=main.unknown_limit)
279
+ loop.instructions, loop.git = conversation.project_instructions, git
280
+ loop.budget, loop.auto_compact, loop.session = budget, main.auto_compact, main.session
281
+
282
+ def counted(usage) -> None: # The session total includes in-process members.
283
+ main.total_usage = add_usage(main.total_usage, usage)
284
+
285
+ loop.on_usage = counted
286
+ log = SubagentLog(
287
+ store.directory / member.transcript,
288
+ {
289
+ "type": "teammate",
290
+ "v": 1,
291
+ "team": store.name,
292
+ "name": member.name,
293
+ "agent_type": agent.name,
294
+ "created": now(),
295
+ },
296
+ )
297
+ loop.on_commit = log.append
298
+ runner.loop, runner.owned = loop, owned
299
+ return runner
300
+
301
+
302
+ def load_history(store: TeamStore, member: Member) -> list:
303
+ """The member's conversation from its transcript; revive_failed when it cannot be trusted. Blocking."""
304
+ from ..session import load_session
305
+
306
+ path = store.directory / member.transcript if member.transcript else None
307
+ if path is None or not path.exists():
308
+ return []
309
+ try:
310
+ restored = load_session(path)
311
+ except (OSError, UnicodeError) as exc:
312
+ raise TeamError("revive_failed", f"无法读取对话记录 {path.name}:{exc}") from None
313
+ if restored.skipped:
314
+ raise TeamError("revive_failed", f"对话记录 {path.name} 已损坏({restored.skipped} 行无法读取)。")
315
+ return restored.history
316
+
317
+
318
+ def restore_member(runner: MemberRunner, member: Member, history: list) -> None:
319
+ """The old conversation, its token total and its plan approval."""
320
+ runner.loop.restore(history)
321
+ usage = member.usage or {} # The record keeps the member's total across revivals.
322
+ runner.loop.total_usage = UsageTotals(
323
+ **{key: int(usage.get(key, 0) or 0) for key in ("input_tokens", "output_tokens", "cached_input_tokens")}
324
+ )
325
+ runner.approved = member.approved
326
+
327
+
328
+ class TeamRuntime:
329
+ def __init__(
330
+ self,
331
+ conversation: Conversation,
332
+ *,
333
+ home: Path | None = None,
334
+ max_members: int = MAX_MEMBERS,
335
+ poll: float = POLL,
336
+ teammate_mode: str = IN_PROCESS,
337
+ launch: dict | None = None,
338
+ ):
339
+ self.conversation = conversation
340
+ self.home = home
341
+ self.max_members = max_members
342
+ self.poll = poll
343
+ # Where members run, and what a pane member needs to load the same configuration.
344
+ self.teammate_mode = teammate_mode
345
+ self.launch = dict(launch or {}) # {"config": path, "provider": name}
346
+ self.env: dict | None = None # Tests replace these; None/defaults use the real environment.
347
+ self.which = shutil.which
348
+ self.run = subprocess.run
349
+ self.python: str | None = None
350
+ self.start_timeout = START_TIMEOUT
351
+ self._synced_mode = "" # The session mode last written to pane members' records.
352
+ self._revived: dict[str, str] = {}
353
+ self.current: TeamStore | None = None
354
+ self.runners: dict[str, MemberRunner | PaneRunner] = {}
355
+ self.plan_requests: dict[str, str] = {} # Member -> request_id the lead has not answered.
356
+ self.on_approval: Callable[[ApprovalRequest], None] | None = None # The TUI shows it.
357
+ self.on_note: Callable[[str], None] | None = None
358
+ self.on_change: Callable[[], None] | None = None # A member's status changed.
359
+ self.on_withdraw: Callable[[str], None] | None = None # A card to take down.
360
+ self.on_commit: Callable[[str], None] | None = None # An in-process member's transcript grew.
361
+ self.askers: dict[str, str] = {} # Approval request id -> the member asking.
362
+ self.forwarded: dict[str, tuple[ApprovalRequest, asyncio.Future]] = {} # Pane members' requests.
363
+ self._relays: set[asyncio.Task] = set()
364
+ self.stop_wait = STOP_WAIT
365
+ self._stops: set[asyncio.Task] = set()
366
+ self.notices: list[str] = [] # Corrupt files kept aside and the like.
367
+ self._lead_wake = asyncio.Event()
368
+ self._watcher: asyncio.Task | None = None
369
+ self._writes: set[asyncio.Task] = set()
370
+ self._write_lock = asyncio.Lock() # Status writes land in the order they happened.
371
+ self._merge_lock = asyncio.Lock() # One team_merge at a time, from the first check to the merge.
372
+
373
+ def guide(self) -> str:
374
+ """The fixed team section of the lead's system prompt."""
375
+ return LEAD_GUIDE.format(max_members=self.max_members)
376
+
377
+ # Places ---------------------------------------------------------------------------------------
378
+
379
+ @property
380
+ def project(self) -> Path:
381
+ conversation = self.conversation
382
+ return Path(conversation.context_root or conversation.registry.permissions.root)
383
+
384
+ @property
385
+ def root(self) -> Path:
386
+ return teams_root(self.project, self.home)
387
+
388
+ def teams(self) -> list[TeamStore]:
389
+ return list_teams(self.root)
390
+
391
+ def _note(self, text: str) -> None:
392
+ if self.on_note is not None:
393
+ try:
394
+ self.on_note(text)
395
+ except Exception:
396
+ pass
397
+
398
+ def access(self) -> TeamAccess:
399
+ if self.current is None:
400
+ raise TeamError("no_team", "还没有当前团队;先用 team_create 新建或打开一个团队。")
401
+ return TeamAccess(self.current, LEAD, self.wake)
402
+
403
+ def wake(self, name: str) -> None:
404
+ if name == LEAD:
405
+ self._lead_wake.set()
406
+ elif name in self.runners:
407
+ self.runners[name].wake()
408
+
409
+ # Teams ----------------------------------------------------------------------------------------
410
+
411
+ async def create(self, name: str, description: str = "") -> tuple[str, dict]:
412
+ """New or reopened, then current."""
413
+ store = TeamStore.at(self.root, name, self.notices.append)
414
+ if self.current is not None and self.current.name == name:
415
+ return f"团队 {name} 已经是当前团队。", {"team": name, "opened": True}
416
+ busy = [runner.name for runner in self.runners.values() if runner.status == "running"]
417
+ if busy:
418
+ raise TeamError(
419
+ "team_busy",
420
+ f"当前团队 {self.current.name} 还有成员在运行({'、'.join(busy)});等它们空闲后再切换团队。",
421
+ )
422
+ opened = await asyncio.to_thread(store.exists)
423
+ if not opened:
424
+ await asyncio.to_thread(store.create, description)
425
+ session = self.conversation.session
426
+ team = await asyncio.to_thread(store.acquire, os.getpid(), getattr(session, "id", "") or "", str(self.project))
427
+ await self._leave(keep_panes=True)
428
+ live: list[Member] = []
429
+ if opened: # A pane member that still runs and reports keeps going; everything else is stopped.
430
+
431
+ def refresh(team):
432
+ for member in team.members:
433
+ if (
434
+ member.backend != IN_PROCESS
435
+ and member.status != "stopped"
436
+ and member_alive(member.pid, member.heartbeat)
437
+ ):
438
+ live.append(member)
439
+ else:
440
+ member.status, member.pid = "stopped", None
441
+
442
+ team = await asyncio.to_thread(store.modify, refresh)
443
+ self.current = store
444
+ for member in live:
445
+ self._attach(store, member)
446
+ self._start_watching()
447
+ self._changed()
448
+ if opened:
449
+ states = "、".join(
450
+ f"{m.name}({m.agent_type},{STATUS_NAMES.get(m.status, m.status)})" for m in team.members if m in live
451
+ )
452
+ stopped = "、".join(f"{m.name}({m.agent_type})" for m in team.members if m not in live)
453
+ text = f"已打开已有团队 {name}:"
454
+ if states:
455
+ text += f"仍在窗格中运行的成员 {states}"
456
+ text += f";已停止的成员 {stopped}。" if stopped else "。"
457
+ else:
458
+ text += f"{stopped or '无成员'},成员都处于停止状态。"
459
+ return text, {"team": name, "opened": True, "live": [m.name for m in live]}
460
+ return (
461
+ f"已创建团队 {name} 并设为当前团队。接下来用 team_task_create 拆分任务(写好依赖),"
462
+ "再用 team_spawn 创建成员;owner 只能填已创建的成员,可以先留空,创建成员后再用 team_task_update 指派。"
463
+ ), {"team": name, "opened": False}
464
+
465
+ async def _leave(self, *, keep_panes: bool = False) -> None:
466
+ """Stop this process's members of the current team and let it go; pane members switched
467
+ away from keep running (keep_panes) and are found again when the team is reopened."""
468
+ if self.current is None:
469
+ return
470
+ await self._stop_members(keep_panes=keep_panes)
471
+ try:
472
+ await asyncio.to_thread(self.current.release, os.getpid())
473
+ except TeamError:
474
+ pass
475
+ self.current = None
476
+ self.plan_requests.clear()
477
+
478
+ async def _stop_members(self, *, keep_panes: bool = False) -> None:
479
+ runners, self.runners = list(self.runners.values()), {}
480
+ if keep_panes:
481
+ for runner in [runner for runner in runners if isinstance(runner, PaneRunner)]:
482
+ runners.remove(runner)
483
+ runner.on_status, runner.on_request = None, None
484
+ runner.on_settled = lambda request_id, ended, name=runner.name: self._withdraw(
485
+ name, request_id, ended, note=False
486
+ )
487
+ if runner.job is not None:
488
+ runner.job.cancel()
489
+ await asyncio.gather(runner.job, return_exceptions=True)
490
+ for runner in runners:
491
+ await runner.stop()
492
+ await self.settle()
493
+ if runners and self.current is not None:
494
+ names = {runner.name for runner in runners}
495
+
496
+ def stopped(team):
497
+ for member in team.members:
498
+ if member.name in names:
499
+ member.status, member.pid = "stopped", None
500
+
501
+ try:
502
+ await asyncio.to_thread(self.current.modify, stopped)
503
+ except TeamError:
504
+ pass
505
+ await self._release(self.current, names)
506
+ self._changed()
507
+
508
+ def _start_watching(self) -> None:
509
+ if self._watcher is None or self._watcher.done():
510
+ self._watcher = asyncio.get_running_loop().create_task(self._watch())
511
+
512
+ async def close(self) -> None:
513
+ """Ask every member to stop, wait up to stop_wait in all, cancel the rest; the team stays."""
514
+ if self._watcher is not None:
515
+ self._watcher.cancel()
516
+ await asyncio.gather(self._watcher, return_exceptions=True)
517
+ self._watcher = None
518
+ for job in list(self._stops):
519
+ job.cancel()
520
+ await asyncio.gather(*self._stops, return_exceptions=True)
521
+ store, runners = self.current, list(self.runners.values())
522
+ if store is not None and runners:
523
+ access = TeamAccess(store, LEAD)
524
+ for runner in runners:
525
+ try:
526
+ await asyncio.to_thread(access.send, runner.name, "shutdown_request", reason="Lead 正在退出")
527
+ runner.wake()
528
+ except TeamError:
529
+ pass
530
+ jobs = [runner.job for runner in runners if runner.job is not None]
531
+ if jobs:
532
+ await asyncio.wait(jobs, timeout=self.stop_wait)
533
+ await self.settle()
534
+ try: # Answers nobody will read; a later lead should not see them as news.
535
+ await asyncio.to_thread(store.mailbox.discard_unread, LEAD, {"shutdown_response"})
536
+ except TeamError:
537
+ pass
538
+ await self._leave()
539
+ for job in list(self._relays):
540
+ job.cancel()
541
+ await asyncio.gather(*self._relays, return_exceptions=True)
542
+ await self.settle()
543
+
544
+ async def rows(self) -> list[MemberRow]:
545
+ """Every member of the current team in its record's order; files are read off the loop."""
546
+ store = self.current
547
+ if store is None:
548
+ return []
549
+
550
+ def read():
551
+ team = store.load()
552
+ try:
553
+ tasks = store.board.list()
554
+ except TeamError:
555
+ tasks = []
556
+ return team, tasks
557
+
558
+ try:
559
+ team, tasks = await asyncio.to_thread(read)
560
+ except TeamError:
561
+ return []
562
+ if self.current is not store:
563
+ return []
564
+ rows = []
565
+ for member in team.members:
566
+ runner = self.runners.get(member.name)
567
+ status = runner.status if runner is not None else "stopped"
568
+ task = next(
569
+ (task.subject for task in tasks if task.owner == member.name and task.status == "in_progress"), ""
570
+ )
571
+ rows.append(
572
+ MemberRow(
573
+ member.name,
574
+ status,
575
+ member.backend != IN_PROCESS,
576
+ since=runner.since if runner is not None else None,
577
+ last_tool=runner.last_tool if runner is not None and status == "running" else "",
578
+ task=task,
579
+ window=member.window,
580
+ )
581
+ )
582
+ return rows
583
+
584
+ def transcript(self, name: str) -> Path | None:
585
+ """Where the member view reads; None without a current team."""
586
+ if self.current is None:
587
+ return None
588
+ return self.current.transcripts / f"{name}.jsonl"
589
+
590
+ def counts(self) -> tuple[int, int]:
591
+ """(running, idle) members of this process."""
592
+ states = [runner.status for runner in self.runners.values()]
593
+ return states.count("running"), states.count("idle")
594
+
595
+ def _changed(self) -> None:
596
+ if self.on_change is not None:
597
+ try:
598
+ self.on_change()
599
+ except Exception:
600
+ pass
601
+
602
+ async def _ended(self, store: TeamStore, runner: MemberRunner | PaneRunner) -> None:
603
+ """A member stopped by itself after its shutdown_response (a pane member: its process ended)."""
604
+ if self.runners.get(runner.name) is not runner:
605
+ return # Stopped by the runtime, which cleans up itself.
606
+ del self.runners[runner.name]
607
+ self.plan_requests.pop(runner.name, None)
608
+ await runner.close()
609
+ if isinstance(runner, PaneRunner): # It may have died without saying so.
610
+ await self._mark_stopped(store, runner.name, pid=runner.pid)
611
+ await self._release(store, [runner.name])
612
+ self._note(f"成员 {runner.name} 已停止({label_of(runner)})")
613
+ self._changed()
614
+
615
+ async def _mark_stopped(self, store: TeamStore, name: str, *, pid: int | None = None, error: str = "") -> None:
616
+ def stopped(team):
617
+ member = team.member(name)
618
+ if member is not None and (pid is None or member.pid in {pid, None}):
619
+ member.status, member.pid, member.last_tool = "stopped", None, ""
620
+ if error:
621
+ member.error = error
622
+
623
+ try:
624
+ await asyncio.to_thread(store.modify, stopped)
625
+ except TeamError:
626
+ pass
627
+
628
+ async def _release(self, store: TeamStore, names) -> None:
629
+ """The worktrees of stopped members are no longer in use."""
630
+ try:
631
+ team = await asyncio.to_thread(store.load)
632
+ except TeamError:
633
+ return
634
+ for name in names:
635
+ member = team.member(name)
636
+ if member is None or not member.worktree:
637
+ continue
638
+ try:
639
+ manager = self.conversation.worktree_manager()
640
+ await manager.mark_in_use(member.worktree["flat"], "")
641
+ except Exception:
642
+ pass
643
+
644
+ # Stopping one member ---------------------------------------------------------------
645
+
646
+ async def stop_member(self, name: str) -> str:
647
+ access = self.access()
648
+ store = self.current
649
+ team = await asyncio.to_thread(store.load)
650
+ if team.member(name) is None:
651
+ raise TeamError("unknown_member", f"团队 {team.name} 中没有成员 {name}。")
652
+ runner = self.runners.get(name)
653
+ if runner is None:
654
+ return f"成员 {name} 已经停止。"
655
+ await access.asend(name, "shutdown_request", reason="用户用 /team stop 停止")
656
+ job = asyncio.get_running_loop().create_task(self._stop_later(store, runner))
657
+ self._stops.add(job)
658
+ job.add_done_callback(self._stops.discard)
659
+ return f"已请求成员 {name} 停止:它在这一轮结束后停止;{self.stop_wait:g} 秒内没有停止时强制停止。"
660
+
661
+ async def _stop_later(self, store: TeamStore, runner: MemberRunner | PaneRunner) -> None:
662
+ """Cancel an in-process member, end a pane member's processes, when it has not stopped in time."""
663
+ if runner.job is not None:
664
+ await asyncio.wait([runner.job], timeout=self.stop_wait)
665
+ if self.runners.get(runner.name) is not runner or (runner.job is not None and runner.job.done()):
666
+ return
667
+ del self.runners[runner.name]
668
+ self.plan_requests.pop(runner.name, None)
669
+ await runner.stop()
670
+ await self.settle()
671
+
672
+ await self._mark_stopped(store, runner.name)
673
+ await self._release(store, [runner.name])
674
+ self._note(f"成员 {runner.name} 没有在 {self.stop_wait:g} 秒内停止,已强制停止({label_of(runner)})")
675
+ self._changed()
676
+
677
+ async def delete(self, name: str | None = None, *, force: bool = False) -> tuple[str, dict]:
678
+ """Stop the members, delete clean and merged worktrees, then the team. name: any team of
679
+ this project (/team delete), the current one by default (team_delete)."""
680
+ if name is None or (self.current is not None and self.current.name == name):
681
+ self.access()
682
+ store = self.current
683
+ busy = [runner.name for runner in self.runners.values() if runner.status == "running"]
684
+ if busy and not force:
685
+ raise TeamError(
686
+ "team_busy", f"成员 {'、'.join(busy)} 正在运行;等它们空闲,或用 force: true 停止后删除。"
687
+ )
688
+ for job in list(self._stops):
689
+ job.cancel()
690
+ await self._stop_members()
691
+ if self._watcher is not None:
692
+ self._watcher.cancel()
693
+ await asyncio.gather(self._watcher, return_exceptions=True)
694
+ self._watcher = None
695
+ self.current = None
696
+ self.plan_requests.clear()
697
+ else:
698
+ store = TeamStore.at(self.root, name, self.notices.append)
699
+ if not await asyncio.to_thread(store.exists):
700
+ raise TeamError("unknown_team", f"本项目没有名为 {name} 的团队。")
701
+ await asyncio.to_thread(store.acquire, os.getpid()) # team_held while another lead has it.
702
+ team = await asyncio.to_thread(store.load)
703
+ removed, kept = [], []
704
+ for member in team.members:
705
+ if not member.worktree:
706
+ continue
707
+ worktree = member.worktree.get("name", "")
708
+ try:
709
+ manager = self.conversation.worktree_manager()
710
+ await manager.remove(worktree)
711
+ removed.append(worktree)
712
+ except Exception as exc: # Changes, unmerged commits or a failed deletion: keep it.
713
+ kept.append(f"{worktree}({getattr(exc, 'changes', None) and exc.changes.describe() or exc})")
714
+ await asyncio.to_thread(remove_tree, store.directory)
715
+ text = f"已删除团队 {team.name}。"
716
+ if removed:
717
+ text += "已删除工作树:" + "、".join(removed) + "。"
718
+ if kept:
719
+ text += "保留的工作树(有未合并的提交或改动):" + ";".join(kept) + "。用 /worktree 查看。"
720
+ self._note(f"团队 {team.name} 已删除")
721
+ self._changed()
722
+ return text, {"team": team.name, "removed": removed, "kept": kept}
723
+
724
+ # Reports ----------------------------------------------------------------------
725
+
726
+ def _holder(self, team) -> str:
727
+ pid = int((team.lead or {}).get("pid") or 0)
728
+ if not pid:
729
+ return "无"
730
+ if pid == os.getpid():
731
+ return "本进程"
732
+ return f"PID {pid}({'运行中' if process_alive(pid) else '已退出'})"
733
+
734
+ def _state(self, store: TeamStore, team, member: Member) -> str:
735
+ """In this process the runner knows best; a team no live lead holds has nothing running."""
736
+ if self.current is not None and self.current.name == store.name:
737
+ runner = self.runners.get(member.name)
738
+ return runner.status if runner is not None else "stopped"
739
+ pid = int((team.lead or {}).get("pid") or 0)
740
+ return member.status if pid and process_alive(pid) else "stopped"
741
+
742
+ def overview(self) -> str:
743
+ """Blocking."""
744
+ stores = self.teams()
745
+ if not stores:
746
+ return "本项目还没有团队。让模型用 team_create 建一个。"
747
+ lines = [f"团队({len(stores)})"]
748
+ for store in stores:
749
+ try:
750
+ team = store.load()
751
+ except TeamError as exc:
752
+ lines.append(f" {store.name} ! {exc.message}")
753
+ continue
754
+ states = [self._state(store, team, member) for member in team.members]
755
+ counts = "、".join(
756
+ f"{STATUS_NAMES[state]} {states.count(state)}" for state in STATUS_NAMES if states.count(state)
757
+ )
758
+ current = " 当前" if self.current is not None and self.current.name == store.name else ""
759
+ lines.append(
760
+ f" {store.name}{current} 成员 {len(team.members)}"
761
+ + (f"({counts})" if counts else "")
762
+ + f" 持有者 {self._holder(team)}"
763
+ )
764
+ lines.append("/team <名称> 查看详情;/team stop <成员> 停止当前团队的成员;/team delete <名称> 删除团队。")
765
+ return "\n".join(lines)
766
+
767
+ def detail(self, name: str) -> str:
768
+ """Blocking."""
769
+ store = TeamStore.at(self.root, name)
770
+ if not store.exists():
771
+ raise TeamError("unknown_team", f"本项目没有名为 {name} 的团队。")
772
+ team = store.load()
773
+ lines = [
774
+ f"团队 {team.name}" + (f":{team.description}" if team.description else ""),
775
+ f"创建于 {team.created};持有者 {self._holder(team)}",
776
+ f"成员({len(team.members)})",
777
+ ]
778
+ for member in team.members:
779
+ state = self._state(store, team, member)
780
+ backend = BACKEND_LABELS.get(member.backend, member.backend)
781
+ lines.append(
782
+ f" {member.name} {STATUS_NAMES.get(state, state)} {member.agent_type} "
783
+ f"后端 {backend}({member.backend_reason or '—'})"
784
+ )
785
+ if member.window and member.backend == WINDOWS_TERMINAL:
786
+ lines.append(f" 在团队窗口 {member.window} 中")
787
+ if member.worktree:
788
+ lines.append(f" 工作树 {member.worktree.get('name', '')},分支 {member.worktree.get('branch', '')}")
789
+ else:
790
+ lines.append(" 在主项目目录中工作")
791
+ runner = self.runners.get(member.name) if self.current is not None and self.current.name == name else None
792
+ if runner is not None and runner.last_tool:
793
+ lines.append(f" 最近的工具调用:{runner.last_tool}")
794
+ usage = member.usage or {}
795
+ try:
796
+ unread = store.mailbox.unread_count(member.name)
797
+ except TeamError:
798
+ unread = 0
799
+ lines.append(
800
+ f" Token 入 {usage.get('input_tokens', 0)} / 出 {usage.get('output_tokens', 0)};未读消息 {unread}"
801
+ )
802
+ tasks = store.board.list()
803
+ lines += [f"任务({len(tasks)})", *(" " + line for line in task_listing(tasks).splitlines())]
804
+ return "\n".join(lines)
805
+
806
+ def context(self) -> str:
807
+ """What the lead needs to know again after /clear or /resume; blocking."""
808
+ store = self.current
809
+ if store is None:
810
+ return ""
811
+ team = store.load()
812
+ members = (
813
+ ";".join(
814
+ f"{member.name}({member.agent_type},{STATUS_NAMES.get(self._state(store, team, member))}"
815
+ + (f",分支 {member.worktree['branch']}" if member.worktree else "")
816
+ + ")"
817
+ for member in team.members
818
+ )
819
+ or "无"
820
+ )
821
+ tasks = store.board.list()
822
+ counts = "、".join(
823
+ f"{label} {sum(1 for task in tasks if task.status == key)}"
824
+ for key, label in STATUS_LABELS.items()
825
+ if any(task.status == key for task in tasks)
826
+ )
827
+ return (
828
+ f"<team-context>\n你仍是团队 {team.name} 的 Lead(会话已清空或恢复,之前的对话不在上下文中)。\n"
829
+ f"成员:{members}。\n任务:共 {len(tasks)} 个" + (f"({counts})" if counts else "") + ";"
830
+ "用 team_task_list 查看详情。团队更新会自动送达,不要轮询。\n</team-context>"
831
+ )
832
+
833
+ # Merging --------------------------------------------------------------------------------------
834
+
835
+ async def merge(self, name: str) -> tuple[str, dict]:
836
+ """Checks, the user's confirmation, the checks again, then git merge."""
837
+ from ..permissions import MERGE_CHOICES, Operation
838
+ from ..worktree import WorktreeError
839
+ from . import merge
840
+
841
+ self.access()
842
+ store = self.current
843
+ try:
844
+ manager = self.conversation.worktree_manager()
845
+ except WorktreeError as exc:
846
+ raise TeamError("main_not_ready", str(exc)) from None
847
+ busy = lambda: name in self.runners and self.runners[name].status == "running" # noqa: E731
848
+ async with self._merge_lock:
849
+ plan = await asyncio.to_thread(merge.check, manager, store, name, busy())
850
+ policy = self.conversation._agent.registry.permissions
851
+ operation = Operation("team_merge", name, preview=merge.preview(plan))
852
+ choice = await policy.confirm(
853
+ "", operation, f"把成员 {name} 的分支 {plan.branch} 合并到 {plan.target}?", MERGE_CHOICES
854
+ )
855
+ if choice is None:
856
+ raise TeamError("merge_not_confirmed", "合并需要用户在界面中确认;当前无法请求确认,没有合并。")
857
+ if choice != "merge":
858
+ raise TeamError("merge_declined", "用户拒绝了这次合并,没有合并。停下来问用户想怎么处理,不要重试。")
859
+ plan = await asyncio.to_thread(merge.check, manager, store, name, busy()) # It may have changed meanwhile.
860
+ text, details = await asyncio.to_thread(merge.merge, plan)
861
+ self._note(f"已合并成员 {name} 的分支 {plan.branch}")
862
+ return text, {"team": store.name, **details}
863
+
864
+ # Members --------------------------------------------------------------------------------------
865
+
866
+ def _choose(self) -> Choice:
867
+ """From the current teammate_mode, every time a member starts."""
868
+ return detect_backend(self.teammate_mode, self.env, self.which)
869
+
870
+ async def spawn(
871
+ self,
872
+ name: str,
873
+ agent_type: str,
874
+ prompt: str,
875
+ *,
876
+ require_plan_approval: bool = False,
877
+ isolation: str = "worktree",
878
+ ) -> tuple[str, dict]:
879
+ from ..agents import FORK
880
+
881
+ self.access()
882
+ store = self.current
883
+ problem = validate_member_name(name)
884
+ if problem:
885
+ raise TeamError("invalid_name", f"成员名 {name} 不可用:{problem}。")
886
+ team = await asyncio.to_thread(store.load)
887
+ if team.member(name) is not None:
888
+ raise TeamError("member_exists", f"团队 {team.name} 已有成员 {name}。")
889
+ if len(team.members) >= self.max_members:
890
+ raise TeamError("too_many_members", f"一个团队最多 {self.max_members} 个成员(team_max_members)。")
891
+ catalog = self.conversation.subagents.catalog
892
+ kind = agent_type.strip() or "general"
893
+ agent = catalog.get(kind) if kind != FORK else None
894
+ if agent is None:
895
+ names = "、".join(catalog.names()) or "无"
896
+ raise TeamError("unknown_agent_type", f"没有名为 {kind} 的 Agent 定义(成员不能是 Fork)。可用:{names}。")
897
+ subagents = self.conversation.subagents
898
+ try:
899
+ subagents._check_provider(agent.model, f"成员 {name} ")
900
+ except ProviderError as exc:
901
+ raise TeamError("agent_unavailable", str(exc)) from None
902
+ choice = self._choose() # Before anything is created.
903
+ worktree, root, relative = None, None, ""
904
+ if isolation == "worktree":
905
+ from ..worktree import WorktreeError
906
+ from ..worktree.manager import WORKTREES
907
+
908
+ try:
909
+ manager = self.conversation.worktree_manager()
910
+ created = await manager.create(
911
+ f"team-{team.name}/{name}", manual=True, task=f"团队 {team.name} 成员 {name}"
912
+ )
913
+ except WorktreeError as exc:
914
+ raise TeamError(getattr(exc, "code", "") or "worktree_unavailable", str(exc)) from None
915
+ meta = created.meta
916
+ await manager.mark_in_use(meta.flat, f"团队 {team.name} 成员 {name}") # Also when resumed.
917
+ root = manager.root_in(meta)
918
+ relative = (WORKTREES / meta.flat).as_posix()
919
+ from .merge import current_branch
920
+
921
+ worktree = {
922
+ "name": meta.name,
923
+ "flat": meta.flat,
924
+ "path": str(meta.path),
925
+ "branch": meta.branch,
926
+ "relative": relative,
927
+ "main": await asyncio.to_thread(current_branch, manager.top),
928
+ }
929
+ pane = choice.backend != IN_PROCESS
930
+ member = Member(
931
+ name,
932
+ agent.name,
933
+ choice.backend,
934
+ choice.reason,
935
+ "stopped" if pane else "idle",
936
+ require_plan_approval,
937
+ model=agent.model or "",
938
+ worktree=worktree,
939
+ transcript=f"transcripts/{name}.jsonl",
940
+ prompt=prompt,
941
+ )
942
+ if not pane:
943
+ runner = await self._runner(store, member, agent, root, relative)
944
+ await asyncio.to_thread(store.modify, lambda team: team.members.append(member))
945
+ await asyncio.to_thread(store.mailbox.send, name, Message(LEAD, "text", prompt, "初始任务"))
946
+ if pane:
947
+ runner = await self._launch(store, member, choice, root)
948
+ self._own(store, runner)
949
+ self.runners[name] = runner
950
+ runner.start()
951
+ runner.wake()
952
+ place = f"工作树 {worktree['path']},分支 {worktree['branch']}" if worktree else "在主项目目录中工作"
953
+ plan = ";以计划模式开始,提交的计划会出现在团队更新中,由你审批" if require_plan_approval else ""
954
+ self._note(f"成员 {name} 已创建({agent.name},{BACKEND_LABELS[choice.backend]}{self._window(store, choice)})")
955
+ self._changed()
956
+ return (
957
+ f"已创建成员 {name}({agent.name},后端:{choice.backend}({choice.reason})),{place}{plan}。"
958
+ "它完成一轮后你会收到 <team-update>;在此之前不要轮询。"
959
+ ), {
960
+ "team": team.name,
961
+ "member": name,
962
+ "backend": choice.backend,
963
+ "backend_reason": choice.reason,
964
+ "worktree": worktree["branch"] if worktree else None,
965
+ }
966
+
967
+ async def _launch(self, store: TeamStore, member: Member, choice: Choice, root: Path | None) -> PaneRunner:
968
+ """Open the pane, wait for the member to write its pid. backend_failed leaves the member
969
+ stopped and its worktree kept; nothing falls back to in-process."""
970
+ backend = pane_backend(choice.backend, self.run, self.which, self.python)
971
+ mode = self.conversation._agent.registry.permissions.mode
972
+ name = member.name
973
+
974
+ def reset(team):
975
+ record = team.member(name)
976
+ if record is not None:
977
+ record.backend, record.backend_reason = choice.backend, choice.reason
978
+ record.status, record.pid, record.heartbeat, record.pane, record.error = "stopped", None, None, None, ""
979
+ record.window, record.approvals = "", []
980
+ record.mode, record.last_tool = mode, ""
981
+
982
+ await asyncio.to_thread(store.modify, reset)
983
+ self._synced_mode = mode
984
+ cwd = root or self.project
985
+ arguments = member_arguments(store.directory, name, self.launch.get("config"), self.launch.get("provider"))
986
+ window = team_window(store.directory) if choice.backend == WINDOWS_TERMINAL else ""
987
+ try:
988
+ pane = await asyncio.to_thread(backend.launch, cwd, arguments, member=name, team=store.name, window=window)
989
+ except TeamError as exc:
990
+ await self._mark_stopped(store, name, error=exc.message)
991
+ await self._release(store, [name])
992
+ raise
993
+
994
+ def remember(team):
995
+ record = team.member(name)
996
+ if record is not None:
997
+ record.pane, record.window = pane, window
998
+
999
+ await asyncio.to_thread(store.modify, remember)
1000
+ end = asyncio.get_running_loop().time() + self.start_timeout
1001
+ while True:
1002
+ try:
1003
+ record = (await asyncio.to_thread(store.load)).member(name)
1004
+ except TeamError:
1005
+ record = None
1006
+ if record is not None and record.error:
1007
+ failure = f"成员 {name} 的{BACKEND_LABELS[choice.backend]}启动失败:{record.error}"
1008
+ break
1009
+ if record is not None and record.pid:
1010
+ status = record.status if record.status != "stopped" else "idle"
1011
+ return PaneRunner(store, name, backend, int(record.pid), pane, status=status, poll=self.poll)
1012
+ if asyncio.get_running_loop().time() >= end:
1013
+ failure = (
1014
+ f"成员 {name} 的{BACKEND_LABELS[choice.backend]}在 {self.start_timeout:g} 秒内没有启动"
1015
+ "(没有写入进程号)。"
1016
+ )
1017
+ break
1018
+ await asyncio.sleep(min(0.1, self.poll))
1019
+ await asyncio.to_thread(backend.kill, None, pane) # tmux: the pane; Windows Terminal closes its own.
1020
+ await self._mark_stopped(store, name, error=failure)
1021
+ await self._release(store, [name])
1022
+ self._note(failure)
1023
+ raise TeamError("backend_failed", failure + "成员已标为停止,工作树保留;没有改用其他后端。")
1024
+
1025
+ @staticmethod
1026
+ def _window(store: TeamStore, choice: Choice) -> str:
1027
+ """Where to find a Windows Terminal member."""
1028
+ return f",在团队窗口 {team_window(store.directory)} 中" if choice.backend == WINDOWS_TERMINAL else ""
1029
+
1030
+ def _attach(self, store: TeamStore, member: Member) -> PaneRunner:
1031
+ """A pane member that kept running while no lead held the team."""
1032
+ backend = pane_backend(member.backend, self.run, self.which, self.python)
1033
+ runner = PaneRunner(
1034
+ store, member.name, backend, int(member.pid), member.pane, status=member.status, poll=self.poll
1035
+ )
1036
+ self._own(store, runner)
1037
+ self.runners[member.name] = runner
1038
+ runner.start()
1039
+ return runner
1040
+
1041
+ def _own(self, store: TeamStore, runner: MemberRunner | PaneRunner) -> None:
1042
+ """Status changes: an in-process member's go into its record; a pane member writes its own."""
1043
+
1044
+ def status(state: str, fields: dict) -> None:
1045
+ if not isinstance(runner, PaneRunner) and "last_tool" not in fields: # The status line reads the runner.
1046
+ self._member_status(store, runner.name, state, fields)
1047
+ if state == "stopped" and self.runners.get(runner.name) is runner:
1048
+ self._track(self._ended(store, runner))
1049
+ self._changed()
1050
+
1051
+ runner.on_status = status
1052
+ if isinstance(runner, PaneRunner):
1053
+ runner.on_request = lambda item: self._forward(store, runner.name, item)
1054
+ runner.on_settled = lambda request_id, ended: self._withdraw(runner.name, request_id, ended)
1055
+
1056
+ async def _runner(self, store: TeamStore, member: Member, agent, root: Path | None, relative: str) -> MemberRunner:
1057
+ runner = await build_runner(
1058
+ self.conversation,
1059
+ store,
1060
+ member,
1061
+ agent,
1062
+ root,
1063
+ relative,
1064
+ wake=self.wake,
1065
+ on_approval=lambda request: self._approval(request, member.name),
1066
+ poll=self.poll,
1067
+ )
1068
+ append = runner.loop.on_commit
1069
+
1070
+ def commit(message) -> None: # The member view reads the new lines at once.
1071
+ append(message)
1072
+ if self.on_commit is not None:
1073
+ try:
1074
+ self.on_commit(member.name)
1075
+ except Exception:
1076
+ pass
1077
+
1078
+ runner.loop.on_commit = commit
1079
+ return runner
1080
+
1081
+ def _approval(self, request: ApprovalRequest, name: str = "") -> None:
1082
+ pending = self.conversation._agent.registry.permissions.pending
1083
+ for request_id in [key for key in self.askers if key not in pending]:
1084
+ del self.askers[request_id]
1085
+ if name:
1086
+ self.askers[request.id] = name
1087
+ if self.on_approval is not None:
1088
+ self.on_approval(request)
1089
+
1090
+ def asker(self, request_id: str) -> str:
1091
+ """The member whose approval this is; '' for the lead's own."""
1092
+ return self.askers.get(request_id, "")
1093
+
1094
+ # Pane members' approvals ------------------------------------------------
1095
+
1096
+ def _forward(self, store: TeamStore, name: str, item: dict) -> None:
1097
+ """A pane member's request, shown in the lead's window and answered through the lead's policy."""
1098
+ request = approval_from_record(item, f"成员 {name}(窗格)")
1099
+ if request is None or request.id in self.forwarded:
1100
+ return
1101
+ policy = self.conversation._agent.registry.permissions
1102
+ future = policy.hold(request)
1103
+ self.forwarded[request.id] = (request, future)
1104
+ job = asyncio.get_running_loop().create_task(self._relay(store, name, request, future))
1105
+ self._relays.add(job)
1106
+ job.add_done_callback(self._relays.discard)
1107
+ self._approval(request, name)
1108
+
1109
+ async def _relay(self, store: TeamStore, name: str, request: ApprovalRequest, future: asyncio.Future) -> None:
1110
+ """The answer given here goes to the member as a permission_response."""
1111
+ try:
1112
+ choice = await future
1113
+ except asyncio.CancelledError:
1114
+ return # Withdrawn: answered in the member's window, or the member stopped.
1115
+ finally:
1116
+ self.conversation._agent.registry.permissions.release(request)
1117
+ self.forwarded.pop(request.id, None)
1118
+ try:
1119
+ await TeamAccess(store, LEAD).asend(
1120
+ name, "permission_response", runtime=True, request_id=request.id, choice=choice
1121
+ )
1122
+ except TeamError as exc:
1123
+ self._note(f"无法把审批答复发给成员 {name}:{exc.message}")
1124
+
1125
+ def _withdraw(self, name: str, request_id: str, ended: bool, *, note: bool = True) -> None:
1126
+ """The request left the member's record (answered there) or the member stopped."""
1127
+ entry = self.forwarded.get(request_id)
1128
+ if entry is None or entry[1].done():
1129
+ return # Answered here; the record has caught up.
1130
+ entry[1].cancel()
1131
+ if self.on_withdraw is not None:
1132
+ try:
1133
+ self.on_withdraw(request_id)
1134
+ except Exception:
1135
+ pass
1136
+ if note:
1137
+ self._note(f"成员 {name} 已停止,它的审批已撤下" if ended else f"成员 {name} 的审批已在它的窗口中回答")
1138
+
1139
+ def _member_status(self, store: TeamStore, name: str, state: str, fields: dict) -> None:
1140
+ def change(team):
1141
+ member = team.member(name)
1142
+ if member is not None:
1143
+ member.status = state
1144
+ if "usage" in fields:
1145
+ member.usage = fields["usage"]
1146
+ if "approved" in fields:
1147
+ member.approved = fields["approved"]
1148
+
1149
+ async def write():
1150
+ async with self._write_lock:
1151
+ try:
1152
+ await asyncio.to_thread(store.modify, change)
1153
+ except TeamError:
1154
+ pass
1155
+
1156
+ self._track(write())
1157
+
1158
+ def _track(self, work) -> None:
1159
+ """Background bookkeeping that settle() waits for."""
1160
+ try:
1161
+ job = asyncio.get_running_loop().create_task(work)
1162
+ except RuntimeError:
1163
+ work.close()
1164
+ return
1165
+ self._writes.add(job)
1166
+ job.add_done_callback(self._writes.discard)
1167
+
1168
+ async def settle(self) -> None:
1169
+ """Wait for the status writes already started.
1170
+
1171
+ Only unfinished jobs are awaited: a finished one leaves the set in a callback on the next loop
1172
+ iteration, and gathering finished jobs alone never yields to it, so the loop would spin forever.
1173
+ """
1174
+ while pending := [job for job in self._writes if not job.done()]:
1175
+ await asyncio.gather(*pending, return_exceptions=True)
1176
+
1177
+ # Messages -------------------------------------------------------------------------------------
1178
+
1179
+ async def send(self, to: str, kind: str, body: str = "", summary: str = "", **extra) -> list[str]:
1180
+ return (await self.message(to, kind, body, summary, **extra))[0]
1181
+
1182
+ async def message(
1183
+ self, to: str, kind: str, body: str = "", summary: str = "", **extra
1184
+ ) -> tuple[list[str], list[str]]:
1185
+ """For the lead, a plan answer must name the member's open request. A stopped
1186
+ recipient is revived after the message is in its mailbox. (targets, revived)."""
1187
+ access = self.access()
1188
+ if kind == "plan_approval_response":
1189
+ open_request = self.plan_requests.get(to)
1190
+ if open_request is None or open_request != extra.get("request_id"):
1191
+ raise TeamError(
1192
+ "unknown_request",
1193
+ f"{to} 没有 request_id 为 {extra.get('request_id')} 的待审批计划"
1194
+ + (f"(待审批的是 {open_request})。" if open_request else "。"),
1195
+ )
1196
+ if kind == "shutdown_request" and to not in self.runners:
1197
+ await asyncio.to_thread(access.recipients, to, kind) # unknown_member first.
1198
+ raise TeamError("member_stopped", f"成员 {to} 已经停止,不需要停止请求。")
1199
+ store = self.current
1200
+ targets = await access.asend(to, kind, body, summary, **extra)
1201
+ if kind == "plan_approval_response":
1202
+ self.plan_requests.pop(to, None)
1203
+ verdict = "批准" if extra.get("approve") else "拒绝"
1204
+ self._note(f"Lead {verdict}了 {to} 的计划")
1205
+ revived, failures = [], []
1206
+ for name in targets:
1207
+ if name == LEAD or name in self.runners:
1208
+ continue
1209
+ try:
1210
+ await self._revive(store, name)
1211
+ revived.append(name)
1212
+ except TeamError as exc:
1213
+ failures.append(f"{name}:{exc.message}")
1214
+ if failures:
1215
+ raise TeamError("revive_failed", "消息已写入邮箱,下次恢复时仍会读到;恢复失败——" + ";".join(failures))
1216
+ return targets, revived
1217
+
1218
+ async def _revive(self, store: TeamStore, name: str) -> None:
1219
+ """The backend from the current teammate_mode, the conversation from its transcript, the same
1220
+ worktree, then a turn for the waiting messages. A pane member restores its conversation itself."""
1221
+ from ..agents import FORK
1222
+
1223
+ team = await asyncio.to_thread(store.load)
1224
+ member = team.member(name)
1225
+ if member is None:
1226
+ raise TeamError("revive_failed", "没有这个成员的记录。")
1227
+ agent = self.conversation.subagents.catalog.get(member.agent_type) if member.agent_type != FORK else None
1228
+ if agent is None:
1229
+ raise TeamError("revive_failed", f"Agent 定义 {member.agent_type} 已不存在。")
1230
+ try:
1231
+ self.conversation.subagents._check_provider(agent.model, f"成员 {name} ")
1232
+ except ProviderError as exc:
1233
+ raise TeamError("revive_failed", str(exc)) from None
1234
+ try:
1235
+ choice = self._choose()
1236
+ except TeamError as exc:
1237
+ raise TeamError("revive_failed", exc.message) from None
1238
+ history = await asyncio.to_thread(load_history, store, member)
1239
+ root, relative, manager = None, "", None
1240
+ if member.worktree:
1241
+ from ..worktree.meta import load_meta
1242
+
1243
+ try:
1244
+ manager = self.conversation.worktree_manager()
1245
+ meta = await asyncio.to_thread(load_meta, manager.meta_path(member.worktree["flat"]))
1246
+ except Exception as exc:
1247
+ raise TeamError("revive_failed", f"工作树不可用:{exc}") from None
1248
+ if meta is None or not Path(meta.path).is_dir():
1249
+ raise TeamError("revive_failed", f"工作树 {member.worktree.get('name', '')} 已不存在。")
1250
+ await manager.mark_in_use(meta.flat, f"团队 {store.name} 成员 {name}")
1251
+ root, relative = manager.root_in(meta), member.worktree.get("relative", "")
1252
+ try: # A request left from an earlier stop would stop it again at once.
1253
+ await asyncio.to_thread(store.mailbox.discard_unread, name, {"shutdown_request"})
1254
+ except TeamError:
1255
+ pass
1256
+ if choice.backend == IN_PROCESS:
1257
+ try:
1258
+ runner = await self._runner(store, member, agent, root, relative)
1259
+ except Exception as exc:
1260
+ if manager is not None:
1261
+ await manager.mark_in_use(member.worktree["flat"], "")
1262
+ raise TeamError("revive_failed", str(exc)) from None
1263
+ restore_member(runner, member, history)
1264
+ if member.backend != IN_PROCESS:
1265
+
1266
+ def moved(team):
1267
+ record = team.member(name)
1268
+ if record is not None:
1269
+ record.backend, record.backend_reason, record.pane = choice.backend, choice.reason, None
1270
+ record.window, record.approvals = "", []
1271
+
1272
+ await asyncio.to_thread(store.modify, moved)
1273
+ else:
1274
+ try:
1275
+ runner = await self._launch(store, member, choice, root)
1276
+ except TeamError as exc:
1277
+ raise TeamError("revive_failed", exc.message) from None
1278
+ changed = (
1279
+ "" if choice.backend == member.backend else f",上次是{BACKEND_LABELS.get(member.backend, member.backend)}"
1280
+ )
1281
+ self._revived[name] = f"后端:{choice.backend},{choice.reason}{changed}" # Said in the result.
1282
+ self._own(store, runner)
1283
+ self.runners[name] = runner
1284
+ runner.start()
1285
+ runner.wake()
1286
+ self._note(f"成员 {name} 已恢复({BACKEND_LABELS[choice.backend]}{self._window(store, choice)}{changed})")
1287
+ self._changed()
1288
+
1289
+ async def _watch(self) -> None:
1290
+ """Read the lead's mailbox when woken or every poll; hand it to the conversation."""
1291
+ while True:
1292
+ try:
1293
+ await asyncio.wait_for(self._lead_wake.wait(), self.poll)
1294
+ except TimeoutError:
1295
+ pass
1296
+ self._lead_wake.clear()
1297
+ store = self.current
1298
+ if store is None:
1299
+ continue
1300
+ await self._sync_mode(store)
1301
+ try:
1302
+ messages = await asyncio.to_thread(store.mailbox.read_unread, LEAD)
1303
+ except TeamError:
1304
+ continue
1305
+ if messages:
1306
+ await self._deliver(store, messages)
1307
+
1308
+ def revived_with(self, name: str) -> str:
1309
+ return self._revived.get(name, "")
1310
+
1311
+ async def _sync_mode(self, store: TeamStore) -> None:
1312
+ """A pane member reads the session mode from its record at the start of each turn."""
1313
+ mode = self.conversation._agent.registry.permissions.mode
1314
+ if mode == self._synced_mode or not any(isinstance(r, PaneRunner) for r in self.runners.values()):
1315
+ return
1316
+
1317
+ def write(team):
1318
+ for member in team.members:
1319
+ if member.backend != IN_PROCESS:
1320
+ member.mode = mode
1321
+
1322
+ try:
1323
+ await asyncio.to_thread(store.modify, write)
1324
+ self._synced_mode = mode
1325
+ except TeamError:
1326
+ pass
1327
+
1328
+ async def _deliver(self, store: TeamStore, messages: list[Message]) -> None:
1329
+ ready = []
1330
+ if any(m.type == "idle_notification" and m.extra.get("completed_tasks") for m in messages):
1331
+ from .board import is_ready
1332
+
1333
+ try:
1334
+ tasks = await asyncio.to_thread(store.board.list)
1335
+ except TeamError:
1336
+ tasks = []
1337
+ index = {task.id: task for task in tasks}
1338
+ done = {
1339
+ key for m in messages if m.type == "idle_notification" for key in m.extra.get("completed_tasks") or []
1340
+ }
1341
+ ready = [
1342
+ f"{task.id}({task.owner or '未指派'})"
1343
+ for task in tasks # Made ready by these.
1344
+ if task.status == "pending" and done & set(task.blocked_by) and is_ready(task, index)
1345
+ ]
1346
+ for message in messages:
1347
+ if message.type == "plan_approval_request":
1348
+ self.plan_requests[message.sender] = str(message.extra.get("request_id", ""))
1349
+ note = note_for(message)
1350
+ if message.type == "idle_notification": # With its backend.
1351
+ note += f"({label_of(self.runners.get(message.sender))})"
1352
+ self._note(f"团队 {store.name}:" + note)
1353
+ conversation = self.conversation
1354
+ conversation._agent.notifications.append(render_update(store.name, messages, ready))
1355
+ conversation._tasks_ready()