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,276 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime, timedelta, timezone
5
+
6
+ from agentpool.models import CapacitySnapshot, Confidence, UsageStatus, UsageWindow, UsageWindowKind
7
+
8
+
9
+ def parse_codex_status(provider_id: str, text: str) -> CapacitySnapshot | None:
10
+ windows: list[UsageWindow] = []
11
+ for name, percent, reset in re.findall(
12
+ r"(?P<name>5h|Weekly)\s+limit:\s+\[[^\]]+\]\s+(?P<percent>\d+(?:\.\d+)?)%\s+left\s+\(resets\s+(?P<reset>[^)]+)\)",
13
+ text,
14
+ re.I,
15
+ ):
16
+ windows.append(
17
+ UsageWindow(
18
+ name=name.lower(),
19
+ kind=UsageWindowKind.FIVE_HOUR if name.lower() == "5h" else UsageWindowKind.WEEKLY,
20
+ remaining_percent=float(percent),
21
+ used_percent=100.0 - float(percent),
22
+ confidence=Confidence.LOCAL_CLI,
23
+ raw_text=f"{name} limit resets {reset}",
24
+ )
25
+ )
26
+ footer = re.search(
27
+ r"\b5h\s+(?P<hour>\d+(?:\.\d+)?)%\s+.*?\bweekly\s+(?P<weekly>\d+(?:\.\d+)?)%",
28
+ text,
29
+ re.I | re.S,
30
+ )
31
+ if footer and not windows:
32
+ windows.extend(
33
+ [
34
+ UsageWindow(
35
+ name="5h",
36
+ kind=UsageWindowKind.FIVE_HOUR,
37
+ remaining_percent=float(footer.group("hour")),
38
+ used_percent=100.0 - float(footer.group("hour")),
39
+ confidence=Confidence.LOCAL_CLI,
40
+ raw_text=footer.group(0),
41
+ ),
42
+ UsageWindow(
43
+ name="weekly",
44
+ kind=UsageWindowKind.WEEKLY,
45
+ remaining_percent=float(footer.group("weekly")),
46
+ used_percent=100.0 - float(footer.group("weekly")),
47
+ confidence=Confidence.LOCAL_CLI,
48
+ raw_text=footer.group(0),
49
+ ),
50
+ ]
51
+ )
52
+ if not windows:
53
+ return None
54
+ status = _status_from_remaining(min(w.remaining_percent or 100.0 for w in windows))
55
+ return CapacitySnapshot(provider_id=provider_id, status=status, confidence=Confidence.LOCAL_CLI, windows=windows)
56
+
57
+
58
+
59
+ def parse_claude_usage(provider_id: str, text: str) -> CapacitySnapshot | None:
60
+ clean = _strip_ansi(text)
61
+ if re.search(r"failed to load usage|token expired|not authenticated|login required", clean, re.I):
62
+ return CapacitySnapshot(
63
+ provider_id=provider_id,
64
+ status=UsageStatus.UNAUTHENTICATED,
65
+ confidence=Confidence.LOCAL_CLI,
66
+ warnings=["Claude CLI reported that usage could not be loaded."],
67
+ raw={"source": "claude_cli_usage_error"},
68
+ )
69
+ windows: list[UsageWindow] = []
70
+ for label, name in (
71
+ ("Current session", "session"),
72
+ ("Current week (all models)", "weekly"),
73
+ ("Current week (Opus)", "weekly_opus"),
74
+ ("Current week (Sonnet only)", "weekly_sonnet"),
75
+ ("Current week (Sonnet)", "weekly_sonnet"),
76
+ ("Current week", "weekly"),
77
+ ):
78
+ window = _extract_labeled_percent(clean, label, name)
79
+ if window and all(existing.name != window.name for existing in windows):
80
+ windows.append(window)
81
+ if not windows:
82
+ return None
83
+ credits_remaining = None
84
+ extra = re.search(
85
+ r"Extra usage(?P<context>.{0,260}?\$(?P<spent>\d+(?:\.\d+)?)\s*/\s*\$(?P<limit>\d+(?:\.\d+)?)\s+spent)",
86
+ clean,
87
+ re.I | re.S,
88
+ )
89
+ if extra:
90
+ spent = float(extra.group("spent"))
91
+ limit = float(extra.group("limit"))
92
+ remaining = max(0.0, limit - spent)
93
+ credits_remaining = round(remaining, 2)
94
+ used_percent = (spent / limit) * 100 if limit > 0 else 100.0
95
+ windows.append(
96
+ UsageWindow(
97
+ name="extra_usage",
98
+ kind=UsageWindowKind.ON_DEMAND,
99
+ used_percent=max(0.0, min(100.0, used_percent)),
100
+ remaining_percent=max(0.0, min(100.0, 100.0 - used_percent)),
101
+ used_units=spent,
102
+ remaining_units=credits_remaining,
103
+ confidence=Confidence.LOCAL_CLI,
104
+ raw_text=extra.group("context").strip(),
105
+ )
106
+ )
107
+ status = _status_from_remaining(min(w.remaining_percent or 100.0 for w in windows))
108
+ return CapacitySnapshot(
109
+ provider_id=provider_id,
110
+ status=status,
111
+ confidence=Confidence.LOCAL_CLI,
112
+ windows=windows,
113
+ credits_remaining=credits_remaining,
114
+ )
115
+
116
+
117
+ def parse_devin_usage(provider_id: str, text: str) -> CapacitySnapshot | None:
118
+ match = re.search(r"Quota used:\s+(?P<used>\d+(?:\.\d+)?)%\s+\(remaining:\s+(?P<remaining>\d+(?:\.\d+)?)%\)", text, re.I)
119
+ if not match:
120
+ banner = re.search(r"\b(?P<remaining>\d+(?:\.\d+)?)%\s+remaining\s+\(resets\s+in\s+(?P<reset>[^)]+)\)", text, re.I)
121
+ if not banner:
122
+ return None
123
+ remaining = float(banner.group("remaining"))
124
+ used = 100.0 - remaining
125
+ raw = banner.group(0)
126
+ else:
127
+ used = float(match.group("used"))
128
+ remaining = float(match.group("remaining"))
129
+ raw = match.group(0)
130
+ credits = None
131
+ credits_match = re.search(r"Extra usage balance:\s+\$(?P<credits>\d+(?:\.\d+)?)", text, re.I)
132
+ if credits_match:
133
+ credits = float(credits_match.group("credits"))
134
+ reset_at = _parse_devin_reset(text)
135
+ return CapacitySnapshot(
136
+ provider_id=provider_id,
137
+ status=_status_from_remaining(remaining),
138
+ confidence=Confidence.LOCAL_CLI,
139
+ credits_remaining=credits,
140
+ warnings=["Devin CLI /usage exposes the weekly included quota; daily quota requires the plan-status API."],
141
+ windows=[
142
+ UsageWindow(
143
+ name="weekly",
144
+ kind=UsageWindowKind.WEEKLY,
145
+ status="weekly",
146
+ used_percent=used,
147
+ remaining_percent=remaining,
148
+ reset_at=reset_at,
149
+ confidence=Confidence.LOCAL_CLI,
150
+ raw_text=raw,
151
+ )
152
+ ],
153
+ )
154
+
155
+
156
+ def parse_droid_status(provider_id: str, text: str) -> CapacitySnapshot | None:
157
+ if "Credit Usage (Current Session)" not in text and "Session Token Usage" not in text:
158
+ return None
159
+ raw: dict[str, float] = {}
160
+ for key, value in re.findall(r"(Input|Output|Cache Creation|Cache Read):\s+([\d.]+)\s+(?:credits|tokens)", text, re.I):
161
+ raw[key.lower().replace(" ", "_")] = float(value)
162
+ return CapacitySnapshot(
163
+ provider_id=provider_id,
164
+ status=UsageStatus.UNKNOWN,
165
+ confidence=Confidence.LOCAL_CLI,
166
+ warnings=["Droid CLI status exposes current-session usage, not subscription quota."],
167
+ raw={"current_session": raw},
168
+ )
169
+
170
+
171
+ def parse_opencode_stats(provider_id: str, text: str) -> CapacitySnapshot | None:
172
+ if "COST & TOKENS" not in text and "MODEL USAGE" not in text:
173
+ return None
174
+ raw: dict[str, float | int] = {}
175
+ money = re.search(r"Total Cost\s+\$(?P<cost>\d+(?:\.\d+)?)", text, re.I)
176
+ messages = re.search(r"Messages\s+(?P<messages>[\d,]+)", text, re.I)
177
+ sessions = re.search(r"Sessions\s+(?P<sessions>[\d,]+)", text, re.I)
178
+ if money:
179
+ raw["total_cost"] = float(money.group("cost"))
180
+ if messages:
181
+ raw["messages"] = int(messages.group("messages").replace(",", ""))
182
+ if sessions:
183
+ raw["sessions"] = int(sessions.group("sessions").replace(",", ""))
184
+ return CapacitySnapshot(
185
+ provider_id=provider_id,
186
+ status=UsageStatus.UNKNOWN,
187
+ confidence=Confidence.LOCAL_CLI,
188
+ warnings=["OpenCode stats expose local token/cost history, not subscription quota."],
189
+ raw=raw,
190
+ )
191
+
192
+
193
+ def _status_from_remaining(remaining: float) -> UsageStatus:
194
+ if remaining <= 0:
195
+ return UsageStatus.LIMIT_REACHED
196
+ if remaining <= 15:
197
+ return UsageStatus.NEAR_LIMIT
198
+ return UsageStatus.AVAILABLE
199
+
200
+
201
+ def _strip_ansi(text: str) -> str:
202
+ return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
203
+
204
+
205
+ def _parse_devin_reset(text: str) -> datetime | None:
206
+ match = re.search(
207
+ r"Quota resets\s+(?P<month>[A-Z][a-z]+)\s+(?P<day>\d{1,2}),\s+(?P<time>\d{1,2}:\d{2}\s+[AP]M)\s+\(UTC(?P<offset>[+-]\d{1,2})\)",
208
+ text,
209
+ )
210
+ if not match:
211
+ return None
212
+ year = datetime.now().year
213
+ offset = timezone(timedelta(hours=int(match.group("offset"))))
214
+ try:
215
+ parsed = datetime.strptime(
216
+ f"{match.group('month')} {match.group('day')} {year} {match.group('time')}",
217
+ "%B %d %Y %I:%M %p",
218
+ )
219
+ except ValueError:
220
+ return None
221
+ reset_at = parsed.replace(tzinfo=offset)
222
+ if reset_at < datetime.now(offset) - timedelta(days=180):
223
+ reset_at = reset_at.replace(year=year + 1)
224
+ return reset_at
225
+
226
+
227
+ def _extract_labeled_percent(text: str, label: str, name: str) -> UsageWindow | None:
228
+ pattern = re.compile(
229
+ rf"{re.escape(label)}(?P<context>.{{0,800}}?)(?=(?:Current session|Current week|\Z))",
230
+ re.I | re.S,
231
+ )
232
+ match = pattern.search(text)
233
+ if not match:
234
+ return None
235
+ context = match.group("context")
236
+ percent_match = re.search(
237
+ r"(?P<percent>\d+(?:\.\d+)?)%\s*(?P<direction>left|remaining|used)?",
238
+ context,
239
+ re.I,
240
+ )
241
+ if not percent_match:
242
+ return None
243
+ percent = float(percent_match.group("percent"))
244
+ direction = (percent_match.group("direction") or "remaining").lower()
245
+ if direction == "used":
246
+ used = percent
247
+ remaining = 100.0 - percent
248
+ else:
249
+ remaining = percent
250
+ used = 100.0 - percent
251
+ reset_match = re.search(r"resets?\s+(?:in\s+)?(?P<reset>[^\n\r|·]+)", context, re.I)
252
+ raw = f"{label}{context[:400]}"
253
+ if reset_match:
254
+ raw = f"{raw} reset {reset_match.group('reset').strip()}"
255
+ return UsageWindow(
256
+ name=name,
257
+ kind=_kind_from_name(name),
258
+ used_percent=max(0.0, min(100.0, used)),
259
+ remaining_percent=max(0.0, min(100.0, remaining)),
260
+ confidence=Confidence.LOCAL_CLI,
261
+ raw_text=raw.strip(),
262
+ )
263
+
264
+
265
+ def _kind_from_name(name: str) -> UsageWindowKind:
266
+ if name == "session":
267
+ return UsageWindowKind.SESSION
268
+ if name == "weekly" or name.startswith("weekly_"):
269
+ return UsageWindowKind.WEEKLY
270
+ if name == "daily":
271
+ return UsageWindowKind.DAILY
272
+ if name == "5h":
273
+ return UsageWindowKind.FIVE_HOUR
274
+ if name in {"extra_usage", "on_demand"}:
275
+ return UsageWindowKind.ON_DEMAND
276
+ return UsageWindowKind.UNKNOWN
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from agentpool.models import CapacitySnapshot, Confidence, ProviderDescriptor, UsageStatus
7
+
8
+
9
+ def build_usage_summary(
10
+ snapshots: list[CapacitySnapshot],
11
+ min_remaining_percent: int = 10,
12
+ stale_after_seconds: int = 1800,
13
+ provider_descriptors: list[ProviderDescriptor] | None = None,
14
+ ) -> dict[str, Any]:
15
+ now = datetime.now(timezone.utc)
16
+ descriptor_by_id = {descriptor.id: descriptor for descriptor in provider_descriptors or []}
17
+ rows = {
18
+ snapshot.provider_id: _provider_summary(
19
+ snapshot,
20
+ min_remaining_percent=min_remaining_percent,
21
+ stale_after_seconds=stale_after_seconds,
22
+ now=now,
23
+ descriptor=descriptor_by_id.get(snapshot.provider_id),
24
+ )
25
+ for snapshot in sorted(snapshots, key=lambda item: item.provider_id)
26
+ }
27
+ next_provider, next_at = _next_available(rows)
28
+ return {
29
+ "checked_at": datetime.now(timezone.utc).isoformat(),
30
+ "providers": rows,
31
+ "min_remaining_percent": min_remaining_percent,
32
+ "next_available_provider": next_provider,
33
+ "next_available_at": next_at,
34
+ "counts": {
35
+ "total": len(rows),
36
+ "usable": sum(1 for row in rows.values() if row["usable"]),
37
+ "unusable": sum(1 for row in rows.values() if not row["usable"]),
38
+ "available": sum(1 for row in rows.values() if row["status"] == UsageStatus.AVAILABLE.value),
39
+ "near_limit": sum(1 for row in rows.values() if row["status"] == UsageStatus.NEAR_LIMIT.value),
40
+ "limit_reached": sum(1 for row in rows.values() if row["status"] == UsageStatus.LIMIT_REACHED.value),
41
+ "unknown": sum(1 for row in rows.values() if row["status"] == UsageStatus.UNKNOWN.value),
42
+ "unavailable": sum(1 for row in rows.values() if row["status"] == UsageStatus.UNAVAILABLE.value),
43
+ },
44
+ }
45
+
46
+
47
+ def _provider_summary(
48
+ snapshot: CapacitySnapshot,
49
+ min_remaining_percent: int,
50
+ stale_after_seconds: int,
51
+ now: datetime,
52
+ descriptor: ProviderDescriptor | None = None,
53
+ ) -> dict[str, Any]:
54
+ windows = [
55
+ {
56
+ "name": window.name,
57
+ "kind": window.kind.value if hasattr(window.kind, "value") else str(window.kind),
58
+ "remaining_percent": window.remaining_percent,
59
+ "used_percent": window.used_percent,
60
+ "remaining_units": window.remaining_units,
61
+ "reset_at": window.reset_at.isoformat() if window.reset_at else None,
62
+ "confidence": window.confidence.value if hasattr(window.confidence, "value") else str(window.confidence),
63
+ }
64
+ for window in snapshot.windows
65
+ ]
66
+ age_seconds = max(0.0, (now - snapshot.checked_at).total_seconds())
67
+ stale = age_seconds > stale_after_seconds
68
+ usable, unusable_reason = _usable_reason(snapshot, windows, min_remaining_percent, descriptor, stale)
69
+ return {
70
+ "provider_id": snapshot.provider_id,
71
+ "status": snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status),
72
+ "confidence": snapshot.confidence.value if hasattr(snapshot.confidence, "value") else str(snapshot.confidence),
73
+ "installed": descriptor.installed if descriptor else None,
74
+ "auth_status": descriptor.auth.status if descriptor else None,
75
+ "usable": usable,
76
+ "unusable_reason": unusable_reason,
77
+ "stale": stale,
78
+ "age_seconds": age_seconds,
79
+ "checked_at": snapshot.checked_at.isoformat(),
80
+ "windows": windows,
81
+ "credits_remaining": snapshot.credits_remaining,
82
+ "warnings": snapshot.warnings,
83
+ "source": snapshot.raw.get("source"),
84
+ "summary": _summary_text(snapshot, windows),
85
+ }
86
+
87
+
88
+ def _usable_reason(
89
+ snapshot: CapacitySnapshot,
90
+ windows: list[dict[str, Any]],
91
+ min_remaining_percent: int,
92
+ descriptor: ProviderDescriptor | None,
93
+ stale: bool,
94
+ ) -> tuple[bool, str | None]:
95
+ if descriptor and not descriptor.installed:
96
+ return False, "not_installed"
97
+ if descriptor and descriptor.auth.status in {"unauthenticated", "unavailable"}:
98
+ return False, f"auth_{descriptor.auth.status}"
99
+ status = snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status)
100
+ if status in {UsageStatus.LIMIT_REACHED.value, UsageStatus.UNAVAILABLE.value, UsageStatus.UNAUTHENTICATED.value}:
101
+ return False, status
102
+ if status == UsageStatus.UNKNOWN.value:
103
+ return False, "usage_unknown"
104
+ confidence = snapshot.confidence.value if hasattr(snapshot.confidence, "value") else str(snapshot.confidence)
105
+ allowed_confidence = {
106
+ Confidence.OFFICIAL.value,
107
+ Confidence.LOCAL_CLI.value,
108
+ Confidence.LOCAL_CONFIG.value,
109
+ Confidence.USER_CONFIGURED.value,
110
+ }
111
+ if confidence not in allowed_confidence:
112
+ return False, f"confidence_{confidence}"
113
+ if stale:
114
+ return False, "usage_stale"
115
+ for window in windows:
116
+ remaining = window["remaining_percent"]
117
+ if remaining is not None and remaining < min_remaining_percent:
118
+ return False, f"{window['name']}_below_{min_remaining_percent}_percent"
119
+ return True, None
120
+
121
+
122
+ def _next_available(rows: dict[str, dict[str, Any]]) -> tuple[str | None, str | None]:
123
+ candidates: list[tuple[datetime, str]] = []
124
+ now = datetime.now(timezone.utc)
125
+ for provider_id, row in rows.items():
126
+ if row["usable"]:
127
+ continue
128
+ for window in row["windows"]:
129
+ reset_at = window.get("reset_at")
130
+ if not reset_at:
131
+ continue
132
+ try:
133
+ parsed = datetime.fromisoformat(reset_at)
134
+ except ValueError:
135
+ continue
136
+ if parsed.tzinfo is None:
137
+ parsed = parsed.replace(tzinfo=timezone.utc)
138
+ if parsed > now:
139
+ candidates.append((parsed, provider_id))
140
+ if not candidates:
141
+ return None, None
142
+ reset_at, provider_id = min(candidates, key=lambda item: item[0])
143
+ return provider_id, reset_at.isoformat()
144
+
145
+
146
+ def _summary_text(snapshot: CapacitySnapshot, windows: list[dict[str, Any]]) -> str:
147
+ parts: list[str] = []
148
+ seen: set[tuple[str, float | None, float | None]] = set()
149
+ for window in windows:
150
+ label = window["kind"] if window["kind"] != "unknown" else window["name"]
151
+ remaining = window["remaining_percent"]
152
+ key = (label, remaining, window["remaining_units"])
153
+ if key in seen:
154
+ continue
155
+ seen.add(key)
156
+ if remaining is not None:
157
+ parts.append(f"{label} {remaining:g}% left")
158
+ elif window["remaining_units"] is not None:
159
+ parts.append(f"{label} {window['remaining_units']:g} units left")
160
+ if snapshot.credits_remaining is not None:
161
+ parts.append(f"${snapshot.credits_remaining:g} credits")
162
+ if parts:
163
+ return ", ".join(parts)
164
+ if snapshot.warnings:
165
+ return "; ".join(snapshot.warnings)
166
+ return snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status)
agentpool/utils.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def utc_now_iso() -> str:
14
+ return datetime.now(timezone.utc).isoformat()
15
+
16
+
17
+ def new_session_id() -> str:
18
+ return f"ap_{uuid.uuid4().hex[:12]}"
19
+
20
+
21
+ def repo_hash(path: Path) -> str:
22
+ return hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
23
+
24
+
25
+ def sha256_file(path: Path) -> str:
26
+ digest = hashlib.sha256()
27
+ with path.open("rb") as fh:
28
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
29
+ digest.update(chunk)
30
+ return digest.hexdigest()
31
+
32
+
33
+ def write_json(path: Path, data: Any) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_text(json.dumps(data, indent=2, sort_keys=True, default=str) + "\n")
36
+
37
+
38
+ def append_jsonl(path: Path, data: Any) -> None:
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ with path.open("a", encoding="utf-8") as fh:
41
+ fh.write(json.dumps(data, sort_keys=True, default=str) + "\n")
42
+
43
+
44
+ def run_capture(args: list[str], cwd: Path | None = None, timeout: float = 10) -> subprocess.CompletedProcess[str]:
45
+ try:
46
+ return subprocess.run(
47
+ args,
48
+ cwd=str(cwd) if cwd else None,
49
+ text=True,
50
+ capture_output=True,
51
+ timeout=timeout,
52
+ check=False,
53
+ )
54
+ except subprocess.TimeoutExpired as exc:
55
+ return subprocess.CompletedProcess(args, 124, exc.stdout or "", exc.stderr or "timed out")
56
+
57
+
58
+ def expand_user_path(value: str) -> Path:
59
+ return Path(os.path.expandvars(value)).expanduser()