mosscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The window of a member in its own pane — its turns and tool calls, and its approvals.
|
|
2
|
+
|
|
3
|
+
The input box only answers approvals: a member takes its work from the lead, never from the user.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.containers import Vertical
|
|
14
|
+
from textual.widgets import Input, OptionList, RichLog, Static
|
|
15
|
+
|
|
16
|
+
from ..appearance import OPERATION_COLOR, PARAMETER_COLOR
|
|
17
|
+
from ..messages import RunStopped
|
|
18
|
+
from ..permissions import ApprovalRequest, safe_display
|
|
19
|
+
from .view import Feed
|
|
20
|
+
|
|
21
|
+
INPUT_HINT = "成员只接受 Lead 的指令,不接受新任务;需要调整它时告诉 Lead。审批出现时在这里输入序号回答。"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MemberApp(App):
|
|
25
|
+
CSS = """
|
|
26
|
+
#title { height: 1; background: $primary-background; color: $text; text-style: bold; padding: 0 1; }
|
|
27
|
+
#log { height: 1fr; padding: 0 1; }
|
|
28
|
+
#running { height: auto; max-height: 6; display: none; padding: 0 1; }
|
|
29
|
+
#approval { height: auto; display: none; border-top: solid $warning; padding: 0 1; }
|
|
30
|
+
#approval-options { height: auto; max-height: 6; }
|
|
31
|
+
#state { height: 1; padding: 0 1; color: $text-muted; }
|
|
32
|
+
#input { height: 3; }
|
|
33
|
+
"""
|
|
34
|
+
BINDINGS = [Binding("escape", "interrupt", "中断 / 拒绝"), Binding("ctrl+c", "quit", "退出", priority=True)]
|
|
35
|
+
|
|
36
|
+
def __init__(self, process):
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.process = process
|
|
39
|
+
self.heading = f"成员 {process.name} · 团队 {process.team}"
|
|
40
|
+
self._requests: list[ApprovalRequest] = []
|
|
41
|
+
self._job: asyncio.Task | None = None
|
|
42
|
+
self._feed = Feed()
|
|
43
|
+
|
|
44
|
+
def compose(self) -> ComposeResult:
|
|
45
|
+
yield Static(self.heading, id="title")
|
|
46
|
+
yield RichLog(id="log", wrap=True, markup=False)
|
|
47
|
+
yield Static("", id="running", markup=False)
|
|
48
|
+
with Vertical(id="approval"):
|
|
49
|
+
yield Static(id="approval-detail")
|
|
50
|
+
yield OptionList(id="approval-options")
|
|
51
|
+
yield Static("启动中…", id="state")
|
|
52
|
+
yield Input(placeholder=INPUT_HINT, id="input")
|
|
53
|
+
|
|
54
|
+
async def on_mount(self) -> None:
|
|
55
|
+
self.title = self.heading
|
|
56
|
+
self.process.on_event = self._event
|
|
57
|
+
self.process.on_approval = self._approval
|
|
58
|
+
self.process.on_settled = self._settled
|
|
59
|
+
self._job = asyncio.get_running_loop().create_task(self._serve())
|
|
60
|
+
|
|
61
|
+
async def _serve(self) -> None:
|
|
62
|
+
code = await self.process.serve()
|
|
63
|
+
self.process.exit_code = code
|
|
64
|
+
self.exit()
|
|
65
|
+
|
|
66
|
+
async def on_unmount(self) -> None:
|
|
67
|
+
if self._job is not None and not self._job.done():
|
|
68
|
+
self._job.cancel() # Its finally writes the record: stopped.
|
|
69
|
+
await asyncio.gather(self._job, return_exceptions=True)
|
|
70
|
+
|
|
71
|
+
# Events -----------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _log(self, text: Text | str) -> None:
|
|
74
|
+
self.query_one("#log", RichLog).write(text)
|
|
75
|
+
|
|
76
|
+
def _state(self, text: str) -> None:
|
|
77
|
+
self.query_one("#state", Static).update(text)
|
|
78
|
+
|
|
79
|
+
def _event(self, event) -> None:
|
|
80
|
+
for line in self._feed.event(event):
|
|
81
|
+
self._log(line)
|
|
82
|
+
running = self.query_one("#running", Static)
|
|
83
|
+
running.update(self._feed.running_text())
|
|
84
|
+
running.display = bool(self._feed.waiting)
|
|
85
|
+
if isinstance(event, list): # The messages of a new turn.
|
|
86
|
+
self._state("运行中 · Esc 中断这一轮")
|
|
87
|
+
elif isinstance(event, RunStopped):
|
|
88
|
+
self._state("空闲 · 等待 Lead 的消息")
|
|
89
|
+
|
|
90
|
+
# Approvals --------------------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _approval(self, request: ApprovalRequest) -> None:
|
|
93
|
+
self._requests.append(request)
|
|
94
|
+
if len(self._requests) == 1:
|
|
95
|
+
self._show()
|
|
96
|
+
|
|
97
|
+
def _show(self) -> None:
|
|
98
|
+
from ..tui import choice_label
|
|
99
|
+
|
|
100
|
+
panel = self.query_one("#approval")
|
|
101
|
+
if not self._requests:
|
|
102
|
+
panel.display = False
|
|
103
|
+
self.query_one("#input", Input).focus()
|
|
104
|
+
return
|
|
105
|
+
request = self._requests[0]
|
|
106
|
+
text = Text(f"需要授权 · {request.operation.tool}\n", style=f"bold {OPERATION_COLOR}")
|
|
107
|
+
text.append(safe_display(request.operation.target), style=PARAMETER_COLOR)
|
|
108
|
+
if request.operation.cwd is not None:
|
|
109
|
+
text.append("\n工作目录:" + request.operation.cwd)
|
|
110
|
+
text.append("\n" + request.reason)
|
|
111
|
+
self.query_one("#approval-detail", Static).update(text)
|
|
112
|
+
options = self.query_one("#approval-options", OptionList)
|
|
113
|
+
options.clear_options()
|
|
114
|
+
options.add_options(
|
|
115
|
+
[f"{index}. {choice_label(request, choice)}" for index, choice in enumerate(request.choices, 1)]
|
|
116
|
+
)
|
|
117
|
+
options.highlighted = 0
|
|
118
|
+
panel.display = True
|
|
119
|
+
options.focus()
|
|
120
|
+
self._state("等待授权 · 回车确认,Esc 拒绝本次")
|
|
121
|
+
|
|
122
|
+
def _answer(self, choice: str) -> None:
|
|
123
|
+
if not self._requests:
|
|
124
|
+
return
|
|
125
|
+
request = self._requests.pop(0)
|
|
126
|
+
self.process.conversation.registry.permissions.respond(request.id, choice)
|
|
127
|
+
self.process.answered_here()
|
|
128
|
+
self._log(Text(f" 已回答:{choice}", style="dim"))
|
|
129
|
+
self._state("运行中 · Esc 中断这一轮")
|
|
130
|
+
self._show()
|
|
131
|
+
|
|
132
|
+
def _settled(self, request_id: str, choice: str | None) -> None:
|
|
133
|
+
"""Answered in the lead's window (choice), or no longer waiting: the card goes."""
|
|
134
|
+
from ..tui import choice_label
|
|
135
|
+
|
|
136
|
+
request = next((item for item in self._requests if item.id == request_id), None)
|
|
137
|
+
if request is None:
|
|
138
|
+
return
|
|
139
|
+
shown = self._requests[0] is request
|
|
140
|
+
self._requests.remove(request)
|
|
141
|
+
if choice is not None:
|
|
142
|
+
self._log(Text(f" 已在 Lead 的窗口中回答:{choice_label(request, choice)}", style="dim"))
|
|
143
|
+
if shown:
|
|
144
|
+
self._state("运行中 · Esc 中断这一轮")
|
|
145
|
+
self._show()
|
|
146
|
+
|
|
147
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
148
|
+
if event.option_list.id == "approval-options" and self._requests:
|
|
149
|
+
self._answer(self._requests[0].choices[event.option_index])
|
|
150
|
+
|
|
151
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
152
|
+
value = event.value.strip()
|
|
153
|
+
event.input.value = ""
|
|
154
|
+
if self._requests and value.isdigit() and 1 <= int(value) <= len(self._requests[0].choices):
|
|
155
|
+
self._answer(self._requests[0].choices[int(value) - 1])
|
|
156
|
+
return
|
|
157
|
+
if value:
|
|
158
|
+
self._log(Text(INPUT_HINT, style=OPERATION_COLOR))
|
|
159
|
+
|
|
160
|
+
def action_interrupt(self) -> None:
|
|
161
|
+
"""Esc refuses the approval on screen, otherwise ends the current turn."""
|
|
162
|
+
if self._requests:
|
|
163
|
+
self._answer("deny")
|
|
164
|
+
return
|
|
165
|
+
runner = self.process.runner
|
|
166
|
+
if runner is not None and runner.interrupt():
|
|
167
|
+
self._log(Text("已中断这一轮;成员会告诉 Lead。", style=OPERATION_COLOR))
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""A member in a pane of its own — another MossCode process, started by the lead with
|
|
2
|
+
`--team-dir <team> --team-member <name>`.
|
|
3
|
+
|
|
4
|
+
It writes its pid into the team record, then a heartbeat every HEARTBEAT seconds; it reads its mailbox
|
|
5
|
+
every POLL seconds, takes the session mode from its record each turn and writes its status there.
|
|
6
|
+
It never receives a key: the configuration is loaded again from the lead's path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import Member, TeamError, TeamStore
|
|
18
|
+
from .backends import HEARTBEAT
|
|
19
|
+
from .lock import now
|
|
20
|
+
from .member import POLL, MemberRunner, approval_record
|
|
21
|
+
|
|
22
|
+
MISSING_ENV = "窗格中缺少环境变量 {name}:设为用户环境变量,或改用 in-process"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def missing_key(config) -> str:
|
|
26
|
+
"""A key that should come from the environment but does not."""
|
|
27
|
+
env = getattr(config, "api_key_env", None)
|
|
28
|
+
if env and not os.environ.get(env):
|
|
29
|
+
return MISSING_ENV.format(name=env)
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fail(store: TeamStore, name: str, text: str) -> int:
|
|
34
|
+
"""The lead reads the error from the record and reports backend_failed."""
|
|
35
|
+
|
|
36
|
+
def write(team):
|
|
37
|
+
member = team.member(name)
|
|
38
|
+
if member is not None:
|
|
39
|
+
member.error, member.status, member.pid = text, "stopped", None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
store.modify(write)
|
|
43
|
+
except TeamError:
|
|
44
|
+
pass
|
|
45
|
+
print(f"MossCode:{text}", file=sys.stderr, flush=True)
|
|
46
|
+
return 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MemberProcess:
|
|
50
|
+
"""The member side of a pane: its runner, its record and its heartbeat."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
store: TeamStore,
|
|
55
|
+
name: str,
|
|
56
|
+
conversation,
|
|
57
|
+
*,
|
|
58
|
+
heartbeat: float = HEARTBEAT,
|
|
59
|
+
poll: float = POLL,
|
|
60
|
+
interactive: bool = False,
|
|
61
|
+
):
|
|
62
|
+
self.store, self.name, self.conversation = store, name, conversation
|
|
63
|
+
self.heartbeat, self.poll, self.interactive = heartbeat, poll, interactive
|
|
64
|
+
self.runner: MemberRunner | None = None
|
|
65
|
+
self.mode = "default"
|
|
66
|
+
self.exit_code = 0
|
|
67
|
+
self.on_event: Callable[[object], None] | None = None # The pane's window.
|
|
68
|
+
self.on_approval: Callable[[object], None] | None = None
|
|
69
|
+
# A request settled elsewhere (answered in the lead's window: the choice; else None).
|
|
70
|
+
self.on_settled: Callable[[str, str | None], None] | None = None
|
|
71
|
+
self._asking: dict[str, object] = {} # Requests waiting for an answer, here or from the lead.
|
|
72
|
+
self._answers: asyncio.Task | None = None
|
|
73
|
+
self._nudge = asyncio.Event()
|
|
74
|
+
self._writes: set[asyncio.Task] = set()
|
|
75
|
+
self._lock = asyncio.Lock()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def team(self) -> str:
|
|
79
|
+
return self.store.name
|
|
80
|
+
|
|
81
|
+
def _record(self) -> Member:
|
|
82
|
+
member = self.store.load().member(self.name)
|
|
83
|
+
if member is None:
|
|
84
|
+
raise TeamError("unknown_member", f"团队 {self.team} 中没有成员 {self.name}。")
|
|
85
|
+
return member
|
|
86
|
+
|
|
87
|
+
def _session_mode(self) -> str:
|
|
88
|
+
"""The lead writes the session mode into the record; read at the start of each turn."""
|
|
89
|
+
try:
|
|
90
|
+
self.mode = self._record().mode or self.mode
|
|
91
|
+
except TeamError:
|
|
92
|
+
pass
|
|
93
|
+
return self.mode
|
|
94
|
+
|
|
95
|
+
def _write(self, change: Callable[[Member], None]) -> None:
|
|
96
|
+
async def write():
|
|
97
|
+
async with self._lock:
|
|
98
|
+
|
|
99
|
+
def apply(team):
|
|
100
|
+
member = team.member(self.name)
|
|
101
|
+
if member is not None:
|
|
102
|
+
change(member)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
await asyncio.to_thread(self.store.modify, apply)
|
|
106
|
+
except TeamError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
job = asyncio.get_running_loop().create_task(write())
|
|
110
|
+
self._writes.add(job)
|
|
111
|
+
job.add_done_callback(self._writes.discard)
|
|
112
|
+
|
|
113
|
+
async def settle(self) -> None:
|
|
114
|
+
while pending := [job for job in self._writes if not job.done()]: # See TeamRuntime.settle in runtime.py.
|
|
115
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
116
|
+
|
|
117
|
+
def _status(self, state: str, fields: dict) -> None:
|
|
118
|
+
def change(member: Member) -> None:
|
|
119
|
+
member.status = state
|
|
120
|
+
member.last_tool = fields.get("last_tool", "") if state == "running" else ""
|
|
121
|
+
if "usage" in fields:
|
|
122
|
+
member.usage = fields["usage"]
|
|
123
|
+
if "approved" in fields:
|
|
124
|
+
member.approved = fields["approved"]
|
|
125
|
+
|
|
126
|
+
self._write(change)
|
|
127
|
+
|
|
128
|
+
async def prepare(self) -> MemberRunner:
|
|
129
|
+
"""The same runner as an in-process member, restored from its transcript."""
|
|
130
|
+
from ..agents import FORK
|
|
131
|
+
from .runtime import build_runner, load_history, restore_member
|
|
132
|
+
|
|
133
|
+
member = await asyncio.to_thread(self._record)
|
|
134
|
+
conversation = self.conversation
|
|
135
|
+
await conversation.start()
|
|
136
|
+
agent = conversation.subagents.catalog.get(member.agent_type) if member.agent_type != FORK else None
|
|
137
|
+
if agent is None:
|
|
138
|
+
raise TeamError("backend_failed", f"Agent 定义 {member.agent_type} 不存在。")
|
|
139
|
+
if agent.model and not conversation._is_current(agent.model):
|
|
140
|
+
config = conversation._fork_provider_config(agent.model)
|
|
141
|
+
problem = missing_key(config) if config is not None else ""
|
|
142
|
+
if problem:
|
|
143
|
+
raise TeamError("backend_failed", problem)
|
|
144
|
+
history = await asyncio.to_thread(load_history, self.store, member)
|
|
145
|
+
if member.worktree: # In use while this process lives, even after the lead has gone.
|
|
146
|
+
try:
|
|
147
|
+
await conversation.worktree_manager().mark_in_use(
|
|
148
|
+
member.worktree["flat"], f"团队 {self.team} 成员 {self.name}", pid=os.getpid()
|
|
149
|
+
)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
root = Path.cwd().resolve() if member.worktree else None
|
|
153
|
+
relative = (member.worktree or {}).get("relative", "")
|
|
154
|
+
self.mode = member.mode or "default"
|
|
155
|
+
runner = await build_runner(
|
|
156
|
+
conversation,
|
|
157
|
+
self.store,
|
|
158
|
+
member,
|
|
159
|
+
agent,
|
|
160
|
+
root,
|
|
161
|
+
relative,
|
|
162
|
+
on_approval=self._approval,
|
|
163
|
+
poll=self.poll,
|
|
164
|
+
session_mode=self._session_mode,
|
|
165
|
+
)
|
|
166
|
+
restore_member(runner, member, history)
|
|
167
|
+
runner.on_status = self._status
|
|
168
|
+
runner.on_event = self._event
|
|
169
|
+
self.runner = runner
|
|
170
|
+
return runner
|
|
171
|
+
|
|
172
|
+
def _approval(self, request) -> None:
|
|
173
|
+
"""Shown in this window, and published in the record for the lead to show too."""
|
|
174
|
+
self._asking[request.id] = request
|
|
175
|
+
item = approval_record(request)
|
|
176
|
+
|
|
177
|
+
def publish(member: Member) -> None:
|
|
178
|
+
if all(entry.get("id") != request.id for entry in member.approvals if isinstance(entry, dict)):
|
|
179
|
+
member.approvals.append(item)
|
|
180
|
+
|
|
181
|
+
self._write(publish)
|
|
182
|
+
if self._answers is None or self._answers.done():
|
|
183
|
+
self._answers = asyncio.get_running_loop().create_task(self._watch_answers())
|
|
184
|
+
if self.on_approval is not None:
|
|
185
|
+
self.on_approval(request)
|
|
186
|
+
|
|
187
|
+
def answered_here(self) -> None:
|
|
188
|
+
"""This window answered a request: take it out of the record now rather than at the next poll."""
|
|
189
|
+
self._nudge.set()
|
|
190
|
+
|
|
191
|
+
async def _watch_answers(self) -> None:
|
|
192
|
+
"""While a request waits, take the lead's answers (and only those) from the mailbox; a request
|
|
193
|
+
answered either way leaves the record, which tells the lead to withdraw its card."""
|
|
194
|
+
policy = self.conversation.registry.permissions
|
|
195
|
+
while self._asking:
|
|
196
|
+
try:
|
|
197
|
+
await asyncio.wait_for(self._nudge.wait(), self.poll)
|
|
198
|
+
except TimeoutError:
|
|
199
|
+
pass
|
|
200
|
+
self._nudge.clear()
|
|
201
|
+
try:
|
|
202
|
+
answers = await asyncio.to_thread(self.store.mailbox.take, self.name, {"permission_response"})
|
|
203
|
+
except TeamError:
|
|
204
|
+
answers = []
|
|
205
|
+
remote: dict[str, str] = {}
|
|
206
|
+
for message in answers:
|
|
207
|
+
request_id, choice = message.extra.get("request_id"), message.extra.get("choice")
|
|
208
|
+
if request_id in self._asking:
|
|
209
|
+
try:
|
|
210
|
+
if policy.respond(request_id, choice):
|
|
211
|
+
remote[request_id] = choice
|
|
212
|
+
except ValueError:
|
|
213
|
+
pass
|
|
214
|
+
done = {
|
|
215
|
+
request_id
|
|
216
|
+
for request_id in self._asking
|
|
217
|
+
if (future := policy.pending.get(request_id)) is None or future.done()
|
|
218
|
+
}
|
|
219
|
+
if not done:
|
|
220
|
+
continue
|
|
221
|
+
for request_id in done:
|
|
222
|
+
del self._asking[request_id]
|
|
223
|
+
if self.on_settled is not None:
|
|
224
|
+
self.on_settled(request_id, remote.get(request_id))
|
|
225
|
+
|
|
226
|
+
def drop(member: Member, done: set = done) -> None: # Runs later; keep this round's set.
|
|
227
|
+
member.approvals = [
|
|
228
|
+
entry for entry in member.approvals if not isinstance(entry, dict) or entry.get("id") not in done
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
self._write(drop)
|
|
232
|
+
|
|
233
|
+
def _event(self, event) -> None:
|
|
234
|
+
if self.on_event is not None:
|
|
235
|
+
self.on_event(event)
|
|
236
|
+
|
|
237
|
+
async def _beat(self) -> None:
|
|
238
|
+
"""Every HEARTBEAT seconds while it runs."""
|
|
239
|
+
while True:
|
|
240
|
+
await asyncio.sleep(self.heartbeat)
|
|
241
|
+
pid = os.getpid()
|
|
242
|
+
|
|
243
|
+
def beat(member: Member, pid: int = pid) -> None:
|
|
244
|
+
member.heartbeat, member.pid = now(), pid
|
|
245
|
+
|
|
246
|
+
self._write(beat)
|
|
247
|
+
|
|
248
|
+
async def serve(self) -> int:
|
|
249
|
+
"""Prepare, announce the pid, run until a shutdown_request ends it; the record says stopped after."""
|
|
250
|
+
try:
|
|
251
|
+
runner = await self.prepare()
|
|
252
|
+
except (TeamError, Exception) as exc: # Whatever stops it from starting goes to the lead.
|
|
253
|
+
message = exc.message if isinstance(exc, TeamError) else f"{type(exc).__name__}: {exc}"
|
|
254
|
+
await asyncio.to_thread(fail, self.store, self.name, message)
|
|
255
|
+
self.exit_code = 1
|
|
256
|
+
return 1
|
|
257
|
+
pid = os.getpid()
|
|
258
|
+
|
|
259
|
+
def started(member: Member) -> None:
|
|
260
|
+
member.pid, member.heartbeat, member.error = pid, now(), ""
|
|
261
|
+
member.status, member.approvals = "idle", [] # None left from a process that died.
|
|
262
|
+
|
|
263
|
+
self._write(started)
|
|
264
|
+
await self.settle()
|
|
265
|
+
beat = asyncio.get_running_loop().create_task(self._beat())
|
|
266
|
+
try:
|
|
267
|
+
runner.start()
|
|
268
|
+
runner.wake()
|
|
269
|
+
await runner.job
|
|
270
|
+
finally:
|
|
271
|
+
beat.cancel()
|
|
272
|
+
if self._answers is not None:
|
|
273
|
+
self._answers.cancel()
|
|
274
|
+
await asyncio.gather(beat, *([self._answers] if self._answers else []), return_exceptions=True)
|
|
275
|
+
await runner.close()
|
|
276
|
+
|
|
277
|
+
def stopped(member: Member) -> None:
|
|
278
|
+
member.status, member.pid, member.last_tool, member.approvals = "stopped", None, "", []
|
|
279
|
+
|
|
280
|
+
self._write(stopped)
|
|
281
|
+
await self.settle()
|
|
282
|
+
try:
|
|
283
|
+
await self.conversation.close()
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def member_conversation(settings, config, project: Path, *, interactive: bool):
|
|
290
|
+
"""Like the CLI's, without teams, MCP or memory extraction; hooks as in -p."""
|
|
291
|
+
from ..agents import AgentCatalog
|
|
292
|
+
from ..conversation import Conversation
|
|
293
|
+
from ..hooks import HookEngine
|
|
294
|
+
from ..providers import create_provider
|
|
295
|
+
from ..session import Session
|
|
296
|
+
from ..tools import create_default_registry
|
|
297
|
+
|
|
298
|
+
registry = create_default_registry(project, shell=settings.shell)
|
|
299
|
+
registry.permissions.interactive = interactive
|
|
300
|
+
tool = registry.get("execute_command")
|
|
301
|
+
hooks = HookEngine(project, shell=getattr(tool, "shell", None), enabled=True, trust_project=False)
|
|
302
|
+
conversation = Conversation(
|
|
303
|
+
create_provider(config),
|
|
304
|
+
settings.system_prompt,
|
|
305
|
+
registry,
|
|
306
|
+
max_iterations=settings.max_iterations,
|
|
307
|
+
context_root=project,
|
|
308
|
+
budget=config.budget(),
|
|
309
|
+
session=Session.new(project),
|
|
310
|
+
auto_compact=settings.auto_compact,
|
|
311
|
+
persist=False,
|
|
312
|
+
provider_configs=settings.providers,
|
|
313
|
+
provider_factory=create_provider,
|
|
314
|
+
provider_info=(config.name, config.protocol, config.model),
|
|
315
|
+
hooks=hooks,
|
|
316
|
+
agents=AgentCatalog.load(project),
|
|
317
|
+
worktree_links=settings.worktree_links,
|
|
318
|
+
worktree_stale_hours=0,
|
|
319
|
+
)
|
|
320
|
+
conversation._session_started = True # A member is not a new session of the user's (no session_start hooks).
|
|
321
|
+
return conversation
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run_member(team_dir: str, name: str, config_path: str | None, provider: str | None) -> int:
|
|
325
|
+
"""cli.main with --team-dir/--team-member: the pane's window, or plain lines when not on a terminal."""
|
|
326
|
+
from ..config import ConfigError, find_config, load_settings
|
|
327
|
+
|
|
328
|
+
store = TeamStore(Path(team_dir))
|
|
329
|
+
try:
|
|
330
|
+
team = store.load()
|
|
331
|
+
except TeamError as exc:
|
|
332
|
+
print(f"MossCode:{exc.message}", file=sys.stderr)
|
|
333
|
+
return 1
|
|
334
|
+
if team.member(name) is None:
|
|
335
|
+
print(f"MossCode:团队 {team.name} 中没有成员 {name}。", file=sys.stderr)
|
|
336
|
+
return 1
|
|
337
|
+
try:
|
|
338
|
+
settings = load_settings(config_path if config_path else find_config())
|
|
339
|
+
config = settings.select(provider)
|
|
340
|
+
except ConfigError as exc:
|
|
341
|
+
return fail(store, name, str(exc))
|
|
342
|
+
problem = missing_key(config)
|
|
343
|
+
if problem:
|
|
344
|
+
return fail(store, name, problem)
|
|
345
|
+
project = Path(team.project) if team.project else Path.cwd()
|
|
346
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
347
|
+
try:
|
|
348
|
+
conversation = member_conversation(settings, config, project, interactive=interactive)
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
return fail(store, name, f"{type(exc).__name__}: {exc}")
|
|
351
|
+
process = MemberProcess(store, name, conversation, heartbeat=HEARTBEAT, poll=POLL, interactive=interactive)
|
|
352
|
+
if interactive:
|
|
353
|
+
from .pane_app import MemberApp
|
|
354
|
+
|
|
355
|
+
MemberApp(process).run()
|
|
356
|
+
return process.exit_code
|
|
357
|
+
process.on_event = _print_event
|
|
358
|
+
return asyncio.run(process.serve())
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _print_event(event) -> None:
|
|
362
|
+
"""Without a terminal (tests, logs): one line per message batch, tool call and reply."""
|
|
363
|
+
from ..messages import Completed, ToolProgress
|
|
364
|
+
|
|
365
|
+
if isinstance(event, list):
|
|
366
|
+
for message in event:
|
|
367
|
+
print(f"[{message.sender} · {message.type}] {message.summary or message.body[:80]}", flush=True)
|
|
368
|
+
elif isinstance(event, ToolProgress) and event.phase == "running":
|
|
369
|
+
print(f"● {event.call.name}", flush=True)
|
|
370
|
+
elif isinstance(event, Completed) and event.message.text:
|
|
371
|
+
print(event.message.text, flush=True)
|