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.
Files changed (60) hide show
  1. agentpool/__init__.py +3 -0
  2. agentpool/agent_io.py +134 -0
  3. agentpool/artifacts.py +151 -0
  4. agentpool/cli.py +1199 -0
  5. agentpool/config.py +373 -0
  6. agentpool/docs/agentpool-skill.md +85 -0
  7. agentpool/docs/onboarding.md +169 -0
  8. agentpool/event_detection.py +150 -0
  9. agentpool/fixtures/__init__.py +1 -0
  10. agentpool/fixtures/fake_agents/__init__.py +1 -0
  11. agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
  12. agentpool/fixtures/fake_agents/fake_common.py +44 -0
  13. agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
  14. agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
  15. agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
  16. agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
  17. agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
  18. agentpool/git_worktree.py +144 -0
  19. agentpool/mcp/__init__.py +1 -0
  20. agentpool/mcp/resources.py +64 -0
  21. agentpool/mcp/tools.py +259 -0
  22. agentpool/mcp_server.py +487 -0
  23. agentpool/models.py +310 -0
  24. agentpool/onboarding.py +1279 -0
  25. agentpool/policy.py +63 -0
  26. agentpool/provider_model_catalog.json +997 -0
  27. agentpool/providers/__init__.py +3 -0
  28. agentpool/providers/base.py +411 -0
  29. agentpool/providers/registry.py +139 -0
  30. agentpool/redaction.py +30 -0
  31. agentpool/runtimes/__init__.py +3 -0
  32. agentpool/runtimes/base.py +36 -0
  33. agentpool/runtimes/tmux.py +133 -0
  34. agentpool/session_manager.py +1061 -0
  35. agentpool/stats/__init__.py +6 -0
  36. agentpool/stats/card.py +74 -0
  37. agentpool/stats/compute.py +496 -0
  38. agentpool/stats/queries.py +138 -0
  39. agentpool/stats/render.py +103 -0
  40. agentpool/stats/window.py +85 -0
  41. agentpool/store.py +478 -0
  42. agentpool/usage/__init__.py +1 -0
  43. agentpool/usage/_common.py +223 -0
  44. agentpool/usage/ccusage.py +130 -0
  45. agentpool/usage/claude.py +23 -0
  46. agentpool/usage/codex.py +210 -0
  47. agentpool/usage/codexbar.py +186 -0
  48. agentpool/usage/combine.py +71 -0
  49. agentpool/usage/copilot.py +146 -0
  50. agentpool/usage/devin.py +265 -0
  51. agentpool/usage/parsers.py +41 -0
  52. agentpool/usage/probes.py +52 -0
  53. agentpool/usage/provider_parsers.py +276 -0
  54. agentpool/usage/summary.py +166 -0
  55. agentpool/utils.py +59 -0
  56. agentpool_cli-0.1.0.dist-info/METADATA +292 -0
  57. agentpool_cli-0.1.0.dist-info/RECORD +60 -0
  58. agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
  59. agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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
+ }