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,1363 @@
1
+ """The built-in slash commands. Handlers reach the screen only through ctx.ui."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from rich.cells import cell_len
10
+ from rich.text import Text
11
+
12
+ from .agents import SOURCE_LABELS as AGENT_SOURCES
13
+ from .agents.runtime import GLOBAL_BAN, WORKSPACE_TOOLS, effective_mode, stats
14
+ from .agents.tasks import ORIGIN_LABELS, STATUS_LABELS
15
+ from .appearance import KEY_HELP
16
+ from .commands import Command, CommandContext, CommandRegistry, Parsed, argument_problem
17
+ from .hooks.config import SOURCE_LABELS as HOOK_SOURCES
18
+ from .memory import SCOPE_LABELS
19
+ from .messages import ProviderError
20
+ from .permissions import MODE_BADGES, MODE_LABELS, MODES
21
+ from .skills import SOURCE_LABELS, ForkRequest
22
+
23
+ USAGE_WIDTH = 22
24
+
25
+ PLAN_ENTERED = (
26
+ "└ 已进入计划模式:只读调查和规划(含只读命令),模型提交计划后由你批准执行。"
27
+ "/do、Shift+Tab 或 /permissions 可手动退出。"
28
+ )
29
+ CLEARED = "└ 已开始新会话:屏幕、模型上下文和 Token 统计已清空,权限档位与会话授权保留。"
30
+ PERMISSION_HELP = (
31
+ "/permissions default:读取自动通过,写入和命令默认确认\n"
32
+ "/permissions acceptEdits:文件写入和编辑自动通过,命令默认确认\n"
33
+ "/permissions plan:只读调查,批准计划后执行\n"
34
+ "/permissions bypassPermissions:跳过未匹配规则的确认;硬限制、高风险确认和拒绝仍生效\n"
35
+ "Shift+Tab 依次切换 default → acceptEdits → plan"
36
+ )
37
+ MODE_CHOICES = (
38
+ ("default", "默认:读取自动通过,写入和命令确认"),
39
+ ("acceptEdits", "接受编辑:文件写入/编辑自动通过,命令确认"),
40
+ ("plan", "计划:只读调查,批准计划后执行"),
41
+ ("bypassPermissions", "跳过审批:仍遵守硬限制和拒绝规则"),
42
+ )
43
+
44
+
45
+ async def dispatch(ctx: CommandContext, parsed: Parsed) -> None:
46
+ """Check the argument, then run the handler or send the expanded template."""
47
+ command, argument = parsed.command, parsed.argument
48
+ problem = argument_problem(command, argument)
49
+ if problem:
50
+ await ctx.ui.note(problem)
51
+ elif command.kind == "prompt":
52
+ await ctx.ui.submit(command.expand(argument), command=typed(command, argument))
53
+ elif command.kind == "skill":
54
+ await run_skill(ctx, command, argument)
55
+ else:
56
+ await command.handler(ctx, argument)
57
+
58
+
59
+ def typed(command: Command, argument: str) -> str:
60
+ """The canonical form stored as Message.command: "/review 安全"."""
61
+ return f"/{command.name}" + (f" {argument}" if argument else "")
62
+
63
+
64
+ async def run_skill(ctx: CommandContext, command: Command, argument: str) -> None:
65
+ """Read the skill again, check its tools, then send the rendered SOP as the user's message."""
66
+ try:
67
+ skill, prompt, notices = ctx.conversation.prepare_skill(command.skill, argument)
68
+ except ProviderError as exc:
69
+ await ctx.ui.note(str(exc))
70
+ return
71
+ for notice in notices:
72
+ await ctx.ui.note("└ " + notice)
73
+ if skill.mode == "fork":
74
+ await ctx.ui.note(f"└ 在独立上下文中运行 Skill {skill.name}")
75
+ await ctx.ui.submit(prompt, command=typed(command, argument), fork=ForkRequest(skill, prompt))
76
+ return
77
+ await ctx.ui.submit(prompt, command=typed(command, argument))
78
+
79
+
80
+ # Help -------------------------------------------------------------------------------------
81
+
82
+
83
+ def help_text(registry: CommandRegistry, name: str = "", skills=None) -> str:
84
+ if name:
85
+ command = registry.get(name)
86
+ if command is None or command.hidden:
87
+ return f"未知命令 /{name.removeprefix('/')}。输入 /help 查看命令。"
88
+ skill = skills.get(command.skill) if skills is not None and command.kind == "skill" else None
89
+ if skill is not None:
90
+ kind = f"Skill:{skill.mode},来源{SOURCE_LABELS[skill.source]}(/skills {skill.name} 查看详情)"
91
+ elif command.kind == "prompt":
92
+ kind = "提示词命令:把预设的请求发给模型"
93
+ else:
94
+ kind = "本地命令:不经过模型"
95
+ lines = [
96
+ command.usage,
97
+ command.description,
98
+ kind,
99
+ "别名:" + ("、".join(f"/{alias}" for alias in command.aliases) or "无"),
100
+ ]
101
+ if command.details:
102
+ lines.append(command.details)
103
+ if command.kind == "prompt":
104
+ lines.append("模板首行:" + command.template.strip().splitlines()[0])
105
+ if skill is not None:
106
+ lines.append("规程首行:" + _first_line(skill.body))
107
+ return "\n".join(lines)
108
+ commands = registry.visible()
109
+ width = max([USAGE_WIDTH, *(cell_len(command.usage) + 2 for command in commands)])
110
+ rows = [
111
+ (
112
+ command.usage,
113
+ command.description
114
+ + ("(别名 " + "、".join(f"/{alias}" for alias in command.aliases) + ")" if command.aliases else ""),
115
+ )
116
+ for command in commands
117
+ ]
118
+ rows.append(("/help 命令", "查看该命令的用法和别名;// 开头的文字直接发给模型"))
119
+ lines = ["命令", *(usage + " " * (width - cell_len(usage)) + description for usage, description in rows)]
120
+ return "\n".join(lines) + "\n\n" + KEY_HELP
121
+
122
+
123
+ async def _help(ctx: CommandContext, argument: str) -> None:
124
+ await ctx.ui.note(help_text(ctx.registry, argument, ctx.conversation.skills))
125
+
126
+
127
+ # Session ----------------------------------------------------------------------------------
128
+
129
+ TASK_OPTIONS = ("取消这些任务并继续", "返回")
130
+
131
+
132
+ async def confirm_tasks(ui, conversation, proceed) -> None:
133
+ """Running background tasks are cancelled only once the user says so; Esc means 返回."""
134
+ tasks = getattr(conversation, "tasks", None)
135
+ running = tasks.background() if tasks is not None else []
136
+ if not running:
137
+ await proceed()
138
+ return
139
+
140
+ async def pick(index: int) -> None:
141
+ if index == 0:
142
+ count = await conversation.cancel_tasks()
143
+ await ui.note(f"└ 已取消 {count} 个后台任务。")
144
+ await proceed()
145
+
146
+ labels = "、".join(f"{task.id}({task.label})" for task in running[:3]) + ("等" if len(running) > 3 else "")
147
+ ui.choose(f"有 {len(running)} 个后台任务在运行:{labels}", TASK_OPTIONS, 1, pick)
148
+
149
+
150
+ async def _clear(ctx: CommandContext, argument: str) -> None:
151
+ await confirm_tasks(ctx.ui, ctx.conversation, lambda: _do_clear(ctx))
152
+
153
+
154
+ async def _do_clear(ctx: CommandContext) -> None:
155
+ notices = await ctx.conversation.clear()
156
+ ctx.ui.refresh_status()
157
+ await ctx.ui.clear_screen()
158
+ await ctx.ui.note(CLEARED)
159
+ for notice in notices:
160
+ await ctx.ui.note("└ " + notice)
161
+
162
+
163
+ async def _compact(ctx: CommandContext, argument: str) -> None:
164
+ await ctx.ui.compact(argument)
165
+
166
+
167
+ async def _resume(ctx: CommandContext, argument: str) -> None:
168
+ if argument:
169
+ try:
170
+ ctx.conversation.resolve_session(argument)
171
+ except ProviderError as exc:
172
+ await ctx.ui.note(str(exc))
173
+ else:
174
+ await confirm_tasks(ctx.ui, ctx.conversation, lambda: _resume_now(ctx, argument))
175
+ return
176
+ await open_sessions(ctx)
177
+
178
+
179
+ async def _resume_now(ctx: CommandContext, session_id: str) -> None:
180
+ ctx.ui.resume(session_id)
181
+
182
+
183
+ async def open_sessions(ctx: CommandContext) -> None:
184
+ conversation = ctx.conversation
185
+ reason = conversation.unavailable()
186
+ if reason:
187
+ await ctx.ui.note(reason)
188
+ return
189
+ current = conversation.session_id
190
+ sessions = await asyncio.to_thread(conversation.sessions)
191
+ if not any(item.id != current for item in sessions):
192
+ await ctx.ui.note("本项目没有可恢复的会话。")
193
+ return
194
+ rows = []
195
+ for item in sessions:
196
+ mark = " (当前)" if item.id == current else " (使用中)" if item.locked else ""
197
+ rows.append(
198
+ Text(
199
+ f"{item.modified:%m-%d %H:%M} {item.title} · {item.messages} 条 · {item.id[-4:]}{mark}",
200
+ no_wrap=True,
201
+ overflow="ellipsis",
202
+ )
203
+ )
204
+
205
+ async def pick(index: int) -> None:
206
+ await confirm_tasks(ctx.ui, conversation, lambda: _resume_now(ctx, sessions[index].id))
207
+
208
+ ctx.ui.choose(
209
+ "恢复会话 · 本项目最近的会话", rows, next((i for i, item in enumerate(sessions) if item.id != current), 0), pick
210
+ )
211
+
212
+
213
+ def session_report(conversation, sessions=()) -> str:
214
+ session = conversation.session
215
+ if session is None:
216
+ return "本次运行没有会话。"
217
+ current = next((item for item in sessions if item.id == session.id), None)
218
+ try:
219
+ started = datetime.strptime(session.id[:15], "%Y%m%d-%H%M%S").strftime("%Y-%m-%d %H:%M:%S")
220
+ except ValueError:
221
+ started = "未知"
222
+ archive = (
223
+ str(session.transcript_path)
224
+ if conversation.persist and session.transcript_path is not None
225
+ else "未存档(persist_sessions: false)"
226
+ )
227
+ return "\n".join(
228
+ [
229
+ f"会话 {session.id}",
230
+ f" 存档 {archive}",
231
+ f" 标题 {current.title if current else '(尚无消息)'}",
232
+ f" 消息 {current.messages if current else 0} 条",
233
+ f" 开始 {started}",
234
+ "/resume 恢复其他会话。",
235
+ ]
236
+ )
237
+
238
+
239
+ async def _session(ctx: CommandContext, argument: str) -> None:
240
+ sessions = await asyncio.to_thread(ctx.conversation.sessions)
241
+ await ctx.ui.note(session_report(ctx.conversation, sessions))
242
+
243
+
244
+ # Memory -----------------------------------------------------------------------------------
245
+
246
+
247
+ def memory_report(conversation, name: str = "") -> str:
248
+ store = conversation.memory
249
+ files = conversation.instruction_files
250
+ instructions = [
251
+ "项目指令(启动、/clear 和恢复会话时加载)",
252
+ *(f" {item}" for item in files or ("(未找到 AGENTS.md 或 CLAUDE.md)",)),
253
+ ]
254
+ if store is None:
255
+ return "\n".join([*instructions, "记忆已关闭(memory: false)。"])
256
+ notes = store.load()
257
+ if name:
258
+ matches = [note for note in notes if note.name == name]
259
+ if not matches:
260
+ return f"没有名为 {name} 的笔记。输入 /memory 查看全部笔记。"
261
+ parts = []
262
+ for note in matches:
263
+ try:
264
+ content = note.path.read_text(encoding="utf-8").strip()
265
+ except (OSError, UnicodeError):
266
+ content = "(无法读取)"
267
+ parts.append(f"{SCOPE_LABELS[note.scope]} · {note.path}\n\n{content}")
268
+ return "\n\n".join(parts)
269
+ lines = list(instructions)
270
+ for scope in ("user", "project"):
271
+ lines.append(f"{SCOPE_LABELS[scope]}笔记 · {store.directory(scope)}")
272
+ scoped = [note for note in notes if note.scope == scope]
273
+ for note in scoped:
274
+ if note.error:
275
+ lines.append(f" {note.path.name} 格式错误:{note.error}")
276
+ else:
277
+ lines.append(f" {note.updated} {note.type:<9} {note.name} — {note.description}")
278
+ if not scoped:
279
+ lines.append(" (暂无)")
280
+ lines.append(
281
+ f"自动笔记:{extraction_state(conversation)}。/memory 名称 查看全文;修改或删除可以直接编辑文件,"
282
+ "或让模型用 memory 工具完成。"
283
+ )
284
+ return "\n".join(lines)
285
+
286
+
287
+ def extraction_state(conversation) -> str:
288
+ extractor = conversation.extractor
289
+ return "关闭" if extractor is None else "已暂停" if extractor.paused else "开启"
290
+
291
+
292
+ async def _memory(ctx: CommandContext, argument: str) -> None:
293
+ await ctx.ui.note(memory_report(ctx.conversation, argument))
294
+
295
+
296
+ def _note_names(ctx: CommandContext):
297
+ store = ctx.conversation.memory
298
+ if store is None:
299
+ return []
300
+ seen, names = set(), []
301
+ for note in store.load():
302
+ if not note.error and note.name not in seen:
303
+ seen.add(note.name)
304
+ names.append((note.name, f"{SCOPE_LABELS[note.scope]} · {note.description}"))
305
+ return names
306
+
307
+
308
+ # Model and modes --------------------------------------------------------------------------
309
+
310
+
311
+ def _providers(ctx: CommandContext):
312
+ return ctx.settings.providers if ctx.settings else ()
313
+
314
+
315
+ async def _model(ctx: CommandContext, argument: str) -> None:
316
+ providers = _providers(ctx)
317
+ if argument:
318
+ if not any(item.name == argument for item in providers):
319
+ names = "、".join(item.name for item in providers) or "无"
320
+ await ctx.ui.note(f"未找到提供商 {argument}。可用:{names}")
321
+ else:
322
+ await ctx.ui.switch_model(argument)
323
+ return
324
+ current = ctx.ui.provider_name
325
+ if len(providers) < 2:
326
+ await ctx.ui.note(
327
+ f"当前模型:{current}"
328
+ + (f"({ctx.ui.model})" if ctx.ui.model else "")
329
+ + "。在配置文件的 providers 中添加更多提供商后可以切换。"
330
+ )
331
+ return
332
+ rows = [
333
+ Text(
334
+ f"{item.name}{' ✓' if item.name == current else ''} {item.protocol} / {item.model}",
335
+ no_wrap=True,
336
+ overflow="ellipsis",
337
+ )
338
+ for item in providers
339
+ ]
340
+
341
+ async def pick(index: int) -> None:
342
+ await ctx.ui.switch_model(providers[index].name)
343
+
344
+ ctx.ui.choose(
345
+ "切换模型 · 对话历史保留", rows, next((i for i, item in enumerate(providers) if item.name == current), 0), pick
346
+ )
347
+
348
+
349
+ def _provider_names(ctx: CommandContext):
350
+ providers = _providers(ctx)
351
+ return [(item.name, "切换到该提供商") for item in providers] if len(providers) > 1 else []
352
+
353
+
354
+ async def _plan(ctx: CommandContext, argument: str) -> None:
355
+ try:
356
+ ctx.conversation.set_mode("plan")
357
+ except ProviderError as exc:
358
+ await ctx.ui.note(str(exc))
359
+ return
360
+ ctx.ui.refresh_status()
361
+ if argument:
362
+ await ctx.ui.submit(argument)
363
+ else:
364
+ await ctx.ui.note(PLAN_ENTERED)
365
+
366
+
367
+ async def _do(ctx: CommandContext, argument: str) -> None:
368
+ """Leave plan mode for the mode it was entered from; A task outside plan mode is not sent."""
369
+ registry = ctx.conversation.registry
370
+ if registry is None:
371
+ await ctx.ui.note("当前会话未启用工具。")
372
+ return
373
+ if registry.permissions.mode != "plan":
374
+ await ctx.ui.note("当前不在计划模式。" + ("任务未发送。" if argument else ""))
375
+ return
376
+ ctx.conversation.set_mode("normal")
377
+ mode = registry.permissions.mode
378
+ ctx.ui.refresh_status()
379
+ await ctx.ui.note(f"└ 已退出计划模式,回到{MODE_LABELS[mode]}({mode})。")
380
+ if argument:
381
+ await ctx.ui.submit(argument)
382
+
383
+
384
+ async def _permissions(ctx: CommandContext, argument: str) -> None:
385
+ conversation = ctx.conversation
386
+ if not conversation.registry:
387
+ await ctx.ui.note("当前会话未启用工具。")
388
+ elif argument in MODES:
389
+ conversation.set_permission_mode(argument)
390
+ ctx.ui.refresh_status()
391
+ mode = conversation.registry.permissions.mode
392
+ await ctx.ui.note("└ 权限档位:" + MODE_LABELS[mode] + f"({mode});硬限制和显式拒绝仍生效。")
393
+ else:
394
+ await ctx.ui.note(PERMISSION_HELP)
395
+
396
+
397
+ # Status -----------------------------------------------------------------------------------
398
+
399
+
400
+ def _model_line(ctx: CommandContext) -> str:
401
+ name, model = ctx.ui.provider_name, ctx.ui.model
402
+ config = next((item for item in _providers(ctx) if item.name == name), None)
403
+ protocol = (
404
+ config.protocol if config else ctx.conversation.provider_info[1] if ctx.conversation.provider_info else ""
405
+ )
406
+ detail = " / ".join(part for part in (protocol, model) if part)
407
+ return name + (f"({detail})" if detail else "")
408
+
409
+
410
+ def _context_line(conversation) -> str:
411
+ usage = conversation.total_usage
412
+ estimate = conversation.context_estimate() if usage.requests else None
413
+ if estimate is None:
414
+ return "尚未发送请求"
415
+ budget = conversation.budget
416
+ line = (
417
+ f"约 {estimate:,} / {budget.effective:,} Token({round(100 * estimate / budget.effective)}%),"
418
+ f"自动压缩阈值 {budget.auto_threshold:,};已压缩 {conversation.compactions} 次"
419
+ )
420
+ return line + (";未配置 context_window,按 128K 估算" if not budget.configured else "")
421
+
422
+
423
+ def _usage_line(conversation) -> str:
424
+ usage = conversation.total_usage
425
+ if not usage.requests:
426
+ return "尚未发送请求"
427
+ return (
428
+ f"输入 {usage.input_tokens:,}(缓存命中 {usage.cached_input_tokens:,})· 输出 {usage.output_tokens:,}"
429
+ f" · 请求 {usage.requests} 次"
430
+ )
431
+
432
+
433
+ def _mcp_line(conversation) -> str:
434
+ manager = conversation.mcp_manager
435
+ if manager is None or not (manager.connections or manager.notices):
436
+ return "未配置"
437
+ states = [status.state for status in manager.statuses()]
438
+ ready = states.count("ready")
439
+ waiting = sum(state in {"new", "connecting"} for state in states)
440
+ failed = len(states) - ready - waiting
441
+ parts = (
442
+ [f"{ready} 个已连接"]
443
+ + ([f"{waiting} 个连接中"] if waiting else [])
444
+ + ([f"{failed} 个失败"] if failed else [])
445
+ + ([f"{len(manager.notices)} 条配置问题"] if manager.notices else [])
446
+ )
447
+ return ",".join(parts) + "(/mcp 查看)"
448
+
449
+
450
+ def status_report(ctx: CommandContext) -> str:
451
+ conversation = ctx.conversation
452
+ registry = conversation.registry
453
+ if registry is None:
454
+ mode = "未启用工具"
455
+ else:
456
+ current = registry.permissions.mode
457
+ mode = f"{MODE_BADGES[current]}({current})"
458
+ files = conversation.instruction_files
459
+ memory = (
460
+ "关闭(memory: false)" if conversation.memory is None else f"开启,自动笔记{extraction_state(conversation)}"
461
+ )
462
+ rows = [
463
+ ("模型", _model_line(ctx)),
464
+ ("权限档位", mode),
465
+ ("上下文", _context_line(conversation)),
466
+ ("本会话用量", _usage_line(conversation)),
467
+ ("会话", conversation.session_id or "无"),
468
+ ("工作目录", str(conversation.context_root or Path.cwd())),
469
+ ("项目指令", f"{len(files)} 个文件(/memory 查看)" if files else "未找到"),
470
+ ("MCP", _mcp_line(conversation)),
471
+ ("记忆", memory),
472
+ ]
473
+ if conversation.hooks is not None:
474
+ rows.append(("Hook", hooks_line(conversation.hooks)))
475
+ if conversation.active_skills:
476
+ rows.append(("已激活 Skill", "、".join(conversation.active_skills)))
477
+ if registry is not None and getattr(conversation, "agents_enabled", False):
478
+ rows.append(("子 Agent", agents_line(conversation)))
479
+ if getattr(conversation, "coordinator", False):
480
+ from .coordinator import STATUS
481
+
482
+ rows.append(("角色", STATUS))
483
+ width = max(12, *(cell_len(label) + 2 for label, _ in rows))
484
+ return "状态\n" + "\n".join(f" {label}{' ' * (width - cell_len(label))}{value}" for label, value in rows)
485
+
486
+
487
+ async def _status(ctx: CommandContext, argument: str) -> None:
488
+ await ctx.ui.note(status_report(ctx))
489
+
490
+
491
+ # MCP --------------------------------------------------------------------------------------
492
+
493
+
494
+ def mcp_report(conversation, name: str = "") -> str:
495
+ manager = conversation.mcp_manager
496
+ if manager is None or not (manager.connections or manager.notices):
497
+ return (
498
+ "未配置 MCP Server。在 ~/.mosscode/mcp.yaml 或项目 .mosscode/mcp.yaml 的 mcp_servers 中添加;"
499
+ "设置 import_mcp_json: true 可读取项目的 .mcp.json。"
500
+ )
501
+ statuses = manager.statuses()
502
+ if name:
503
+ status = next((item for item in statuses if item.name == name), None)
504
+ if status is None:
505
+ return f"未找到 MCP Server {name}。已配置:" + ("、".join(item.name for item in statuses) or "无")
506
+ lines = [f"MCP {status.name}({status.type})· {status.describe()}"]
507
+ for tool in status.tools:
508
+ summary = (
509
+ tool.description.removeprefix(f"MCP Server {tool.server_name} 的工具 {tool.remote_name}。")
510
+ .strip()
511
+ .splitlines()
512
+ or [""]
513
+ )[0]
514
+ lines.append(
515
+ f" {tool.name}" + (" [只读]" if tool.read_only else "") + (f" {summary[:80]}" if summary else "")
516
+ )
517
+ if status.state in {"ready", "closed"} and not status.tools:
518
+ lines.append(" (Server 没有提供工具)")
519
+ return "\n".join(lines)
520
+ marks = {"ready": "●", "connecting": "○", "new": "○"}
521
+ lines = [f"MCP Server({len(statuses)})"]
522
+ width = max((len(item.name) for item in statuses), default=0)
523
+ lines.extend(
524
+ f"{marks.get(item.state, '×')} {item.name.ljust(width)} {item.type:<5} {item.describe()}" for item in statuses
525
+ )
526
+ lines.extend("! " + notice for notice in manager.notices)
527
+ lines.append("/mcp 名称 查看工具;修改配置后重新启动生效,不自动重连。")
528
+ return "\n".join(lines)
529
+
530
+
531
+ async def _mcp(ctx: CommandContext, argument: str) -> None:
532
+ await ctx.ui.note(mcp_report(ctx.conversation, argument))
533
+
534
+
535
+ def _server_names(ctx: CommandContext):
536
+ manager = ctx.conversation.mcp_manager
537
+ if manager is None:
538
+ return []
539
+ return [(connection.config.name, "查看该 Server 的工具") for connection in manager.connections]
540
+
541
+
542
+ # Skills -----------------------------------------------------------------------------------
543
+
544
+
545
+ def _first_line(text: str) -> str:
546
+ return next((line.strip() for line in text.splitlines() if line.strip()), "(空)")
547
+
548
+
549
+ def skills_report(catalog, name: str = "", active=()) -> str:
550
+ if name:
551
+ skill = catalog.get(name)
552
+ if skill is None:
553
+ names = "、".join(catalog.skills) or "无"
554
+ return f"未找到 Skill {name}。已加载:{names}"
555
+ rows = [
556
+ ("路径", str(skill.path)),
557
+ ("来源", SOURCE_LABELS[skill.source]),
558
+ ("覆盖", "、".join(str(path) for path in skill.overrides) or "无"),
559
+ ("模式", skill.mode + (f"(fork_context: {skill.fork_context})" if skill.mode == "fork" else "")),
560
+ ("模型", skill.model or "当前模型"),
561
+ ("工具白名单", "、".join(skill.allowed_tools) or "未限制"),
562
+ ("模型可见", "是" if skill.model_invocable else "否(disable-model-invocation)"),
563
+ ("已激活", "是" if skill.name in active else "否"),
564
+ ("命令", f"/{skill.name}" if skill.user_invocable else "不列出(user-invocable: false),仍可输入调用"),
565
+ ("规程首行", _first_line(skill.body)),
566
+ ]
567
+ lines = [f"Skill {skill.name}", *(f" {label}{' ' * (12 - cell_len(label))}{value}" for label, value in rows)]
568
+ if skill.tools:
569
+ lines.append(" 专属工具")
570
+ for tool in skill.tools:
571
+ lines.append(f" {tool.full_name} {' '.join(tool.command)} 超时 {tool.timeout:g} 秒")
572
+ return "\n".join(lines)
573
+ if not catalog.skills and not catalog.problems:
574
+ return "没有 Skill。可以在 .mosscode/skills/<名称>/SKILL.md 中添加。"
575
+ lines = [f"Skill({len(catalog.skills)})"]
576
+ width = max((cell_len(f"/{item}") for item in catalog.skills), default=0)
577
+ for skill in catalog.skills.values():
578
+ label = f"/{skill.name}"
579
+ lines.append(
580
+ f"{label}{' ' * (width - cell_len(label))} {SOURCE_LABELS[skill.source]} {skill.mode:<6} "
581
+ f"{skill.description}"
582
+ )
583
+ lines.extend("! " + problem for problem in catalog.problems)
584
+ lines.append("输入 /<名称> 调用;/skills 名称 查看详情;/skills reload 重新扫描。")
585
+ return "\n".join(lines)
586
+
587
+
588
+ async def _skills(ctx: CommandContext, argument: str) -> None:
589
+ if argument != "reload":
590
+ await ctx.ui.note(skills_report(ctx.conversation.skills, argument, ctx.conversation.active_skills))
591
+ return
592
+ old = ctx.conversation.skills
593
+ catalog = await asyncio.to_thread(old.reload)
594
+ problems = ctx.registry.set_skills(catalog)
595
+ notices = ctx.conversation.use_skills(catalog)
596
+ added = [name for name in catalog.skills if name not in old.skills]
597
+ removed = [name for name in old.skills if name not in catalog.skills]
598
+ lines = [
599
+ f"└ 已重新扫描 Skill:共 {len(catalog.skills)} 个"
600
+ + (f",新增 {'、'.join(added)}" if added else "")
601
+ + (f",移除 {'、'.join(removed)}" if removed else "")
602
+ + "。"
603
+ ]
604
+ lines.extend(" ! " + problem for problem in (*catalog.problems, *problems, *notices))
605
+ await ctx.ui.note("\n".join(lines))
606
+
607
+
608
+ def _skill_names(ctx: CommandContext):
609
+ return [
610
+ ("reload", "重新扫描 Skill 目录"),
611
+ *((skill.name, skill.description) for skill in ctx.conversation.skills.skills.values()),
612
+ ]
613
+
614
+
615
+ # Hooks ------------------------------------------------------------------------------
616
+
617
+ TRUST_OPTIONS = ("信任并启用", "本次不启用")
618
+ OUTCOME_LABELS = {
619
+ "ok": "成功",
620
+ "failed": "失败",
621
+ "rejected": "拒绝",
622
+ "timeout": "超时",
623
+ "cancelled": "已取消",
624
+ "skipped": "跳过",
625
+ }
626
+
627
+
628
+ def hooks_line(engine) -> str:
629
+ if not engine.enabled:
630
+ return "已关闭(--no-hooks)"
631
+ waiting = sum(len(file.hooks) for file in engine.untrusted)
632
+ return f"已启用 {len(engine.hooks)} 条" + (f"(项目级未确认 {waiting} 条)" if waiting else "") + "(/hooks 查看)"
633
+
634
+
635
+ ACTION_LABELS = {"command": "命令", "http": "请求", "prompt": "注入", "agent": "子 Agent"}
636
+
637
+
638
+ def _shown(path: Path, root: Path) -> str:
639
+ """Hook files as the user knows them: relative inside the project, ~ under home."""
640
+ for base, prefix in ((root, ""), (Path.home(), "~")):
641
+ try:
642
+ relative = path.relative_to(base)
643
+ except ValueError:
644
+ continue
645
+ return str(Path(prefix, relative)) if prefix else str(relative)
646
+ return str(path)
647
+
648
+
649
+ def _hook_lines(hook, head: str = "") -> list[str]:
650
+ """One line for id and event, then the condition and the action indented below, so long ones wrap legibly."""
651
+ lines = [f" {hook.id} · {hook.event}{head}"]
652
+ if hook.condition is not None:
653
+ lines.append(f" 条件 {hook.condition.text}")
654
+ action = hook.action
655
+ if action is None:
656
+ lines.append(f" 拒绝 {hook.reject.replace(chr(10), ' ')}")
657
+ elif action.type in {"prompt", "agent"}:
658
+ text = action.message if action.type == "prompt" else action.prompt
659
+ lines.append(f" {ACTION_LABELS[action.type]} {text.replace(chr(10), ' ')[:60]}")
660
+ else:
661
+ lines.append(f" {ACTION_LABELS[action.type]} {action.summary()}")
662
+ return lines
663
+
664
+
665
+ def _hook_row(engine, hook) -> list[str]:
666
+ flags = "".join(f" [{flag}]" for flag in hook.flags())
667
+ return _hook_lines(hook, f"{flags} · {engine.status(hook)}")
668
+
669
+
670
+ def _run_row(run) -> str:
671
+ detail = f" {run.detail.splitlines()[0][:80]}" if run.detail.strip() else ""
672
+ return (
673
+ f" {run.time[11:19]} {run.hook} {run.event} {OUTCOME_LABELS.get(run.outcome, run.outcome)}"
674
+ f" {run.duration_ms} ms{detail}"
675
+ )
676
+
677
+
678
+ def _masked(raw: dict) -> dict:
679
+ """Header values are secrets more often than not."""
680
+ action = raw.get("action")
681
+ if isinstance(action, dict) and isinstance(action.get("headers"), dict):
682
+ raw = {**raw, "action": {**action, "headers": {key: "***" for key in action["headers"]}}}
683
+ return raw
684
+
685
+
686
+ def hooks_report(engine, name: str = "") -> str:
687
+ import yaml
688
+
689
+ if engine is None or not engine.enabled:
690
+ return "Hook 已关闭(本次运行使用了 --no-hooks)。"
691
+ if name:
692
+ hook = engine.find(name)
693
+ if hook is None:
694
+ known = "、".join(h.id for f in engine.config.files for h in f.hooks) or "无"
695
+ return f"未找到 Hook {name}。已加载:{known}"
696
+ lines = [
697
+ f"Hook {hook.id} · {HOOK_SOURCES[hook.source]} · {engine.status(hook)}",
698
+ f" 文件 {_shown(hook.path, engine.root)}",
699
+ " 配置",
700
+ ]
701
+ dumped = yaml.safe_dump(_masked(hook.raw), allow_unicode=True, sort_keys=False).rstrip()
702
+ lines.extend(" " + line for line in dumped.splitlines())
703
+ runs = [run for run in engine.recent if run.hook == hook.id]
704
+ lines.append(" 本次运行的执行记录" + ("" if runs else ":无"))
705
+ lines.extend(" " + _run_row(run) for run in runs[-10:])
706
+ lines.append(f" 日志 {engine.log_path or '(本次运行没有会话目录)'}")
707
+ return "\n".join(lines)
708
+ files = [file for file in engine.config.files if file.hooks]
709
+ if not files and not engine.problems:
710
+ return (
711
+ "没有 Hook。可以在 ~/.mosscode/hooks.yaml 或项目 .mosscode/hooks.yaml、.mosscode/hooks.local.yaml 中添加,"
712
+ "格式见 docs/reference/hooks.md。"
713
+ )
714
+ lines = [f"Hook(已启用 {len(engine.hooks)} 条)"]
715
+ for file in files:
716
+ mark = "" if file.trusted else "(未确认,未启用)"
717
+ lines.append(f"{HOOK_SOURCES[file.source]} {_shown(file.path, engine.root)}{mark}")
718
+ for hook in file.hooks:
719
+ lines.extend(_hook_row(engine, hook))
720
+ lines.extend("! " + problem for problem in engine.problems)
721
+ for file in engine.untrusted:
722
+ lines.append(f"! 项目 Hook 文件 {_shown(file.path, engine.root)} 未确认;/hooks reload 后可以确认。")
723
+ if engine.recent:
724
+ lines.append("最近执行")
725
+ lines.extend(_run_row(run) for run in list(engine.recent)[-10:])
726
+ lines.append("/hooks <id> 查看详情;/hooks reload 重新加载。")
727
+ return "\n".join(lines)
728
+
729
+
730
+ def trust_text(file, root: Path) -> str:
731
+ lines = [
732
+ f"项目 Hook 文件 {_shown(file.path, root)} 尚未确认。",
733
+ "Hook 以 MossCode 的权限自动运行,不经过工具审批;请先确认内容:",
734
+ ]
735
+ for hook in file.hooks:
736
+ lines.extend(_hook_lines(hook))
737
+ return "\n".join(lines)
738
+
739
+
740
+ async def offer_trust(ui, engine, on_done=None) -> None:
741
+ """One picker per untrusted project file; Esc means 本次不启用. Returns at once (callbacks chain)."""
742
+ pending = list(engine.pending_trust()) if engine is not None and engine.enabled else []
743
+
744
+ async def next_file() -> None:
745
+ if not pending:
746
+ if on_done is not None:
747
+ await on_done()
748
+ return
749
+ file = pending.pop(0)
750
+ shown = _shown(file.path, engine.root)
751
+ await ui.note(trust_text(file, engine.root))
752
+
753
+ async def pick(index: int) -> None:
754
+ if index == 0:
755
+ try:
756
+ engine.trust(file)
757
+ await ui.note(f"└ 已信任并启用 {shown}。")
758
+ except OSError as exc:
759
+ await ui.note(f"└ 无法写入信任记录({exc.strerror or exc}),未启用 {shown}。")
760
+ else:
761
+ engine.decline(file)
762
+ await ui.note(f"└ 本次不启用 {shown};/hooks reload 时会再次询问。")
763
+ ui.refresh_status()
764
+ await next_file()
765
+
766
+ async def cancel() -> None:
767
+ await pick(1)
768
+
769
+ ui.choose(f"信任项目 Hook · {file.path.name}", TRUST_OPTIONS, 0, pick, on_cancel=cancel)
770
+
771
+ await next_file()
772
+
773
+
774
+ async def _hooks(ctx: CommandContext, argument: str) -> None:
775
+ engine = ctx.conversation.hooks
776
+ if argument != "reload" or engine is None or not engine.enabled:
777
+ await ctx.ui.note(hooks_report(engine, argument))
778
+ return
779
+ added, removed = await asyncio.to_thread(engine.reload)
780
+ lines = [
781
+ f"└ 已重新加载 Hook:已启用 {len(engine.hooks)} 条"
782
+ + (f",新增 {'、'.join(added)}" if added else "")
783
+ + (f",移除 {'、'.join(removed)}" if removed else "")
784
+ + "。"
785
+ ]
786
+ lines.extend(" ! " + problem for problem in engine.problems)
787
+ await ctx.ui.note("\n".join(lines))
788
+ ctx.ui.refresh_status()
789
+ await offer_trust(ctx.ui, engine)
790
+
791
+
792
+ def _hook_names(ctx: CommandContext):
793
+ engine = ctx.conversation.hooks
794
+ hooks = [hook for file in engine.config.files for hook in file.hooks] if engine is not None else []
795
+ return [("reload", "重新加载 Hook 文件"), *((hook.id, f"{hook.event} · {hook.summary()}") for hook in hooks)]
796
+
797
+
798
+ # Sub-agents ---------------------------------------------------------------------------
799
+
800
+
801
+ def agents_line(conversation) -> str:
802
+ catalog, tasks = conversation.agents, conversation.tasks
803
+ running = tasks.background()
804
+ line = f"{len(catalog.agents)} 个定义" + (f"({len(catalog.problems)} 个有问题)" if catalog.problems else "")
805
+ line += f",后台任务 {len(running)} 个运行中" if running else ",没有运行中的后台任务"
806
+ return line + "(/agents、/tasks 查看)"
807
+
808
+
809
+ def _model_missing(conversation, agent) -> bool:
810
+ return (
811
+ bool(agent.model)
812
+ and not conversation._is_current(agent.model)
813
+ and conversation._fork_provider_config(agent.model) is None
814
+ )
815
+
816
+
817
+ def _agent_model(conversation, agent) -> str:
818
+ """A missing provider is shown here, and the call fails; it never falls back to the current model."""
819
+ if not agent.model:
820
+ return "继承当前模型"
821
+ return f"{agent.model}(没有该提供商,调用时报错)" if _model_missing(conversation, agent) else agent.model
822
+
823
+
824
+ def agents_report(conversation, name: str = "") -> str:
825
+ if conversation.registry is None or not getattr(conversation, "agents_enabled", False):
826
+ return "当前会话未启用工具,子 Agent 不可用。"
827
+ catalog = conversation.agents
828
+ if name:
829
+ agent = catalog.get(name)
830
+ if agent is None:
831
+ return f"未找到 Agent 定义 {name}。已加载:" + ("、".join(catalog.agents) or "无")
832
+ names = conversation.registry.names()
833
+ available = [tool for tool in names if tool not in GLOBAL_BAN and agent.allows(tool)]
834
+ isolated = agent.isolation == "worktree"
835
+ lost = [tool for tool in available if tool not in WORKSPACE_TOOLS] if isolated else []
836
+ available = [tool for tool in available if tool not in lost]
837
+ rows = [
838
+ ("路径", str(agent.path)),
839
+ ("来源", AGENT_SOURCES[agent.source]),
840
+ ("覆盖", "、".join(str(path) for path in agent.overrides) or "无"),
841
+ ("描述", agent.description),
842
+ ("模型", _agent_model(conversation, agent)),
843
+ (
844
+ "权限模式",
845
+ f"{agent.permission_mode or '继承会话'};本会话实际为 "
846
+ f"{effective_mode(agent, conversation.registry.permissions.mode)}",
847
+ ),
848
+ ("最大轮数", str(agent.max_turns) if agent.max_turns else "与主对话相同"),
849
+ ("默认后台", "是" if agent.background else "否"),
850
+ ("隔离", "worktree(在独立的 git 工作树中运行)" if isolated else "无"),
851
+ ("工具白名单", "、".join(agent.tools) or "未限制"),
852
+ ("禁用工具", "、".join(agent.disallowed_tools) or "无"),
853
+ ("可用工具", "、".join(available) or "无"),
854
+ *([("隔离时不可用", "、".join(lost))] if lost else []),
855
+ ("未支持字段", "、".join(agent.unsupported) or "无"),
856
+ ("不存在的工具", "、".join(agent.unknown_tools(names)) or "无"),
857
+ ("提示词首行", _first_line(agent.prompt)),
858
+ ]
859
+ return "\n".join(
860
+ [f"Agent {agent.name}", *(f" {label}{' ' * (14 - cell_len(label))}{value}" for label, value in rows)]
861
+ )
862
+ lines = [f"Agent 定义({len(catalog.agents)})"]
863
+ width = max((cell_len(item) for item in catalog.agents), default=0)
864
+ for agent in catalog.agents.values():
865
+ marks = (f" [覆盖 {len(agent.overrides)} 个]" if agent.overrides else "") + (
866
+ f" [模型 {agent.model} 不存在]" if _model_missing(conversation, agent) else ""
867
+ )
868
+ lines.append(
869
+ f"{agent.name}{' ' * (width - cell_len(agent.name))} {AGENT_SOURCES[agent.source]} "
870
+ f"{agent.description}{marks}"
871
+ )
872
+ lines.extend("! " + problem for problem in catalog.problems)
873
+ lines.append("模型用 agent 工具调用;/agents 名称 查看详情;/agents reload 重新加载。")
874
+ return "\n".join(lines)
875
+
876
+
877
+ async def _agents(ctx: CommandContext, argument: str) -> None:
878
+ conversation = ctx.conversation
879
+ if argument != "reload" or conversation.registry is None or not getattr(conversation, "agents_enabled", False):
880
+ await ctx.ui.note(agents_report(conversation, argument))
881
+ return
882
+ old = conversation.agents
883
+ catalog = await asyncio.to_thread(old.reload)
884
+ conversation.use_agents(catalog)
885
+ added = [name for name in catalog.agents if name not in old.agents]
886
+ removed = [name for name in old.agents if name not in catalog.agents]
887
+ changed = [name for name, agent in catalog.agents.items() if name in old.agents and agent != old.agents[name]]
888
+ lines = [
889
+ f"└ 已重新加载 Agent 定义:共 {len(catalog.agents)} 个"
890
+ + (f",新增 {'、'.join(added)}" if added else "")
891
+ + (f",移除 {'、'.join(removed)}" if removed else "")
892
+ + (f",修改 {'、'.join(changed)}" if changed else "")
893
+ + ";下一次请求起生效。"
894
+ ]
895
+ lines.extend(" ! " + problem for problem in catalog.problems)
896
+ await ctx.ui.note("\n".join(lines))
897
+ ctx.ui.refresh_status()
898
+
899
+
900
+ def _agent_names(ctx: CommandContext):
901
+ if not getattr(ctx.conversation, "agents_enabled", False):
902
+ return []
903
+ return [
904
+ ("reload", "重新加载 Agent 定义"),
905
+ *((agent.name, agent.description) for agent in ctx.conversation.agents.agents.values()),
906
+ ]
907
+
908
+
909
+ def _task_usage(task) -> str:
910
+ usage = task.usage
911
+ return (
912
+ f"输入 {usage.input_tokens:,}(缓存命中 {usage.cached_input_tokens:,})· 输出 {usage.output_tokens:,}"
913
+ f" · 请求 {usage.requests} 次"
914
+ )
915
+
916
+
917
+ def _preview(text: str, lines: int = 12, size: int = 1500) -> list[str]:
918
+ shown = text[:size].splitlines()[:lines]
919
+ more = len(text) > size or len(text.splitlines()) > lines
920
+ return [*shown, *(["…(完整内容见对话记录)"] if more else [])]
921
+
922
+
923
+ def tasks_report(conversation, task_id: str = "") -> str:
924
+ tasks = conversation.tasks
925
+ if task_id:
926
+ task = tasks.get(task_id)
927
+ if task is None:
928
+ return f"没有任务 {task_id}。输入 /tasks 查看本会话的任务。"
929
+ rows = [
930
+ ("来源", ORIGIN_LABELS[task.origin]),
931
+ ("类型", task.agent_type),
932
+ ("标签", task.description),
933
+ ("状态", STATUS_LABELS[task.status] + ("(后台)" if task.background else "(前台)")),
934
+ ("统计", stats(task)),
935
+ ("用量", _task_usage(task)),
936
+ ("最近工具", task.last_tool or "无"),
937
+ ("对话记录", str(task.transcript) if task.transcript is not None else "未保存"),
938
+ ]
939
+ worktree = task.worktree
940
+ if worktree:
941
+ rows.append(("工作树", f"{worktree['relative']}(分支 {worktree['branch']})· {worktree['outcome']}"))
942
+ lines = [f"任务 {task.id}", *(f" {label}{' ' * (10 - cell_len(label))}{value}" for label, value in rows)]
943
+ lines.append(" 任务内容")
944
+ lines.extend(" " + line for line in _preview(task.prompt, 6, 600))
945
+ if task.result:
946
+ lines.append(" 结果")
947
+ lines.extend(" " + line for line in _preview(task.result))
948
+ if task.status == "running":
949
+ lines.append(f"/tasks stop {task.id} 停止。")
950
+ return "\n".join(lines)
951
+ if not tasks.tasks:
952
+ return "本会话还没有子 Agent 任务。"
953
+ lines = [f"任务({len(tasks.tasks)})"]
954
+ for task in tasks.tasks.values():
955
+ place = "后台" if task.background else "前台"
956
+ lines.append(
957
+ f" {task.id:<4} {STATUS_LABELS[task.status]} {place} {task.label} "
958
+ f"{task.elapsed:.0f} 秒 Token {task.tokens:,}"
959
+ )
960
+ lines.append("/tasks <id> 查看详情;/tasks stop <id> 停止。")
961
+ return "\n".join(lines)
962
+
963
+
964
+ async def _tasks(ctx: CommandContext, argument: str) -> None:
965
+ action, _, rest = argument.partition(" ")
966
+ if action != "stop":
967
+ await ctx.ui.note(tasks_report(ctx.conversation, argument))
968
+ return
969
+ task_id = rest.strip()
970
+ if not task_id:
971
+ await ctx.ui.note("用法:/tasks stop <id>")
972
+ return
973
+ tasks = ctx.conversation.tasks
974
+ task = tasks.get(task_id)
975
+ if task is None:
976
+ await ctx.ui.note(f"没有任务 {task_id}。")
977
+ elif await tasks.stop(task_id):
978
+ await ctx.ui.note(f"└ 已停止任务 {task_id}({task.label});不会再收到它的通知。")
979
+ else:
980
+ await ctx.ui.note(f"任务 {task_id} 已结束({STATUS_LABELS[task.status]}),无需停止。")
981
+ ctx.ui.refresh_status()
982
+
983
+
984
+ def _task_names(ctx: CommandContext):
985
+ tasks = getattr(ctx.conversation, "tasks", None)
986
+ if tasks is None:
987
+ return []
988
+ return [
989
+ *((task.id, f"{STATUS_LABELS[task.status]} · {task.label}") for task in tasks.tasks.values()),
990
+ *((f"stop {task.id}", f"停止 · {task.label}") for task in tasks.running()),
991
+ ]
992
+
993
+
994
+ # Worktrees ---------------------------------------------------------------------------
995
+
996
+ WORKTREE_USAGE = (
997
+ "用法:/worktree [list] · /worktree create <名称> · /worktree remove <名称> [--discard] · /worktree clean"
998
+ )
999
+
1000
+
1001
+ def worktree_manager(conversation):
1002
+ """(manager, problem)."""
1003
+ from .worktree import WorktreeError
1004
+
1005
+ getter = getattr(conversation, "worktree_manager", None)
1006
+ if getter is None:
1007
+ return None, "当前会话不支持工作树。"
1008
+ try:
1009
+ return getter(), ""
1010
+ except WorktreeError as exc:
1011
+ return None, f"工作树不可用:{exc}"
1012
+
1013
+
1014
+ def _when(stamp: str) -> str:
1015
+ try:
1016
+ return datetime.fromisoformat(stamp).astimezone().strftime("%Y-%m-%d %H:%M")
1017
+ except (TypeError, ValueError):
1018
+ return stamp or "未知"
1019
+
1020
+
1021
+ async def worktree_report(manager) -> str:
1022
+ entries = await manager.entries()
1023
+ if not entries:
1024
+ return "没有 MossCode 管理的工作树。/worktree create <名称> 创建一个;子 Agent 隔离运行时会自动创建。"
1025
+ lines = [f"工作树({len(entries)})"]
1026
+ for entry in entries:
1027
+ meta = entry.meta
1028
+ if meta is None:
1029
+ lines.append(f" {entry.name} {entry.status}")
1030
+ continue
1031
+ lines.append(f" {meta.name} {'手动' if meta.manual else '临时'} {entry.status}")
1032
+ lines.append(
1033
+ f" 分支 {meta.branch},基于 {meta.base[:7]},创建于 {_when(meta.created)};{manager.display(meta.path)}"
1034
+ )
1035
+ lines.append("/worktree remove <名称> 删除;/worktree clean 清理过期的临时工作树。")
1036
+ return "\n".join(lines)
1037
+
1038
+
1039
+ def clean_text(report, *, automatic: bool = False) -> str:
1040
+ if automatic:
1041
+ return f"└ 已自动清理 {len(report.removed)} 个过期的临时工作树:{'、'.join(report.removed)}"
1042
+ lines = [
1043
+ f"└ 已删除 {len(report.removed)} 个,跳过 {len(report.skipped)} 个"
1044
+ + (f":{'、'.join(report.removed)}" if report.removed else "。")
1045
+ ]
1046
+ lines.extend(f" {name}:{reason}" for name, reason in report.skipped)
1047
+ return "\n".join(lines)
1048
+
1049
+
1050
+ async def _worktree(ctx: CommandContext, argument: str) -> None:
1051
+ from .worktree import WorktreeError
1052
+
1053
+ action, _, rest = argument.strip().partition(" ")
1054
+ rest = rest.strip()
1055
+ manager, problem = worktree_manager(ctx.conversation)
1056
+ if manager is None:
1057
+ await ctx.ui.note(problem)
1058
+ return
1059
+ try:
1060
+ if action in ("", "list") and not rest:
1061
+ await ctx.ui.note(await worktree_report(manager))
1062
+ elif action == "create" and rest and " " not in rest:
1063
+ created = await manager.create(rest)
1064
+ meta = created.meta
1065
+ root = manager.root_in(meta)
1066
+ head = f"工作树 {meta.name} 已存在" if created.resumed else f"已创建工作树 {meta.name}"
1067
+ lines = [
1068
+ f"└ {head}:{manager.display(meta.path)}",
1069
+ f" 分支 {meta.branch},基于 {meta.base[:7]}",
1070
+ *(f" ! {warning}" for warning in created.warnings),
1071
+ *([" ! 主目录有未提交的改动,它们不在这个工作树中"] if created.dirty else []),
1072
+ f' 在另一个终端中进入:cd "{root}"',
1073
+ ]
1074
+ await ctx.ui.note("\n".join(lines))
1075
+ elif action == "remove" and rest:
1076
+ words = rest.split()
1077
+ discard = "--discard" in words
1078
+ names = [word for word in words if word != "--discard"]
1079
+ if len(names) != 1:
1080
+ await ctx.ui.note(WORKTREE_USAGE)
1081
+ return
1082
+ changes = await manager.remove(names[0], discard=discard)
1083
+ dropped = f"(已丢弃:{changes.describe()})" if changes.any else ""
1084
+ await ctx.ui.note(f"└ 已删除工作树 {names[0]} 和它的分支{dropped}。")
1085
+ elif action == "clean" and not rest:
1086
+ await ctx.ui.note(clean_text(await manager.clean()))
1087
+ else:
1088
+ await ctx.ui.note(WORKTREE_USAGE)
1089
+ except WorktreeError as exc:
1090
+ await ctx.ui.note(f"! {exc}")
1091
+
1092
+
1093
+ def _worktree_names(ctx: CommandContext):
1094
+ choices = [
1095
+ ("list", "列出工作树"),
1096
+ ("create", "创建或恢复工作树"),
1097
+ ("remove", "删除工作树"),
1098
+ ("clean", "清理过期的临时工作树"),
1099
+ ]
1100
+ manager, _ = worktree_manager(ctx.conversation) if getattr(ctx.conversation, "_worktrees", None) else (None, "")
1101
+ if manager is not None:
1102
+ choices.extend(
1103
+ (f"remove {meta.name}", "删除 · " + ("手动" if meta.manual else "临时")) for _, meta in manager.metas()
1104
+ )
1105
+ return choices
1106
+
1107
+
1108
+ def _worktree_reads(argument: str) -> bool:
1109
+ """Listing is safe during a run."""
1110
+ return argument.strip() in ("", "list")
1111
+
1112
+
1113
+ # Teams -------------------------------------------------------------------------------
1114
+
1115
+ TEAM_USAGE = "用法:/team · /team <名称> · /team stop <成员> · /team delete <名称>"
1116
+ TEAM_DETAILS = (
1117
+ "子命令:\n"
1118
+ " /team 列出本项目的团队:成员数、各状态人数、当前团队和持有者\n"
1119
+ " /team <名称> 团队详情:成员状态、后端、工作树、最近工具调用、用量、未读消息和任务\n"
1120
+ " /team stop <成员> 请当前团队的成员在这一轮结束后停止,超时强制停止\n"
1121
+ " /team delete <名称> 确认后停止成员、删除已合并且干净的工作树和团队目录"
1122
+ )
1123
+ DELETE_OPTIONS = ("删除团队", "取消")
1124
+
1125
+
1126
+ async def _team(ctx: CommandContext, argument: str) -> None:
1127
+ from .teams import TeamError
1128
+
1129
+ runtime = getattr(ctx.conversation, "teams", None)
1130
+ if runtime is None:
1131
+ await ctx.ui.note("当前会话不支持团队。")
1132
+ return
1133
+ words = argument.split()
1134
+ try:
1135
+ if not words:
1136
+ await ctx.ui.note(await asyncio.to_thread(runtime.overview))
1137
+ elif words[0] == "stop" and len(words) == 2:
1138
+ await ctx.ui.note("└ " + await runtime.stop_member(words[1]))
1139
+ ctx.ui.refresh_status()
1140
+ elif words[0] == "delete" and len(words) == 2:
1141
+ name = words[1]
1142
+ await asyncio.to_thread(runtime.detail, name) # unknown_team before asking.
1143
+
1144
+ async def picked(index: int) -> None:
1145
+ if index != 0:
1146
+ await ctx.ui.note("已取消,团队保留。")
1147
+ return
1148
+ try:
1149
+ text, _ = await runtime.delete(name, force=True)
1150
+ except TeamError as exc:
1151
+ await ctx.ui.note(f"! {exc.message}")
1152
+ return
1153
+ await ctx.ui.note("└ " + text)
1154
+ ctx.ui.refresh_status()
1155
+
1156
+ async def cancelled() -> None:
1157
+ await ctx.ui.note("已取消,团队保留。")
1158
+
1159
+ ctx.ui.choose(
1160
+ f"删除团队 {name}?会停止它的成员,删除已合并且干净的工作树和团队目录(任务、邮箱、对话记录)。",
1161
+ DELETE_OPTIONS,
1162
+ 1,
1163
+ picked,
1164
+ cancelled,
1165
+ )
1166
+ elif len(words) == 1 and words[0] not in ("stop", "delete"):
1167
+ await ctx.ui.note(await asyncio.to_thread(runtime.detail, words[0]))
1168
+ else:
1169
+ await ctx.ui.note(TEAM_USAGE)
1170
+ except TeamError as exc:
1171
+ await ctx.ui.note(f"! {exc.message}")
1172
+
1173
+
1174
+ def _team_names(ctx: CommandContext):
1175
+ runtime = getattr(ctx.conversation, "teams", None)
1176
+ if runtime is None:
1177
+ return []
1178
+ choices = []
1179
+ for store in runtime.teams():
1180
+ choices += [(store.name, "团队详情"), (f"delete {store.name}", "删除团队")]
1181
+ if runtime.current is not None:
1182
+ choices += [(f"stop {name}", f"停止 · {runner.status}") for name, runner in runtime.runners.items()]
1183
+ return choices
1184
+
1185
+
1186
+ def _team_reads(argument: str) -> bool:
1187
+ """Listing, details and stopping are safe during a run; deleting waits."""
1188
+ return argument.split()[:1] != ["delete"]
1189
+
1190
+
1191
+ async def _exit(ctx: CommandContext, argument: str) -> None:
1192
+ async def leave() -> None:
1193
+ ctx.ui.exit()
1194
+
1195
+ await confirm_tasks(ctx.ui, ctx.conversation, leave)
1196
+
1197
+
1198
+ BUILTIN = CommandRegistry(
1199
+ (
1200
+ Command(
1201
+ "help",
1202
+ "查看命令和快捷键",
1203
+ "/help [命令]",
1204
+ "local",
1205
+ argument="optional",
1206
+ argument_hint="[命令]",
1207
+ handler=_help,
1208
+ ),
1209
+ Command("clear", "开始新会话:清空模型上下文和 Token 统计", "/clear", "local", handler=_clear),
1210
+ Command(
1211
+ "compact",
1212
+ "压缩上下文:较早的对话换成摘要,近期消息保留",
1213
+ "/compact [说明]",
1214
+ "local",
1215
+ argument="optional",
1216
+ argument_hint="[说明]",
1217
+ handler=_compact,
1218
+ ),
1219
+ Command(
1220
+ "resume",
1221
+ "恢复本项目之前的会话",
1222
+ "/resume [ID]",
1223
+ "local",
1224
+ argument="optional",
1225
+ argument_hint="[ID]",
1226
+ handler=_resume,
1227
+ ),
1228
+ Command("session", "查看当前会话的 ID、存档位置和消息数", "/session", "local", handler=_session),
1229
+ Command(
1230
+ "memory",
1231
+ "查看已加载的项目指令和记忆笔记",
1232
+ "/memory [名称]",
1233
+ "local",
1234
+ argument="optional",
1235
+ argument_hint="[名称]",
1236
+ handler=_memory,
1237
+ complete=_note_names,
1238
+ ),
1239
+ Command(
1240
+ "model",
1241
+ "切换提供商与模型,对话历史保留",
1242
+ "/model [名称]",
1243
+ "local",
1244
+ argument="optional",
1245
+ argument_hint="[名称]",
1246
+ handler=_model,
1247
+ complete=_provider_names,
1248
+ ),
1249
+ Command(
1250
+ "plan",
1251
+ "只读调查和规划,提交计划经批准后执行",
1252
+ "/plan [任务]",
1253
+ "local",
1254
+ argument="optional",
1255
+ argument_hint="[任务]",
1256
+ handler=_plan,
1257
+ ),
1258
+ Command(
1259
+ "do",
1260
+ "退出计划模式,回到进入前的权限档位",
1261
+ "/do [任务]",
1262
+ "local",
1263
+ argument="optional",
1264
+ argument_hint="[任务]",
1265
+ handler=_do,
1266
+ ),
1267
+ Command(
1268
+ "permissions",
1269
+ "查看或切换工具权限档位",
1270
+ "/permissions [档位]",
1271
+ "local",
1272
+ aliases=("permission",),
1273
+ argument="optional",
1274
+ argument_hint="[档位]",
1275
+ handler=_permissions,
1276
+ complete=lambda ctx: MODE_CHOICES,
1277
+ ),
1278
+ Command(
1279
+ "status", "查看模型、权限、上下文、用量和连接状态", "/status", "local", handler=_status, while_running=True
1280
+ ),
1281
+ Command(
1282
+ "mcp",
1283
+ "查看 MCP Server 的连接状态与工具",
1284
+ "/mcp [名称]",
1285
+ "local",
1286
+ argument="optional",
1287
+ argument_hint="[名称]",
1288
+ handler=_mcp,
1289
+ complete=_server_names,
1290
+ ),
1291
+ Command(
1292
+ "skills",
1293
+ "查看、重新扫描 Skill",
1294
+ "/skills [名称|reload]",
1295
+ "local",
1296
+ aliases=("skill",),
1297
+ argument="optional",
1298
+ argument_hint="[名称|reload]",
1299
+ handler=_skills,
1300
+ complete=_skill_names,
1301
+ ),
1302
+ Command(
1303
+ "hooks",
1304
+ "查看、重新加载 Hook",
1305
+ "/hooks [id|reload]",
1306
+ "local",
1307
+ aliases=("hook",),
1308
+ argument="optional",
1309
+ argument_hint="[id|reload]",
1310
+ handler=_hooks,
1311
+ complete=_hook_names,
1312
+ ),
1313
+ Command(
1314
+ "agents",
1315
+ "查看、重新加载子 Agent 定义",
1316
+ "/agents [名称|reload]",
1317
+ "local",
1318
+ aliases=("agent",),
1319
+ argument="optional",
1320
+ argument_hint="[名称|reload]",
1321
+ handler=_agents,
1322
+ complete=_agent_names,
1323
+ ),
1324
+ Command(
1325
+ "tasks",
1326
+ "查看、停止本会话的子 Agent 任务",
1327
+ "/tasks [id|stop id]",
1328
+ "local",
1329
+ aliases=("task",),
1330
+ argument="optional",
1331
+ argument_hint="[id|stop id]",
1332
+ handler=_tasks,
1333
+ complete=_task_names,
1334
+ while_running=True,
1335
+ ),
1336
+ Command(
1337
+ "worktree",
1338
+ "查看、创建、删除 git 工作树",
1339
+ "/worktree [子命令]",
1340
+ "local",
1341
+ aliases=("worktrees",),
1342
+ argument="optional",
1343
+ argument_hint="[子命令]",
1344
+ handler=_worktree,
1345
+ complete=_worktree_names,
1346
+ while_running=_worktree_reads,
1347
+ ),
1348
+ Command(
1349
+ "team",
1350
+ "查看、停止、删除 Agent 团队",
1351
+ "/team [子命令]",
1352
+ "local",
1353
+ aliases=("teams",),
1354
+ argument="optional",
1355
+ argument_hint="[子命令]",
1356
+ handler=_team,
1357
+ complete=_team_names,
1358
+ while_running=_team_reads,
1359
+ details=TEAM_DETAILS,
1360
+ ),
1361
+ Command("exit", "退出 MossCode", "/exit", "local", aliases=("quit",), handler=_exit),
1362
+ )
1363
+ )