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,230 @@
1
+ """The team's shared task list, tasks.json under its lock.
2
+
3
+ Not agents/tasks.py: those are the one-shot sub-agents of a session (/tasks, task_output).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable, Iterable
9
+ from dataclasses import asdict, dataclass, field, fields
10
+ from pathlib import Path
11
+
12
+ from .lock import TeamError, atomic_write, file_lock, load_json, now
13
+ from .mailbox import LEAD
14
+
15
+ STATUSES = ("pending", "in_progress", "completed")
16
+ STATUS_LABELS = {"pending": "待办", "in_progress": "进行中", "completed": "已完成"}
17
+ TASK_LIMIT = 200
18
+ SUBJECT_LIMIT = 120
19
+ DESCRIPTION_LIMIT = 20000
20
+
21
+
22
+ @dataclass
23
+ class TeamTask:
24
+ id: str
25
+ subject: str
26
+ description: str = ""
27
+ status: str = "pending"
28
+ owner: str = ""
29
+ blocked_by: list[str] = field(default_factory=list)
30
+ created: str = field(default_factory=now)
31
+ updated: str = field(default_factory=now)
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: dict) -> TeamTask:
35
+ known = {item.name for item in fields(cls)}
36
+ return cls(**{key: value for key, value in data.items() if key in known})
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Updated:
41
+ task: TeamTask
42
+ ready: tuple[TeamTask, ...] = () # Pending tasks this update made ready.
43
+
44
+
45
+ def _valid(data) -> bool:
46
+ return (
47
+ isinstance(data, dict)
48
+ and isinstance(data.get("tasks"), list)
49
+ and all(isinstance(item, dict) and isinstance(item.get("id"), str) for item in data["tasks"])
50
+ )
51
+
52
+
53
+ def is_ready(task: TeamTask, tasks: dict[str, TeamTask]) -> bool:
54
+ """Every task it waits for is completed."""
55
+ return all(tasks[dep].status == "completed" for dep in task.blocked_by if dep in tasks)
56
+
57
+
58
+ def blocks(task_id: str, tasks: Iterable[TeamTask]) -> list[str]:
59
+ """Derived, never stored."""
60
+ return [task.id for task in tasks if task_id in task.blocked_by]
61
+
62
+
63
+ def _reaches(start: str, target: str, tasks: dict[str, TeamTask]) -> bool:
64
+ """Whether start waits, directly or not, for target."""
65
+ seen, stack = set(), [start]
66
+ while stack:
67
+ current = stack.pop()
68
+ if current == target:
69
+ return True
70
+ if current in seen or current not in tasks:
71
+ continue
72
+ seen.add(current)
73
+ stack.extend(tasks[current].blocked_by)
74
+ return False
75
+
76
+
77
+ def _text(value: str, name: str, limit: int, *, required: bool = False) -> str:
78
+ value = value.strip()
79
+ if required and not value:
80
+ raise TeamError("invalid_task", f"{name} 不能为空。")
81
+ if len(value) > limit:
82
+ raise TeamError("invalid_task", f"{name} 最多 {limit} 字。")
83
+ return value
84
+
85
+
86
+ class TaskBoard:
87
+ def __init__(self, path: Path, on_notice: Callable[[str], None] | None = None):
88
+ self.path = path
89
+ self.on_notice = on_notice
90
+
91
+ def _load(self) -> dict[str, TeamTask]:
92
+ data = load_json(self.path, lambda: {"v": 1, "tasks": []}, _valid, self.on_notice)
93
+ tasks = {}
94
+ for item in data["tasks"]:
95
+ try:
96
+ task = TeamTask.from_dict(item)
97
+ except TypeError:
98
+ continue
99
+ tasks[task.id] = task
100
+ return tasks
101
+
102
+ def _save(self, tasks: dict[str, TeamTask]) -> None:
103
+ atomic_write(self.path, {"v": 1, "tasks": [asdict(task) for task in tasks.values()]})
104
+
105
+ def list(self) -> list[TeamTask]:
106
+ return list(self._load().values())
107
+
108
+ def get(self, task_id: str) -> TeamTask:
109
+ task = self._load().get(task_id)
110
+ if task is None:
111
+ raise TeamError("unknown_task", f"没有编号为 {task_id} 的任务。")
112
+ return task
113
+
114
+ def create(
115
+ self,
116
+ subject: str,
117
+ description: str = "",
118
+ blocked_by: Iterable[str] = (),
119
+ *,
120
+ owner: str = "",
121
+ actor: str = LEAD,
122
+ participants: Iterable[str] = (),
123
+ ) -> TeamTask:
124
+ subject = _text(subject, "subject", SUBJECT_LIMIT, required=True)
125
+ description = _text(description, "description", DESCRIPTION_LIMIT)
126
+ with file_lock(self.path):
127
+ tasks = self._load()
128
+ if len(tasks) >= TASK_LIMIT:
129
+ raise TeamError("too_many_tasks", f"一个团队最多 {TASK_LIMIT} 个任务。")
130
+ deps = self._deps(blocked_by, tasks)
131
+ owner = self._owner(owner, actor, participants)
132
+ number = max((int(key) for key in tasks if key.isdigit()), default=0) + 1
133
+ task = TeamTask(str(number), subject, description, owner=owner, blocked_by=deps)
134
+ tasks[task.id] = task
135
+ self._save(tasks)
136
+ return task
137
+
138
+ def update(
139
+ self,
140
+ task_id: str,
141
+ *,
142
+ actor: str,
143
+ participants: Iterable[str] = (),
144
+ status: str | None = None,
145
+ owner: str | None = None,
146
+ subject: str | None = None,
147
+ description: str | None = None,
148
+ add_blocked_by: Iterable[str] = (),
149
+ remove_blocked_by: Iterable[str] = (),
150
+ ) -> Updated:
151
+ """The result lists the tasks that became ready."""
152
+ add_blocked_by, remove_blocked_by = list(add_blocked_by), list(remove_blocked_by)
153
+ with file_lock(self.path):
154
+ tasks = self._load()
155
+ task = tasks.get(task_id)
156
+ if task is None:
157
+ raise TeamError("unknown_task", f"没有编号为 {task_id} 的任务。")
158
+ lead = actor == LEAD
159
+ if not lead and task.owner not in {"", actor}:
160
+ raise TeamError("not_owner", f"任务 {task_id} 归 {task.owner} 负责,只有负责人或 Lead 能修改。")
161
+ if remove_blocked_by and not lead:
162
+ raise TeamError("lead_only", "只有 Lead 能移除依赖。")
163
+ before = {key: is_ready(item, tasks) for key, item in tasks.items() if item.status == "pending"}
164
+ if subject is not None:
165
+ task.subject = _text(subject, "subject", SUBJECT_LIMIT, required=True)
166
+ if description is not None:
167
+ task.description = _text(description, "description", DESCRIPTION_LIMIT)
168
+ if owner is not None:
169
+ task.owner = self._owner(owner, actor, participants)
170
+ if remove_blocked_by:
171
+ task.blocked_by = [dep for dep in task.blocked_by if dep not in remove_blocked_by]
172
+ for dep in self._deps(add_blocked_by, tasks):
173
+ if dep == task.id or _reaches(dep, task.id, tasks):
174
+ raise TeamError("dependency_cycle", f"任务 {task.id} 依赖 {dep} 会形成循环依赖。")
175
+ if dep not in task.blocked_by:
176
+ task.blocked_by.append(dep)
177
+ if status is not None:
178
+ self._move(task, status, actor, tasks)
179
+ task.updated = now()
180
+ self._save(tasks)
181
+ ready = tuple(
182
+ tasks[key]
183
+ for key, was in before.items()
184
+ if not was and tasks[key].status == "pending" and is_ready(tasks[key], tasks)
185
+ )
186
+ return Updated(task, ready)
187
+
188
+ @staticmethod
189
+ def _deps(blocked_by: Iterable[str], tasks: dict[str, TeamTask]) -> list[str]:
190
+ deps = []
191
+ for dep in blocked_by:
192
+ dep = str(dep).strip()
193
+ if dep not in tasks:
194
+ raise TeamError("unknown_task", f"依赖的任务 {dep} 不存在。")
195
+ if dep not in deps:
196
+ deps.append(dep)
197
+ return deps
198
+
199
+ @staticmethod
200
+ def _owner(owner: str, actor: str, participants: Iterable[str]) -> str:
201
+ """A member takes tasks for itself only; the lead assigns anyone on the roster."""
202
+ owner = owner.strip()
203
+ if not owner:
204
+ if actor != LEAD:
205
+ raise TeamError("lead_only", "只有 Lead 能清除负责人。")
206
+ return ""
207
+ if actor != LEAD and owner != actor:
208
+ raise TeamError("not_owner", "成员只能把任务领给自己。")
209
+ if owner != LEAD and owner not in set(participants):
210
+ raise TeamError("unknown_member", f"团队中没有成员 {owner};先用 team_spawn 创建它,或先不填负责人。")
211
+ return owner
212
+
213
+ @staticmethod
214
+ def _move(task: TeamTask, status: str, actor: str, tasks: dict[str, TeamTask]) -> None:
215
+ """Forward only, and only once ready; the lead may send a task back to pending."""
216
+ if status not in STATUSES:
217
+ raise TeamError("invalid_task", f"状态只能是 {'、'.join(STATUSES)}。")
218
+ old, new = STATUSES.index(task.status), STATUSES.index(status)
219
+ if new < old:
220
+ if actor != LEAD or status != "pending":
221
+ raise TeamError("invalid_transition", "状态只能前进;只有 Lead 能把任务改回 pending。")
222
+ elif new > old:
223
+ if not is_ready(task, tasks):
224
+ waiting = [dep for dep in task.blocked_by if dep in tasks and tasks[dep].status != "completed"]
225
+ raise TeamError("task_blocked", f"任务 {task.id} 还在等待任务 {'、'.join(waiting)} 完成。")
226
+ if not task.owner:
227
+ if actor == LEAD:
228
+ raise TeamError("no_owner", f"任务 {task.id} 没有负责人;先指定 owner。")
229
+ task.owner = actor
230
+ task.status = status
mosscode/teams/lock.py ADDED
@@ -0,0 +1,173 @@
1
+ """A lock file next to each data file, and writes a reader never sees half done.
2
+
3
+ Every team file (config, tasks, mailboxes) is read, changed and written under its lock; the lock is
4
+ held only for the file work, never across a model call.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import random
12
+ import secrets
13
+ import time
14
+ from collections.abc import Callable
15
+ from contextlib import contextmanager
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ LOCK_RETRIES = 10
21
+ BACKOFF = (0.005, 0.1) # Seconds, random within.
22
+ STALE_AFTER = 10.0 # Seconds; an older lock file belongs to a process that died holding it.
23
+ DEADLINE = 5.0 # Seconds of waiting in all, however often the lock changed hands.
24
+ REPLACE_RETRIES = 3
25
+
26
+
27
+ class TeamError(Exception):
28
+ """A team operation that did not happen; code is the tool error code."""
29
+
30
+ def __init__(self, code: str, message: str):
31
+ super().__init__(message)
32
+ self.code = code
33
+ self.message = message
34
+
35
+
36
+ def now() -> str:
37
+ return datetime.now().astimezone().isoformat(timespec="seconds")
38
+
39
+
40
+ def _stale(lock: Path, after: float) -> bool:
41
+ try:
42
+ return time.time() - lock.stat().st_mtime > after
43
+ except OSError:
44
+ return False # Gone already: the next attempt will tell.
45
+
46
+
47
+ def _holder(lock: Path) -> str:
48
+ try:
49
+ return lock.read_text(encoding="utf-8")
50
+ except OSError:
51
+ return ""
52
+
53
+
54
+ @contextmanager
55
+ def file_lock(
56
+ path: Path,
57
+ *,
58
+ busy: str = "team_busy",
59
+ retries: int = LOCK_RETRIES,
60
+ stale: float = STALE_AFTER,
61
+ deadline: float = DEADLINE,
62
+ ):
63
+ """O_CREAT|O_EXCL on <file>.lock; retries with random backoff, then TeamError(busy).
64
+
65
+ The count starts again whenever the lock changed hands (others are making progress, it is not stuck),
66
+ within an overall deadline; a lock held by one holder throughout fails after `retries`.
67
+ """
68
+ lock = path.with_name(path.name + ".lock")
69
+ lock.parent.mkdir(parents=True, exist_ok=True)
70
+ token = f"{os.getpid()} {now()} {secrets.token_hex(4)}"
71
+ attempt, seen, start = 0, None, time.monotonic()
72
+ while True:
73
+ try:
74
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
75
+ except (FileExistsError, PermissionError): # Windows: PermissionError while it is being deleted.
76
+ if _stale(lock, stale):
77
+ try:
78
+ os.remove(lock)
79
+ except OSError:
80
+ pass
81
+ holder = _holder(lock)
82
+ if holder and seen is not None and holder != seen:
83
+ attempt = 0
84
+ seen = holder or seen
85
+ if attempt >= retries or time.monotonic() - start > deadline:
86
+ raise TeamError(
87
+ busy, f"{path.name} 正被其他进程使用,重试 {retries} 次后仍拿不到锁;稍后再试。"
88
+ ) from None
89
+ attempt += 1
90
+ time.sleep(random.uniform(*BACKOFF))
91
+ continue
92
+ try:
93
+ os.write(fd, token.encode("utf-8"))
94
+ finally:
95
+ os.close(fd)
96
+ break
97
+ try:
98
+ yield
99
+ finally:
100
+ _release(lock)
101
+
102
+
103
+ def _release(lock: Path) -> None:
104
+ """Windows refuses to delete a file another waiter has open for reading its holder: retry briefly,
105
+ or the lock would stay until it is stale."""
106
+ for _ in range(40):
107
+ try:
108
+ os.remove(lock)
109
+ return
110
+ except FileNotFoundError:
111
+ return
112
+ except PermissionError:
113
+ time.sleep(0.005)
114
+ except OSError:
115
+ return
116
+
117
+
118
+ def atomic_write(path: Path, data: Any) -> None:
119
+ """A temporary file in the same directory, then os.replace (retried briefly on Windows)."""
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
122
+ temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
123
+ try:
124
+ for attempt in range(REPLACE_RETRIES + 1):
125
+ try:
126
+ os.replace(temporary, path)
127
+ return
128
+ except PermissionError: # Windows: another process has it open for a moment.
129
+ if attempt == REPLACE_RETRIES:
130
+ raise
131
+ time.sleep(0.02)
132
+ finally:
133
+ if temporary.exists():
134
+ try:
135
+ temporary.unlink()
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def read_text(path: Path) -> str:
141
+ """Readers without the lock (/team, peek) can meet os.replace mid-way on Windows: retry briefly."""
142
+ for attempt in range(REPLACE_RETRIES + 1):
143
+ try:
144
+ return path.read_text(encoding="utf-8")
145
+ except PermissionError:
146
+ if attempt == REPLACE_RETRIES:
147
+ raise
148
+ time.sleep(0.02)
149
+
150
+
151
+ def load_json(
152
+ path: Path, default: Callable[[], Any], valid: Callable[[Any], bool], on_notice: Callable[[str], None] | None = None
153
+ ) -> Any:
154
+ """Missing -> default; unreadable or the wrong shape -> kept as <file>.corrupt-<time>, then default."""
155
+ try:
156
+ text = read_text(path)
157
+ except FileNotFoundError:
158
+ return default()
159
+ try:
160
+ data = json.loads(text)
161
+ if valid(data):
162
+ return data
163
+ except ValueError:
164
+ pass
165
+ kept = path.with_name(f"{path.name}.corrupt-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
166
+ try:
167
+ os.replace(path, kept)
168
+ note = f"{path} 已损坏,原文件保留为 {kept.name},按空文件继续。"
169
+ except OSError as exc:
170
+ note = f"{path} 已损坏,且无法改名保留({exc}),按空文件继续。"
171
+ if on_notice is not None:
172
+ on_notice(note)
173
+ return default()
@@ -0,0 +1,172 @@
1
+ """One JSON file of messages per participant, changed only under its lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from .lock import TeamError, atomic_write, file_lock, load_json, now
11
+
12
+ LEAD = "lead"
13
+ BROADCAST = "*"
14
+ TYPES = (
15
+ "text",
16
+ "plan_approval_request",
17
+ "plan_approval_response",
18
+ "shutdown_request",
19
+ "shutdown_response",
20
+ "idle_notification",
21
+ "permission_response",
22
+ )
23
+ # permission_response: the user's answer, given in the lead's window, to a pane member's approval.
24
+ LEAD_ONLY = frozenset({"plan_approval_response", "shutdown_request", "permission_response"})
25
+ MEMBER_ONLY = frozenset({"shutdown_response", "plan_approval_request", "idle_notification"})
26
+ # Sent for the model, never by it.
27
+ RUNTIME_ONLY = frozenset({"plan_approval_request", "idle_notification", "permission_response"})
28
+ EXTRA_FIELDS = {
29
+ "plan_approval_request": ("request_id", "plan"),
30
+ "plan_approval_response": ("request_id", "approve", "feedback"),
31
+ "shutdown_request": ("reason",),
32
+ "shutdown_response": ("request_id", "approve", "reason"),
33
+ "idle_notification": ("reason", "completed_tasks"), # Its summary is the message's own.
34
+ "permission_response": ("request_id", "choice"),
35
+ }
36
+ BODY_LIMIT = 20000
37
+ SUMMARY_LIMIT = 80
38
+ KEEP_READ = 100
39
+ UNREAD_LIMIT = 500
40
+
41
+
42
+ @dataclass
43
+ class Message:
44
+ sender: str
45
+ type: str
46
+ body: str = ""
47
+ summary: str = ""
48
+ id: str = field(default_factory=lambda: f"m-{secrets.token_hex(4)}")
49
+ timestamp: str = field(default_factory=now)
50
+ read: bool = False
51
+ extra: dict = field(default_factory=dict) # The type's own fields.
52
+
53
+ def to_dict(self) -> dict:
54
+ return {
55
+ "id": self.id,
56
+ "from": self.sender,
57
+ "type": self.type,
58
+ "body": self.body,
59
+ "summary": self.summary,
60
+ "timestamp": self.timestamp,
61
+ "read": self.read,
62
+ **self.extra,
63
+ }
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: dict) -> Message:
67
+ known = {"id", "from", "type", "body", "summary", "timestamp", "read"}
68
+ return cls(
69
+ sender=str(data.get("from", "")),
70
+ type=str(data.get("type", "text")),
71
+ body=str(data.get("body", "")),
72
+ summary=str(data.get("summary", "")),
73
+ id=str(data.get("id", "")),
74
+ timestamp=str(data.get("timestamp", "")),
75
+ read=bool(data.get("read", False)),
76
+ extra={key: value for key, value in data.items() if key not in known},
77
+ )
78
+
79
+
80
+ def check(message: Message) -> None:
81
+ """What any message must satisfy, whoever sends it; TeamError otherwise."""
82
+ if message.type not in TYPES:
83
+ raise TeamError("invalid_message", f"未知的消息类型 {message.type};可用:{'、'.join(TYPES)}。")
84
+ if message.type in LEAD_ONLY and message.sender != LEAD:
85
+ raise TeamError("invalid_message", f"只有 Lead 能发送 {message.type}。")
86
+ if message.type in MEMBER_ONLY and message.sender == LEAD:
87
+ raise TeamError("invalid_message", f"Lead 不能发送 {message.type}。")
88
+ if message.type == "text":
89
+ if not message.summary.strip():
90
+ raise TeamError("invalid_message", "text 消息必须有 summary(一句话摘要)。")
91
+ if len(message.summary) > SUMMARY_LIMIT:
92
+ raise TeamError("invalid_message", f"summary 最多 {SUMMARY_LIMIT} 字。")
93
+ if len(message.body) > BODY_LIMIT:
94
+ raise TeamError("invalid_message", f"正文最多 {BODY_LIMIT} 字,当前 {len(message.body)} 字。")
95
+ missing = [name for name in EXTRA_FIELDS.get(message.type, ()) if name not in message.extra]
96
+ if missing:
97
+ raise TeamError("invalid_message", f"{message.type} 消息缺少字段:{'、'.join(missing)}。")
98
+
99
+
100
+ def _valid(data) -> bool:
101
+ return isinstance(data, list) and all(isinstance(item, dict) for item in data)
102
+
103
+
104
+ def _trim(messages: list[dict]) -> list[dict]:
105
+ """Every unread message and the latest KEEP_READ read ones, in order."""
106
+ read = [index for index, item in enumerate(messages) if item.get("read")]
107
+ dropped = set(read[:-KEEP_READ]) if len(read) > KEEP_READ else set()
108
+ return [item for index, item in enumerate(messages) if index not in dropped]
109
+
110
+
111
+ class Mailbox:
112
+ """The mailbox directory of one team: mailbox/<name>.json for each participant, lead included."""
113
+
114
+ def __init__(self, directory: Path, on_notice: Callable[[str], None] | None = None):
115
+ self.directory = directory
116
+ self.on_notice = on_notice
117
+
118
+ def path(self, name: str) -> Path:
119
+ return self.directory / f"{name}.json"
120
+
121
+ def _load(self, name: str) -> list[dict]:
122
+ return load_json(self.path(name), list, _valid, self.on_notice)
123
+
124
+ def send(self, to: str, message: Message) -> None:
125
+ """Append under the recipient's lock; TeamError when busy or full, never a silent drop."""
126
+ check(message)
127
+ path = self.path(to)
128
+ with file_lock(path, busy="mailbox_busy"):
129
+ messages = self._load(to)
130
+ if sum(1 for item in messages if not item.get("read")) >= UNREAD_LIMIT:
131
+ raise TeamError("mailbox_full", f"{to} 的未读消息已达 {UNREAD_LIMIT} 条,暂不能再发送。")
132
+ messages.append(message.to_dict())
133
+ atomic_write(path, _trim(messages))
134
+
135
+ def read_unread(self, name: str) -> list[Message]:
136
+ """The unread messages, marked read in the same locked step."""
137
+ path = self.path(name)
138
+ with file_lock(path, busy="mailbox_busy"):
139
+ messages = self._load(name)
140
+ unread = [item for item in messages if not item.get("read")]
141
+ if not unread:
142
+ return []
143
+ for item in unread:
144
+ item["read"] = True
145
+ atomic_write(path, _trim(messages))
146
+ return [Message.from_dict(item) for item in unread]
147
+
148
+ def take(self, name: str, kinds: frozenset[str] | set[str]) -> list[Message]:
149
+ """The unread messages of these types only, marked read; the rest stay unread (answers mid-turn)."""
150
+ path = self.path(name)
151
+ if not path.exists():
152
+ return []
153
+ with file_lock(path, busy="mailbox_busy"):
154
+ messages = self._load(name)
155
+ taken = [item for item in messages if not item.get("read") and item.get("type") in kinds]
156
+ if not taken:
157
+ return []
158
+ for item in taken:
159
+ item["read"] = True
160
+ atomic_write(path, _trim(messages))
161
+ return [Message.from_dict(item) for item in taken]
162
+
163
+ def discard_unread(self, name: str, kinds: frozenset[str] | set[str]) -> int:
164
+ """Mark stale unread messages of these types read (an old shutdown request or answer)."""
165
+ return len(self.take(name, kinds))
166
+
167
+ def peek(self, name: str) -> list[Message]:
168
+ """Every kept message, read or not; for /team. No lock: a reader sees the old file or the new one."""
169
+ return [Message.from_dict(item) for item in self._load(name)]
170
+
171
+ def unread_count(self, name: str) -> int:
172
+ return sum(1 for message in self.peek(name) if not message.read)