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,287 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from pydantic import Field
6
+
7
+ from .base import Params, Tool, ToolFailure, file_io
8
+ from .diffing import unified_diff
9
+
10
+ READ_LIMIT = 2000
11
+ LINE_LIMIT = 2000 # Characters per displayed line.
12
+ READ_BUDGET = 56 * 1024 # Bytes per page, below the registry's 64 KiB result cap.
13
+ PREVIEW_LINES = 16 # Diff lines shown in the approval panel.
14
+
15
+
16
+ class ReadParams(Params):
17
+ path: str
18
+ offset: int = Field(default=1, ge=1)
19
+ limit: int = Field(default=READ_LIMIT, ge=1, le=100000)
20
+
21
+
22
+ class WriteParams(Params):
23
+ path: str
24
+ content: str
25
+
26
+
27
+ class EditParams(Params):
28
+ path: str
29
+ old_text: str = Field(min_length=1)
30
+ new_text: str
31
+ replace_all: bool = False
32
+
33
+
34
+ class FileState:
35
+ """What the model last saw of each file: (mtime_ns, size) at read or own write."""
36
+
37
+ def __init__(self):
38
+ self._seen: dict[str, tuple[int, int]] = {}
39
+
40
+ @staticmethod
41
+ def stamp(path: Path) -> tuple[int, int]:
42
+ info = path.stat()
43
+ return info.st_mtime_ns, info.st_size
44
+
45
+ @staticmethod
46
+ def _key(path: Path) -> str:
47
+ return os.path.normcase(str(path))
48
+
49
+ def clear(self) -> None:
50
+ self._seen.clear()
51
+
52
+ def copy(self) -> "FileState":
53
+ """A fork starts from what its parent has read."""
54
+ state = FileState()
55
+ state._seen = dict(self._seen)
56
+ return state
57
+
58
+ def record(self, path: Path, stamp: tuple[int, int] | None = None) -> None:
59
+ self._seen[self._key(path)] = stamp if stamp is not None else self.stamp(path)
60
+
61
+ def check(self, path: Path) -> None:
62
+ seen = self._seen.get(self._key(path))
63
+ if seen is None:
64
+ raise ToolFailure("not_read", "修改已有文件前需要先用 read_file 读取它;文件未修改。")
65
+ if self.stamp(path) != seen:
66
+ raise ToolFailure(
67
+ "stale_read", "文件在上次读取后被修改(可能来自用户、命令或其他程序);请重新读取后再修改。文件未修改。"
68
+ )
69
+
70
+
71
+ def read_text(path: Path) -> str:
72
+ with path.open("r", encoding="utf-8", newline="") as stream:
73
+ text = stream.read()
74
+ if "\0" in text:
75
+ raise ToolFailure("binary_file", "目标包含二进制内容,不能作为文本文件操作。")
76
+ return text
77
+
78
+
79
+ def atomic_write(path: Path, text: str) -> None:
80
+ if path.is_dir():
81
+ raise IsADirectoryError()
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ previous_mode = path.stat().st_mode if path.exists() else None
84
+ descriptor, temporary = tempfile.mkstemp(prefix=".mosscode-", dir=path.parent)
85
+ try:
86
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream:
87
+ stream.write(text)
88
+ if previous_mode is not None:
89
+ os.chmod(temporary, previous_mode)
90
+ os.replace(temporary, path)
91
+ finally:
92
+ if os.path.exists(temporary):
93
+ os.unlink(temporary)
94
+
95
+
96
+ def line_count(text: str) -> int:
97
+ return len(text.splitlines())
98
+
99
+
100
+ class FileTool(Tool):
101
+ def __init__(self, root: Path, state: FileState | None = None):
102
+ super().__init__(root)
103
+ self.state = state if state is not None else FileState()
104
+
105
+
106
+ class ReadFile(FileTool):
107
+ read_only = True
108
+ name = "read_file"
109
+ description = (
110
+ f"读取 UTF-8 文本文件。每行格式为“行号 + Tab + 原文”;offset 从 1 开始,limit 默认 {READ_LIMIT} 行。"
111
+ f"超过 {LINE_LIMIT} 字符的行会截断,单页还有字节上限并会提示下一页的 offset。项目目录之外的路径需要用户确认。"
112
+ )
113
+ prompt_summary = "读取文本文件,可按行分段查看"
114
+ prompt_guidelines = (
115
+ "使用 read_file 查看文本原文,优先于 shell 读取;修改或覆盖已有文件前必须先用 read_file 读取,文件被外部改动后需重新读取。",
116
+ "read_file 输出中 Tab 之前的行号不是文件正文,不要把行号加入编辑参数。遇到截断时根据返回提示继续读取相关范围。",
117
+ )
118
+ params_model = ReadParams
119
+
120
+ async def execute(self, params: ReadParams):
121
+ def read():
122
+ path = self.read_path(params.path)
123
+ # Stamp before reading: a change during the read makes the record stale.
124
+ stamp = FileState.stamp(path)
125
+ selected, more, size, long_lines, total = [], False, 0, 0, 0
126
+ with path.open("r", encoding="utf-8", newline="") as stream:
127
+ for number, line in enumerate(stream, 1):
128
+ total = number
129
+ if "\0" in line:
130
+ raise ToolFailure("binary_file", "目标包含二进制内容。")
131
+ if number < params.offset:
132
+ continue
133
+ if more:
134
+ continue # Count remaining lines only.
135
+ if len(selected) >= params.limit:
136
+ more = True
137
+ continue
138
+ text = line.rstrip("\n").rstrip("\r")
139
+ if len(text) > LINE_LIMIT:
140
+ text = text[:LINE_LIMIT] + "…(本行过长,已截断)"
141
+ long_lines += 1
142
+ displayed = f"{number:>6}\t{text}"
143
+ encoded = len(displayed.encode("utf-8")) + 1
144
+ if size + encoded > READ_BUDGET:
145
+ more = True
146
+ continue
147
+ selected.append(displayed)
148
+ size += encoded
149
+ self.state.record(path, stamp)
150
+ next_offset = params.offset + len(selected)
151
+ if not selected:
152
+ output = "(文件为空)" if total == 0 else f"(offset={params.offset} 超出文件末尾,文件共 {total} 行)"
153
+ else:
154
+ output = "\n".join(selected)
155
+ if more:
156
+ output += f"\n(共 {total} 行,还有内容,请从 offset={next_offset} 继续读取)"
157
+ return output, {
158
+ "path": str(path),
159
+ "lines": len(selected),
160
+ "total_lines": total,
161
+ "truncated": more,
162
+ "long_lines": long_lines,
163
+ "next_offset": next_offset if more else None,
164
+ }
165
+
166
+ return await file_io(read)
167
+
168
+
169
+ class WriteFile(FileTool):
170
+ name = "write_file"
171
+ description = "创建或覆盖 UTF-8 文本文件,自动创建父目录。content 是要写入的完整文件内容。覆盖已有文件前必须先用 read_file 读取。"
172
+ prompt_summary = "创建文件或整体重写文件"
173
+ prompt_guidelines = (
174
+ "write_file 用于新建文件或确有必要的整体重写,优先于 shell 写入;局部修改优先使用可用的精确编辑工具。",
175
+ "write_file 会覆盖已有内容;覆盖前先读取文件,保留需要的原有内容和用户无关改动。",
176
+ )
177
+ params_model = WriteParams
178
+
179
+ def approval_preview(self, params: WriteParams) -> str:
180
+ path = self.path(params.path)
181
+ name = self.display(path)
182
+ if path.exists():
183
+ return unified_diff(read_text(path), params.content, name, limit=PREVIEW_LINES) or "内容与现有文件相同。"
184
+ lines = params.content.splitlines()
185
+ shown = [f"+{line}" for line in lines[:PREVIEW_LINES]]
186
+ if len(lines) > PREVIEW_LINES:
187
+ shown.append(f"…(共 {len(lines)} 行,仅显示前 {PREVIEW_LINES} 行)")
188
+ return "\n".join([f"新建文件 {name}({line_count(params.content)} 行)", *shown])
189
+
190
+ async def execute(self, params: WriteParams):
191
+ def write():
192
+ path = self.path(params.path)
193
+ existed, before = path.is_file(), None
194
+ if existed:
195
+ self.state.check(path)
196
+ try:
197
+ before = read_text(path)
198
+ except (UnicodeError, ToolFailure):
199
+ pass # Overwriting a non-text file: no diff.
200
+ atomic_write(path, params.content)
201
+ self.state.record(path)
202
+ name = self.display(path)
203
+ details = {"path": str(path), "bytes": len(params.content.encode("utf-8")), "created": not existed}
204
+ if not existed:
205
+ return f"已创建 {name}({line_count(params.content)} 行)。", details
206
+ if before is None:
207
+ return f"已覆盖 {name}(原文件不是 UTF-8 文本)。", details
208
+ details["diff"] = unified_diff(before, params.content, name)
209
+ return f"已覆盖 {name}:{line_count(before)} 行 → {line_count(params.content)} 行。", details
210
+
211
+ return await file_io(write)
212
+
213
+
214
+ class EditFile(FileTool):
215
+ name = "edit_file"
216
+ description = (
217
+ "将文件中的 old_text 精确替换为 new_text,空格和换行也参与匹配。默认要求恰好匹配一处;"
218
+ "replace_all=true 时替换全部匹配。零次或不唯一的匹配不修改文件。编辑前必须先用 read_file 读取。"
219
+ )
220
+ prompt_summary = "以唯一原文匹配精确替换一个片段"
221
+ prompt_guidelines = (
222
+ "局部修改优先 edit_file;编辑前读取相关原文,old_text 不包含读取输出的行号。",
223
+ "edit_file 的 old_text 使用足以唯一定位的最小原文上下文,避免携带大段无关内容;new_text 保留周边缩进和代码风格。重命名等需要替换全部出现位置时使用 replace_all。",
224
+ "edit_file 匹配为零时重新读取当前文件;匹配多次时补充邻近上下文。依据返回的错误调整参数,不猜测原文或原样重复失败调用。",
225
+ )
226
+ params_model = EditParams
227
+
228
+ @staticmethod
229
+ def _replace(text: str, params: EditParams) -> tuple[str, int, int, int]:
230
+ """New text, match count and the changed line range; nothing is written."""
231
+ if params.old_text == params.new_text:
232
+ raise ToolFailure("no_change", "old_text 与 new_text 相同,无需修改;文件未修改。")
233
+ # The model reads CRLF files as LF lines; match in LF and write back CRLF.
234
+ crlf = "\r\n" in text and text.count("\r\n") == text.count("\n")
235
+ work, old, new = text, params.old_text, params.new_text
236
+ if crlf:
237
+ work, old, new = (value.replace("\r\n", "\n") for value in (text, old, new))
238
+ positions, start = [], 0
239
+ step = len(old) if params.replace_all else 1 # Overlaps count when one match is required.
240
+ while (position := work.find(old, start)) >= 0:
241
+ positions.append(position)
242
+ start = position + step
243
+ count = len(positions)
244
+ if count == 0:
245
+ raise ToolFailure(
246
+ "match_count",
247
+ "原文匹配 0 次;文件未修改。请重新读取文件确认当前内容,old_text 不要包含行号。",
248
+ matches=0,
249
+ )
250
+ if count > 1 and not params.replace_all:
251
+ raise ToolFailure(
252
+ "match_count",
253
+ f"原文匹配 {count} 次,需要恰好 1 次;文件未修改。"
254
+ "补充邻近上下文使其唯一,或设置 replace_all=true 替换全部。",
255
+ matches=count,
256
+ )
257
+ result = work.replace(old, new) if params.replace_all else work.replace(old, new, 1)
258
+ # Line range of the change in the new text; the prefix before the first match is unchanged.
259
+ first = work[: positions[0]].count("\n") + 1
260
+ last_start = positions[-1] + (len(new) - len(old)) * (count - 1)
261
+ last = result[:last_start].count("\n") + 1 + new.count("\n")
262
+ if crlf:
263
+ result = result.replace("\n", "\r\n")
264
+ return result, count, first, last
265
+
266
+ def approval_preview(self, params: EditParams) -> str:
267
+ path = self.path(params.path)
268
+ text = read_text(path)
269
+ result = self._replace(text, params)[0]
270
+ return unified_diff(text, result, self.display(path), limit=PREVIEW_LINES)
271
+
272
+ async def execute(self, params: EditParams):
273
+ def edit():
274
+ path = self.path(params.path)
275
+ self.state.check(path)
276
+ text = read_text(path)
277
+ result, count, first, last = self._replace(text, params)
278
+ atomic_write(path, result)
279
+ self.state.record(path)
280
+ name = self.display(path)
281
+ where = f"第 {first} 行" if last <= first else f"第 {first}–{last} 行"
282
+ return (
283
+ f"已修改 {name}:替换 {count} 处,{where}。",
284
+ {"path": str(path), "matches": count, "diff": unified_diff(text, result, name)},
285
+ )
286
+
287
+ return await file_io(edit)
@@ -0,0 +1,65 @@
1
+ """The memory tool: save or delete one note; never takes a path."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import Field
6
+
7
+ from ..memory import SCOPE_LABELS, MemoryFailure, MemoryStore
8
+ from .base import Params, Tool, ToolFailure, file_io
9
+
10
+
11
+ class MemoryParams(Params):
12
+ action: Literal["save", "delete"]
13
+ scope: Literal["user", "project"]
14
+ name: str = Field(min_length=1, max_length=200)
15
+ type: Literal["user", "feedback", "project", "reference"] | None = None
16
+ description: str = Field(default="", max_length=2000)
17
+ body: str = Field(default="", max_length=40000)
18
+
19
+
20
+ class Memory(Tool):
21
+ name = "memory"
22
+ permission_kind = "memory" # No approval: it writes only the two note directories.
23
+ plan_allowed = True # Offered in plan mode too; not read-only, so it never runs concurrently.
24
+ description = (
25
+ "保存或删除跨会话的记忆笔记。save 写入或覆盖同名笔记(更新日期),delete 删除笔记;"
26
+ "成功后返回文件路径,索引由程序重新生成,下次会话开始时出现在系统提示中。"
27
+ "只写笔记目录,不需要审批,计划模式中也可以直接调用。"
28
+ "scope:user 为用户级(所有项目通用),project 为项目级(只用于当前项目)。"
29
+ "type:user(用户本人的偏好、习惯)、feedback(用户纠正或确认过的做法)、"
30
+ "project(代码和 git 历史看不出的项目知识,只能写到 project)、reference(外部资源的位置)。"
31
+ )
32
+ prompt_summary = "保存或删除跨会话的记忆笔记"
33
+ prompt_guidelines = (
34
+ "用户明确要求“记住……”“以后……”“不要再……”时立即调用 memory 保存;发现已有笔记有误时更新或删除。"
35
+ "计划模式中同样直接调用,不写进计划等待批准。",
36
+ "先看系统提示中的笔记索引,有同名或同义的笔记就用相同的 scope 和 name 覆盖更新,而不是新建。",
37
+ "不记录代码结构、git 历史中已有的内容和只与本次对话有关的细节;不记录密钥、令牌、密码。",
38
+ "name 只用小写字母、数字和连字符;description 一行,最多 150 字符,写清什么时候有用;body 最多 4 KB。"
39
+ "feedback 和 project 类型的正文写出事实后,另起两行“原因:……”和“适用方式:……”。",
40
+ )
41
+ params_model = MemoryParams
42
+
43
+ def __init__(self, root, store: MemoryStore):
44
+ super().__init__(root)
45
+ self.store = store
46
+
47
+ async def execute(self, params: MemoryParams):
48
+ label = SCOPE_LABELS[params.scope]
49
+ try:
50
+ if params.action == "delete":
51
+ path = await file_io(self.store.delete, params.scope, params.name)
52
+ return (
53
+ f"已删除{label}笔记 {params.name}:{path}",
54
+ {"action": "delete", "scope": params.scope, "name": params.name, "path": str(path)},
55
+ )
56
+ note, created = await file_io(
57
+ self.store.save, params.scope, params.name, params.type, params.description, params.body
58
+ )
59
+ except MemoryFailure as exc:
60
+ raise ToolFailure(exc.code, str(exc)) from None
61
+ verb = "已保存" if created else "已更新"
62
+ return (
63
+ f"{verb}{label}笔记 {note.name}:{note.path}\n索引已重新生成;本会话的系统提示不会变化,下次会话开始时生效。",
64
+ {"action": "save", "scope": params.scope, "name": note.name, "path": str(note.path), "created": created},
65
+ )
mosscode/tools/plan.py ADDED
@@ -0,0 +1,30 @@
1
+ """The plan-mode exit: the permission policy asks the user and switches modes."""
2
+
3
+ from pydantic import Field
4
+
5
+ from .base import Params, Tool
6
+
7
+
8
+ class PlanParams(Params):
9
+ plan: str = Field(min_length=1, max_length=20000)
10
+
11
+
12
+ class ExitPlanMode(Tool):
13
+ name = "exit_plan_mode"
14
+ plan_only = True # Not read-only: runs alone, after any reads in the same response.
15
+ description = (
16
+ "计划模式下调查完成后调用,提交 Markdown 格式的实施计划请用户审阅。用户批准后立即退出计划模式,"
17
+ "可以继续使用写入、编辑和命令工具执行计划;用户选择继续规划时仍处于计划模式。"
18
+ )
19
+ prompt_summary = "提交计划,请用户批准后开始执行"
20
+ prompt_guidelines = (
21
+ "计划模式下完成调查后用 exit_plan_mode 提交计划,不要用文字询问用户是否开始执行;计划写明要改的文件、步骤和验证方法,简洁具体。",
22
+ "只为需要动手实施的任务提交计划;纯调查或问答直接回答。用户选择继续规划时,根据反馈调整后再提交。",
23
+ )
24
+ params_model = PlanParams
25
+
26
+ async def execute(self, params: PlanParams):
27
+ return (
28
+ "用户已批准计划,已退出计划模式。现在按计划执行;写入、编辑和命令按当前权限档位处理。",
29
+ {"plan": params.plan},
30
+ )
@@ -0,0 +1,216 @@
1
+ """Bounded asynchronous subprocess I/O, including process-tree cleanup."""
2
+
3
+ import asyncio
4
+ import os
5
+ import signal
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from pydantic import Field
10
+
11
+ from .base import Params, Tool, ToolFailure
12
+ from .shell import Shell, resolve_shell
13
+
14
+ OUTPUT_LIMIT = 32768
15
+ # Command output keeps both ends: failure summaries are usually at the end.
16
+ COMMAND_HEAD, COMMAND_TAIL = 1024 * 1024, 1024 * 1024 # Memory guard; layer 1 stores large output.
17
+
18
+
19
+ async def terminate_tree(process: asyncio.subprocess.Process) -> None:
20
+ if os.name == "nt":
21
+ # A Job Object owns only this invocation and its descendants. Closing it
22
+ # terminates descendants even if the original shell has already exited.
23
+ job = getattr(process, "_mosscode_job", None)
24
+ if job is not None:
25
+ job.close()
26
+ elif process.returncode is None:
27
+ killer = await asyncio.create_subprocess_exec(
28
+ "taskkill",
29
+ "/PID",
30
+ str(process.pid),
31
+ "/T",
32
+ "/F",
33
+ stdout=asyncio.subprocess.DEVNULL,
34
+ stderr=asyncio.subprocess.DEVNULL,
35
+ creationflags=0x08000000,
36
+ )
37
+ await killer.wait()
38
+ else:
39
+ try:
40
+ os.killpg(process.pid, signal.SIGKILL)
41
+ except ProcessLookupError:
42
+ pass
43
+ if process.returncode is None:
44
+ try:
45
+ process.kill()
46
+ except ProcessLookupError:
47
+ pass
48
+ await process.wait()
49
+
50
+
51
+ def _decode_cut(data: bytes, *, start: bool) -> str:
52
+ """Decode one side of a cut without a broken character at the cut edge."""
53
+ if start:
54
+ skip = 0
55
+ while skip < min(3, len(data)) and 0x80 <= data[skip] < 0xC0:
56
+ skip += 1
57
+ return data[skip:].decode("utf-8", errors="replace")
58
+ for trim in range(min(3, len(data)) + 1):
59
+ try:
60
+ return data[: len(data) - trim].decode("utf-8")
61
+ except UnicodeDecodeError:
62
+ continue
63
+ return data.decode("utf-8", errors="replace")
64
+
65
+
66
+ async def _feed(stream, data: bytes) -> None:
67
+ """Write stdin and close it; a child that exits without reading is not an error."""
68
+ try:
69
+ if data:
70
+ stream.write(data)
71
+ await stream.drain()
72
+ except (BrokenPipeError, ConnectionResetError):
73
+ pass
74
+ finally:
75
+ stream.close()
76
+
77
+
78
+ async def run_process(
79
+ args: list[str],
80
+ cwd: Path,
81
+ *,
82
+ check: bool = True,
83
+ limit: int = OUTPUT_LIMIT,
84
+ tail: int = 0,
85
+ env: dict[str, str] | None = None,
86
+ input: bytes | None = None,
87
+ ):
88
+ """input, when given, is written to the child's stdin, which is then closed; otherwise stdin is empty."""
89
+ options = {"creationflags": 0x08000000} if os.name == "nt" else {"start_new_session": True}
90
+ # Windows commands wait on a one-byte gate until Job assignment completes.
91
+ # This closes the race in which a fast shell could spawn before assignment.
92
+ # os.read takes exactly the gate byte, so the child inherits the rest of the pipe as its stdin.
93
+ inherit = "" if input is not None else ",stdin=subprocess.DEVNULL"
94
+ gate = f"import os,subprocess,sys; os.read(0,1); sys.exit(subprocess.call(sys.argv[1:]{inherit}))"
95
+ command = [sys.executable, "-c", gate, *args] if os.name == "nt" else args
96
+ piped = os.name == "nt" or input is not None
97
+ spawn = asyncio.create_task(
98
+ asyncio.create_subprocess_exec(
99
+ *command,
100
+ cwd=cwd,
101
+ stdin=asyncio.subprocess.PIPE if piped else asyncio.subprocess.DEVNULL,
102
+ stdout=asyncio.subprocess.PIPE,
103
+ stderr=asyncio.subprocess.PIPE,
104
+ env=env,
105
+ **options,
106
+ )
107
+ )
108
+ try:
109
+ process = await asyncio.shield(spawn)
110
+ except asyncio.CancelledError:
111
+ process = await spawn
112
+ await terminate_tree(process)
113
+ raise
114
+ if os.name == "nt":
115
+ from .windows_job import WindowsJob
116
+
117
+ try:
118
+ process._mosscode_job = WindowsJob(process.pid)
119
+ except OSError:
120
+ await terminate_tree(process)
121
+ raise ToolFailure("process_cleanup_unavailable", "无法建立命令进程的生命周期边界,命令已终止。") from None
122
+ feeder = None
123
+ if piped:
124
+ feeder = asyncio.create_task(_feed(process.stdin, (b"\n" if os.name == "nt" else b"") + (input or b"")))
125
+
126
+ async def drain(stream):
127
+ # Keep the first `limit` bytes and, with `tail`, a rolling window of the
128
+ # bytes after them; head + end is the whole stream when nothing was dropped.
129
+ head, end = bytearray(), bytearray()
130
+ total = 0
131
+ while chunk := await stream.read(8192):
132
+ total += len(chunk)
133
+ room = max(0, limit - len(head))
134
+ head.extend(chunk[:room])
135
+ if tail:
136
+ end.extend(chunk[room:])
137
+ del end[: max(0, len(end) - tail)]
138
+ omitted = total - len(head) - len(end)
139
+ if omitted <= 0:
140
+ return (bytes(head) + bytes(end)).decode("utf-8", errors="replace"), False
141
+ if not tail:
142
+ return head.decode("utf-8", errors="replace"), True
143
+ marker = f"\n…(中间省略 {omitted} 字节)…\n"
144
+ return _decode_cut(bytes(head), start=False) + marker + _decode_cut(bytes(end), start=True), True
145
+
146
+ stdout_task = asyncio.create_task(drain(process.stdout))
147
+ stderr_task = asyncio.create_task(drain(process.stderr))
148
+ try:
149
+ await process.wait()
150
+ stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
151
+ details = {
152
+ "stdout": stdout[0],
153
+ "stderr": stderr[0],
154
+ "exit_code": process.returncode,
155
+ "truncated": stdout[1] or stderr[1],
156
+ }
157
+ if check and process.returncode != 0:
158
+ raise ToolFailure("nonzero_exit", f"命令退出码为 {process.returncode}。", **details)
159
+ return details
160
+ finally:
161
+ await terminate_tree(process)
162
+ await asyncio.gather(stdout_task, stderr_task, *(task for task in (feeder,) if task), return_exceptions=True)
163
+
164
+
165
+ class CommandParams(Params):
166
+ command: str = Field(min_length=1)
167
+ cwd: str | None = None
168
+ timeout: float = Field(default=120.0, gt=0, le=600, allow_inf_nan=False)
169
+
170
+
171
+ SHELL_GUIDELINES = {
172
+ "Git Bash": "当前 shell 是 Windows 上的 Git Bash:使用 bash 语法和正斜杠路径(如 C:/Users 或 /c/Users),空设备写 /dev/null 而不是 NUL;Windows 原生程序仍可直接调用。",
173
+ "PowerShell": "当前 shell 是 PowerShell:使用 PowerShell 语法;Windows PowerShell 5 不支持 && 和 ||,用 ; 分隔并检查 $LASTEXITCODE。",
174
+ }
175
+
176
+
177
+ class ExecuteCommand(Tool):
178
+ name = "execute_command"
179
+ prompt_summary = "执行测试、构建等 shell 命令"
180
+ params_model = CommandParams
181
+ plan_commands = True
182
+
183
+ def __init__(self, root: Path, shell: Shell | str | None = None):
184
+ super().__init__(root)
185
+ self.shell = shell if isinstance(shell, Shell) else resolve_shell(shell or "auto")
186
+ self.description = (
187
+ f"在 {self.shell.label} 中执行命令,返回 stdout、stderr 和退出码。cwd 可指定工作目录;"
188
+ "timeout 以秒计,默认 120、最大 600。输出过长时保留开头和结尾。"
189
+ )
190
+ self.prompt_guidelines = (
191
+ "execute_command 用于测试、构建和专用工具未覆盖的任务;已有专用工具时优先用于读取、搜索、写入和编辑。",
192
+ "execute_command 的命令遵循当前 shell 的语法,正确引用含空格的路径。依据退出码和输出判断结果,不能仅因有输出就宣布成功。",
193
+ *((SHELL_GUIDELINES[self.shell.label],) if self.shell.label in SHELL_GUIDELINES else ()),
194
+ "git status/diff/log/show、ls、cat、grep 等只读命令(参数限于项目内,不含管道和重定向)无需审批,计划模式中也可使用;其他命令按权限档位审批。",
195
+ "execute_command 超时或取消可能留下部分效果;重复有副作用的命令前先检查状态,不通过增大超时掩盖持续失败。",
196
+ )
197
+
198
+ async def execute(self, params: CommandParams):
199
+ cwd = self.path(params.cwd) if params.cwd is not None else self.root
200
+ details = await run_process(
201
+ self.shell.args(params.command),
202
+ cwd,
203
+ check=False,
204
+ limit=COMMAND_HEAD,
205
+ tail=COMMAND_TAIL,
206
+ env=self.shell.env(params.command),
207
+ )
208
+ output = details["stdout"]
209
+ if details["stderr"]:
210
+ output += ("\n" if output else "") + "stderr:\n" + details["stderr"]
211
+ if details["exit_code"] != 0:
212
+ # The model sees only output, so the failing command's streams must be in it.
213
+ raise ToolFailure(
214
+ "nonzero_exit", f"命令退出码为 {details['exit_code']}。" + (f"\n{output}" if output else ""), **details
215
+ )
216
+ return output or "命令完成,无输出。", details