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
mosscode/teams/view.py ADDED
@@ -0,0 +1,182 @@
1
+ """A member's conversation as short lines — the same in the lead's member view
2
+ (read from its transcript) and in the member's own window (from the events of its turns)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from rich.text import Text
11
+
12
+ from ..appearance import ERROR_COLOR, OPERATION_COLOR, PARAMETER_COLOR
13
+ from ..messages import Completed, Message, ProviderError, RunStopped, ToolCall, ToolProgress, ToolResult
14
+ from ..permissions import safe_display
15
+ from ..serialization import message_from_json
16
+ from .member import TYPE_LABELS
17
+
18
+ # render_incoming's head of each message: "[来自 lead · 消息 · id=m-1] 摘要:…".
19
+ HEAD = re.compile(r"^\[来自 (\S+) · (.+?) · id=[^\]]*\]\s*(.*)$")
20
+ COMPACTED = "(较早的对话已压缩)"
21
+
22
+
23
+ def incoming_line(sender: str, label: str, summary: str) -> Text:
24
+ line = Text(f"← {sender} · {label}", style="bold")
25
+ if summary:
26
+ line.append(f":{summary[:200]}")
27
+ return line
28
+
29
+
30
+ def call_line(call: ToolCall) -> Text:
31
+ line = Text("● ", style=OPERATION_COLOR)
32
+ line.append(call.name, style="bold")
33
+ line.append(" " + safe_display(call.arguments, 160), style=PARAMETER_COLOR)
34
+ return line
35
+
36
+
37
+ def result_line(result: ToolResult) -> Text:
38
+ first = (result.output or "").strip().splitlines()[:1]
39
+ style = ERROR_COLOR if result.is_error else "dim"
40
+ return Text(" └ " + safe_display(first[0] if first else "完成", 200), style=style)
41
+
42
+
43
+ def _incoming(text: str) -> list[Text]:
44
+ lines, rows = text.splitlines(), []
45
+ for index, line in enumerate(lines):
46
+ match = HEAD.match(line)
47
+ if match is None:
48
+ continue
49
+ sender, label, rest = match.groups()
50
+ rest = rest.removeprefix("摘要:").strip()
51
+ if not rest: # The body starts on the next line.
52
+ rest = next((item.strip() for item in lines[index + 1 :] if item.strip()), "")
53
+ rest = "" if HEAD.match(rest) or rest.startswith("</incoming") else rest
54
+ rows.append(incoming_line(sender, label, rest))
55
+ return rows
56
+
57
+
58
+ def message_lines(message: Message) -> list[Text]:
59
+ """One committed message of the transcript, without its tool calls (Feed pairs them with their results)."""
60
+ if message.role == "tool":
61
+ return [result_line(result) for result in message.tool_results]
62
+ if message.role == "user":
63
+ if message.synthetic == "compact":
64
+ return [Text(COMPACTED, style="dim")]
65
+ text = message.text.strip()
66
+ if text.startswith("<incoming-messages>"):
67
+ return _incoming(text)
68
+ first = text.splitlines()[0] if text else ""
69
+ return [Text("❯ " + safe_display(first, 200), style="dim")] if first else []
70
+ return [Text(message.text.strip())] if message.text.strip() else []
71
+
72
+
73
+ class Feed:
74
+ """A member's conversation as lines, from its transcript (lead) or its turn's events (own window).
75
+
76
+ A tool call is written together with its result, so the results of parallel calls sit under their own
77
+ calls; until then the call is in `running`, shown apart from the log as the lead shows its own."""
78
+
79
+ def __init__(self) -> None:
80
+ self.waiting: dict[str, ToolCall] = {}
81
+
82
+ @property
83
+ def running(self) -> list[Text]:
84
+ return [call_line(call) for call in self.waiting.values()]
85
+
86
+ def running_text(self) -> Text:
87
+ text = Text("\n").join(self.running)
88
+ text.no_wrap, text.overflow = True, "ellipsis"
89
+ return text
90
+
91
+ def flush(self) -> list[Text]:
92
+ """Calls whose results never came (an interrupted turn) are written as they were."""
93
+ lines, self.waiting = self.running, {}
94
+ return lines
95
+
96
+ def result(self, result: ToolResult) -> list[Text]:
97
+ call = self.waiting.pop(result.call_id, None)
98
+ return ([call_line(call)] if call is not None else []) + [result_line(result)]
99
+
100
+ def message(self, message: Message) -> list[Text]:
101
+ if message.role == "tool":
102
+ return [line for result in message.tool_results for line in self.result(result)]
103
+ lines = self.flush() + message_lines(message)
104
+ self.waiting.update((call.id, call) for call in message.tool_calls)
105
+ return lines
106
+
107
+ def record(self, record: dict) -> list[Text]:
108
+ kind = record.get("type")
109
+ if kind == "message":
110
+ try:
111
+ message = message_from_json(record.get("message"))
112
+ except (ValueError, TypeError, KeyError):
113
+ return []
114
+ return self.message(message)
115
+ if kind == "compact":
116
+ return self.flush() + [Text(COMPACTED, style="dim")]
117
+ return []
118
+
119
+ def event(self, event) -> list[Text]:
120
+ """A member's own window, as its turn runs."""
121
+ if isinstance(event, list): # The messages of a new turn.
122
+ return self.flush() + [
123
+ incoming_line(
124
+ message.sender,
125
+ TYPE_LABELS.get(message.type, message.type),
126
+ message.summary or (message.body.strip().splitlines() or [""])[0],
127
+ )
128
+ for message in event
129
+ ]
130
+ if isinstance(event, ToolProgress):
131
+ if event.phase == "running":
132
+ self.waiting[event.call.id] = event.call
133
+ elif event.phase == "finished" and event.result is not None:
134
+ return self.result(event.result)
135
+ elif isinstance(event, Completed) and event.message.text:
136
+ return self.flush() + [Text(event.message.text)]
137
+ elif isinstance(event, RunStopped):
138
+ lines = self.flush()
139
+ return lines + (
140
+ [Text(event.notice, style=ERROR_COLOR)] if event.reason not in {"completed", "max_tokens"} else []
141
+ )
142
+ elif isinstance(event, ProviderError):
143
+ return self.flush() + [Text(str(event), style=ERROR_COLOR)]
144
+ return []
145
+
146
+
147
+ class TranscriptTail:
148
+ """The records appended to a transcript since the last read. A line still being written stays
149
+ for the next read; a line that cannot be parsed is skipped."""
150
+
151
+ def __init__(self, path: Path):
152
+ self.path = path
153
+ self.offset = 0
154
+ self._partial = b""
155
+
156
+ def read(self) -> list[dict]:
157
+ """Blocking. OSError when the file cannot be read; [] while it does not exist yet."""
158
+ try:
159
+ size = self.path.stat().st_size
160
+ except FileNotFoundError:
161
+ return []
162
+ if size < self.offset: # Replaced by a shorter file: start over.
163
+ self.offset, self._partial = 0, b""
164
+ if size == self.offset:
165
+ return []
166
+ with self.path.open("rb") as stream:
167
+ stream.seek(self.offset)
168
+ data = stream.read(size - self.offset)
169
+ self.offset += len(data)
170
+ data = self._partial + data
171
+ *complete, self._partial = data.split(b"\n")
172
+ records = []
173
+ for raw in complete:
174
+ if not raw.strip():
175
+ continue
176
+ try:
177
+ record = json.loads(raw)
178
+ except (ValueError, UnicodeError):
179
+ continue
180
+ if isinstance(record, dict):
181
+ records.append(record)
182
+ return records
@@ -0,0 +1,86 @@
1
+ """Layer 1: oversized tool results go to the session directory; the history keeps a preview.
2
+
3
+ Decided once, before a result enters the history, and never revisited, so requests already sent
4
+ keep a byte-identical prefix for prompt caching.
5
+ """
6
+
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .context import estimate_text
12
+ from .messages import Message, ToolResult
13
+ from .session import Session
14
+
15
+ RESULT_TOKENS = 12_500
16
+ MESSAGE_TOKENS = 50_000
17
+ HEAD, TAIL = 2_000, 1_000
18
+ # read_file is paged and reads files already on disk; storing it would loop on the stored copy.
19
+ EXEMPT = frozenset({"read_file"})
20
+
21
+
22
+ def preview(text: str, tokens: int, path: Path | None) -> str:
23
+ size = f"输出过大(约 {tokens} Token,{len(text)} 字符)"
24
+ if path is None:
25
+ lead = f"{size},本次未能保存完整内容。"
26
+ else:
27
+ lead = (
28
+ f"{size},完整内容已保存到:\n{path}\n"
29
+ "需要完整内容时用 read_file 读取这个文件(可用 offset 和 limit 分页)。"
30
+ )
31
+ omitted = len(text) - HEAD - TAIL
32
+ return (
33
+ f"{lead}\n以下是开头 {HEAD} 字符和末尾 {TAIL} 字符:\n{text[:HEAD]}\n"
34
+ f"…(中间省略 {omitted} 字符)…\n{text[-TAIL:]}"
35
+ )
36
+
37
+
38
+ def store(text: str, details: dict[str, Any], session: Session | None, call_id: str) -> tuple[str, dict[str, Any]]:
39
+ tokens = estimate_text(text)
40
+ path = None
41
+ if session is not None:
42
+ try:
43
+ path = session.persist_result(call_id, text)
44
+ except OSError:
45
+ path = None
46
+ extra = {"persisted": str(path)} if path else {"truncated": True}
47
+ extra["budgeted"] = True # Already a preview: never stored again.
48
+ return preview(text, tokens, path), {**details, **extra}
49
+
50
+
51
+ def bound_result(
52
+ name: str, call_id: str, text: str, details: dict[str, Any], session: Session | None
53
+ ) -> tuple[str, dict[str, Any]]:
54
+ """One result over the threshold is stored."""
55
+ if name in EXEMPT or estimate_text(text) <= RESULT_TOKENS:
56
+ return text, details
57
+ return store(text, details, session, call_id)
58
+
59
+
60
+ def bound_message(message: Message, session: Session | None) -> Message:
61
+ """Results of one batch over the combined budget are stored, largest first."""
62
+ results = list(message.tool_results)
63
+ sizes = [estimate_text(r.model_text()) for r in results]
64
+ total = sum(sizes)
65
+ if total <= MESSAGE_TOKENS:
66
+ return message
67
+ candidates = sorted(
68
+ (i for i, r in enumerate(results) if r.name not in EXEMPT and not r.details.get("budgeted")),
69
+ key=lambda i: sizes[i],
70
+ reverse=True,
71
+ )
72
+ for index in candidates:
73
+ if total <= MESSAGE_TOKENS:
74
+ break
75
+ result = results[index]
76
+ output, details = store(result.output, result.details, session, result.call_id)
77
+ results[index] = replace(result, output=output, details=details)
78
+ updated = estimate_text(results[index].model_text())
79
+ total += updated - sizes[index]
80
+ sizes[index] = updated
81
+ return replace(message, tool_results=tuple(results))
82
+
83
+
84
+ def persisted_note(result: ToolResult) -> str:
85
+ path = result.details.get("persisted")
86
+ return f"完整结果已存盘:{path}" if path else ""
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+
3
+ from ..permissions import PermissionPolicy
4
+ from .base import ToolRegistry
5
+ from .files import EditFile, FileState, ReadFile, WriteFile
6
+ from .plan import ExitPlanMode
7
+ from .process import ExecuteCommand
8
+ from .search import Glob, Grep
9
+ from .shell import Shell
10
+
11
+
12
+ def create_default_registry(
13
+ root: Path | None = None,
14
+ *,
15
+ permission_mode: str | None = None,
16
+ load_permissions: bool = True,
17
+ permissions: PermissionPolicy | None = None,
18
+ shell: Shell | str | None = "auto",
19
+ ) -> ToolRegistry:
20
+ root = Path.cwd() if root is None else root
21
+ policy = permissions or PermissionPolicy(root, mode=permission_mode, load=load_permissions)
22
+ state = FileState() # Shared so edits can see what read_file returned.
23
+ tools = (
24
+ ReadFile(root, state),
25
+ WriteFile(root, state),
26
+ EditFile(root, state),
27
+ ExecuteCommand(root, shell),
28
+ Glob(root),
29
+ Grep(root),
30
+ ExitPlanMode(root),
31
+ )
32
+ return ToolRegistry(tools, permissions=policy)
33
+
34
+
35
+ def shell_label(registry: ToolRegistry | None) -> str | None:
36
+ """The execute_command shell's name, e.g. "Git Bash", for prompts and the UI."""
37
+ shell = getattr(registry.get("execute_command"), "shell", None) if registry is not None else None
38
+ return shell.label if isinstance(shell, Shell) else None
@@ -0,0 +1,127 @@
1
+ """The agent tool and the two task tools of the main conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import TYPE_CHECKING, Literal
7
+
8
+ from pydantic import Field
9
+
10
+ from .base import CURRENT_CALL, Params, Tool, ToolFailure
11
+
12
+ if TYPE_CHECKING:
13
+ from ..agents.runtime import SubAgents
14
+
15
+ WAIT_LIMIT = 300
16
+ PREVIEW = 4000 # Characters of a running task's last reply shown by task_output.
17
+
18
+
19
+ class AgentParams(Params):
20
+ description: str = Field(min_length=1, max_length=60)
21
+ prompt: str = Field(min_length=1, max_length=40000)
22
+ agent_type: str = Field(default="", max_length=64)
23
+ run_in_background: bool = False
24
+ isolation: Literal["none", "worktree"] = "none"
25
+
26
+
27
+ class AgentTool(Tool):
28
+ name = "agent"
29
+ permission_kind = "agent" # No approval: every call the sub-agent makes is checked on its own.
30
+ plan_allowed = True # In plan mode its sub-agents are in plan mode too.
31
+ timeout = None # Foreground sub-agents end on their own, or move to the background.
32
+ description = (
33
+ "把一段可以独立完成的工作交给子 Agent,你只收到它的结论。系统提示的“子 Agent”一节列出了可用的类型。"
34
+ "agent_type 写类型名时,子 Agent 从空白对话开始,prompt 必须写清楚完整的任务、背景和期望的结果;"
35
+ "省略 agent_type 时为 Fork:它继承当前对话的全部内容,总在后台运行,完成后你会收到 <task-notification>。"
36
+ "description 是 3–10 个字的任务标签。isolation 为 worktree 时,子 Agent 在独立的 git 工作树中运行。"
37
+ )
38
+ prompt_summary = "把独立的子任务交给子 Agent"
39
+ prompt_guidelines = (
40
+ "适合委派:需要大量搜索或阅读、但只需要结论的调查;互不依赖的多个子任务(在一次回复中同时发起,它们会并行运行)。",
41
+ "不适合委派:一两次工具调用就能完成的事;需要与用户来回确认的事。",
42
+ "子 Agent 不能向用户提问;它的结论不会直接显示给用户,需要时由你转述。",
43
+ "run_in_background 为 true 时立即返回任务 id;不要在等待通知期间重复做同一件事,需要结果时用 task_output。",
44
+ "会改动文件、且可能与你或其他子 Agent 的改动冲突的任务(例如并行的实验性修改),用 isolation: worktree 让它在独立的"
45
+ "工作树中进行;有改动时工作树会保留,结果中给出分支名,由你决定是否合并。Fork 不能隔离。",
46
+ )
47
+ params_model = AgentParams
48
+
49
+ def __init__(self, root, runner: SubAgents):
50
+ super().__init__(root)
51
+ self.runner = runner
52
+
53
+ async def execute(self, params: AgentParams):
54
+ call = CURRENT_CALL.get()
55
+ return await self.runner.call(
56
+ params.description.strip(),
57
+ params.prompt,
58
+ params.agent_type.strip(),
59
+ params.run_in_background,
60
+ call.id if call is not None else "",
61
+ isolation=params.isolation,
62
+ )
63
+
64
+
65
+ class TaskOutputParams(Params):
66
+ task_id: str = Field(min_length=1, max_length=20)
67
+ wait: int = Field(default=0, ge=0, le=WAIT_LIMIT)
68
+
69
+
70
+ class TaskOutput(Tool):
71
+ name = "task_output"
72
+ permission_kind = "agent"
73
+ read_only = True
74
+ timeout = WAIT_LIMIT + 10
75
+ description = (
76
+ "查看后台任务(子 Agent)的状态与结果。wait 为等待完成的秒数(最多 300),0 表示立即返回;"
77
+ "任务完成时你也会自动收到 <task-notification>,一般不需要轮询。"
78
+ )
79
+ prompt_summary = "查看后台任务的状态与结果"
80
+ params_model = TaskOutputParams
81
+
82
+ def __init__(self, root, runner: SubAgents):
83
+ super().__init__(root)
84
+ self.runner = runner
85
+
86
+ async def execute(self, params: TaskOutputParams):
87
+ task = self.runner.tasks.get(params.task_id.strip())
88
+ if task is None:
89
+ raise ToolFailure("unknown_task", f"没有任务 {params.task_id}。")
90
+ if task.status == "running" and params.wait and task.job is not None:
91
+ await asyncio.wait({task.job}, timeout=params.wait)
92
+ from ..agents.runtime import stats
93
+ from ..agents.tasks import STATUS_LABELS
94
+
95
+ head = f"任务 {task.id}({task.label}):{STATUS_LABELS[task.status]},{stats(task)}。"
96
+ if task.status == "running":
97
+ last = f"最近一次工具调用:{task.last_tool}。" if task.last_tool else "尚未调用工具。"
98
+ return f"{head}\n{last}", {"task": task.id, "status": task.status}
99
+ return f"{head}\n\n{task.result or ''}", {"task": task.id, "status": task.status}
100
+
101
+
102
+ class TaskStopParams(Params):
103
+ task_id: str = Field(min_length=1, max_length=20)
104
+
105
+
106
+ class TaskStop(Tool):
107
+ name = "task_stop"
108
+ permission_kind = "agent"
109
+ plan_allowed = True
110
+ description = "停止一个运行中的后台任务(子 Agent)。停止后任务记为已取消,不再收到它的通知。"
111
+ prompt_summary = "停止后台任务"
112
+ params_model = TaskStopParams
113
+
114
+ def __init__(self, root, runner: SubAgents):
115
+ super().__init__(root)
116
+ self.runner = runner
117
+
118
+ async def execute(self, params: TaskStopParams):
119
+ task_id = params.task_id.strip()
120
+ task = self.runner.tasks.get(task_id)
121
+ if task is None:
122
+ raise ToolFailure("unknown_task", f"没有任务 {task_id}。")
123
+ if not await self.runner.tasks.stop(task_id):
124
+ from ..agents.tasks import STATUS_LABELS
125
+
126
+ return f"任务 {task_id} 已结束({STATUS_LABELS[task.status]}),无需停止。", {"task": task_id}
127
+ return f"已停止任务 {task_id}。", {"task": task_id, "stopped": True}