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,130 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ import subprocess
7
+ from typing import Any
8
+
9
+ from agentpool.models import CapacitySnapshot, Confidence, UsageStatus, UsageWindow, UsageWindowKind
10
+ from agentpool.usage._common import (
11
+ ProbeError,
12
+ _clamp_percent,
13
+ _extract_json_payload,
14
+ _number,
15
+ _parse_datetime,
16
+ unavailable,
17
+ unknown,
18
+ )
19
+
20
+
21
+ def detect_ccusage(binary: str | None = None) -> dict[str, Any]:
22
+ command = _ccusage_command(binary)
23
+ if not command:
24
+ return {"installed": False, "path": None, "version": None, "safe_source": "local_claude_code_logs"}
25
+ version = None
26
+ try:
27
+ proc = subprocess.run([*command, "--version"], capture_output=True, text=True, timeout=5, check=False)
28
+ if proc.returncode == 0:
29
+ version = (proc.stdout or proc.stderr).strip().splitlines()[0][:200]
30
+ except (OSError, subprocess.TimeoutExpired):
31
+ version = None
32
+ return {
33
+ "installed": True,
34
+ "path": command[0],
35
+ "command": command,
36
+ "version": version,
37
+ "safe_source": "local_claude_code_logs",
38
+ }
39
+
40
+
41
+ def ccusage_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
42
+ if provider_id != "claude-code":
43
+ return unknown(provider_id, f"ccusage is only mapped for claude-code, not {provider_id}.", source="ccusage")
44
+ command = _ccusage_command(binary)
45
+ if not command:
46
+ return unavailable(
47
+ provider_id,
48
+ "ccusage CLI is not installed. Set AGENTPOOL_CCUSAGE_COMMAND to an explicit command if desired.",
49
+ )
50
+ try:
51
+ proc = subprocess.run(
52
+ [*command, "blocks", "--json", "--offline", "--active", "--no-color"],
53
+ capture_output=True,
54
+ text=True,
55
+ timeout=45,
56
+ check=False,
57
+ )
58
+ except (OSError, subprocess.TimeoutExpired) as exc:
59
+ return unknown(provider_id, f"ccusage probe failed: {exc}", source="ccusage")
60
+ text = "\n".join(part for part in [proc.stdout, proc.stderr] if part)
61
+ try:
62
+ payload = _extract_json_payload(text)
63
+ snapshot = parse_ccusage_blocks(provider_id, payload)
64
+ if proc.returncode != 0:
65
+ snapshot.warnings.append(f"ccusage exited with status {proc.returncode}.")
66
+ return snapshot
67
+ except ProbeError as exc:
68
+ return unknown(provider_id, f"ccusage probe failed: {exc}", source="ccusage")
69
+
70
+
71
+ def parse_ccusage_blocks(provider_id: str, payload: Any) -> CapacitySnapshot:
72
+ if not isinstance(payload, dict):
73
+ raise ProbeError("ccusage output must be a JSON object.")
74
+ blocks = payload.get("blocks")
75
+ if not isinstance(blocks, list):
76
+ raise ProbeError("ccusage output did not include blocks.")
77
+ active = None
78
+ for block in blocks:
79
+ if isinstance(block, dict) and block.get("isActive") is True and not block.get("isGap"):
80
+ active = block
81
+ if active is None:
82
+ raise ProbeError("ccusage output did not include an active usage block.")
83
+ projection = active.get("projection") if isinstance(active.get("projection"), dict) else {}
84
+ burn_rate = active.get("burnRate") if isinstance(active.get("burnRate"), dict) else {}
85
+ token_counts = active.get("tokenCounts") if isinstance(active.get("tokenCounts"), dict) else {}
86
+ token_limit_status = active.get("tokenLimitStatus") if isinstance(active.get("tokenLimitStatus"), dict) else {}
87
+ token_limit_used = _number(token_limit_status.get("percentUsed"))
88
+ window = UsageWindow(
89
+ name="active_block",
90
+ kind=UsageWindowKind.FIVE_HOUR,
91
+ status="active_local_log_block",
92
+ used_percent=_clamp_percent(token_limit_used) if token_limit_used is not None else None,
93
+ used_units=_number(active.get("totalTokens")),
94
+ reset_at=_parse_datetime(active.get("endTime")),
95
+ confidence=Confidence.OBSERVED,
96
+ raw_text="ccusage:blocks:active",
97
+ )
98
+ raw = {
99
+ "source": "ccusage_local_logs",
100
+ "block_id": active.get("id"),
101
+ "start_time": active.get("startTime"),
102
+ "actual_end_time": active.get("actualEndTime"),
103
+ "entries": active.get("entries"),
104
+ "cost_usd": _number(active.get("costUSD")),
105
+ "models": active.get("models") if isinstance(active.get("models"), list) else [],
106
+ "token_counts": token_counts,
107
+ "burn_rate": burn_rate,
108
+ "projection": projection,
109
+ "token_limit_status": token_limit_status,
110
+ }
111
+ return CapacitySnapshot(
112
+ provider_id=provider_id,
113
+ status=UsageStatus.UNKNOWN,
114
+ confidence=Confidence.OBSERVED,
115
+ windows=[window],
116
+ warnings=["ccusage reads local Claude Code logs; it is telemetry, not authoritative quota."],
117
+ raw=raw,
118
+ )
119
+
120
+
121
+ def _ccusage_command(binary: str | None = None) -> list[str] | None:
122
+ if binary:
123
+ return shlex.split(binary)
124
+ configured = os.environ.get("AGENTPOOL_CCUSAGE_COMMAND")
125
+ if configured:
126
+ return shlex.split(configured)
127
+ executable = shutil.which("ccusage")
128
+ if executable:
129
+ return [executable]
130
+ return None
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+
5
+ from agentpool.models import CapacitySnapshot
6
+ from agentpool.usage._common import _tmux_slash_usage_probe, unavailable
7
+ from agentpool.usage.provider_parsers import parse_claude_usage
8
+
9
+
10
+ def claude_code_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
11
+ executable = binary or shutil.which("claude")
12
+ if not executable:
13
+ return unavailable(provider_id, "Claude Code is not installed.")
14
+ return _tmux_slash_usage_probe(
15
+ provider_id=provider_id,
16
+ command=[executable, "--allowed-tools", ""],
17
+ slash_command="/usage",
18
+ parser=parse_claude_usage,
19
+ source="claude_pty_usage",
20
+ startup_delay=1.2,
21
+ timeout=18.0,
22
+ extra_keys_after_match=[["PageDown"]],
23
+ )
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import select
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from typing import Any
9
+
10
+ from agentpool.models import CapacitySnapshot, Confidence, UsageWindow, UsageWindowKind
11
+ from agentpool.usage._common import (
12
+ ProbeError,
13
+ _clamp_percent,
14
+ _epoch_seconds,
15
+ _int_number,
16
+ _number,
17
+ _safe_read_pipe,
18
+ _status_from_windows,
19
+ _terminate_process,
20
+ unavailable,
21
+ unknown,
22
+ )
23
+
24
+
25
+ def codex_cli_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
26
+ executable = binary or shutil.which("codex")
27
+ if not executable:
28
+ return unavailable(provider_id, "Codex CLI is not installed.")
29
+ try:
30
+ response = _codex_rpc_rate_limits(executable)
31
+ snapshot = parse_codex_rate_limits(provider_id, response)
32
+ snapshot.raw["source"] = "codex_app_server"
33
+ return snapshot
34
+ except ProbeError as exc:
35
+ recovered = _recover_codex_error_snapshot(provider_id, str(exc))
36
+ if recovered:
37
+ recovered.warnings.append("Codex app-server returned usage inside an error response.")
38
+ return recovered
39
+ return unknown(provider_id, f"Codex app-server usage probe failed: {exc}", source="codex_app_server")
40
+
41
+
42
+ def parse_codex_rate_limits(provider_id: str, payload: dict[str, Any]) -> CapacitySnapshot:
43
+ rate_limits = payload.get("rateLimits") if isinstance(payload.get("rateLimits"), dict) else payload
44
+ windows: list[UsageWindow] = []
45
+ for fallback_name, item in (("primary", rate_limits.get("primary")), ("secondary", rate_limits.get("secondary"))):
46
+ if not isinstance(item, dict):
47
+ continue
48
+ used = _number(item.get("usedPercent"))
49
+ if used is None:
50
+ continue
51
+ duration = _int_number(item.get("windowDurationMins"))
52
+ name = _codex_window_name(fallback_name, duration)
53
+ reset_at = _epoch_seconds(item.get("resetsAt"))
54
+ windows.append(
55
+ UsageWindow(
56
+ name=name,
57
+ kind=_codex_window_kind(name),
58
+ used_percent=_clamp_percent(used),
59
+ remaining_percent=_clamp_percent(100.0 - used),
60
+ reset_at=reset_at,
61
+ confidence=Confidence.OFFICIAL,
62
+ raw_text=f"{fallback_name}:{duration or 'unknown'}",
63
+ )
64
+ )
65
+ credits_remaining = None
66
+ credits = rate_limits.get("credits")
67
+ if isinstance(credits, dict) and not credits.get("unlimited"):
68
+ credits_remaining = _number(credits.get("balance"))
69
+ if not windows and credits_remaining is None:
70
+ raise ProbeError("No rate-limit windows or credits in Codex response.")
71
+ return CapacitySnapshot(
72
+ provider_id=provider_id,
73
+ status=_status_from_windows(windows),
74
+ confidence=Confidence.OFFICIAL,
75
+ windows=windows,
76
+ credits_remaining=credits_remaining,
77
+ raw={"credits": _safe_credit_summary(credits) if isinstance(credits, dict) else None},
78
+ )
79
+
80
+
81
+ def _codex_rpc_rate_limits(executable: str) -> dict[str, Any]:
82
+ proc = subprocess.Popen(
83
+ [executable, "-s", "read-only", "-a", "untrusted", "app-server"],
84
+ stdin=subprocess.PIPE,
85
+ stdout=subprocess.PIPE,
86
+ stderr=subprocess.PIPE,
87
+ text=True,
88
+ )
89
+ try:
90
+ _json_rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "agentpool", "version": "0.1.0"}}, 8.0)
91
+ _json_rpc_notify(proc, "initialized")
92
+ return _json_rpc_request(proc, 2, "account/rateLimits/read", {}, 4.0)
93
+ finally:
94
+ _terminate_process(proc)
95
+
96
+
97
+ def _json_rpc_request(
98
+ proc: subprocess.Popen[str],
99
+ request_id: int,
100
+ method: str,
101
+ params: dict[str, Any],
102
+ timeout: float,
103
+ ) -> dict[str, Any]:
104
+ _write_json_line(proc, {"id": request_id, "method": method, "params": params})
105
+ deadline = time.monotonic() + timeout
106
+ while time.monotonic() < deadline:
107
+ if proc.poll() is not None:
108
+ stderr = _safe_read_pipe(proc.stderr)
109
+ raise ProbeError(f"codex app-server exited during {method}: {stderr}".strip())
110
+ remaining = max(0.05, deadline - time.monotonic())
111
+ assert proc.stdout is not None
112
+ ready, _, _ = select.select([proc.stdout], [], [], remaining)
113
+ if not ready:
114
+ continue
115
+ line = proc.stdout.readline()
116
+ if not line:
117
+ continue
118
+ try:
119
+ message = json.loads(line)
120
+ except json.JSONDecodeError:
121
+ continue
122
+ if message.get("id") != request_id:
123
+ continue
124
+ if "error" in message:
125
+ error = message["error"]
126
+ raise ProbeError(error.get("message", str(error)) if isinstance(error, dict) else str(error))
127
+ result = message.get("result")
128
+ if not isinstance(result, dict):
129
+ raise ProbeError(f"JSON-RPC {method} returned no object result.")
130
+ return result
131
+ _terminate_process(proc)
132
+ raise ProbeError(f"codex app-server timed out during {method}.")
133
+
134
+
135
+ def _json_rpc_notify(proc: subprocess.Popen[str], method: str) -> None:
136
+ _write_json_line(proc, {"method": method, "params": {}})
137
+
138
+
139
+ def _write_json_line(proc: subprocess.Popen[str], payload: dict[str, Any]) -> None:
140
+ if proc.stdin is None:
141
+ raise ProbeError("process stdin is unavailable")
142
+ proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
143
+ proc.stdin.flush()
144
+
145
+
146
+ def _recover_codex_error_snapshot(provider_id: str, message: str) -> CapacitySnapshot | None:
147
+ start = message.find("{")
148
+ end = message.rfind("}")
149
+ if start < 0 or end <= start:
150
+ return None
151
+ try:
152
+ payload = json.loads(message[start : end + 1])
153
+ except json.JSONDecodeError:
154
+ return None
155
+ rate_limit = payload.get("rate_limit")
156
+ if not isinstance(rate_limit, dict):
157
+ return None
158
+ windows = []
159
+ details = rate_limit.get("details") if isinstance(rate_limit.get("details"), list) else []
160
+ for item in details:
161
+ if not isinstance(item, dict):
162
+ continue
163
+ used = _number(item.get("used_percent"))
164
+ seconds = _int_number(item.get("limit_window_seconds"))
165
+ reset = _epoch_seconds(item.get("reset_at"))
166
+ if used is None:
167
+ continue
168
+ windows.append(
169
+ UsageWindow(
170
+ name=_codex_window_name("window", int(seconds / 60) if seconds else None),
171
+ kind=_codex_window_kind(_codex_window_name("window", int(seconds / 60) if seconds else None)),
172
+ used_percent=_clamp_percent(used),
173
+ remaining_percent=_clamp_percent(100.0 - used),
174
+ reset_at=reset,
175
+ confidence=Confidence.OFFICIAL,
176
+ )
177
+ )
178
+ if not windows:
179
+ return None
180
+ return CapacitySnapshot(
181
+ provider_id=provider_id,
182
+ status=_status_from_windows(windows),
183
+ confidence=Confidence.OFFICIAL,
184
+ windows=windows,
185
+ raw={"source": "codex_app_server_error_body"},
186
+ )
187
+
188
+
189
+ def _codex_window_name(fallback: str, duration_mins: int | None) -> str:
190
+ if duration_mins == 300:
191
+ return "5h"
192
+ if duration_mins == 10080:
193
+ return "weekly"
194
+ return fallback
195
+
196
+
197
+ def _codex_window_kind(name: str) -> UsageWindowKind:
198
+ if name == "5h":
199
+ return UsageWindowKind.FIVE_HOUR
200
+ if name == "weekly":
201
+ return UsageWindowKind.WEEKLY
202
+ return UsageWindowKind.UNKNOWN
203
+
204
+
205
+ def _safe_credit_summary(credits: dict[str, Any]) -> dict[str, Any]:
206
+ return {
207
+ "hasCredits": bool(credits.get("hasCredits")),
208
+ "unlimited": bool(credits.get("unlimited")),
209
+ "hasBalance": credits.get("balance") is not None,
210
+ }
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from agentpool.models import CapacitySnapshot, Confidence, UsageWindow, UsageWindowKind
8
+ from agentpool.usage._common import (
9
+ ProbeError,
10
+ _clamp_percent,
11
+ _duration_window_kind,
12
+ _extract_json_payload,
13
+ _int_number,
14
+ _number,
15
+ _parse_datetime,
16
+ _status_from_windows,
17
+ _clean_optional_string,
18
+ unavailable,
19
+ unknown,
20
+ )
21
+
22
+ CODEXBAR_PROVIDER_MAP = {
23
+ "claude-code": "claude",
24
+ "codex-cli": "codex",
25
+ "copilot-cli": "copilot",
26
+ "cursor-cli": "cursor",
27
+ "opencode": "opencode",
28
+ }
29
+
30
+ CODEXBAR_SAFE_SOURCE_MAP = {
31
+ "claude-code": "cli",
32
+ "codex-cli": "cli",
33
+ "copilot-cli": "api",
34
+ "cursor-cli": "cli",
35
+ "opencode": "api",
36
+ }
37
+
38
+
39
+ def detect_codexbar(binary: str | None = None) -> dict[str, Any]:
40
+ executable = binary or shutil.which("codexbar")
41
+ if not executable:
42
+ return {
43
+ "installed": False,
44
+ "path": None,
45
+ "version": None,
46
+ "supported_agentpool_providers": sorted(CODEXBAR_PROVIDER_MAP),
47
+ "safe_sources": CODEXBAR_SAFE_SOURCE_MAP,
48
+ }
49
+ version = None
50
+ try:
51
+ proc = subprocess.run([executable, "--version"], capture_output=True, text=True, timeout=3, check=False)
52
+ if proc.returncode == 0:
53
+ version = (proc.stdout or proc.stderr).strip().splitlines()[0][:200]
54
+ except (OSError, subprocess.TimeoutExpired):
55
+ version = None
56
+ return {
57
+ "installed": True,
58
+ "path": executable,
59
+ "version": version,
60
+ "supported_agentpool_providers": sorted(CODEXBAR_PROVIDER_MAP),
61
+ "safe_sources": CODEXBAR_SAFE_SOURCE_MAP,
62
+ }
63
+
64
+
65
+ def codexbar_usage_snapshot(
66
+ provider_id: str,
67
+ binary: str | None = None,
68
+ source: str | None = None,
69
+ ) -> CapacitySnapshot:
70
+ executable = binary or shutil.which("codexbar")
71
+ if not executable:
72
+ return unavailable(provider_id, "CodexBar CLI is not installed.")
73
+ codexbar_provider = CODEXBAR_PROVIDER_MAP.get(provider_id)
74
+ if not codexbar_provider:
75
+ return unknown(
76
+ provider_id,
77
+ f"CodexBar does not expose a safe mapped usage provider for {provider_id}.",
78
+ source="codexbar",
79
+ )
80
+ safe_source = source or CODEXBAR_SAFE_SOURCE_MAP.get(provider_id)
81
+ if not safe_source:
82
+ return unknown(provider_id, f"No safe CodexBar source is configured for {provider_id}.", source="codexbar")
83
+ command = [
84
+ executable,
85
+ "usage",
86
+ "--provider",
87
+ codexbar_provider,
88
+ "--source",
89
+ safe_source,
90
+ "--format",
91
+ "json",
92
+ "--json-only",
93
+ "--no-color",
94
+ ]
95
+ try:
96
+ proc = subprocess.run(command, capture_output=True, text=True, timeout=45, check=False)
97
+ except (OSError, subprocess.TimeoutExpired) as exc:
98
+ return unknown(provider_id, f"CodexBar usage probe failed: {exc}", source="codexbar")
99
+ text = "\n".join(part for part in [proc.stdout, proc.stderr] if part)
100
+ try:
101
+ payload = _extract_json_payload(text)
102
+ snapshot = parse_codexbar_usage(provider_id, payload, expected_provider=codexbar_provider)
103
+ snapshot.raw["source"] = "codexbar"
104
+ snapshot.raw["codexbar_provider"] = codexbar_provider
105
+ snapshot.raw["codexbar_source"] = safe_source
106
+ if proc.returncode != 0:
107
+ snapshot.warnings.append(f"CodexBar exited with status {proc.returncode}.")
108
+ return snapshot
109
+ except ProbeError as exc:
110
+ return unknown(provider_id, f"CodexBar usage probe failed: {exc}", source="codexbar")
111
+
112
+
113
+ def parse_codexbar_usage(
114
+ provider_id: str,
115
+ payload: Any,
116
+ expected_provider: str | None = None,
117
+ ) -> CapacitySnapshot:
118
+ entries = payload if isinstance(payload, list) else [payload]
119
+ entry = None
120
+ for item in entries:
121
+ if not isinstance(item, dict):
122
+ continue
123
+ if expected_provider is None or item.get("provider") == expected_provider:
124
+ entry = item
125
+ break
126
+ if entry is None:
127
+ raise ProbeError("CodexBar output did not include the requested provider.")
128
+ error = entry.get("error")
129
+ if isinstance(error, dict):
130
+ raise ProbeError(_clean_optional_string(error.get("message")) or str(error))
131
+ usage = entry.get("usage")
132
+ if not isinstance(usage, dict):
133
+ raise ProbeError("CodexBar output did not include a usage object.")
134
+ windows = []
135
+ for name in ("primary", "secondary", "tertiary"):
136
+ item = usage.get(name)
137
+ if not isinstance(item, dict):
138
+ continue
139
+ used = _number(item.get("usedPercent"))
140
+ remaining = _number(item.get("remainingPercent"))
141
+ if used is None and remaining is None:
142
+ continue
143
+ if remaining is None and used is not None:
144
+ remaining = 100.0 - used
145
+ if used is None and remaining is not None:
146
+ used = 100.0 - remaining
147
+ duration = _int_number(item.get("windowMinutes"))
148
+ windows.append(
149
+ UsageWindow(
150
+ name=_codexbar_window_name(name, duration),
151
+ kind=_duration_window_kind(duration),
152
+ status=name,
153
+ used_percent=_clamp_percent(used) if used is not None else None,
154
+ remaining_percent=_clamp_percent(remaining) if remaining is not None else None,
155
+ reset_at=_parse_datetime(item.get("resetsAt")),
156
+ confidence=Confidence.LOCAL_CLI,
157
+ raw_text=f"codexbar:{name}:{duration or 'unknown'}",
158
+ )
159
+ )
160
+ credits_remaining = None
161
+ credits = entry.get("credits")
162
+ if isinstance(credits, dict):
163
+ credits_remaining = _number(credits.get("remaining"))
164
+ if not windows and credits_remaining is None:
165
+ raise ProbeError("CodexBar output did not include parseable windows or credits.")
166
+ raw: dict[str, Any] = {
167
+ "codexbar_source": entry.get("source"),
168
+ "version": entry.get("version"),
169
+ "login_method": _clean_optional_string(usage.get("loginMethod")),
170
+ "account_email": _clean_optional_string(usage.get("accountEmail")),
171
+ }
172
+ return CapacitySnapshot(
173
+ provider_id=provider_id,
174
+ status=_status_from_windows(windows),
175
+ confidence=Confidence.LOCAL_CLI,
176
+ windows=windows,
177
+ credits_remaining=credits_remaining,
178
+ raw={key: value for key, value in raw.items() if value is not None},
179
+ )
180
+
181
+
182
+ def _codexbar_window_name(fallback: str, duration_mins: int | None) -> str:
183
+ kind = _duration_window_kind(duration_mins)
184
+ if kind != UsageWindowKind.UNKNOWN:
185
+ return kind.value
186
+ return fallback
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from agentpool.models import CapacitySnapshot, UsageStatus, UsageWindow
6
+ from agentpool.usage._common import _status_from_windows
7
+
8
+
9
+ def combine_usage_snapshots(
10
+ native: CapacitySnapshot,
11
+ codexbar: CapacitySnapshot,
12
+ ccusage: CapacitySnapshot | None = None,
13
+ ) -> CapacitySnapshot:
14
+ if codexbar.status in {UsageStatus.UNKNOWN, UsageStatus.UNAVAILABLE, UsageStatus.UNAUTHENTICATED}:
15
+ snapshot = native.model_copy(deep=True)
16
+ snapshot.raw["alternate_sources"] = [_usage_source_summary(codexbar)]
17
+ if codexbar.warnings:
18
+ snapshot.warnings.extend(f"CodexBar: {warning}" for warning in codexbar.warnings)
19
+ return _add_ccusage_enrichment(snapshot, ccusage)
20
+ if native.status in {UsageStatus.UNKNOWN, UsageStatus.UNAVAILABLE, UsageStatus.UNAUTHENTICATED}:
21
+ snapshot = codexbar.model_copy(deep=True)
22
+ snapshot.raw["source"] = "combined"
23
+ snapshot.raw["primary_source"] = "codexbar"
24
+ snapshot.raw["alternate_sources"] = [_usage_source_summary(native)]
25
+ if native.warnings:
26
+ snapshot.warnings.extend(f"Native probe: {warning}" for warning in native.warnings)
27
+ return _add_ccusage_enrichment(snapshot, ccusage)
28
+ snapshot = native.model_copy(deep=True)
29
+ existing = {_usage_window_key(window) for window in snapshot.windows}
30
+ for window in codexbar.windows:
31
+ key = _usage_window_key(window)
32
+ if key not in existing:
33
+ snapshot.windows.append(window)
34
+ existing.add(key)
35
+ if snapshot.credits_remaining is None and codexbar.credits_remaining is not None:
36
+ snapshot.credits_remaining = codexbar.credits_remaining
37
+ snapshot.status = _status_from_windows(snapshot.windows)
38
+ snapshot.raw["source"] = "combined"
39
+ snapshot.raw["primary_source"] = native.raw.get("source", "native")
40
+ snapshot.raw["alternate_sources"] = [_usage_source_summary(codexbar)]
41
+ return _add_ccusage_enrichment(snapshot, ccusage)
42
+
43
+
44
+ def _add_ccusage_enrichment(
45
+ snapshot: CapacitySnapshot,
46
+ ccusage: CapacitySnapshot | None = None,
47
+ ) -> CapacitySnapshot:
48
+ if ccusage is None:
49
+ return snapshot
50
+ enriched = snapshot.model_copy(deep=True)
51
+ enriched.raw.setdefault("alternate_sources", [])
52
+ enriched.raw["alternate_sources"].append(_usage_source_summary(ccusage))
53
+ if ccusage.status in {UsageStatus.UNAVAILABLE, UsageStatus.UNAUTHENTICATED}:
54
+ return enriched
55
+ if ccusage.warnings:
56
+ enriched.warnings.extend(f"ccusage: {warning}" for warning in ccusage.warnings)
57
+ enriched.raw["ccusage"] = ccusage.raw
58
+ return enriched
59
+
60
+
61
+ def _usage_window_key(window: UsageWindow) -> tuple[str, str, str | None]:
62
+ return (window.kind.value, window.name, window.reset_at.isoformat() if window.reset_at else None)
63
+
64
+
65
+ def _usage_source_summary(snapshot: CapacitySnapshot) -> dict[str, Any]:
66
+ return {
67
+ "source": snapshot.raw.get("source"),
68
+ "status": snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status),
69
+ "confidence": snapshot.confidence.value if hasattr(snapshot.confidence, "value") else str(snapshot.confidence),
70
+ "windows": len(snapshot.windows),
71
+ }