mosscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/hooks/engine.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Match hooks to an event, run them in order, log and report."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .actions import ActionResult, public_url, render, run_command, run_http, short, stdin_json
|
|
15
|
+
from .config import Hook, HookConfig, HookFile, TrustStore, load_hooks
|
|
16
|
+
from .events import EVENTS
|
|
17
|
+
|
|
18
|
+
FILE_TOOLS = {"read_file", "write_file", "edit_file", "glob", "grep"}
|
|
19
|
+
RESULT_LIMIT = 64 * 1024
|
|
20
|
+
RECENT = 50
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class HookEvent:
|
|
25
|
+
name: str
|
|
26
|
+
agent: str = "main"
|
|
27
|
+
fields: Mapping[str, str] = field(default_factory=dict) # Condition fields, as strings.
|
|
28
|
+
payload: Mapping[str, Any] = field(default_factory=dict) # The rest of the stdin JSON (args, result ...).
|
|
29
|
+
file_path: Path | None = None # MOSSCODE_FILE_PATH / ${FILE_PATH}
|
|
30
|
+
tool: str = ""
|
|
31
|
+
agent_type: str = "" # "" in the main conversation, the definition name, "fork" or "skill:<name>".
|
|
32
|
+
nested: bool = False # A tool event of a sub-agent started by an agent action.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class HookOutcome:
|
|
37
|
+
rejection: tuple[str, str] | None = None # (hook id, message for the model)
|
|
38
|
+
notes: tuple[tuple[str, str], ...] = () # (hook id, injected text)
|
|
39
|
+
ran: tuple[str, ...] = () # Synchronous hooks that ran, in order.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
NOTHING = HookOutcome()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class HookRun:
|
|
47
|
+
time: str
|
|
48
|
+
hook: str
|
|
49
|
+
source: str
|
|
50
|
+
event: str
|
|
51
|
+
agent: str
|
|
52
|
+
action: str
|
|
53
|
+
outcome: str
|
|
54
|
+
duration_ms: int
|
|
55
|
+
exit_code: int | None = None
|
|
56
|
+
status: int | None = None
|
|
57
|
+
detail: str = ""
|
|
58
|
+
|
|
59
|
+
def record(self) -> dict:
|
|
60
|
+
entry = {key: value for key, value in self.__dict__.items() if value is not None}
|
|
61
|
+
return entry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _canonical(value) -> str:
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
return value
|
|
67
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tool_event(
|
|
71
|
+
name: str,
|
|
72
|
+
agent: str,
|
|
73
|
+
tool: str,
|
|
74
|
+
args: Mapping[str, Any],
|
|
75
|
+
operation,
|
|
76
|
+
root: Path,
|
|
77
|
+
result=None,
|
|
78
|
+
*,
|
|
79
|
+
agent_type: str = "",
|
|
80
|
+
) -> HookEvent:
|
|
81
|
+
"""Fields of the tool events: path and command are what the permission rules see."""
|
|
82
|
+
args = args if isinstance(args, Mapping) else {}
|
|
83
|
+
path = command = ""
|
|
84
|
+
file_path = None
|
|
85
|
+
if operation is not None and tool in FILE_TOOLS:
|
|
86
|
+
path = operation.target
|
|
87
|
+
file_path = Path(path) if operation.outside else root / path
|
|
88
|
+
elif operation is not None and tool == "execute_command":
|
|
89
|
+
command = operation.target
|
|
90
|
+
fields = {
|
|
91
|
+
"tool": tool,
|
|
92
|
+
"path": path,
|
|
93
|
+
"command": command,
|
|
94
|
+
**{f"args.{key}": _canonical(value) for key, value in args.items()},
|
|
95
|
+
}
|
|
96
|
+
payload = {"tool": tool, "args": dict(args), "path": path, "command": command}
|
|
97
|
+
if name == "permission_request" and operation is not None:
|
|
98
|
+
fields["risk"] = operation.risk
|
|
99
|
+
if result is not None:
|
|
100
|
+
fields["error"] = "true" if result.is_error else "false"
|
|
101
|
+
fields["code"] = result.code or ""
|
|
102
|
+
content = result.output.encode("utf-8")[:RESULT_LIMIT].decode("utf-8", errors="ignore")
|
|
103
|
+
payload["result"] = {"content": content, "is_error": result.is_error, "code": result.code}
|
|
104
|
+
return HookEvent(name, agent, fields, payload, file_path, tool, agent_type)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class HookEngine:
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
root: Path,
|
|
111
|
+
*,
|
|
112
|
+
home: Path | None = None,
|
|
113
|
+
shell=None,
|
|
114
|
+
enabled: bool = True,
|
|
115
|
+
trust_project: bool = False,
|
|
116
|
+
config: HookConfig | None = None,
|
|
117
|
+
):
|
|
118
|
+
self.root = Path(root).resolve()
|
|
119
|
+
self.home = home
|
|
120
|
+
self.shell = shell
|
|
121
|
+
self.enabled = enabled
|
|
122
|
+
self.trust_project = trust_project
|
|
123
|
+
self.store = TrustStore(self.root, home)
|
|
124
|
+
self.config = (
|
|
125
|
+
config
|
|
126
|
+
if config is not None
|
|
127
|
+
else (
|
|
128
|
+
load_hooks(self.root, home, store=self.store, trust_project=trust_project) if enabled else HookConfig()
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
self.declined: set[Path] = set() # Files the user chose not to enable this run.
|
|
132
|
+
self.recent: deque[HookRun] = deque(maxlen=RECENT)
|
|
133
|
+
self.session = None
|
|
134
|
+
self.session_id = ""
|
|
135
|
+
self.on_notice: Callable[[str], None] | None = None
|
|
136
|
+
self.notices: list[str] = [] # When nobody listens yet.
|
|
137
|
+
# Runs an agent action; set by the Conversation.
|
|
138
|
+
self.agent_runner: Callable[[Hook, HookEvent, Mapping[str, str]], Awaitable[ActionResult]] | None = None
|
|
139
|
+
self._once: set[str] = set()
|
|
140
|
+
self._noticed: set[str] = set() # Per turn.
|
|
141
|
+
self._agent_noticed: set[str] = set() # Per session.
|
|
142
|
+
self._background: set[asyncio.Task] = set()
|
|
143
|
+
|
|
144
|
+
# Configuration ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def hooks(self) -> tuple[Hook, ...]:
|
|
148
|
+
return self.config.hooks if self.enabled else ()
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def problems(self) -> tuple[str, ...]:
|
|
152
|
+
return self.config.problems
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def untrusted(self) -> tuple[HookFile, ...]:
|
|
156
|
+
return self.config.untrusted
|
|
157
|
+
|
|
158
|
+
def pending_trust(self) -> tuple[HookFile, ...]:
|
|
159
|
+
"""Untrusted files the user has not declined during this run."""
|
|
160
|
+
return tuple(file for file in self.untrusted if file.path not in self.declined)
|
|
161
|
+
|
|
162
|
+
def wants(self, event: str) -> bool:
|
|
163
|
+
return any(hook.event == event for hook in self.hooks)
|
|
164
|
+
|
|
165
|
+
def trust(self, file: HookFile) -> None:
|
|
166
|
+
if file.digest is not None:
|
|
167
|
+
self.store.trust(file.path.name, file.digest)
|
|
168
|
+
self.declined.discard(file.path)
|
|
169
|
+
self.config = replace(
|
|
170
|
+
self.config, files=tuple(replace(f, trusted=True) if f.path == file.path else f for f in self.config.files)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def decline(self, file: HookFile) -> None:
|
|
174
|
+
self.declined.add(file.path)
|
|
175
|
+
|
|
176
|
+
def reload(self) -> tuple[list[str], list[str]]:
|
|
177
|
+
"""Reread the files; once state is kept by id. Returns (added, removed) ids."""
|
|
178
|
+
before = {hook.id for hook in self.hooks}
|
|
179
|
+
self.declined.clear()
|
|
180
|
+
self.config = (
|
|
181
|
+
load_hooks(self.root, self.home, store=self.store, trust_project=self.trust_project)
|
|
182
|
+
if self.enabled
|
|
183
|
+
else HookConfig()
|
|
184
|
+
)
|
|
185
|
+
after = {hook.id for hook in self.hooks}
|
|
186
|
+
return sorted(after - before), sorted(before - after)
|
|
187
|
+
|
|
188
|
+
def find(self, hook_id: str) -> Hook | None:
|
|
189
|
+
return next((hook for file in self.config.files for hook in file.hooks if hook.id == hook_id), None)
|
|
190
|
+
|
|
191
|
+
def status(self, hook: Hook) -> str:
|
|
192
|
+
file = next((f for f in self.config.files if f.path == hook.path), None)
|
|
193
|
+
if file is not None and not file.trusted:
|
|
194
|
+
return "未确认"
|
|
195
|
+
if hook.once and hook.id in self._once:
|
|
196
|
+
return "once 已执行"
|
|
197
|
+
return "已启用"
|
|
198
|
+
|
|
199
|
+
# Sessions and turns -----------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def use_session(self, session) -> None:
|
|
202
|
+
self.session = session
|
|
203
|
+
self.session_id = getattr(session, "id", "") or ""
|
|
204
|
+
|
|
205
|
+
def new_session(self, session=None) -> None:
|
|
206
|
+
"""Once resets with every new session (/clear, resume)."""
|
|
207
|
+
self._once.clear()
|
|
208
|
+
self._agent_noticed.clear()
|
|
209
|
+
if session is not None:
|
|
210
|
+
self.use_session(session)
|
|
211
|
+
|
|
212
|
+
def begin_turn(self) -> None:
|
|
213
|
+
self._noticed.clear()
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def log_path(self) -> Path | None:
|
|
217
|
+
return self.session.directory / "hooks.log" if self.session is not None else None
|
|
218
|
+
|
|
219
|
+
# Running ---------------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async def fire(self, event: HookEvent) -> HookOutcome:
|
|
222
|
+
"""Run the matching hooks in order. Never raises, except CancelledError."""
|
|
223
|
+
if not self.enabled:
|
|
224
|
+
return NOTHING
|
|
225
|
+
# subagent_start/stop carry the sub-agent's own agent_type in their fields.
|
|
226
|
+
fields = {"agent_type": event.agent_type, **event.fields, "event": event.name, "agent": event.agent}
|
|
227
|
+
notes, ran = [], []
|
|
228
|
+
for hook in self.hooks:
|
|
229
|
+
if hook.event != event.name or (hook.condition is not None and not hook.condition.matches(fields)):
|
|
230
|
+
continue
|
|
231
|
+
if hook.once:
|
|
232
|
+
if hook.id in self._once:
|
|
233
|
+
continue
|
|
234
|
+
self._once.add(hook.id) # Consumed when it starts, also if it fails.
|
|
235
|
+
if hook.background:
|
|
236
|
+
self.spawn(self._run(hook, event, fields))
|
|
237
|
+
continue
|
|
238
|
+
if hook.action is None: # Static rejection without an action.
|
|
239
|
+
self._log(hook, event, "reject", ActionResult("rejected", short(hook.reject)), 0)
|
|
240
|
+
return HookOutcome(
|
|
241
|
+
(hook.id, f"Hook {hook.id} 拒绝了此调用:{hook.reject}"), tuple(notes), (*ran, hook.id)
|
|
242
|
+
)
|
|
243
|
+
blocking = hook.reject is None and hook.on_error == "block"
|
|
244
|
+
result = await self._run(hook, event, fields, quiet=blocking)
|
|
245
|
+
ran.append(hook.id)
|
|
246
|
+
if hook.reject is not None:
|
|
247
|
+
return HookOutcome((hook.id, f"Hook {hook.id} 拒绝了此调用:{hook.reject}"), tuple(notes), tuple(ran))
|
|
248
|
+
if result.outcome == "rejected":
|
|
249
|
+
return HookOutcome((hook.id, f"Hook {hook.id} 拒绝了此调用:{result.reason}"), tuple(notes), tuple(ran))
|
|
250
|
+
if result.outcome in {"failed", "timeout"} and blocking:
|
|
251
|
+
return HookOutcome(
|
|
252
|
+
(hook.id, f"Hook {hook.id} 执行失败,按配置阻止此调用:{result.detail}"), tuple(notes), tuple(ran)
|
|
253
|
+
)
|
|
254
|
+
if result.outcome == "ok" and result.output:
|
|
255
|
+
notes.append((hook.id, result.output))
|
|
256
|
+
return HookOutcome(None, tuple(notes), tuple(ran))
|
|
257
|
+
|
|
258
|
+
def spawn(self, coroutine) -> asyncio.Task:
|
|
259
|
+
task = asyncio.get_running_loop().create_task(coroutine)
|
|
260
|
+
self._background.add(task)
|
|
261
|
+
task.add_done_callback(self._settled)
|
|
262
|
+
return task
|
|
263
|
+
|
|
264
|
+
def _settled(self, task: asyncio.Task) -> None:
|
|
265
|
+
self._background.discard(task)
|
|
266
|
+
if not task.cancelled():
|
|
267
|
+
task.exception() # Retrieved; _run never raises anything else.
|
|
268
|
+
|
|
269
|
+
async def drain(self, timeout: float) -> None:
|
|
270
|
+
"""Wait for background hooks, then cancel the rest (their process trees are killed)."""
|
|
271
|
+
pending = set(self._background)
|
|
272
|
+
if not pending:
|
|
273
|
+
return
|
|
274
|
+
_, late = await asyncio.wait(pending, timeout=timeout)
|
|
275
|
+
for task in late:
|
|
276
|
+
task.cancel()
|
|
277
|
+
if late:
|
|
278
|
+
await asyncio.gather(*late, return_exceptions=True)
|
|
279
|
+
|
|
280
|
+
def _context(self, hook: Hook, event: HookEvent, fields) -> dict:
|
|
281
|
+
context = {
|
|
282
|
+
"event": event.name,
|
|
283
|
+
"hook_id": hook.id,
|
|
284
|
+
"session_id": self.session_id,
|
|
285
|
+
"project_root": str(self.root),
|
|
286
|
+
"agent": event.agent,
|
|
287
|
+
}
|
|
288
|
+
context.update({key: value for key, value in fields.items() if not key.startswith("args.")})
|
|
289
|
+
context.update(event.payload)
|
|
290
|
+
return context
|
|
291
|
+
|
|
292
|
+
def _env(self, hook: Hook, event: HookEvent) -> dict[str, str]:
|
|
293
|
+
env = {
|
|
294
|
+
"MOSSCODE_HOOK_ID": hook.id,
|
|
295
|
+
"MOSSCODE_HOOK_EVENT": event.name,
|
|
296
|
+
"MOSSCODE_SESSION_ID": self.session_id,
|
|
297
|
+
"MOSSCODE_PROJECT_ROOT": str(self.root),
|
|
298
|
+
"MOSSCODE_AGENT": event.agent,
|
|
299
|
+
"MOSSCODE_AGENT_TYPE": event.fields.get("agent_type", event.agent_type),
|
|
300
|
+
"PYTHONUTF8": "1",
|
|
301
|
+
"PYTHONIOENCODING": "utf-8",
|
|
302
|
+
}
|
|
303
|
+
if EVENTS[event.name].tool:
|
|
304
|
+
env["MOSSCODE_TOOL_NAME"] = event.tool
|
|
305
|
+
env["MOSSCODE_FILE_PATH"] = str(event.file_path) if event.file_path is not None else ""
|
|
306
|
+
return env
|
|
307
|
+
|
|
308
|
+
async def _execute(self, hook: Hook, event: HookEvent, fields) -> ActionResult:
|
|
309
|
+
action = hook.action
|
|
310
|
+
if action.type == "prompt":
|
|
311
|
+
return ActionResult("ok", output=render(action.message, fields).strip()[:8000])
|
|
312
|
+
if action.type == "agent":
|
|
313
|
+
if event.nested: # No sub-agent starts another one.
|
|
314
|
+
return ActionResult("skipped", "由 Hook 启动的子 Agent 中不再运行 agent 动作")
|
|
315
|
+
if self.agent_runner is None:
|
|
316
|
+
return ActionResult("skipped", "当前运行方式不支持子 Agent")
|
|
317
|
+
return await self.agent_runner(hook, event, fields)
|
|
318
|
+
context = self._context(hook, event, fields)
|
|
319
|
+
if action.type == "http":
|
|
320
|
+
return await run_http(action, fields=fields, context=context)
|
|
321
|
+
values = {
|
|
322
|
+
"FILE_PATH": str(event.file_path) if event.file_path is not None else "",
|
|
323
|
+
"PROJECT_ROOT": str(self.root),
|
|
324
|
+
"PYTHON": sys.executable,
|
|
325
|
+
"TOOL_NAME": event.tool,
|
|
326
|
+
"EVENT": event.name,
|
|
327
|
+
}
|
|
328
|
+
return await run_command(
|
|
329
|
+
action,
|
|
330
|
+
root=self.root,
|
|
331
|
+
shell=self.shell,
|
|
332
|
+
env=self._env(hook, event),
|
|
333
|
+
values=values,
|
|
334
|
+
stdin=stdin_json(context),
|
|
335
|
+
rejecting=event.name == "pre_tool_use",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async def _run(self, hook: Hook, event: HookEvent, fields, *, quiet: bool = False) -> ActionResult:
|
|
339
|
+
started = time.monotonic()
|
|
340
|
+
try:
|
|
341
|
+
result = await self._execute(hook, event, fields)
|
|
342
|
+
except asyncio.CancelledError:
|
|
343
|
+
self._log(hook, event, hook.action.type, ActionResult("cancelled", "已取消"), started)
|
|
344
|
+
raise
|
|
345
|
+
except Exception as exc: # A hook must never break the main flow.
|
|
346
|
+
result = ActionResult("failed", f"内部错误:{type(exc).__name__}")
|
|
347
|
+
self._log(hook, event, hook.action.type, result, started)
|
|
348
|
+
if result.outcome in {"failed", "timeout"} and not quiet:
|
|
349
|
+
self._notify(hook, f"Hook {hook.id}({event.name})失败:{result.detail.rstrip('。')};已忽略。")
|
|
350
|
+
elif result.outcome == "skipped" and hook.id not in self._agent_noticed:
|
|
351
|
+
self._agent_noticed.add(hook.id)
|
|
352
|
+
self._emit(f"Hook {hook.id}({event.name})的子 Agent 动作已跳过:{result.detail}。")
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
def _notify(self, hook: Hook, text: str) -> None:
|
|
356
|
+
if hook.id in self._noticed:
|
|
357
|
+
return
|
|
358
|
+
self._noticed.add(hook.id)
|
|
359
|
+
self._emit(text)
|
|
360
|
+
|
|
361
|
+
def _emit(self, text: str) -> None:
|
|
362
|
+
if self.on_notice is not None:
|
|
363
|
+
try:
|
|
364
|
+
self.on_notice(text)
|
|
365
|
+
return
|
|
366
|
+
except Exception:
|
|
367
|
+
pass
|
|
368
|
+
self.notices.append(text)
|
|
369
|
+
|
|
370
|
+
def take_notices(self) -> list[str]:
|
|
371
|
+
notices, self.notices = self.notices, []
|
|
372
|
+
return notices
|
|
373
|
+
|
|
374
|
+
def _log(self, hook: Hook, event: HookEvent, action: str, result: ActionResult, started: float) -> None:
|
|
375
|
+
duration = int((time.monotonic() - started) * 1000) if started else 0
|
|
376
|
+
detail = result.detail
|
|
377
|
+
if hook.action is not None and hook.action.type == "http":
|
|
378
|
+
detail = detail.replace(hook.action.url, public_url(hook.action.url))
|
|
379
|
+
run = HookRun(
|
|
380
|
+
datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
381
|
+
hook.id,
|
|
382
|
+
hook.source,
|
|
383
|
+
event.name,
|
|
384
|
+
event.agent,
|
|
385
|
+
action,
|
|
386
|
+
result.outcome,
|
|
387
|
+
duration,
|
|
388
|
+
result.exit_code,
|
|
389
|
+
result.status,
|
|
390
|
+
short(detail, 500),
|
|
391
|
+
)
|
|
392
|
+
self.recent.append(run)
|
|
393
|
+
path = self.log_path
|
|
394
|
+
if path is None:
|
|
395
|
+
return
|
|
396
|
+
try:
|
|
397
|
+
self.session.ensure()
|
|
398
|
+
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
|
399
|
+
stream.write(json.dumps(run.record(), ensure_ascii=False) + "\n")
|
|
400
|
+
except OSError:
|
|
401
|
+
pass # The log is a convenience; a full disk must not break a turn.
|
mosscode/hooks/events.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The hook events, their condition fields, allowed actions and how they run."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
ACTIONS = ("command", "prompt", "http", "agent")
|
|
6
|
+
EXTERNAL = ("command", "http", "agent")
|
|
7
|
+
COMMON_FIELDS = ("event", "agent", "agent_type")
|
|
8
|
+
TOOL_FIELDS = ("tool", "path", "command", "worktree") # Worktree is "" outside one.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class EventSpec:
|
|
13
|
+
fields: tuple[str, ...]
|
|
14
|
+
actions: tuple[str, ...]
|
|
15
|
+
background: bool = False # Command, http and agent actions always run in the background.
|
|
16
|
+
sync_only: bool = False # pre_tool_use: async is not allowed.
|
|
17
|
+
feedback: bool = False # Command output may be injected.
|
|
18
|
+
tool: bool = False # args.<name> fields; MOSSCODE_TOOL_NAME and MOSSCODE_FILE_PATH.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EVENTS: dict[str, EventSpec] = {
|
|
22
|
+
"session_start": EventSpec(("source",), ACTIONS, feedback=True),
|
|
23
|
+
"session_end": EventSpec(("reason",), EXTERNAL),
|
|
24
|
+
"turn_start": EventSpec(("prompt",), ACTIONS, feedback=True),
|
|
25
|
+
"turn_end": EventSpec(("reason",), EXTERNAL),
|
|
26
|
+
"pre_send": EventSpec(("iteration",), ACTIONS, background=True),
|
|
27
|
+
"post_receive": EventSpec(("iteration", "tool_calls"), EXTERNAL, background=True),
|
|
28
|
+
"pre_tool_use": EventSpec(TOOL_FIELDS, ACTIONS, sync_only=True, feedback=True, tool=True),
|
|
29
|
+
"post_tool_use": EventSpec((*TOOL_FIELDS, "error", "code"), ACTIONS, feedback=True, tool=True),
|
|
30
|
+
"permission_request": EventSpec((*TOOL_FIELDS, "risk"), EXTERNAL, background=True, tool=True),
|
|
31
|
+
"compact": EventSpec(("trigger",), EXTERNAL),
|
|
32
|
+
"error": EventSpec(("error",), EXTERNAL),
|
|
33
|
+
# Fired on the side that started the sub-agent; agent_type here is the sub-agent's.
|
|
34
|
+
"subagent_start": EventSpec(
|
|
35
|
+
("task_id", "description", "background", "worktree"), ("command", "http", "prompt"), feedback=True
|
|
36
|
+
),
|
|
37
|
+
"subagent_stop": EventSpec(
|
|
38
|
+
("task_id", "status", "background", "worktree", "worktree_kept"), EXTERNAL, background=True
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# The only events a fork sub-agent fires.
|
|
43
|
+
TOOL_EVENTS = frozenset(name for name, spec in EVENTS.items() if spec.tool)
|
|
44
|
+
ALL_FIELDS = frozenset((*COMMON_FIELDS, *(field for spec in EVENTS.values() for field in spec.fields)))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fields_of(event: str) -> tuple[str, ...]:
|
|
48
|
+
return (*COMMON_FIELDS, *EVENTS[event].fields)
|
mosscode/launcher.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The `moss` / `mosscode` command.
|
|
2
|
+
|
|
3
|
+
Arguments, --help, --version and the subcommands are handled with nothing heavy imported; a session
|
|
4
|
+
imports the rest through cli.run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
OUTPUT_FORMATS = ("text", "json", "stream-json")
|
|
14
|
+
SUBCOMMANDS = ("setup", "doctor")
|
|
15
|
+
NAMES = ("moss", "mosscode")
|
|
16
|
+
USAGE = "%(prog)s [选项] [PROMPT]\n %(prog)s setup | doctor"
|
|
17
|
+
DESCRIPTION = "MossCode:终端中的 coding agent。在项目目录中运行即可开始。"
|
|
18
|
+
EXAMPLES = """\
|
|
19
|
+
子命令:
|
|
20
|
+
setup 创建用户级配置 ~/.mosscode/config.yaml
|
|
21
|
+
doctor 检查安装、配置和依赖工具,不发送请求
|
|
22
|
+
|
|
23
|
+
例子:
|
|
24
|
+
moss 在当前目录启动交互界面
|
|
25
|
+
moss "解释这个项目的入口" 启动后立即发送这条消息
|
|
26
|
+
moss -p "列出 src 下的模块" 无界面执行一次任务后退出
|
|
27
|
+
Get-Content error.log | moss -p "解释这个错误"
|
|
28
|
+
moss -c 恢复本项目最近的会话
|
|
29
|
+
|
|
30
|
+
moss 与 mosscode 是同一个命令。要把 setup 或 doctor 当作消息发送,写成 moss -- doctor。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version() -> str:
|
|
35
|
+
from importlib.metadata import PackageNotFoundError
|
|
36
|
+
from importlib.metadata import version as installed
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return installed("mosscode")
|
|
40
|
+
except PackageNotFoundError: # Run from a source tree that was never installed.
|
|
41
|
+
return "0+unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def program() -> str:
|
|
45
|
+
name = Path(sys.argv[0]).stem if sys.argv and sys.argv[0] else ""
|
|
46
|
+
return name if name in NAMES else "moss"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _permission_mode(value: str) -> str:
|
|
50
|
+
from .permissions import PermissionConfigError, normalize_mode
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return normalize_mode(value)
|
|
54
|
+
except PermissionConfigError as exc:
|
|
55
|
+
raise argparse.ArgumentTypeError(str(exc)) from None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parser() -> argparse.ArgumentParser:
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog=program(),
|
|
61
|
+
usage=USAGE,
|
|
62
|
+
description=DESCRIPTION,
|
|
63
|
+
epilog=EXAMPLES,
|
|
64
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"initial", nargs="?", metavar="PROMPT", help="启动后立即发送的第一条消息;与 -p 一起用时是无界面执行的任务"
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument("-v", "--version", action="version", version=f"mosscode {version()}", help="显示版本后退出")
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"-p", "--prompt", nargs="?", const="", help="无界面执行一次任务并输出结果;管道输入附在任务之后"
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"-c", "--continue", dest="continue_session", action="store_true", help="恢复本项目最近活动的会话"
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--resume", nargs="?", const="", metavar="ID", help="恢复指定会话;不带 ID 时在交互界面中打开会话列表"
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument("--config", help="YAML 配置路径;默认依次查找 .mosscode/config.yaml、~/.mosscode/config.yaml")
|
|
80
|
+
parser.add_argument("--provider", help="指定命名 Provider,跳过启动选择;无界面模式默认使用 default_provider")
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--permission-mode",
|
|
83
|
+
type=_permission_mode,
|
|
84
|
+
metavar="MODE",
|
|
85
|
+
help="权限档位:default/acceptEdits/plan/bypassPermissions;仍遵守硬限制和显式拒绝",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--output-format",
|
|
89
|
+
choices=OUTPUT_FORMATS,
|
|
90
|
+
default="text",
|
|
91
|
+
help="无界面输出格式:text(默认,流式正文)、json(结束时输出一个结果对象)、stream-json(每行一个 JSON 事件)",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--show-usage", action="store_true", help="text 格式在 stderr 显示逐次请求的 Token 与缓存用量")
|
|
94
|
+
parser.add_argument("--list-providers", action="store_true", help="列出配置,不发请求")
|
|
95
|
+
parser.add_argument("--no-hooks", action="store_true", help="本次运行不加载任何 Hook")
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--trust-project-hooks",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="本次运行信任项目级 Hook 文件(不写入确认记录),用于无界面模式",
|
|
100
|
+
)
|
|
101
|
+
# A team member in a pane of its own; started by the lead, not by hand.
|
|
102
|
+
parser.add_argument("--team-dir", help=argparse.SUPPRESS)
|
|
103
|
+
parser.add_argument("--team-member", help=argparse.SUPPRESS)
|
|
104
|
+
return parser
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
108
|
+
"""Parse and check the combinations; exits with 2 on a mistake, as argparse does."""
|
|
109
|
+
command = parser()
|
|
110
|
+
args = command.parse_args(argv)
|
|
111
|
+
if (args.team_dir is None) != (args.team_member is None):
|
|
112
|
+
command.error("--team-dir 和 --team-member 需要一起使用。")
|
|
113
|
+
if args.prompt is not None: # The task is the value of -p or the positional prompt, not both.
|
|
114
|
+
if args.prompt and args.initial is not None:
|
|
115
|
+
command.error("无界面任务只能给一个:-p 的值或位置参数 PROMPT。")
|
|
116
|
+
if not args.prompt and args.initial is not None:
|
|
117
|
+
args.prompt = args.initial
|
|
118
|
+
args.initial = None
|
|
119
|
+
if args.output_format != "text" and args.prompt is None:
|
|
120
|
+
command.error("--output-format 只用于 -p/--prompt 无界面模式。")
|
|
121
|
+
if args.continue_session and args.resume is not None:
|
|
122
|
+
command.error("--continue 和 --resume 只能选择一个。")
|
|
123
|
+
if args.resume == "" and args.prompt is not None:
|
|
124
|
+
command.error("无界面模式中 --resume 需要会话 ID;恢复最近的会话请用 --continue。")
|
|
125
|
+
if args.resume == "" and args.initial is not None:
|
|
126
|
+
command.error("打开会话列表时不能同时给出第一条消息;请用 --resume ID 或 -c。")
|
|
127
|
+
return args
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_terminal(stream) -> bool:
|
|
131
|
+
try:
|
|
132
|
+
return stream is not None and stream.isatty()
|
|
133
|
+
except (AttributeError, ValueError, OSError):
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def with_stdin(prompt: str, stdin=None) -> str:
|
|
138
|
+
"""Piped input follows the task after a blank line; alone, it is the task."""
|
|
139
|
+
stdin = sys.stdin if stdin is None else stdin
|
|
140
|
+
if is_terminal(stdin) or stdin is None:
|
|
141
|
+
return prompt
|
|
142
|
+
try:
|
|
143
|
+
piped = stdin.read()
|
|
144
|
+
except (OSError, ValueError, UnicodeError):
|
|
145
|
+
return prompt
|
|
146
|
+
piped = piped.strip("\n")
|
|
147
|
+
if not piped.strip():
|
|
148
|
+
return prompt
|
|
149
|
+
return f"{prompt}\n\n{piped}" if prompt.strip() else piped
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _subcommand(name: str, argv: list[str]) -> int:
|
|
153
|
+
if name == "setup":
|
|
154
|
+
from .setup import main as setup
|
|
155
|
+
|
|
156
|
+
return setup(argv)
|
|
157
|
+
from .doctor import main as doctor
|
|
158
|
+
|
|
159
|
+
return doctor(argv)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: list[str] | None = None) -> None:
|
|
163
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
164
|
+
if argv and argv[0] in SUBCOMMANDS:
|
|
165
|
+
raise SystemExit(_subcommand(argv[0], argv[1:]))
|
|
166
|
+
args = parse_arguments(argv)
|
|
167
|
+
if args.team_dir is None and not args.list_providers:
|
|
168
|
+
if args.prompt is not None:
|
|
169
|
+
args.prompt = with_stdin(args.prompt)
|
|
170
|
+
if not args.prompt.strip():
|
|
171
|
+
parser().error("-p 需要任务:写在 -p 之后,或通过管道输入。")
|
|
172
|
+
elif not is_terminal(sys.stdin):
|
|
173
|
+
print("MossCode: 交互界面需要终端;无界面执行请用 -p。", file=sys.stderr)
|
|
174
|
+
raise SystemExit(2)
|
|
175
|
+
from .cli import run
|
|
176
|
+
|
|
177
|
+
run(args)
|