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
mosscode/teams/member.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""A member's long-lived conversation, and what any participant may do in a team.
|
|
2
|
+
|
|
3
|
+
The same runner serves an in-process member and a member in its own terminal pane.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from contextlib import aclosing
|
|
13
|
+
from dataclasses import asdict, fields
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from ..messages import Completed, ProviderError, RunStopped, ToolProgress
|
|
17
|
+
from ..permissions import ALL_CHOICES, ApprovalRequest, Decision, Operation, PolicyView
|
|
18
|
+
from .board import STATUS_LABELS, TeamTask, blocks, is_ready
|
|
19
|
+
from .lock import TeamError
|
|
20
|
+
from .mailbox import BROADCAST, LEAD, RUNTIME_ONLY, Message
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ..agent import AgentLoop
|
|
24
|
+
from . import TeamStore
|
|
25
|
+
|
|
26
|
+
POLL = 0.5 # Seconds between mailbox reads when nothing woke the reader.
|
|
27
|
+
SUMMARY = 300 # Characters of the last reply in an idle notification.
|
|
28
|
+
TYPE_LABELS = {
|
|
29
|
+
"text": "消息",
|
|
30
|
+
"plan_approval_request": "计划审批请求",
|
|
31
|
+
"plan_approval_response": "计划审批答复",
|
|
32
|
+
"shutdown_request": "停止请求",
|
|
33
|
+
"shutdown_response": "停止答复",
|
|
34
|
+
"idle_notification": "空闲通知",
|
|
35
|
+
"permission_response": "审批答复",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def addendum(team: str, name: str, worktree: dict | None) -> str:
|
|
40
|
+
"""Fixed text, only the names differ."""
|
|
41
|
+
lines = [
|
|
42
|
+
f"<team-member>\n你是团队 {team} 的成员 {name},Lead 的名称是 {LEAD}。",
|
|
43
|
+
"- 你的文字回复不会被任何人看到:汇报、提问、交付结果都必须用 send_message(通常发给 lead)。",
|
|
44
|
+
"- 工作流程:team_task_list 查看任务 → 领取就绪的任务(team_task_update 把 owner 设为自己、status 设为 in_progress)"
|
|
45
|
+
"→ 完成 → git commit 提交改动 → 把任务设为 completed → send_message 向 lead 汇报结果。",
|
|
46
|
+
"- 每次收到的消息都在 <incoming-messages> 中;处理完就结束这一轮,等待下一条消息,不要空等或轮询。",
|
|
47
|
+
"- 收到 shutdown_request 时,结束手头的工作,用 send_message(type: shutdown_response,request_id 为该请求的 id,"
|
|
48
|
+
"approve: true)回复;确有未完成的工作时可以 approve: false 并说明理由。",
|
|
49
|
+
]
|
|
50
|
+
if worktree:
|
|
51
|
+
main = worktree.get("main") or "<主分支>"
|
|
52
|
+
lines.append(
|
|
53
|
+
f"- 你在自己的 git 工作树中工作:{worktree['path']},分支 {worktree['branch']},主分支是 {main}。"
|
|
54
|
+
"完成的工作必须提交到这个分支,Lead 才能合并;Lead 要求你解决合并冲突时,在工作树中执行 "
|
|
55
|
+
f"git merge {main},解决冲突后提交。"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
lines.append("- 你在主项目目录中工作,与其他人共用文件;只改分配给你的文件。")
|
|
59
|
+
return "\n".join(lines) + "\n</team-member>"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def member_mode(agent_mode: Callable[[str], str], session_mode: str, *, require: bool, approved: bool) -> str:
|
|
63
|
+
"""Plan until the lead approves; then the sub-agent rules against the current session mode."""
|
|
64
|
+
if require and not approved:
|
|
65
|
+
return "plan"
|
|
66
|
+
return agent_mode(session_mode)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MemberPolicy(PolicyView):
|
|
70
|
+
"""A member's exit_plan_mode goes to the lead, not to the user; the tool sends it."""
|
|
71
|
+
|
|
72
|
+
async def _approve_plan(self, call_id, params, gate=None, notify=None) -> Decision:
|
|
73
|
+
if self.mode != "plan":
|
|
74
|
+
return Decision("deny", "not_in_plan", "当前不在计划模式,无需提交计划。")
|
|
75
|
+
if gate is not None:
|
|
76
|
+
rejected = await gate(None)
|
|
77
|
+
if rejected is not None:
|
|
78
|
+
return rejected
|
|
79
|
+
return Decision("allow")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _task_line(task: TeamTask, tasks: dict[str, TeamTask]) -> str:
|
|
83
|
+
owner = task.owner or "未指派"
|
|
84
|
+
waiting = [dep for dep in task.blocked_by if dep in tasks and tasks[dep].status != "completed"]
|
|
85
|
+
tail = f";等待 {'、'.join(waiting)}" if waiting else (";就绪" if task.status == "pending" else "")
|
|
86
|
+
return f"{task.id} [{STATUS_LABELS.get(task.status, task.status)}] {owner} · {task.subject}{tail}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def task_listing(tasks: list[TeamTask]) -> str:
|
|
90
|
+
if not tasks:
|
|
91
|
+
return "还没有任务。"
|
|
92
|
+
index = {task.id: task for task in tasks}
|
|
93
|
+
return "\n".join(_task_line(task, index) for task in tasks)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def task_detail(task: TeamTask, tasks: list[TeamTask]) -> str:
|
|
97
|
+
index = {item.id: item for item in tasks}
|
|
98
|
+
lines = [
|
|
99
|
+
f"任务 {task.id}:{task.subject}",
|
|
100
|
+
f"状态:{STATUS_LABELS.get(task.status, task.status)}",
|
|
101
|
+
f"负责人:{task.owner or '未指派'}",
|
|
102
|
+
f"依赖:{'、'.join(task.blocked_by) or '无'}({'就绪' if is_ready(task, index) else '未就绪'})",
|
|
103
|
+
f"被依赖:{'、'.join(blocks(task.id, tasks)) or '无'}",
|
|
104
|
+
f"更新:{task.updated}",
|
|
105
|
+
]
|
|
106
|
+
if task.description:
|
|
107
|
+
lines += ["", task.description]
|
|
108
|
+
return "\n".join(lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TeamAccess:
|
|
112
|
+
"""What one participant (the lead or a member) does in a team: messages and shared tasks.
|
|
113
|
+
|
|
114
|
+
Methods are blocking file work; callers run them in a thread and then call wake() on the event loop.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, store: TeamStore, actor: str, wake: Callable[[str], None] | None = None):
|
|
118
|
+
self.store = store
|
|
119
|
+
self.actor = actor
|
|
120
|
+
self._wake = wake
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def team(self) -> str:
|
|
124
|
+
return self.store.name
|
|
125
|
+
|
|
126
|
+
def wake(self, names) -> None:
|
|
127
|
+
if self._wake is not None:
|
|
128
|
+
for name in names:
|
|
129
|
+
self._wake(name)
|
|
130
|
+
|
|
131
|
+
def participants(self) -> tuple[str, ...]:
|
|
132
|
+
return self.store.load().names()
|
|
133
|
+
|
|
134
|
+
def recipients(self, to: str, kind: str) -> list[str]:
|
|
135
|
+
"""Who a message goes to; TeamError when nobody or the wrong one."""
|
|
136
|
+
names = self.participants()
|
|
137
|
+
if to == BROADCAST:
|
|
138
|
+
if kind != "text":
|
|
139
|
+
raise TeamError("invalid_message", "只有 text 消息可以广播。")
|
|
140
|
+
found = [name for name in names if name != self.actor]
|
|
141
|
+
if not found:
|
|
142
|
+
raise TeamError("unknown_member", "团队中没有其他成员可以接收广播。")
|
|
143
|
+
return found
|
|
144
|
+
if to == self.actor:
|
|
145
|
+
raise TeamError("invalid_message", "不能给自己发消息。")
|
|
146
|
+
if to != LEAD and to not in names:
|
|
147
|
+
raise TeamError("unknown_member", f"团队 {self.team} 中没有成员 {to};成员:{'、'.join(names) or '无'}。")
|
|
148
|
+
return [to]
|
|
149
|
+
|
|
150
|
+
def send(
|
|
151
|
+
self, to: str, kind: str, body: str = "", summary: str = "", *, runtime: bool = False, **extra
|
|
152
|
+
) -> list[str]:
|
|
153
|
+
"""The model never sends idle notifications or plan requests itself (runtime=False)."""
|
|
154
|
+
if kind in RUNTIME_ONLY and not runtime:
|
|
155
|
+
raise TeamError("invalid_message", f"{kind} 由系统自动发送,不能手动发送。")
|
|
156
|
+
targets = self.recipients(to, kind)
|
|
157
|
+
for target in targets:
|
|
158
|
+
self.store.mailbox.send(target, Message(self.actor, kind, body, summary, extra=dict(extra)))
|
|
159
|
+
return targets
|
|
160
|
+
|
|
161
|
+
async def asend(self, to: str, kind: str, body: str = "", summary: str = "", **extra) -> list[str]:
|
|
162
|
+
targets = await asyncio.to_thread(self.send, to, kind, body, summary, **extra)
|
|
163
|
+
self.wake(targets)
|
|
164
|
+
return targets
|
|
165
|
+
|
|
166
|
+
# Shared tasks -----------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def task_create(self, subject: str, description: str = "", blocked_by=(), owner: str = "") -> TeamTask:
|
|
169
|
+
return self.store.board.create(
|
|
170
|
+
subject, description, blocked_by, owner=owner, actor=self.actor, participants=self.participants()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def task_update(self, task_id: str, **changes):
|
|
174
|
+
return self.store.board.update(task_id, actor=self.actor, participants=self.participants(), **changes)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def render_incoming(messages: list[Message]) -> str:
|
|
178
|
+
"""One user message for every unread message of a turn."""
|
|
179
|
+
parts = []
|
|
180
|
+
for message in messages:
|
|
181
|
+
head = f"[来自 {message.sender} · {TYPE_LABELS.get(message.type, message.type)} · id={message.id}]"
|
|
182
|
+
extra = message.extra
|
|
183
|
+
if message.type == "text":
|
|
184
|
+
parts.append(f"{head} 摘要:{message.summary}\n{message.body}")
|
|
185
|
+
elif message.type == "plan_approval_response":
|
|
186
|
+
verdict = "已批准" if extra.get("approve") else "被拒绝"
|
|
187
|
+
parts.append(
|
|
188
|
+
f"{head} 你的计划(request_id={extra.get('request_id')}){verdict}。"
|
|
189
|
+
+ (f"\n反馈:{extra.get('feedback')}" if extra.get("feedback") else "")
|
|
190
|
+
)
|
|
191
|
+
elif message.type == "shutdown_request":
|
|
192
|
+
parts.append(f"{head} Lead 请求你停止。理由:{extra.get('reason') or '无'}")
|
|
193
|
+
else:
|
|
194
|
+
parts.append(f"{head}\n{message.body}")
|
|
195
|
+
return "<incoming-messages>\n" + "\n\n".join(parts) + "\n</incoming-messages>"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def approval_record(request: ApprovalRequest) -> dict:
|
|
199
|
+
"""A pane member's waiting request, as its record keeps it for the lead."""
|
|
200
|
+
return {
|
|
201
|
+
"id": request.id,
|
|
202
|
+
"call_id": request.call_id,
|
|
203
|
+
"operation": asdict(request.operation),
|
|
204
|
+
"reason": request.reason,
|
|
205
|
+
"choices": list(request.choices),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def approval_from_record(item, source: str) -> ApprovalRequest | None:
|
|
210
|
+
"""The lead's side: None when the record cannot be shown or answered."""
|
|
211
|
+
try:
|
|
212
|
+
known = {field.name for field in fields(Operation)}
|
|
213
|
+
operation = Operation(**{key: value for key, value in item["operation"].items() if key in known})
|
|
214
|
+
choices = tuple(item["choices"])
|
|
215
|
+
if not item["id"] or not choices or not all(choice in ALL_CHOICES for choice in choices):
|
|
216
|
+
return None
|
|
217
|
+
return ApprovalRequest(
|
|
218
|
+
str(item["id"]), str(item.get("call_id", "")), operation, str(item.get("reason", "")), choices, source
|
|
219
|
+
)
|
|
220
|
+
except (KeyError, TypeError, AttributeError):
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class MemberRunner:
|
|
225
|
+
"""Read the mailbox, run a turn, tell the lead, wait; until stopped."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
access: TeamAccess,
|
|
230
|
+
name: str,
|
|
231
|
+
*,
|
|
232
|
+
agent_mode: Callable[[str], str],
|
|
233
|
+
session_mode: Callable[[], str],
|
|
234
|
+
require_plan_approval: bool = False,
|
|
235
|
+
on_approval: Callable[[ApprovalRequest], None] | None = None,
|
|
236
|
+
on_status: Callable[[str, dict], None] | None = None,
|
|
237
|
+
poll: float = POLL,
|
|
238
|
+
on_event: Callable[[object], None] | None = None,
|
|
239
|
+
):
|
|
240
|
+
self.access, self.name = access, name
|
|
241
|
+
self.loop: AgentLoop | None = None # Set once the tools that need this runner exist.
|
|
242
|
+
self.owned = False # The loop's provider is closed when the member stops.
|
|
243
|
+
self.agent_mode, self.session_mode = agent_mode, session_mode
|
|
244
|
+
self.require_plan_approval = require_plan_approval
|
|
245
|
+
self.approved = False
|
|
246
|
+
self.awaiting: str | None = None # request_id of the plan waiting for the lead.
|
|
247
|
+
self.shutdown: bool | None = None # Set by its shutdown_response: True stops after this turn.
|
|
248
|
+
self.on_approval, self.on_status = on_approval, on_status
|
|
249
|
+
self.on_event = on_event # A pane member's own window shows the turn.
|
|
250
|
+
self.poll = poll
|
|
251
|
+
self.status = "idle"
|
|
252
|
+
self.since = time.monotonic() # When it entered its status.
|
|
253
|
+
self.last_tool = ""
|
|
254
|
+
self.note = "" # Why the mode is what it is after an approval.
|
|
255
|
+
self.wakeup = asyncio.Event()
|
|
256
|
+
self.job: asyncio.Task | None = None
|
|
257
|
+
self._run: asyncio.Task | None = None # The model run of the current turn (Esc stops it).
|
|
258
|
+
self._interrupted = False
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def policy(self):
|
|
262
|
+
return self.loop.registry.permissions
|
|
263
|
+
|
|
264
|
+
def wake(self) -> None:
|
|
265
|
+
self.wakeup.set()
|
|
266
|
+
|
|
267
|
+
def interrupt(self) -> bool:
|
|
268
|
+
"""End the current turn early; the member still reports and goes idle."""
|
|
269
|
+
if self._run is None or self._run.done():
|
|
270
|
+
return False
|
|
271
|
+
self._interrupted = True
|
|
272
|
+
self._run.cancel()
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
def start(self) -> asyncio.Task:
|
|
276
|
+
self.job = asyncio.get_running_loop().create_task(self.serve())
|
|
277
|
+
return self.job
|
|
278
|
+
|
|
279
|
+
async def stop(self) -> None:
|
|
280
|
+
if self.job is not None and not self.job.done():
|
|
281
|
+
self.job.cancel()
|
|
282
|
+
await asyncio.gather(self.job, return_exceptions=True)
|
|
283
|
+
await self.close()
|
|
284
|
+
|
|
285
|
+
async def close(self) -> None:
|
|
286
|
+
if self.owned and self.loop is not None:
|
|
287
|
+
self.owned = False
|
|
288
|
+
await self.loop.provider.close()
|
|
289
|
+
|
|
290
|
+
def _status(self, status: str, **fields) -> None:
|
|
291
|
+
if status != self.status:
|
|
292
|
+
self.since = time.monotonic()
|
|
293
|
+
self.status = status
|
|
294
|
+
if self.on_status is not None:
|
|
295
|
+
self.on_status(status, fields)
|
|
296
|
+
|
|
297
|
+
async def serve(self) -> None:
|
|
298
|
+
try:
|
|
299
|
+
while self.shutdown is not True:
|
|
300
|
+
try:
|
|
301
|
+
await asyncio.wait_for(self.wakeup.wait(), self.poll)
|
|
302
|
+
except TimeoutError:
|
|
303
|
+
pass
|
|
304
|
+
self.wakeup.clear()
|
|
305
|
+
messages = await asyncio.to_thread(self.access.store.mailbox.read_unread, self.name)
|
|
306
|
+
# An answer to an approval already settled in the member's own window; not for the model.
|
|
307
|
+
messages = [message for message in messages if message.type != "permission_response"]
|
|
308
|
+
if messages:
|
|
309
|
+
await self.turn(messages)
|
|
310
|
+
finally:
|
|
311
|
+
self._status("stopped")
|
|
312
|
+
|
|
313
|
+
def submit_plan(self, plan: str) -> str:
|
|
314
|
+
"""From the member's exit_plan_mode; blocking (run in a thread by the tool)."""
|
|
315
|
+
request_id = f"r-{secrets.token_hex(4)}"
|
|
316
|
+
self.access.send(LEAD, "plan_approval_request", plan, runtime=True, request_id=request_id, plan=plan)
|
|
317
|
+
self.awaiting = request_id
|
|
318
|
+
return request_id
|
|
319
|
+
|
|
320
|
+
def _absorb(self, messages: list[Message]) -> None:
|
|
321
|
+
"""Answers to this member's plan change its state before the turn starts."""
|
|
322
|
+
for message in messages:
|
|
323
|
+
if message.type != "plan_approval_response" or message.extra.get("request_id") != self.awaiting:
|
|
324
|
+
continue
|
|
325
|
+
self.awaiting = None
|
|
326
|
+
if message.extra.get("approve"):
|
|
327
|
+
self.approved = True
|
|
328
|
+
|
|
329
|
+
def _completed(self) -> set[str]:
|
|
330
|
+
try:
|
|
331
|
+
tasks = self.access.store.board.list()
|
|
332
|
+
except TeamError:
|
|
333
|
+
return set()
|
|
334
|
+
return {task.id for task in tasks if task.owner == self.name and task.status == "completed"}
|
|
335
|
+
|
|
336
|
+
async def _consume(self, prompt: str):
|
|
337
|
+
final, stopped = "", None
|
|
338
|
+
async with aclosing(self.loop.run(prompt)) as events:
|
|
339
|
+
async for event in events:
|
|
340
|
+
if self.on_event is not None:
|
|
341
|
+
self.on_event(event)
|
|
342
|
+
if isinstance(event, ApprovalRequest):
|
|
343
|
+
if self.on_approval is not None:
|
|
344
|
+
self.on_approval(event)
|
|
345
|
+
elif isinstance(event, ToolProgress) and event.phase == "running":
|
|
346
|
+
from ..agents.runtime import describe_call
|
|
347
|
+
|
|
348
|
+
self.last_tool = describe_call(event.call, self.loop.registry.permissions.root)
|
|
349
|
+
self._status("running", last_tool=self.last_tool)
|
|
350
|
+
elif isinstance(event, Completed):
|
|
351
|
+
final = event.message.text
|
|
352
|
+
elif isinstance(event, RunStopped):
|
|
353
|
+
stopped = event
|
|
354
|
+
return final, stopped
|
|
355
|
+
|
|
356
|
+
async def turn(self, messages: list[Message]) -> None:
|
|
357
|
+
self._absorb(messages)
|
|
358
|
+
request = next((m for m in messages if m.type == "shutdown_request"), None)
|
|
359
|
+
if request is not None:
|
|
360
|
+
self.shutdown = None # Its shutdown_response this turn decides.
|
|
361
|
+
mode = member_mode(
|
|
362
|
+
self.agent_mode, self.session_mode(), require=self.require_plan_approval, approved=self.approved
|
|
363
|
+
)
|
|
364
|
+
prompt = render_incoming(messages)
|
|
365
|
+
if self.on_event is not None:
|
|
366
|
+
self.on_event(messages)
|
|
367
|
+
if self.approved and mode == "plan":
|
|
368
|
+
prompt += "\n(会话当前处于计划模式,你仍只能做只读调查;等用户退出计划模式后再动手。)"
|
|
369
|
+
self.policy.set_mode(mode)
|
|
370
|
+
before = await asyncio.to_thread(self._completed)
|
|
371
|
+
self._status("running")
|
|
372
|
+
final, stopped, failure, interrupted = "", None, "", False
|
|
373
|
+
self._interrupted = False
|
|
374
|
+
self._run = asyncio.ensure_future(self._consume(prompt))
|
|
375
|
+
try:
|
|
376
|
+
final, stopped = await self._run
|
|
377
|
+
except ProviderError as exc:
|
|
378
|
+
failure = str(exc)
|
|
379
|
+
except asyncio.CancelledError:
|
|
380
|
+
if not self._interrupted or asyncio.current_task().cancelling():
|
|
381
|
+
raise
|
|
382
|
+
failure, interrupted = "用户在成员窗格中中断了这一轮", True
|
|
383
|
+
finally:
|
|
384
|
+
self._run = None
|
|
385
|
+
if not failure and (stopped is None or stopped.reason not in {"completed", "max_tokens"}):
|
|
386
|
+
failure = stopped.notice if stopped is not None else "没有完整回复"
|
|
387
|
+
done = sorted(
|
|
388
|
+
await asyncio.to_thread(self._completed) - before, key=lambda key: int(key) if key.isdigit() else 0
|
|
389
|
+
)
|
|
390
|
+
self.last_tool = ""
|
|
391
|
+
if request is not None and self.shutdown is None:
|
|
392
|
+
self.shutdown = True # It never answered: the request stands.
|
|
393
|
+
try:
|
|
394
|
+
await asyncio.to_thread(
|
|
395
|
+
self.access.send, LEAD, "shutdown_response", request_id=request.id, approve=True, reason=""
|
|
396
|
+
)
|
|
397
|
+
self.access.wake([LEAD])
|
|
398
|
+
except TeamError:
|
|
399
|
+
pass
|
|
400
|
+
# The plan request itself tells the lead; a member that stops says so in its shutdown_response.
|
|
401
|
+
if (self.awaiting is None or failure) and self.shutdown is not True:
|
|
402
|
+
reason = "interrupted" if interrupted else "failed" if failure else "completed"
|
|
403
|
+
summary = (failure or final.strip())[:SUMMARY]
|
|
404
|
+
try:
|
|
405
|
+
await asyncio.to_thread(
|
|
406
|
+
self.access.send,
|
|
407
|
+
LEAD,
|
|
408
|
+
"idle_notification",
|
|
409
|
+
"",
|
|
410
|
+
summary,
|
|
411
|
+
runtime=True,
|
|
412
|
+
reason=reason,
|
|
413
|
+
completed_tasks=done,
|
|
414
|
+
)
|
|
415
|
+
self.access.wake([LEAD])
|
|
416
|
+
except TeamError:
|
|
417
|
+
pass # The lead's mailbox is busy or gone; the status below still shows the member idle.
|
|
418
|
+
usage = self.loop.total_usage
|
|
419
|
+
self._status(
|
|
420
|
+
"idle",
|
|
421
|
+
usage={
|
|
422
|
+
"input_tokens": usage.input_tokens,
|
|
423
|
+
"output_tokens": usage.output_tokens,
|
|
424
|
+
"cached_input_tokens": usage.cached_input_tokens,
|
|
425
|
+
},
|
|
426
|
+
approved=self.approved,
|
|
427
|
+
)
|
mosscode/teams/merge.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Merging a member's branch into the main directory's current branch.
|
|
2
|
+
|
|
3
|
+
Blocking git work; the runtime runs it in a thread and asks the user in between.
|
|
4
|
+
Nothing here ever resets, checks out or cleans files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ..worktree.git import WorktreeError, run_git
|
|
14
|
+
from ..worktree.meta import load_meta
|
|
15
|
+
from .lock import TeamError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..worktree import WorktreeManager
|
|
19
|
+
from . import TeamStore
|
|
20
|
+
|
|
21
|
+
COMMIT_LIMIT = 20
|
|
22
|
+
STAT_LIMIT = 4000
|
|
23
|
+
FILE_LIMIT = 30
|
|
24
|
+
IN_PROGRESS = ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MergePlan:
|
|
29
|
+
"""What the user confirms, and what the merge needs."""
|
|
30
|
+
|
|
31
|
+
member: str
|
|
32
|
+
branch: str
|
|
33
|
+
target: str
|
|
34
|
+
top: Path
|
|
35
|
+
commits: list[str]
|
|
36
|
+
count: int
|
|
37
|
+
stat: str
|
|
38
|
+
untracked: list[str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _git(args, top: Path):
|
|
42
|
+
try:
|
|
43
|
+
return run_git(args, top)
|
|
44
|
+
except WorktreeError as exc:
|
|
45
|
+
raise TeamError("main_not_ready", str(exc)) from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _output(done) -> str:
|
|
49
|
+
return (done.stderr or done.stdout or "").strip()[:2000]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def git_dir(top: Path) -> Path:
|
|
53
|
+
done = _git(["rev-parse", "--git-dir"], top)
|
|
54
|
+
path = Path(done.stdout.strip())
|
|
55
|
+
return path if path.is_absolute() else top / path
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def current_branch(top: Path) -> str:
|
|
59
|
+
""" "" when HEAD is detached or top is no repository."""
|
|
60
|
+
try:
|
|
61
|
+
done = run_git(["symbolic-ref", "-q", "--short", "HEAD"], top)
|
|
62
|
+
except WorktreeError:
|
|
63
|
+
return ""
|
|
64
|
+
return done.stdout.strip() if done.returncode == 0 else ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unmerged(top: Path, branch: str) -> int:
|
|
68
|
+
"""Commits of branch that HEAD does not have; 0 when the branch is gone."""
|
|
69
|
+
done = _git(["rev-list", "--count", f"HEAD..refs/heads/{branch}"], top)
|
|
70
|
+
try:
|
|
71
|
+
return int(done.stdout.strip() or "0") if done.returncode == 0 else 0
|
|
72
|
+
except ValueError:
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _main_state(top: Path) -> tuple[str, list[str]]:
|
|
77
|
+
"""The target branch and the untracked files; TeamError otherwise."""
|
|
78
|
+
target = current_branch(top)
|
|
79
|
+
if not target:
|
|
80
|
+
raise TeamError("main_not_ready", "主目录的 HEAD 不在分支上(分离 HEAD);先切换到要合并进去的分支。")
|
|
81
|
+
directory = git_dir(top)
|
|
82
|
+
busy = [name for name in IN_PROGRESS if (directory / name).exists()]
|
|
83
|
+
if busy:
|
|
84
|
+
raise TeamError("main_not_ready", f"主目录有进行中的 git 操作({'、'.join(busy)});先完成或放弃它,再合并。")
|
|
85
|
+
done = _git(["status", "--porcelain", "--untracked-files=all"], top)
|
|
86
|
+
if done.returncode != 0:
|
|
87
|
+
raise TeamError("main_not_ready", f"无法读取主目录的 git 状态:{_output(done)}")
|
|
88
|
+
tracked, untracked = [], []
|
|
89
|
+
for line in done.stdout.splitlines():
|
|
90
|
+
name = line[3:].split(" -> ")[-1].strip().strip('"')
|
|
91
|
+
(untracked if line.startswith("??") else tracked).append(name)
|
|
92
|
+
if tracked:
|
|
93
|
+
shown = "、".join(tracked[:FILE_LIMIT]) + ("…" if len(tracked) > FILE_LIMIT else "")
|
|
94
|
+
raise TeamError(
|
|
95
|
+
"main_dirty",
|
|
96
|
+
f"主目录有未提交的改动({len(tracked)} 个文件:{shown})。停下来问用户如何处理"
|
|
97
|
+
"(提交、暂存或放弃由用户决定),不要自己清理。",
|
|
98
|
+
)
|
|
99
|
+
return target, untracked
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check(manager: WorktreeManager, store: TeamStore, name: str, busy: bool) -> MergePlan:
|
|
103
|
+
"""In order; the first failing check is the error."""
|
|
104
|
+
top = manager.top
|
|
105
|
+
target, untracked = _main_state(top)
|
|
106
|
+
team = store.load()
|
|
107
|
+
member = team.member(name)
|
|
108
|
+
if member is None:
|
|
109
|
+
raise TeamError(
|
|
110
|
+
"unknown_member", f"团队 {team.name} 中没有成员 {name};成员:{'、'.join(team.names()) or '无'}。"
|
|
111
|
+
)
|
|
112
|
+
meta = load_meta(manager.meta_path(member.worktree["flat"])) if member.worktree else None
|
|
113
|
+
if meta is None or not Path(meta.path).is_dir():
|
|
114
|
+
raise TeamError("no_worktree", f"成员 {name} 没有工作树,没有可合并的分支。")
|
|
115
|
+
if busy:
|
|
116
|
+
raise TeamError("member_busy", f"成员 {name} 正在运行一轮;等它空闲后再合并。")
|
|
117
|
+
changes = manager.changes(meta)
|
|
118
|
+
if changes.uncommitted:
|
|
119
|
+
files = "、".join(changes.files) or changes.error
|
|
120
|
+
raise TeamError("member_uncommitted", f"成员 {name} 的工作树有未提交的改动({files});让它提交后再合并。")
|
|
121
|
+
count = unmerged(top, meta.branch)
|
|
122
|
+
if count == 0:
|
|
123
|
+
raise TeamError("nothing_to_merge", f"成员 {name} 的分支 {meta.branch} 没有 {target} 中没有的提交。")
|
|
124
|
+
_check_order(top, store, team, name)
|
|
125
|
+
log = _git(["log", "--oneline", "--no-decorate", f"-{COMMIT_LIMIT}", f"HEAD..refs/heads/{meta.branch}"], top)
|
|
126
|
+
stat = _git(["diff", "--stat", f"HEAD...refs/heads/{meta.branch}"], top)
|
|
127
|
+
return MergePlan(
|
|
128
|
+
name,
|
|
129
|
+
meta.branch,
|
|
130
|
+
target,
|
|
131
|
+
top,
|
|
132
|
+
log.stdout.strip().splitlines(),
|
|
133
|
+
count,
|
|
134
|
+
stat.stdout.rstrip()[:STAT_LIMIT],
|
|
135
|
+
untracked,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_order(top: Path, store: TeamStore, team, name: str) -> None:
|
|
140
|
+
"""What this member finished waits on another member's work still outside HEAD."""
|
|
141
|
+
tasks = {task.id: task for task in store.board.list()}
|
|
142
|
+
for task in tasks.values():
|
|
143
|
+
if task.owner != name or task.status != "completed":
|
|
144
|
+
continue
|
|
145
|
+
for dep in task.blocked_by:
|
|
146
|
+
other = tasks.get(dep)
|
|
147
|
+
if other is None or not other.owner or other.owner == name:
|
|
148
|
+
continue
|
|
149
|
+
record = team.member(other.owner)
|
|
150
|
+
if record is None or not record.worktree:
|
|
151
|
+
continue
|
|
152
|
+
if unmerged(top, record.worktree["branch"]):
|
|
153
|
+
raise TeamError(
|
|
154
|
+
"merge_order",
|
|
155
|
+
f"先合并 {other.owner}:任务 {task.id} 依赖它的任务 {dep},而它的分支还有未合并的提交。",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def preview(plan: MergePlan) -> str:
|
|
160
|
+
"""The confirmation card."""
|
|
161
|
+
more = f"(共 {plan.count} 个,只列前 {COMMIT_LIMIT} 个)" if plan.count > len(plan.commits) else ""
|
|
162
|
+
lines = [f"成员:{plan.member}", f"分支:{plan.branch} → {plan.target}", f"提交{more}:"]
|
|
163
|
+
lines += [f" {line}" for line in plan.commits]
|
|
164
|
+
if plan.stat:
|
|
165
|
+
lines += ["改动:", plan.stat]
|
|
166
|
+
if plan.untracked:
|
|
167
|
+
shown = "、".join(plan.untracked[:FILE_LIMIT]) + ("…" if len(plan.untracked) > FILE_LIMIT else "")
|
|
168
|
+
lines.append(f"主目录中未跟踪的文件(不阻止合并):{shown}")
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _abort(top: Path) -> None:
|
|
173
|
+
done = _git(["merge", "--abort"], top)
|
|
174
|
+
if done.returncode != 0:
|
|
175
|
+
raise TeamError(
|
|
176
|
+
"merge_abort_failed",
|
|
177
|
+
"合并失败,执行 git merge --abort 也失败了。立即告诉用户,不要再执行任何 git "
|
|
178
|
+
f"写操作。git 输出:{_output(done)}",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def merge(plan: MergePlan) -> tuple[str, dict]:
|
|
183
|
+
top = plan.top
|
|
184
|
+
done = _git(["merge", "--no-ff", "--no-edit", plan.branch], top)
|
|
185
|
+
if done.returncode == 0:
|
|
186
|
+
commit = _git(["rev-parse", "--short", "HEAD"], top).stdout.strip()
|
|
187
|
+
summary = _git(["diff", "--shortstat", "HEAD^1", "HEAD"], top).stdout.strip()
|
|
188
|
+
text = (
|
|
189
|
+
f"已把成员 {plan.member} 的分支 {plan.branch} 合并到 {plan.target}:合并提交 {commit}"
|
|
190
|
+
+ (f",{summary}" if summary else "")
|
|
191
|
+
+ "。成员的工作树和分支保留,它可以继续工作;"
|
|
192
|
+
"之后再做改动前,让它先合入 " + plan.target + "。"
|
|
193
|
+
)
|
|
194
|
+
return text, {"member": plan.member, "branch": plan.branch, "commit": commit, "merged": True}
|
|
195
|
+
conflicts = _git(["diff", "--name-only", "--diff-filter=U"], top).stdout.split()
|
|
196
|
+
if conflicts:
|
|
197
|
+
_abort(top)
|
|
198
|
+
shown = "、".join(conflicts[:FILE_LIMIT])
|
|
199
|
+
raise TeamError(
|
|
200
|
+
"merge_conflict",
|
|
201
|
+
f"合并 {plan.branch} 有冲突({shown}),已放弃这次合并,主目录恢复原状。"
|
|
202
|
+
f"下一步:让成员 {plan.member} 在它的工作树中执行 git merge {plan.target},"
|
|
203
|
+
"解决冲突并提交后,再调用 team_merge。",
|
|
204
|
+
)
|
|
205
|
+
if (git_dir(top) / "MERGE_HEAD").exists():
|
|
206
|
+
_abort(top)
|
|
207
|
+
raise TeamError("merge_failed", f"git merge 失败,主目录没有改动:{_output(done)}")
|
mosscode/teams/pane.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""What a Windows Terminal pane runs — the member as a child, then exit 0.
|
|
2
|
+
|
|
3
|
+
Windows Terminal keeps a pane open when its process exits non-zero, and taskkill
|
|
4
|
+
always leaves exit code 1. Exiting 0 here closes the pane however the member ended.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
try:
|
|
13
|
+
subprocess.run([sys.executable, "-m", "mosscode", *sys.argv[1:]])
|
|
14
|
+
except BaseException:
|
|
15
|
+
pass
|
|
16
|
+
sys.exit(0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|