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,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)