agentpool-cli 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.
- agentpool/__init__.py +3 -0
- agentpool/agent_io.py +134 -0
- agentpool/artifacts.py +151 -0
- agentpool/cli.py +1199 -0
- agentpool/config.py +373 -0
- agentpool/docs/agentpool-skill.md +85 -0
- agentpool/docs/onboarding.md +169 -0
- agentpool/event_detection.py +150 -0
- agentpool/fixtures/__init__.py +1 -0
- agentpool/fixtures/fake_agents/__init__.py +1 -0
- agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_common.py +44 -0
- agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
- agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
- agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
- agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
- agentpool/git_worktree.py +144 -0
- agentpool/mcp/__init__.py +1 -0
- agentpool/mcp/resources.py +64 -0
- agentpool/mcp/tools.py +259 -0
- agentpool/mcp_server.py +487 -0
- agentpool/models.py +310 -0
- agentpool/onboarding.py +1279 -0
- agentpool/policy.py +63 -0
- agentpool/provider_model_catalog.json +997 -0
- agentpool/providers/__init__.py +3 -0
- agentpool/providers/base.py +411 -0
- agentpool/providers/registry.py +139 -0
- agentpool/redaction.py +30 -0
- agentpool/runtimes/__init__.py +3 -0
- agentpool/runtimes/base.py +36 -0
- agentpool/runtimes/tmux.py +133 -0
- agentpool/session_manager.py +1061 -0
- agentpool/stats/__init__.py +6 -0
- agentpool/stats/card.py +74 -0
- agentpool/stats/compute.py +496 -0
- agentpool/stats/queries.py +138 -0
- agentpool/stats/render.py +103 -0
- agentpool/stats/window.py +85 -0
- agentpool/store.py +478 -0
- agentpool/usage/__init__.py +1 -0
- agentpool/usage/_common.py +223 -0
- agentpool/usage/ccusage.py +130 -0
- agentpool/usage/claude.py +23 -0
- agentpool/usage/codex.py +210 -0
- agentpool/usage/codexbar.py +186 -0
- agentpool/usage/combine.py +71 -0
- agentpool/usage/copilot.py +146 -0
- agentpool/usage/devin.py +265 -0
- agentpool/usage/parsers.py +41 -0
- agentpool/usage/probes.py +52 -0
- agentpool/usage/provider_parsers.py +276 -0
- agentpool/usage/summary.py +166 -0
- agentpool/utils.py +59 -0
- agentpool_cli-0.1.0.dist-info/METADATA +292 -0
- agentpool_cli-0.1.0.dist-info/RECORD +60 -0
- agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import textwrap
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from agentpool.models import Confidence, ObserveEvent, SessionState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
QUESTION_PATTERNS = [
|
|
12
|
+
re.compile(r"Should I\b", re.I),
|
|
13
|
+
re.compile(r"Do you want\b", re.I),
|
|
14
|
+
re.compile(r"Which .* should I\b", re.I),
|
|
15
|
+
re.compile(r"^[^\n]{0,240}\?\s*$", re.I | re.M),
|
|
16
|
+
]
|
|
17
|
+
APPROVAL_PATTERNS = [
|
|
18
|
+
re.compile(r"Proceed\?", re.I),
|
|
19
|
+
re.compile(r"Continue\?", re.I),
|
|
20
|
+
re.compile(r"Do you trust\b", re.I),
|
|
21
|
+
re.compile(r"Hooks need review", re.I),
|
|
22
|
+
re.compile(r"Update available!", re.I),
|
|
23
|
+
re.compile(r"Allow .*\?", re.I),
|
|
24
|
+
re.compile(r"\[y/N\]", re.I),
|
|
25
|
+
re.compile(r"\(y/n\)", re.I),
|
|
26
|
+
]
|
|
27
|
+
OVERAGE_PATTERNS = [
|
|
28
|
+
re.compile(r"extra usage", re.I),
|
|
29
|
+
re.compile(r"overage", re.I),
|
|
30
|
+
re.compile(r"continue with paid", re.I),
|
|
31
|
+
re.compile(r"API credits", re.I),
|
|
32
|
+
]
|
|
33
|
+
LIMIT_PATTERNS = [
|
|
34
|
+
re.compile(r"approaching .*limit", re.I),
|
|
35
|
+
re.compile(r"limit reached", re.I),
|
|
36
|
+
re.compile(r"resets", re.I),
|
|
37
|
+
]
|
|
38
|
+
ERROR_PATTERNS = [
|
|
39
|
+
re.compile(r"command not found", re.I),
|
|
40
|
+
re.compile(r"not authenticated", re.I),
|
|
41
|
+
re.compile(r"login required", re.I),
|
|
42
|
+
re.compile(r"authentication required", re.I),
|
|
43
|
+
re.compile(r"press any key to log in", re.I),
|
|
44
|
+
re.compile(r"access denied.*invalid subscription", re.I | re.S),
|
|
45
|
+
re.compile(r"wrong API endpoint", re.I),
|
|
46
|
+
re.compile(r"unable to reach localhost", re.I),
|
|
47
|
+
re.compile(r"\btraceback\b", re.I),
|
|
48
|
+
re.compile(r"\bexception\b", re.I),
|
|
49
|
+
re.compile(r"\bpanic(?:ked)?\b", re.I),
|
|
50
|
+
]
|
|
51
|
+
DONE_PATTERN = re.compile(
|
|
52
|
+
r"^\s*(?:[•●-]\s*)?AGENTPOOL_RESULT_START\s*$(?P<body>.*?)"
|
|
53
|
+
r"^\s*(?:[•●-]\s*)?AGENTPOOL_RESULT_END\s*$",
|
|
54
|
+
re.I | re.M | re.S,
|
|
55
|
+
)
|
|
56
|
+
SMOKE_DONE_PATTERN = re.compile(r"\bAGENTPOOL_SMOKE_DONE\b", re.I)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Detection:
|
|
61
|
+
state: SessionState
|
|
62
|
+
event: ObserveEvent
|
|
63
|
+
confidence: Confidence
|
|
64
|
+
parsed_question: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def screen_hash(screen: str) -> str:
|
|
68
|
+
return hashlib.sha256(screen.encode("utf-8")).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def trim_excerpt(screen: str, max_chars: int = 4000) -> str:
|
|
72
|
+
return screen.strip()[-max_chars:]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def detect_event(screen: str, previous_hash: str | None = None) -> Detection:
|
|
76
|
+
excerpt = trim_excerpt(screen)
|
|
77
|
+
active_excerpt = trim_excerpt(screen, max_chars=1200)
|
|
78
|
+
approval_excerpt = _active_prompt_region(active_excerpt)
|
|
79
|
+
if SMOKE_DONE_PATTERN.search(_strip_prompt_lines(active_excerpt)):
|
|
80
|
+
return Detection(SessionState.COMPLETED, ObserveEvent.COMPLETED, Confidence.OBSERVED)
|
|
81
|
+
if extract_result_body(excerpt):
|
|
82
|
+
return Detection(SessionState.COMPLETED, ObserveEvent.COMPLETED, Confidence.OBSERVED)
|
|
83
|
+
for pattern in OVERAGE_PATTERNS:
|
|
84
|
+
if pattern.search(approval_excerpt):
|
|
85
|
+
return Detection(SessionState.AWAITING_APPROVAL, ObserveEvent.OVERAGE_PROMPT, Confidence.OBSERVED)
|
|
86
|
+
for pattern in LIMIT_PATTERNS:
|
|
87
|
+
if pattern.search(active_excerpt):
|
|
88
|
+
return Detection(SessionState.RUNNING, ObserveEvent.LIMIT_WARNING, Confidence.OBSERVED)
|
|
89
|
+
for pattern in APPROVAL_PATTERNS:
|
|
90
|
+
if pattern.search(approval_excerpt):
|
|
91
|
+
return Detection(SessionState.AWAITING_APPROVAL, ObserveEvent.APPROVAL_PROMPT, Confidence.OBSERVED)
|
|
92
|
+
for pattern in QUESTION_PATTERNS:
|
|
93
|
+
match = pattern.search(active_excerpt)
|
|
94
|
+
if match:
|
|
95
|
+
line = _line_for_match(active_excerpt, match.start())
|
|
96
|
+
return Detection(SessionState.AWAITING_USER_INPUT, ObserveEvent.QUESTION, Confidence.OBSERVED, line)
|
|
97
|
+
for pattern in ERROR_PATTERNS:
|
|
98
|
+
if pattern.search(active_excerpt):
|
|
99
|
+
return Detection(SessionState.FAILED, ObserveEvent.ERROR, Confidence.OBSERVED)
|
|
100
|
+
current_hash = screen_hash(screen)
|
|
101
|
+
if previous_hash and previous_hash != current_hash:
|
|
102
|
+
return Detection(SessionState.RUNNING, ObserveEvent.SCREEN_CHANGED, Confidence.OBSERVED)
|
|
103
|
+
return Detection(SessionState.RUNNING, ObserveEvent.NONE, Confidence.UNKNOWN)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _line_for_match(text: str, index: int) -> str:
|
|
107
|
+
start = text.rfind("\n", 0, index) + 1
|
|
108
|
+
end = text.find("\n", index)
|
|
109
|
+
if end == -1:
|
|
110
|
+
end = len(text)
|
|
111
|
+
return text[start:end].strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_result_body(screen: str) -> str | None:
|
|
115
|
+
for match in DONE_PATTERN.finditer(trim_excerpt(screen)):
|
|
116
|
+
body = match.group("body")
|
|
117
|
+
if _looks_like_actual_result(body):
|
|
118
|
+
return textwrap.dedent(body).strip()
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _looks_like_actual_result(body: str) -> bool:
|
|
123
|
+
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
|
124
|
+
if not lines:
|
|
125
|
+
return False
|
|
126
|
+
for index, line in enumerate(lines):
|
|
127
|
+
if line.lower().startswith("summary"):
|
|
128
|
+
after_colon = line.split(":", 1)[1].strip() if ":" in line else ""
|
|
129
|
+
if after_colon:
|
|
130
|
+
return True
|
|
131
|
+
next_line = lines[index + 1].lower() if index + 1 < len(lines) else ""
|
|
132
|
+
return bool(next_line and not next_line.startswith("findings"))
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _strip_prompt_lines(text: str) -> str:
|
|
137
|
+
lines = []
|
|
138
|
+
for line in text.splitlines():
|
|
139
|
+
stripped = line.strip()
|
|
140
|
+
if stripped.startswith(("❯", "›", ">")):
|
|
141
|
+
continue
|
|
142
|
+
lines.append(line)
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _active_prompt_region(text: str) -> str:
|
|
147
|
+
matches = list(re.finditer(r"(?m)^›\s+(?!\d+[.)]?\s)", text))
|
|
148
|
+
if not matches:
|
|
149
|
+
return text
|
|
150
|
+
return text[matches[-1].start() :]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged fixtures used by AgentPool smoke tests."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Fake provider scripts packaged for install-time smoke tests."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fake_common import print_result, read_initial_prompt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
print("Fake Approval Agent ready.")
|
|
8
|
+
read_initial_prompt()
|
|
9
|
+
print("Proceed? [y/N]")
|
|
10
|
+
answer = input()
|
|
11
|
+
print(f"Approval answer: {answer}")
|
|
12
|
+
print_result("Approval flow completed.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def read_initial_prompt() -> str:
|
|
11
|
+
lines: list[str] = []
|
|
12
|
+
saw_task = False
|
|
13
|
+
while True:
|
|
14
|
+
line = sys.stdin.readline()
|
|
15
|
+
if not line:
|
|
16
|
+
break
|
|
17
|
+
lines.append(line)
|
|
18
|
+
if saw_task:
|
|
19
|
+
break
|
|
20
|
+
if line.strip() == "Task:":
|
|
21
|
+
saw_task = True
|
|
22
|
+
return "".join(lines)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def print_result(summary: str, files_changed: str = "None", blockers: str = "None") -> None:
|
|
26
|
+
print("AGENTPOOL_RESULT_START")
|
|
27
|
+
print("Summary:")
|
|
28
|
+
print(summary)
|
|
29
|
+
print("Findings:")
|
|
30
|
+
print("Fake provider completed.")
|
|
31
|
+
print("Files inspected:")
|
|
32
|
+
print("None")
|
|
33
|
+
print("Files changed:")
|
|
34
|
+
print(files_changed)
|
|
35
|
+
print("Commands run:")
|
|
36
|
+
print("fake-agent")
|
|
37
|
+
print("Tests run:")
|
|
38
|
+
print("None")
|
|
39
|
+
print("Blockers:")
|
|
40
|
+
print(blockers)
|
|
41
|
+
print("Confidence:")
|
|
42
|
+
print("high")
|
|
43
|
+
print("AGENTPOOL_RESULT_END")
|
|
44
|
+
time.sleep(30)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fake_common import print_result, read_initial_prompt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
print("Fake Completed Agent ready.")
|
|
8
|
+
read_initial_prompt()
|
|
9
|
+
print_result("Completed immediately.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from fake_common import read_initial_prompt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
print("Fake Idle Agent ready.")
|
|
10
|
+
read_initial_prompt()
|
|
11
|
+
print("Working...")
|
|
12
|
+
time.sleep(30)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fake_common import print_result, read_initial_prompt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
print("Fake Limit Agent ready.")
|
|
8
|
+
read_initial_prompt()
|
|
9
|
+
print("Approaching 5-hour limit - resets 5pm.")
|
|
10
|
+
print_result("Limit warning emitted.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fake_common import print_result, read_initial_prompt
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
print("Fake Patch Agent ready.")
|
|
10
|
+
read_initial_prompt()
|
|
11
|
+
target = Path("agentpool_fake_patch.txt")
|
|
12
|
+
target.write_text("patched by fake agent\n", encoding="utf-8")
|
|
13
|
+
print_result("Patch file written.", files_changed=str(target))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fake_common import print_result, read_initial_prompt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
print("Fake Question Agent ready.")
|
|
8
|
+
read_initial_prompt()
|
|
9
|
+
print("I found two possible paths. Should I inspect migrations or auth middleware first?")
|
|
10
|
+
answer = input()
|
|
11
|
+
print(f"Steering received: {answer}")
|
|
12
|
+
print_result("Question path completed after steering.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from agentpool.models import ToolError
|
|
7
|
+
from agentpool.utils import run_capture
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_git_repo(path: Path) -> bool:
|
|
11
|
+
proc = run_capture(["git", "rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
12
|
+
return proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def git_status(path: Path) -> str:
|
|
16
|
+
if not is_git_repo(path):
|
|
17
|
+
return ""
|
|
18
|
+
return run_capture(["git", "status", "--porcelain"], cwd=path).stdout
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def git_diff(path: Path) -> str:
|
|
22
|
+
if not is_git_repo(path):
|
|
23
|
+
return ""
|
|
24
|
+
proc = run_capture(["git", "diff", "--binary"], cwd=path, timeout=30)
|
|
25
|
+
if proc.returncode == 124:
|
|
26
|
+
return "[agentpool] git diff timed out after 30s; diff omitted.\n"
|
|
27
|
+
if proc.returncode != 0:
|
|
28
|
+
return f"[agentpool] git diff failed: {proc.stderr.strip()}\n"
|
|
29
|
+
return proc.stdout
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def changed_files(path: Path) -> list[str]:
|
|
33
|
+
status = git_status(path)
|
|
34
|
+
return [line[3:] for line in status.splitlines() if len(line) > 3]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_worktree(repo_path: Path, provider_id: str, session_id: str) -> Path:
|
|
38
|
+
if not is_git_repo(repo_path):
|
|
39
|
+
raise ToolError("GIT_NOT_REPO", "Worktree isolation requires a git repository.", {"repo_path": str(repo_path)})
|
|
40
|
+
parent = repo_path.parent / ".agentpool-worktrees"
|
|
41
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
worktree_path = parent / session_id
|
|
43
|
+
branch = agentpool_branch(provider_id, session_id)
|
|
44
|
+
proc = subprocess.run(
|
|
45
|
+
["git", "worktree", "add", "-b", branch, str(worktree_path)],
|
|
46
|
+
cwd=str(repo_path),
|
|
47
|
+
text=True,
|
|
48
|
+
capture_output=True,
|
|
49
|
+
check=False,
|
|
50
|
+
)
|
|
51
|
+
if proc.returncode != 0:
|
|
52
|
+
raise ToolError(
|
|
53
|
+
"WORKTREE_FAILED",
|
|
54
|
+
"Failed to create git worktree.",
|
|
55
|
+
{"repo_path": str(repo_path), "stderr": proc.stderr, "branch": branch},
|
|
56
|
+
)
|
|
57
|
+
return worktree_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def agentpool_branch(provider_id: str, session_id: str) -> str:
|
|
61
|
+
safe_provider = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in provider_id).strip("-")
|
|
62
|
+
return f"agentpool/{safe_provider}/{session_id[-6:]}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def delete_agentpool_branch(repo_path: Path, provider_id: str, session_id: str) -> dict[str, str | bool]:
|
|
66
|
+
if not is_git_repo(repo_path):
|
|
67
|
+
return {"deleted": False, "reason": "not_git_repo"}
|
|
68
|
+
branch = agentpool_branch(provider_id, session_id)
|
|
69
|
+
proc = subprocess.run(
|
|
70
|
+
["git", "branch", "-D", branch],
|
|
71
|
+
cwd=str(repo_path),
|
|
72
|
+
text=True,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
check=False,
|
|
75
|
+
)
|
|
76
|
+
if proc.returncode != 0:
|
|
77
|
+
return {"deleted": False, "branch": branch, "stderr": proc.stderr.strip()}
|
|
78
|
+
return {"deleted": True, "branch": branch}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_agentpool_worktrees(repo_path: Path) -> list[dict[str, str | bool]]:
|
|
82
|
+
if not is_git_repo(repo_path):
|
|
83
|
+
return []
|
|
84
|
+
proc = subprocess.run(
|
|
85
|
+
["git", "worktree", "list", "--porcelain"],
|
|
86
|
+
cwd=str(repo_path),
|
|
87
|
+
text=True,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
if proc.returncode != 0:
|
|
92
|
+
return []
|
|
93
|
+
worktrees: list[dict[str, str | bool]] = []
|
|
94
|
+
current: dict[str, str | bool] = {}
|
|
95
|
+
for line in proc.stdout.splitlines():
|
|
96
|
+
if not line:
|
|
97
|
+
if _is_agentpool_worktree(current):
|
|
98
|
+
worktrees.append(current)
|
|
99
|
+
current = {}
|
|
100
|
+
continue
|
|
101
|
+
key, _, value = line.partition(" ")
|
|
102
|
+
if key == "worktree":
|
|
103
|
+
current["path"] = value
|
|
104
|
+
elif key == "branch":
|
|
105
|
+
current["branch"] = value
|
|
106
|
+
elif key == "HEAD":
|
|
107
|
+
current["head"] = value
|
|
108
|
+
elif key == "bare":
|
|
109
|
+
current["bare"] = True
|
|
110
|
+
if _is_agentpool_worktree(current):
|
|
111
|
+
worktrees.append(current)
|
|
112
|
+
return worktrees
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cleanup_worktree(repo_path: Path, worktree_path: Path, force: bool = False) -> dict[str, str | bool]:
|
|
116
|
+
if not is_git_repo(repo_path):
|
|
117
|
+
raise ToolError("GIT_NOT_REPO", "Worktree cleanup requires a git repository.", {"repo_path": str(repo_path)})
|
|
118
|
+
if not worktree_path.exists():
|
|
119
|
+
return {"removed": False, "path": str(worktree_path), "reason": "missing"}
|
|
120
|
+
dirty = bool(git_status(worktree_path).strip())
|
|
121
|
+
if dirty and not force:
|
|
122
|
+
raise ToolError(
|
|
123
|
+
"WORKTREE_DIRTY",
|
|
124
|
+
"Worktree has uncommitted changes; pass force to remove it.",
|
|
125
|
+
{"worktree_path": str(worktree_path)},
|
|
126
|
+
)
|
|
127
|
+
args = ["git", "worktree", "remove"]
|
|
128
|
+
if force:
|
|
129
|
+
args.append("--force")
|
|
130
|
+
args.append(str(worktree_path))
|
|
131
|
+
proc = subprocess.run(args, cwd=str(repo_path), text=True, capture_output=True, check=False)
|
|
132
|
+
if proc.returncode != 0:
|
|
133
|
+
raise ToolError(
|
|
134
|
+
"WORKTREE_CLEANUP_FAILED",
|
|
135
|
+
"Failed to remove git worktree.",
|
|
136
|
+
{"worktree_path": str(worktree_path), "stderr": proc.stderr},
|
|
137
|
+
)
|
|
138
|
+
return {"removed": True, "path": str(worktree_path), "dirty": dirty}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_agentpool_worktree(entry: dict[str, str | bool]) -> bool:
|
|
142
|
+
path = str(entry.get("path") or "")
|
|
143
|
+
branch = str(entry.get("branch") or "")
|
|
144
|
+
return ".agentpool-worktrees" in path or branch.startswith("refs/heads/agentpool/")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP surface for AgentPool."""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentpool.agent_io import compact_artifact_manifest, lockdown_resource, omitted_worker_output, wrap_untrusted
|
|
9
|
+
from agentpool.session_manager import SessionManager
|
|
10
|
+
|
|
11
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_resource(manager: SessionManager, uri: str, lockdown: bool = False) -> str:
|
|
15
|
+
if uri == "agentpool://quickstart":
|
|
16
|
+
return _text_resource("agentpool-skill.md")
|
|
17
|
+
if uri == "agentpool://onboarding":
|
|
18
|
+
return _text_resource("onboarding.md")
|
|
19
|
+
if uri == "agentpool://skill.md":
|
|
20
|
+
return _text_resource("agentpool-skill.md")
|
|
21
|
+
prefix = "agentpool://sessions/"
|
|
22
|
+
if uri.startswith(prefix):
|
|
23
|
+
tail = uri[len(prefix) :]
|
|
24
|
+
parts = tail.split("/")
|
|
25
|
+
session_id = parts[0]
|
|
26
|
+
session = manager._require_session(session_id)
|
|
27
|
+
if len(parts) == 1:
|
|
28
|
+
return session.model_dump_json(indent=2)
|
|
29
|
+
if parts[1] == "transcript":
|
|
30
|
+
if lockdown:
|
|
31
|
+
return _json(lockdown_resource(session.transcript_path, "transcript"))
|
|
32
|
+
return _json(_worker_text_resource(session.id, "transcript", session.transcript_path))
|
|
33
|
+
if parts[1] == "events":
|
|
34
|
+
if lockdown:
|
|
35
|
+
return _json(lockdown_resource(session.events_path, "events"))
|
|
36
|
+
return _json(_worker_text_resource(session.id, "events", session.events_path))
|
|
37
|
+
artifact_prefix = "agentpool://artifacts/"
|
|
38
|
+
if uri.startswith(artifact_prefix):
|
|
39
|
+
session_id = uri[len(artifact_prefix) :]
|
|
40
|
+
return _json(compact_artifact_manifest(manager.artifact_manifest(session_id), lockdown=lockdown))
|
|
41
|
+
raise KeyError(f"Unknown AgentPool resource URI: {uri}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _json(data: Any) -> str:
|
|
45
|
+
return json.dumps(data, indent=2, default=str)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _text_resource(filename: str) -> str:
|
|
49
|
+
packaged = resources.files("agentpool").joinpath("docs", filename)
|
|
50
|
+
if packaged.is_file():
|
|
51
|
+
return packaged.read_text(encoding="utf-8")
|
|
52
|
+
return (PROJECT_ROOT / "docs" / filename).read_text(encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _worker_text_resource(session_id: str, kind: str, path: str | Path) -> dict[str, Any]:
|
|
56
|
+
resolved = Path(path)
|
|
57
|
+
text = resolved.read_text(encoding="utf-8") if resolved.exists() else ""
|
|
58
|
+
return {
|
|
59
|
+
"session_id": session_id,
|
|
60
|
+
"kind": kind,
|
|
61
|
+
"path": str(resolved),
|
|
62
|
+
"exists": resolved.exists(),
|
|
63
|
+
"worker_output": wrap_untrusted(text, "full") if text else omitted_worker_output("full"),
|
|
64
|
+
}
|