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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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"))
|