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,462 @@
1
+ """Create, resume, check, delete and clean the worktrees MossCode manages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from . import git
15
+ from .changes import Changes, count_changes
16
+ from .git import WorktreeError
17
+ from .meta import WorktreeMeta, load_meta, process_alive, save_meta
18
+ from .names import EPHEMERAL, branch_of, ephemeral_name, flatten, validate_name
19
+ from .setup import copy_included, copy_local, create_links, is_link, unlink
20
+
21
+ WORKTREES = Path(".mosscode") / "worktrees"
22
+ META = ".meta"
23
+ CLEAN_INTERVAL = 24 * 3600
24
+ DEFAULT_STALE_HOURS = 24.0
25
+
26
+
27
+ class WorktreeChanged(WorktreeError):
28
+ def __init__(self, name: str, changes: Changes):
29
+ files = "、".join(changes.files)
30
+ super().__init__(
31
+ f"工作树 {name} 有改动({changes.describe()}{':' + files if files else ''}),未删除;"
32
+ "确认丢弃时加 --discard。",
33
+ "worktree_changed",
34
+ )
35
+ self.changes = changes
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Created:
40
+ meta: WorktreeMeta
41
+ warnings: tuple[str, ...] = ()
42
+ resumed: bool = False
43
+ dirty: bool = False # The main directory had uncommitted changes.
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Finished:
48
+ kept: bool
49
+ changes: Changes
50
+ error: str = "" # No changes, but deleting failed.
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Entry:
55
+ meta: WorktreeMeta | None
56
+ name: str
57
+ status: str
58
+
59
+
60
+ @dataclass
61
+ class CleanReport:
62
+ removed: list[str] = field(default_factory=list)
63
+ skipped: list[tuple[str, str]] = field(default_factory=list)
64
+
65
+
66
+ def _now() -> str:
67
+ return datetime.now().astimezone().isoformat(timespec="seconds")
68
+
69
+
70
+ def _writable(function, path, _exc):
71
+ """Git objects are read-only on Windows."""
72
+ os.chmod(path, stat.S_IWRITE)
73
+ function(path)
74
+
75
+
76
+ class WorktreeManager:
77
+ def __init__(
78
+ self,
79
+ top: Path,
80
+ common: Path,
81
+ project: Path | None = None,
82
+ *,
83
+ links=(),
84
+ stale_hours: float = DEFAULT_STALE_HOURS,
85
+ ):
86
+ self.top, self.common = top.resolve(), common.resolve()
87
+ self.project = (project or top).resolve()
88
+ self.links = tuple(links)
89
+ self.stale_hours = stale_hours
90
+ self._lock = asyncio.Lock() # Creating and deleting run one at a time.
91
+
92
+ @classmethod
93
+ def discover(cls, project: Path, **options) -> WorktreeManager:
94
+ top, common = git.find_repo(project)
95
+ return cls(top, common, project, **options)
96
+
97
+ # Paths ---------------------------------------------------------------------------------
98
+
99
+ @property
100
+ def base(self) -> Path:
101
+ return self.top / WORKTREES
102
+
103
+ @property
104
+ def meta_dir(self) -> Path:
105
+ return self.base / META
106
+
107
+ def meta_path(self, flat: str) -> Path:
108
+ return self.meta_dir / f"{flat}.json"
109
+
110
+ @property
111
+ def project_part(self) -> str:
112
+ """Where the project sits inside the repository ("" at the top level)."""
113
+ try:
114
+ relative = self.project.relative_to(self.top).as_posix()
115
+ except ValueError:
116
+ return ""
117
+ return "" if relative == "." else relative
118
+
119
+ def root_in(self, meta: WorktreeMeta) -> Path:
120
+ part = self.project_part
121
+ return Path(meta.path) / part if part else Path(meta.path)
122
+
123
+ def display(self, path: Path | str) -> str:
124
+ path = Path(path)
125
+ try:
126
+ return path.relative_to(self.project).as_posix()
127
+ except ValueError:
128
+ return str(path)
129
+
130
+ def metas(self) -> list[tuple[Path, WorktreeMeta]]:
131
+ try:
132
+ files = sorted(self.meta_dir.glob("*.json"))
133
+ except OSError:
134
+ return []
135
+ found = [(path, load_meta(path)) for path in files]
136
+ return [(path, meta) for path, meta in found if meta is not None]
137
+
138
+ # Creating ------------------------------------------------------------------------------
139
+
140
+ async def create(self, name: str, *, manual: bool = True, task: str = "") -> Created:
141
+ async with self._lock:
142
+ return await asyncio.to_thread(self._create, name, manual, task)
143
+
144
+ async def ephemeral(self, task: str) -> Created:
145
+ """A name nobody chose, marked in use by this process."""
146
+ async with self._lock:
147
+ for _ in range(5):
148
+ name = ephemeral_name()
149
+ if not (self.base / name).exists() and not self.meta_path(name).exists():
150
+ return await asyncio.to_thread(self._create, name, False, task)
151
+ raise WorktreeError("无法生成不重复的工作树名称。")
152
+
153
+ def _create(self, name: str, manual: bool, task: str) -> Created:
154
+ problem = validate_name(name)
155
+ if problem:
156
+ raise WorktreeError(f"名称 {name} 不可用:{problem}。", "invalid_name")
157
+ flat = flatten(name)
158
+ path, meta_path = self.base / flat, self.meta_path(flat)
159
+ self._check_case(flat)
160
+ if path.exists():
161
+ return self._resume(flat, path, meta_path)
162
+ leftover = load_meta(meta_path)
163
+ if leftover is not None: # The directory is gone: finish what the record says.
164
+ self._forget(leftover, meta_path)
165
+ git.ensure_excluded(self.common)
166
+ base = git.git_text(["rev-parse", "HEAD"], self.top)
167
+ branch = branch_of(flat)
168
+ if git.run_git(["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], self.top).returncode == 0:
169
+ raise WorktreeError(f"分支 {branch} 已存在;换一个名称,或先删除这个分支。", "branch_exists")
170
+ dirty = bool(git.git_text(["status", "--porcelain"], self.top))
171
+ self.base.mkdir(parents=True, exist_ok=True)
172
+ done = git.run_git(["worktree", "add", "-b", branch, "--", str(path), base], self.top)
173
+ if done.returncode != 0:
174
+ self._undo(path, branch)
175
+ raise WorktreeError(f"git worktree add 失败:{(done.stderr or done.stdout).strip()[:500]}")
176
+ in_use = {"pid": os.getpid(), "task": task} if task else None
177
+ meta = WorktreeMeta(name, flat, str(path), branch, base, _now(), manual, in_use=in_use)
178
+ save_meta(meta, meta_path)
179
+ warnings = self._setup(meta, meta_path)
180
+ return Created(meta, tuple(warnings), False, dirty)
181
+
182
+ def _check_case(self, flat: str) -> None:
183
+ """Windows file names ignore case, so names must too."""
184
+ folded = flat.casefold()
185
+ names = {meta.flat for _, meta in self.metas()}
186
+ try:
187
+ names.update(child.name for child in self.base.iterdir() if child.name != META)
188
+ except OSError:
189
+ pass
190
+ clash = next((other for other in names if other != flat and other.casefold() == folded), None)
191
+ if clash is not None:
192
+ raise WorktreeError(f"名称与已有的工作树 {clash} 只差大小写。", "invalid_name")
193
+
194
+ def _undo(self, path: Path, branch: str) -> None:
195
+ if path.exists() and not is_link(path):
196
+ shutil.rmtree(path, onexc=_writable)
197
+ git.run_git(["worktree", "prune"], self.top)
198
+ git.run_git(["branch", "-D", branch], self.top)
199
+
200
+ def _setup(self, meta: WorktreeMeta, meta_path: Path) -> list[str]:
201
+ """Links, copies and the setup command in order; the record says when it is complete."""
202
+ warnings: list[str] = []
203
+ path = Path(meta.path)
204
+
205
+ def copied(items) -> None:
206
+ meta.copied.extend(item for item in items if item not in meta.copied)
207
+
208
+ steps = (
209
+ lambda: copied(copy_local(self.top, path, self.project_part, warnings)),
210
+ lambda: meta.links.extend(
211
+ item for item in create_links(self.top, path, self.links, warnings) if item not in meta.links
212
+ ),
213
+ lambda: copied(copy_included(self.top, path, meta.links, warnings)),
214
+ )
215
+ for step in steps:
216
+ try:
217
+ step()
218
+ except Exception as exc: # Best effort: a failed step never fails the worktree.
219
+ warnings.append(f"初始化未完成一步:{exc}")
220
+ if (self.top / ".gitmodules").is_file():
221
+ warnings.append("仓库有子模块,工作树中的子模块未初始化。")
222
+ meta.setup_done = True
223
+ save_meta(meta, meta_path)
224
+ return warnings
225
+
226
+ def _resume(self, flat: str, path: Path, meta_path: Path) -> Created:
227
+ """The filesystem only, no git process."""
228
+ head = git.read_head(path)
229
+ meta = load_meta(meta_path)
230
+ if head is None or meta is None or meta.flat != flat:
231
+ raise WorktreeError(
232
+ f"{self.display(path)} 存在,但不是有效的 MossCode 工作树;"
233
+ f"用 /worktree remove {flat} --discard 清理,或手动处理。",
234
+ "worktree_invalid",
235
+ )
236
+ if meta.removing:
237
+ raise WorktreeError(
238
+ f"工作树 {meta.name} 的删除尚未完成;用 /worktree remove {meta.name} 再试。", "worktree_invalid"
239
+ )
240
+ warnings = []
241
+ if head[1] != f"refs/heads/{meta.branch}":
242
+ warnings.append(f"工作树当前不在分支 {meta.branch} 上。")
243
+ if not meta.setup_done:
244
+ warnings.extend(self._setup(meta, meta_path))
245
+ return Created(meta, tuple(warnings), True, False)
246
+
247
+ # Changes and deleting ------------------------------------------------------------------
248
+
249
+ def changes(self, meta: WorktreeMeta) -> Changes:
250
+ path = Path(meta.path)
251
+ if path.is_dir():
252
+ return count_changes(path, meta.branch, (*meta.links, *meta.copied))
253
+ # The directory is gone: only the branch can still hold work.
254
+ if git.run_git(["rev-parse", "--verify", "--quiet", f"refs/heads/{meta.branch}"], self.top).returncode != 0:
255
+ return Changes()
256
+ done = git.run_git(
257
+ [
258
+ "rev-list",
259
+ "--count",
260
+ f"refs/heads/{meta.branch}",
261
+ "--not",
262
+ f"--exclude={meta.branch}",
263
+ "--branches",
264
+ "--remotes",
265
+ ],
266
+ self.top,
267
+ )
268
+ if done.returncode != 0:
269
+ return Changes(1, 0, (), done.stderr.strip()[:200] or "git rev-list 失败")
270
+ return Changes(0, int(done.stdout.strip() or "0"))
271
+
272
+ async def remove(self, name: str, *, discard: bool = False) -> Changes:
273
+ async with self._lock:
274
+ return await asyncio.to_thread(self._remove, name, discard)
275
+
276
+ def _remove(self, name: str, discard: bool) -> Changes:
277
+ problem = validate_name(name)
278
+ if problem:
279
+ raise WorktreeError(f"名称 {name} 不可用:{problem}。", "invalid_name")
280
+ flat = flatten(name)
281
+ path, meta_path = self.base / flat, self.meta_path(flat)
282
+ meta = load_meta(meta_path)
283
+ if meta is None:
284
+ if path.exists():
285
+ raise WorktreeError(
286
+ f"{self.display(path)} 没有 MossCode 的记录,不会自动删除;请手动处理。", "worktree_invalid"
287
+ )
288
+ raise WorktreeError(f"没有名为 {name} 的工作树。", "unknown_worktree")
289
+ if meta.in_use and process_alive(int(meta.in_use.get("pid", 0))):
290
+ task = meta.in_use.get("task") or "?"
291
+ raise WorktreeError(f"工作树 {name} 正被子 Agent 使用(任务 {task}),结束后再删除。", "worktree_in_use")
292
+ changes = Changes() if meta.removing else self.changes(meta)
293
+ if changes.any and not discard:
294
+ raise WorktreeChanged(name, changes)
295
+ self._delete(meta, meta_path)
296
+ return changes
297
+
298
+ def _forget(self, meta: WorktreeMeta, meta_path: Path) -> None:
299
+ """A record whose directory is gone: drop it; the branch stays unless it holds nothing."""
300
+ drop = meta.removing or not self.changes(meta).any
301
+ git.run_git(["worktree", "prune"], self.top)
302
+ if drop:
303
+ git.run_git(["branch", "-D", meta.branch], self.top)
304
+ meta_path.unlink(missing_ok=True)
305
+
306
+ def _delete(self, meta: WorktreeMeta, meta_path: Path) -> None:
307
+ """After the change check passed or the user discarded. Never `git worktree remove`."""
308
+ path = Path(meta.path).resolve()
309
+ if path.parent != self.base.resolve():
310
+ raise WorktreeError(
311
+ f"记录中的路径 {path} 不在 {self.display(self.base)} 中,拒绝删除。", "worktree_invalid"
312
+ )
313
+ meta.removing, meta.in_use = True, None
314
+ save_meta(meta, meta_path) # An interrupted deletion is retried, never re-checked into "changed".
315
+ for relative in meta.links:
316
+ link = path / relative
317
+ if is_link(link):
318
+ try:
319
+ unlink(link)
320
+ except OSError:
321
+ pass # rmtree below does not follow links either.
322
+ if path.exists():
323
+ if is_link(path):
324
+ raise WorktreeError(f"{self.display(path)} 是一个链接,拒绝删除。", "worktree_invalid")
325
+ try:
326
+ shutil.rmtree(path, onexc=_writable)
327
+ except OSError as exc:
328
+ raise WorktreeError(
329
+ f"删除 {self.display(path)} 未完成:{exc.strerror or exc};"
330
+ f"关闭占用它的程序后用 /worktree remove {meta.name} 重试。",
331
+ "remove_failed",
332
+ ) from None
333
+ git.run_git(["worktree", "prune"], self.top)
334
+ git.run_git(["branch", "-D", meta.branch], self.top)
335
+ meta_path.unlink(missing_ok=True)
336
+
337
+ # Sub-agents ----------------------------------------------------------------------------
338
+
339
+ async def finish(self, meta: WorktreeMeta) -> Finished:
340
+ """No changes -> delete; changes -> keep. Always clears in_use."""
341
+ async with self._lock:
342
+ return await asyncio.to_thread(self._finish, meta)
343
+
344
+ def _finish(self, meta: WorktreeMeta) -> Finished:
345
+ meta_path = self.meta_path(meta.flat)
346
+ current = load_meta(meta_path) or meta
347
+ current.in_use = None
348
+ save_meta(current, meta_path)
349
+ changes = self.changes(current)
350
+ if changes.any:
351
+ return Finished(True, changes)
352
+ try:
353
+ self._delete(current, meta_path)
354
+ except WorktreeError as exc:
355
+ return Finished(False, changes, str(exc))
356
+ return Finished(False, changes)
357
+
358
+ async def mark_in_use(self, flat: str, task: str = "", pid: int | None = None) -> None:
359
+ """A team member's worktree is in use while the member runs; "" clears it. Never deletes.
360
+
361
+ pid: the process running the member (a pane member's own), this one by default."""
362
+ async with self._lock:
363
+ meta_path = self.meta_path(flat)
364
+ meta = load_meta(meta_path)
365
+ if meta is not None:
366
+ meta.in_use = {"pid": pid or os.getpid(), "task": task} if task else None
367
+ await asyncio.to_thread(save_meta, meta, meta_path)
368
+
369
+ def report(self, meta: WorktreeMeta, finished: Finished) -> str:
370
+ """The lines after a sub-agent's result."""
371
+ if not finished.kept:
372
+ if finished.error:
373
+ return f"[工作树] 无改动,但删除未完成:{finished.error}"
374
+ return "[工作树] 无改动,已删除。"
375
+ where = self.display(meta.path)
376
+ return (
377
+ f"[工作树] 已保留:{where}\n"
378
+ f"分支 {meta.branch},基于 {meta.base[:7]};{finished.changes.describe()}。\n"
379
+ f"合并:git merge {meta.branch};查看:git -C {where} status;删除:/worktree remove {meta.name}"
380
+ )
381
+
382
+ # Listing and cleaning ------------------------------------------------------------------
383
+
384
+ async def entries(self) -> list[Entry]:
385
+ return await asyncio.to_thread(self._entries)
386
+
387
+ def _entries(self) -> list[Entry]:
388
+ found, known = [], set()
389
+ for _, meta in self.metas():
390
+ known.add(meta.flat)
391
+ found.append(Entry(meta, meta.name, self._status(meta)))
392
+ try:
393
+ others = sorted(child.name for child in self.base.iterdir() if child.is_dir() and child.name != META)
394
+ except OSError:
395
+ others = []
396
+ found.extend(Entry(None, name, "未登记(没有 MossCode 的记录)") for name in others if name not in known)
397
+ return found
398
+
399
+ def _status(self, meta: WorktreeMeta) -> str:
400
+ if meta.removing:
401
+ return "删除未完成"
402
+ if meta.in_use and process_alive(int(meta.in_use.get("pid", 0))):
403
+ return f"使用中(任务 {meta.in_use.get('task') or '?'})"
404
+ if not Path(meta.path).is_dir():
405
+ return "目录不存在"
406
+ changes = self.changes(meta)
407
+ return f"有改动({changes.describe()})" if changes.any else "无改动"
408
+
409
+ async def clean(self, hours: float | None = None) -> CleanReport:
410
+ async with self._lock:
411
+ return await asyncio.to_thread(self._clean, self.stale_hours if hours is None else hours)
412
+
413
+ def _clean(self, hours: float) -> CleanReport:
414
+ """Only records MossCode made for sub-agents, not in use, old, and without changes."""
415
+ report = CleanReport()
416
+ cutoff = time.time() - hours * 3600
417
+ for meta_path, meta in self.metas():
418
+ if meta.manual:
419
+ report.skipped.append((meta.name, "手动创建"))
420
+ continue
421
+ if not EPHEMERAL.fullmatch(meta.name):
422
+ report.skipped.append((meta.name, "名称不是临时格式"))
423
+ continue
424
+ if meta.in_use and process_alive(int(meta.in_use.get("pid", 0))):
425
+ report.skipped.append((meta.name, "使用中"))
426
+ continue
427
+ try:
428
+ if meta_path.stat().st_mtime > cutoff:
429
+ report.skipped.append((meta.name, "未过期"))
430
+ continue
431
+ except OSError:
432
+ continue
433
+ if not meta.removing:
434
+ changes = self.changes(meta)
435
+ if changes.any:
436
+ report.skipped.append((meta.name, f"有改动({changes.describe()})"))
437
+ continue
438
+ try:
439
+ self._delete(meta, meta_path)
440
+ except WorktreeError as exc:
441
+ report.skipped.append((meta.name, str(exc)))
442
+ continue
443
+ report.removed.append(meta.name)
444
+ try:
445
+ git.run_git(["worktree", "prune"], self.top)
446
+ except WorktreeError:
447
+ pass
448
+ return report
449
+
450
+ async def keep_clean(self, on_report, *, delay: float = 5.0, interval: float = CLEAN_INTERVAL) -> None:
451
+ """In the background, after start and then every day; nothing when turned off."""
452
+ if self.stale_hours <= 0:
453
+ return
454
+ await asyncio.sleep(delay)
455
+ while True:
456
+ try:
457
+ report = await self.clean()
458
+ except Exception: # A broken repository must not take the session down.
459
+ report = None
460
+ if report is not None and report.removed:
461
+ on_report(report)
462
+ await asyncio.sleep(interval)
@@ -0,0 +1,82 @@
1
+ """What MossCode records about each worktree it made; the record is what makes it safe to clean."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass, field, fields
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass
12
+ class WorktreeMeta:
13
+ name: str # As given, may contain /
14
+ flat: str # Directory name and the branch name after mosscode/
15
+ path: str # Absolute
16
+ branch: str
17
+ base: str # Full SHA the worktree started from
18
+ created: str # ISO time
19
+ manual: bool # /worktree create: never cleaned automatically
20
+ links: list[str] = field(default_factory=list) # Relative to the worktree's top level
21
+ copied: list[str] = field(default_factory=list) # Copied files; not work of the sub-agent
22
+ setup_done: bool = False
23
+ in_use: dict | None = None # {"pid": 1234, "task": "t3"} while a sub-agent works in it
24
+ removing: bool = False # Deletion started and did not finish
25
+
26
+ def to_json(self) -> str:
27
+ return json.dumps(asdict(self), ensure_ascii=False, indent=2)
28
+
29
+ @classmethod
30
+ def from_dict(cls, data: dict) -> WorktreeMeta:
31
+ known = {item.name for item in fields(cls)}
32
+ return cls(**{key: value for key, value in data.items() if key in known})
33
+
34
+
35
+ def load_meta(path: Path) -> WorktreeMeta | None:
36
+ try:
37
+ data = json.loads(path.read_text(encoding="utf-8"))
38
+ return WorktreeMeta.from_dict(data) if isinstance(data, dict) else None
39
+ except (OSError, ValueError, TypeError):
40
+ return None
41
+
42
+
43
+ def save_meta(meta: WorktreeMeta, path: Path) -> None:
44
+ """Atomic: a reader sees the old record or the new one."""
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ temporary = path.with_name(path.name + ".tmp")
47
+ temporary.write_text(meta.to_json(), encoding="utf-8")
48
+ os.replace(temporary, path)
49
+
50
+
51
+ def process_alive(pid: int) -> bool:
52
+ """Never os.kill(pid, 0) on Windows: there it terminates the process."""
53
+ if pid <= 0:
54
+ return False
55
+ if os.name == "nt":
56
+ import ctypes
57
+ from ctypes import wintypes
58
+
59
+ kernel = ctypes.WinDLL("kernel32", use_last_error=True)
60
+ kernel.OpenProcess.restype = wintypes.HANDLE
61
+ kernel.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
62
+ kernel.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
63
+ kernel.CloseHandle.argtypes = (wintypes.HANDLE,)
64
+ handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
65
+ if not handle:
66
+ return ctypes.get_last_error() == 5 # Access denied: it exists.
67
+ try:
68
+ code = wintypes.DWORD()
69
+ if not kernel.GetExitCodeProcess(handle, ctypes.byref(code)):
70
+ return True
71
+ return code.value == 259 # STILL_ACTIVE
72
+ finally:
73
+ kernel.CloseHandle(handle)
74
+ try:
75
+ os.kill(pid, 0)
76
+ except ProcessLookupError:
77
+ return False
78
+ except PermissionError:
79
+ return True
80
+ except OSError:
81
+ return False
82
+ return True
@@ -0,0 +1,52 @@
1
+ """Worktree names. A name may come from a command, so it is checked before it reaches a path or git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import secrets
7
+
8
+ NAME_LIMIT = 64
9
+ SEGMENT = re.compile(r"[A-Za-z0-9._-]+")
10
+ EPHEMERAL = re.compile(r"agent-[0-9a-f]{8}")
11
+ BRANCH_PREFIX = "mosscode/"
12
+ # Names Windows reserves for devices, with or without an extension.
13
+ RESERVED = frozenset(
14
+ {"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))}
15
+ )
16
+
17
+
18
+ def validate_name(name: str) -> str | None:
19
+ """None when the name is usable, otherwise the reason."""
20
+ if not name:
21
+ return "名称不能为空"
22
+ if len(name) > NAME_LIMIT:
23
+ return f"名称最长 {NAME_LIMIT} 个字符"
24
+ for segment in name.split("/"):
25
+ if not segment:
26
+ return "名称中不能有空段(// 或开头、结尾的 /)"
27
+ if segment in {".", ".."}:
28
+ return "名称中不能有 . 或 .. 段"
29
+ if ".." in segment: # git refuses it in a branch name.
30
+ return "名称中不能有连续的 .."
31
+ if not SEGMENT.fullmatch(segment):
32
+ return f"段 {segment!r} 只能含字母、数字、.、_ 和 -"
33
+ if segment[0] in ".-":
34
+ return f"段 {segment!r} 不能以 . 或 - 开头"
35
+ if segment.endswith(".") or segment.lower().endswith(".lock"):
36
+ return f"段 {segment!r} 不能以 . 或 .lock 结尾"
37
+ if segment.split(".", 1)[0].lower() in RESERVED:
38
+ return f"段 {segment!r} 是 Windows 保留的设备名"
39
+ return None
40
+
41
+
42
+ def flatten(name: str) -> str:
43
+ """One directory level and one branch level, so a/b never collides with a branch a (git D/F)."""
44
+ return name.replace("/", "+")
45
+
46
+
47
+ def branch_of(flat: str) -> str:
48
+ return BRANCH_PREFIX + flat
49
+
50
+
51
+ def ephemeral_name() -> str:
52
+ return f"agent-{secrets.token_hex(4)}"