llmcode-cli 1.0.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 (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,108 @@
1
+ """Subprocess-based backend for spawning swarm members."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import IO
9
+
10
+
11
+ class SubprocessBackend:
12
+ """Spawn swarm members as asyncio subprocesses.
13
+
14
+ Each member runs llm-code --lite with a role prompt piped to stdin.
15
+ Output is captured to swarm/<id>/output.log.
16
+ """
17
+
18
+ def __init__(self, swarm_dir: Path) -> None:
19
+ self._swarm_dir = Path(swarm_dir)
20
+ self._swarm_dir.mkdir(parents=True, exist_ok=True)
21
+ self._procs: dict[str, asyncio.subprocess.Process] = {}
22
+
23
+ async def spawn(
24
+ self,
25
+ member_id: str,
26
+ role: str,
27
+ task: str,
28
+ extra_args: tuple[str, ...] = (),
29
+ model: str = "",
30
+ ) -> int | None:
31
+ """Spawn a new llm-code --lite process for this member.
32
+
33
+ Returns the PID, or None on failure.
34
+ """
35
+ member_dir = self._swarm_dir / member_id
36
+ member_dir.mkdir(parents=True, exist_ok=True)
37
+ log_path = member_dir / "output.log"
38
+ log_path.touch()
39
+
40
+ llm_code_bin = shutil.which("llm-code") or sys.executable
41
+ cmd_args: list[str] = []
42
+ if llm_code_bin == sys.executable:
43
+ cmd_args = [sys.executable, "-m", "llm_code.cli.tui_main", "--lite"]
44
+ else:
45
+ cmd_args = [llm_code_bin, "--lite"]
46
+
47
+ cmd_args.extend(extra_args)
48
+
49
+ if model:
50
+ cmd_args.extend(["--model", model])
51
+
52
+ prompt = f"You are a swarm worker with role '{role}'. Your task: {task}"
53
+
54
+ log_fh = open(log_path, "w", encoding="utf-8")
55
+ proc = await asyncio.create_subprocess_exec(
56
+ *cmd_args,
57
+ stdin=asyncio.subprocess.PIPE,
58
+ stdout=log_fh,
59
+ stderr=asyncio.subprocess.STDOUT,
60
+ )
61
+ self._procs[member_id] = proc
62
+ # Track open file handles for cleanup
63
+ if not hasattr(self, "_log_files"):
64
+ self._log_files: dict[str, IO[str]] = {}
65
+ self._log_files[member_id] = log_fh
66
+
67
+ # Send initial prompt
68
+ if proc.stdin:
69
+ proc.stdin.write((prompt + "\n").encode())
70
+ await proc.stdin.drain()
71
+
72
+ return proc.pid
73
+
74
+ async def stop(self, member_id: str) -> None:
75
+ """Terminate the process for a member."""
76
+ proc = self._procs.get(member_id)
77
+ if proc is None:
78
+ return
79
+ try:
80
+ proc.terminate()
81
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
82
+ except (ProcessLookupError, asyncio.TimeoutError):
83
+ try:
84
+ proc.kill()
85
+ except ProcessLookupError:
86
+ pass
87
+ self._procs.pop(member_id, None)
88
+ # Close log file handle
89
+ if hasattr(self, "_log_files"):
90
+ fh = self._log_files.pop(member_id, None)
91
+ if fh is not None:
92
+ try:
93
+ fh.close()
94
+ except Exception:
95
+ pass
96
+
97
+ async def stop_all(self) -> None:
98
+ """Terminate all spawned processes."""
99
+ ids = list(self._procs.keys())
100
+ for member_id in ids:
101
+ await self.stop(member_id)
102
+
103
+ def is_running(self, member_id: str) -> bool:
104
+ """Check if a member process is still alive."""
105
+ proc = self._procs.get(member_id)
106
+ if proc is None:
107
+ return False
108
+ return proc.returncode is None
@@ -0,0 +1,103 @@
1
+ """Tmux-based backend for spawning swarm members in panes."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+
11
+ def is_tmux_available() -> bool:
12
+ """Check if tmux is available and we are inside a tmux session."""
13
+ return shutil.which("tmux") is not None and "TMUX" in os.environ
14
+
15
+
16
+ class TmuxBackend:
17
+ """Spawn swarm members as tmux split panes.
18
+
19
+ Each member runs llm-code --lite inside a new tmux pane with a role prompt.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._panes: dict[str, str] = {} # member_id -> pane_id (e.g. "%5")
24
+
25
+ def spawn(
26
+ self,
27
+ member_id: str,
28
+ role: str,
29
+ task: str,
30
+ extra_args: tuple[str, ...] = (),
31
+ model: str = "",
32
+ ) -> str | None:
33
+ """Spawn a new tmux pane running llm-code --lite.
34
+
35
+ Returns the tmux pane ID (e.g. '%5'), or None on failure.
36
+ """
37
+ llm_code_bin = shutil.which("llm-code") or sys.executable
38
+ if llm_code_bin == sys.executable:
39
+ cmd = f"{sys.executable} -m llm_code.cli.tui_main --lite"
40
+ else:
41
+ cmd = f"{llm_code_bin} --lite"
42
+
43
+ if extra_args:
44
+ cmd += " " + " ".join(extra_args)
45
+
46
+ if model:
47
+ cmd += " --model " + shlex.quote(model)
48
+
49
+ prompt = f"You are a swarm worker with role '{role}'. Your task: {task}"
50
+ full_cmd = f"echo {repr(prompt)} | {cmd}"
51
+
52
+ result = subprocess.run(
53
+ [
54
+ "tmux", "split-window", "-h",
55
+ "-P", "-F", "#{pane_id}",
56
+ full_cmd,
57
+ ],
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=10,
61
+ )
62
+
63
+ if result.returncode != 0:
64
+ return None
65
+
66
+ pane_id = result.stdout.strip()
67
+ self._panes[member_id] = pane_id
68
+ return pane_id
69
+
70
+ def stop(self, member_id: str) -> None:
71
+ """Kill the tmux pane for a member."""
72
+ pane_id = self._panes.pop(member_id, None)
73
+ if pane_id is None:
74
+ return
75
+ try:
76
+ subprocess.run(
77
+ ["tmux", "kill-pane", "-t", pane_id],
78
+ capture_output=True,
79
+ timeout=5,
80
+ )
81
+ except (subprocess.TimeoutExpired, OSError):
82
+ pass
83
+
84
+ def stop_all(self) -> None:
85
+ """Kill all managed panes."""
86
+ ids = list(self._panes.keys())
87
+ for member_id in ids:
88
+ self.stop(member_id)
89
+
90
+ def is_running(self, member_id: str) -> bool:
91
+ """Check if the pane still exists."""
92
+ pane_id = self._panes.get(member_id)
93
+ if pane_id is None:
94
+ return False
95
+ try:
96
+ result = subprocess.run(
97
+ ["tmux", "has-session", "-t", pane_id],
98
+ capture_output=True,
99
+ timeout=3,
100
+ )
101
+ return result.returncode == 0
102
+ except (subprocess.TimeoutExpired, OSError):
103
+ return False
@@ -0,0 +1,306 @@
1
+ """Git worktree backend for spawning swarm members in isolated filesystem copies."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import dataclasses
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from llm_code.runtime.config import WorktreeConfig
13
+
14
+
15
+ @dataclasses.dataclass(frozen=True)
16
+ class WorktreeResult:
17
+ """Result from completing a worktree-backed swarm member."""
18
+
19
+ member_id: str
20
+ status: str # "success" | "conflict" | "empty" | "error"
21
+ diff: str = ""
22
+ branch_name: str = ""
23
+ conflict_files: tuple[str, ...] = ()
24
+ message: str = ""
25
+
26
+
27
+ class WorktreeBackend:
28
+ """Spawn swarm members in git worktrees for isolated file system access.
29
+
30
+ Lifecycle per member:
31
+ spawn -> create worktree on a new branch, copy gitignored files, start llm-code --lite
32
+ stop -> terminate the subprocess
33
+ complete -> commit changes, apply on_complete strategy, optionally cleanup
34
+ """
35
+
36
+ def __init__(self, project_dir: Path, config: WorktreeConfig) -> None:
37
+ self._project_dir = Path(project_dir)
38
+ self._config = config
39
+ # member_id -> worktree path
40
+ self._worktrees: dict[str, Path] = {}
41
+ # member_id -> asyncio/subprocess process
42
+ self._procs: dict[str, Any] = {}
43
+ # member_id -> branch name
44
+ self._branch_names: dict[str, str] = {}
45
+
46
+ # ------------------------------------------------------------------
47
+ # Public API
48
+ # ------------------------------------------------------------------
49
+
50
+ async def spawn(
51
+ self,
52
+ member_id: str,
53
+ role: str,
54
+ task: str,
55
+ model: str = "",
56
+ extra_args: tuple[str, ...] = (),
57
+ ) -> int | None:
58
+ """Create a git worktree for member_id and launch llm-code --lite.
59
+
60
+ Returns the PID of the launched process, or None on failure.
61
+ """
62
+ base = Path(self._config.base_dir) if self._config.base_dir else Path("/tmp")
63
+ wt_path = base / f"llm-code-wt-{member_id}"
64
+ branch_name = f"agent/{member_id}"
65
+
66
+ # Create the worktree on a new branch
67
+ subprocess.run(
68
+ ["git", "worktree", "add", str(wt_path), "-b", branch_name],
69
+ cwd=str(self._project_dir),
70
+ capture_output=True,
71
+ text=True,
72
+ )
73
+
74
+ # Copy gitignored files into the worktree
75
+ for rel_path in self._config.copy_gitignored:
76
+ src = self._project_dir / rel_path
77
+ dst = wt_path / rel_path
78
+ if src.exists():
79
+ try:
80
+ shutil.copy2(str(src), str(dst))
81
+ except OSError:
82
+ pass
83
+
84
+ self._worktrees[member_id] = wt_path
85
+ self._branch_names[member_id] = branch_name
86
+
87
+ # Build the llm-code --lite command
88
+ llm_code_bin = shutil.which("llm-code") or sys.executable
89
+ cmd_args: list[str] = []
90
+ if llm_code_bin == sys.executable:
91
+ cmd_args = [sys.executable, "-m", "llm_code.cli.tui_main", "--lite"]
92
+ else:
93
+ cmd_args = [llm_code_bin, "--lite"]
94
+
95
+ cmd_args = list(cmd_args) + list(extra_args)
96
+ if model:
97
+ cmd_args.extend(["--model", model])
98
+
99
+ prompt = f"You are a swarm worker with role '{role}'. Your task: {task}"
100
+
101
+ proc = await asyncio.create_subprocess_exec(
102
+ *cmd_args,
103
+ stdin=asyncio.subprocess.PIPE,
104
+ stdout=asyncio.subprocess.PIPE,
105
+ stderr=asyncio.subprocess.STDOUT,
106
+ cwd=str(wt_path),
107
+ )
108
+ self._procs[member_id] = proc
109
+
110
+ if proc.stdin:
111
+ proc.stdin.write((prompt + "\n").encode())
112
+ await proc.stdin.drain()
113
+
114
+ return proc.pid
115
+
116
+ async def stop(self, member_id: str) -> None:
117
+ """Terminate the process for a member (without completing/merging)."""
118
+ proc = self._procs.get(member_id)
119
+ if proc is None:
120
+ return
121
+ try:
122
+ proc.terminate()
123
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
124
+ except (ProcessLookupError, asyncio.TimeoutError):
125
+ try:
126
+ proc.kill()
127
+ except ProcessLookupError:
128
+ pass
129
+ self._procs.pop(member_id, None)
130
+
131
+ async def complete(self, member_id: str) -> WorktreeResult:
132
+ """Finalise a member's work according to config.on_complete.
133
+
134
+ Strategies:
135
+ "diff" - commit all changes, capture diff, cleanup worktree+branch
136
+ "merge" - commit, merge into current branch, handle conflicts
137
+ "branch" - commit, remove worktree, keep branch (for later review)
138
+
139
+ Unknown member_id returns an error result immediately.
140
+ """
141
+ if member_id not in self._worktrees:
142
+ return WorktreeResult(
143
+ member_id=member_id,
144
+ status="error",
145
+ message=f"No worktree registered for member '{member_id}'",
146
+ )
147
+
148
+ wt_path = self._worktrees[member_id]
149
+ branch_name = self._branch_names[member_id]
150
+ on_complete = self._config.on_complete
151
+
152
+ try:
153
+ if on_complete == "diff":
154
+ return await self._complete_diff(member_id, wt_path, branch_name)
155
+ elif on_complete == "merge":
156
+ return await self._complete_merge(member_id, wt_path, branch_name)
157
+ elif on_complete == "branch":
158
+ return await self._complete_branch(member_id, wt_path, branch_name)
159
+ else:
160
+ return WorktreeResult(
161
+ member_id=member_id,
162
+ status="error",
163
+ message=f"Unknown on_complete strategy: '{on_complete}'",
164
+ )
165
+ except Exception as exc:
166
+ return WorktreeResult(
167
+ member_id=member_id,
168
+ status="error",
169
+ message=str(exc),
170
+ )
171
+
172
+ async def stop_all(self) -> None:
173
+ """Stop all running member processes."""
174
+ ids = list(self._procs.keys())
175
+ for member_id in ids:
176
+ await self.stop(member_id)
177
+
178
+ def is_running(self, member_id: str) -> bool:
179
+ """Return True if the member process is still alive."""
180
+ proc = self._procs.get(member_id)
181
+ if proc is None:
182
+ return False
183
+ return getattr(proc, "returncode", None) is None
184
+
185
+ # ------------------------------------------------------------------
186
+ # Private helpers
187
+ # ------------------------------------------------------------------
188
+
189
+ def _commit_worktree(self, wt_path: Path) -> None:
190
+ """Stage all changes and create a commit in the worktree."""
191
+ wt = str(wt_path)
192
+ subprocess.run(["git", "add", "-A"], cwd=wt, capture_output=True, text=True)
193
+ subprocess.run(
194
+ ["git", "commit", "--allow-empty", "-m", "swarm: agent work complete"],
195
+ cwd=wt,
196
+ capture_output=True,
197
+ text=True,
198
+ )
199
+
200
+ def _remove_worktree(self, wt_path: Path, branch_name: str, remove_branch: bool = True) -> None:
201
+ """Remove the worktree and optionally delete the branch."""
202
+ subprocess.run(
203
+ ["git", "worktree", "remove", "--force", str(wt_path)],
204
+ cwd=str(self._project_dir),
205
+ capture_output=True,
206
+ text=True,
207
+ )
208
+ if remove_branch:
209
+ subprocess.run(
210
+ ["git", "branch", "-d", branch_name],
211
+ cwd=str(self._project_dir),
212
+ capture_output=True,
213
+ text=True,
214
+ )
215
+
216
+ async def _complete_diff(
217
+ self, member_id: str, wt_path: Path, branch_name: str
218
+ ) -> WorktreeResult:
219
+ """Commit, capture diff, then cleanup worktree and branch."""
220
+ self._commit_worktree(wt_path)
221
+
222
+ diff_result = subprocess.run(
223
+ ["git", "diff", "HEAD~1..HEAD"],
224
+ cwd=str(wt_path),
225
+ capture_output=True,
226
+ text=True,
227
+ )
228
+ diff_text = diff_result.stdout
229
+
230
+ if self._config.cleanup_on_success:
231
+ self._remove_worktree(wt_path, branch_name, remove_branch=True)
232
+ self._worktrees.pop(member_id, None)
233
+ self._branch_names.pop(member_id, None)
234
+ self._procs.pop(member_id, None)
235
+
236
+ return WorktreeResult(
237
+ member_id=member_id,
238
+ status="success",
239
+ diff=diff_text,
240
+ branch_name=branch_name,
241
+ )
242
+
243
+ async def _complete_merge(
244
+ self, member_id: str, wt_path: Path, branch_name: str
245
+ ) -> WorktreeResult:
246
+ """Commit agent work, merge into current HEAD branch, handle conflicts."""
247
+ self._commit_worktree(wt_path)
248
+
249
+ merge_result = subprocess.run(
250
+ ["git", "merge", "--no-ff", branch_name, "-m", f"Merge {branch_name}"],
251
+ cwd=str(self._project_dir),
252
+ capture_output=True,
253
+ text=True,
254
+ )
255
+
256
+ if merge_result.returncode != 0:
257
+ status_result = subprocess.run(
258
+ ["git", "diff", "--name-only", "--diff-filter=U"],
259
+ cwd=str(self._project_dir),
260
+ capture_output=True,
261
+ text=True,
262
+ )
263
+ conflict_files = tuple(
264
+ f for f in status_result.stdout.splitlines() if f.strip()
265
+ )
266
+ return WorktreeResult(
267
+ member_id=member_id,
268
+ status="conflict",
269
+ branch_name=branch_name,
270
+ conflict_files=conflict_files,
271
+ message=merge_result.stderr,
272
+ )
273
+
274
+ if self._config.cleanup_on_success:
275
+ self._remove_worktree(wt_path, branch_name, remove_branch=True)
276
+ self._worktrees.pop(member_id, None)
277
+ self._branch_names.pop(member_id, None)
278
+ self._procs.pop(member_id, None)
279
+
280
+ return WorktreeResult(
281
+ member_id=member_id,
282
+ status="success",
283
+ branch_name=branch_name,
284
+ )
285
+
286
+ async def _complete_branch(
287
+ self, member_id: str, wt_path: Path, branch_name: str
288
+ ) -> WorktreeResult:
289
+ """Commit agent work, remove worktree, keep branch for later review."""
290
+ self._commit_worktree(wt_path)
291
+
292
+ subprocess.run(
293
+ ["git", "worktree", "remove", "--force", str(wt_path)],
294
+ cwd=str(self._project_dir),
295
+ capture_output=True,
296
+ text=True,
297
+ )
298
+ self._worktrees.pop(member_id, None)
299
+ self._branch_names.pop(member_id, None)
300
+ self._procs.pop(member_id, None)
301
+
302
+ return WorktreeResult(
303
+ member_id=member_id,
304
+ status="success",
305
+ branch_name=branch_name,
306
+ )
@@ -0,0 +1,74 @@
1
+ """Checkpoint system for agent teams — save/restore agent state for resume."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class AgentCheckpoint:
11
+ member_id: str
12
+ role: str
13
+ status: str
14
+ conversation_snapshot: tuple[dict, ...]
15
+ last_tool_call: str | None = None
16
+ output: str = ""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class TeamCheckpoint:
21
+ team_name: str
22
+ task_description: str
23
+ timestamp: str
24
+ checkpoints: tuple[AgentCheckpoint, ...]
25
+ coordinator_state: dict = field(default_factory=dict)
26
+ completed_members: tuple[str, ...] = ()
27
+
28
+
29
+ def save_checkpoint(checkpoint: TeamCheckpoint, checkpoints_dir: Path) -> Path:
30
+ checkpoints_dir.mkdir(parents=True, exist_ok=True)
31
+ safe_ts = checkpoint.timestamp.replace(":", "-")
32
+ filename = f"{checkpoint.team_name}-{safe_ts}.json"
33
+ path = checkpoints_dir / filename
34
+ data = {
35
+ "team_name": checkpoint.team_name,
36
+ "task_description": checkpoint.task_description,
37
+ "timestamp": checkpoint.timestamp,
38
+ "checkpoints": [
39
+ {
40
+ "member_id": cp.member_id, "role": cp.role, "status": cp.status,
41
+ "conversation_snapshot": list(cp.conversation_snapshot),
42
+ "last_tool_call": cp.last_tool_call, "output": cp.output,
43
+ }
44
+ for cp in checkpoint.checkpoints
45
+ ],
46
+ "coordinator_state": checkpoint.coordinator_state,
47
+ "completed_members": list(checkpoint.completed_members),
48
+ }
49
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
50
+ return path
51
+
52
+
53
+ def load_checkpoint(path: Path) -> TeamCheckpoint:
54
+ data = json.loads(path.read_text(encoding="utf-8"))
55
+ checkpoints = tuple(
56
+ AgentCheckpoint(
57
+ member_id=cp["member_id"], role=cp["role"], status=cp["status"],
58
+ conversation_snapshot=tuple(cp.get("conversation_snapshot", [])),
59
+ last_tool_call=cp.get("last_tool_call"), output=cp.get("output", ""),
60
+ )
61
+ for cp in data.get("checkpoints", [])
62
+ )
63
+ return TeamCheckpoint(
64
+ team_name=data["team_name"], task_description=data.get("task_description", ""),
65
+ timestamp=data.get("timestamp", ""), checkpoints=checkpoints,
66
+ coordinator_state=data.get("coordinator_state", {}),
67
+ completed_members=tuple(data.get("completed_members", [])),
68
+ )
69
+
70
+
71
+ def list_checkpoints(checkpoints_dir: Path) -> list[Path]:
72
+ if not checkpoints_dir.is_dir():
73
+ return []
74
+ return sorted(checkpoints_dir.glob("*.json"))