agent-peer 0.2.1__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.
agent_peer/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agent-peer: Real-time IPC gateway between Claude Code sessions and external agents."""
2
+
3
+ __version__ = "0.2.1"
agent_peer/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
agent_peer/agy_live.py ADDED
@@ -0,0 +1,102 @@
1
+ """On-demand fetch of agy's 5-hour quota via Google's internal
2
+ fetchAvailableModels API. See docs/status.md for the full rationale."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import subprocess
9
+ import urllib.error
10
+ import urllib.request
11
+ from datetime import datetime, timezone
12
+
13
+ QUOTA_API = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"
14
+ KEYCHAIN_PREFIX = "go-keyring-base64:"
15
+
16
+ # Required so the backend recognizes the caller as Antigravity — without
17
+ # these, Gemini's own numbers still come back but Claude/GPT models 404.
18
+ _HEADERS = {
19
+ "Content-Type": "application/json",
20
+ "User-Agent": (
21
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
22
+ "Antigravity/1.0.0 Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36"
23
+ ),
24
+ "Client-Metadata": '{"ideType":"ANTIGRAVITY","platform":"WINDOWS","pluginType":"GEMINI"}',
25
+ }
26
+
27
+ _GEMINI_PROVIDER = "API_PROVIDER_GOOGLE_GEMINI"
28
+ _THIRD_PARTY_PROVIDERS = {"API_PROVIDER_ANTHROPIC_VERTEX", "API_PROVIDER_OPENAI_VERTEX"}
29
+
30
+
31
+ def _read_agy_access_token() -> str | None:
32
+ try:
33
+ raw = subprocess.run(
34
+ ["security", "find-generic-password", "-s", "gemini", "-a", "antigravity", "-w"],
35
+ capture_output=True, text=True, timeout=5,
36
+ )
37
+ if raw.returncode != 0 or not raw.stdout.strip():
38
+ return None
39
+ val = raw.stdout.strip()
40
+ token_raw = (
41
+ base64.b64decode(val[len(KEYCHAIN_PREFIX):]).decode("utf-8")
42
+ if val.startswith(KEYCHAIN_PREFIX)
43
+ else val
44
+ )
45
+ return json.loads(token_raw)["token"]["access_token"]
46
+ except Exception:
47
+ return None
48
+
49
+
50
+ def _reset_in_seconds(reset_time: str | None) -> int | None:
51
+ if not reset_time:
52
+ return None
53
+ try:
54
+ dt = datetime.strptime(reset_time, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
55
+ except ValueError:
56
+ return None
57
+ return max(0, int((dt - datetime.now(timezone.utc)).total_seconds()))
58
+
59
+
60
+ def fetch_live_5h_quota() -> tuple[dict | None, str | None]:
61
+ """Returns ({"gemini_5h": {...} | None, "claude_gpt_5h": {...} | None}, None)
62
+ on a successful call, or (None, error) if anything went wrong."""
63
+ access_token = _read_agy_access_token()
64
+ if not access_token:
65
+ return None, "No agy OAuth token found in keychain."
66
+
67
+ req = urllib.request.Request(
68
+ QUOTA_API,
69
+ data=b"{}",
70
+ headers={**_HEADERS, "Authorization": f"Bearer {access_token}"},
71
+ method="POST",
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=10) as resp:
75
+ data = json.loads(resp.read().decode())
76
+ except urllib.error.HTTPError as e:
77
+ return None, f"fetchAvailableModels failed: HTTP {e.code}"
78
+ except Exception as e:
79
+ return None, f"fetchAvailableModels failed: {e}"
80
+
81
+ # Keep the most-constrained model per group (lowest remainingFraction) -
82
+ # a quota-exempt model reports 100%/no-reset, masking a partially-used pool.
83
+ buckets: dict[str, list[dict]] = {"gemini_5h": [], "claude_gpt_5h": []}
84
+ for model in (data.get("models") or {}).values():
85
+ quota_info = model.get("quotaInfo")
86
+ if not quota_info:
87
+ continue
88
+ entry = {
89
+ "remaining_pct": round((quota_info.get("remainingFraction") or 0) * 100, 1),
90
+ "resets_in_s": _reset_in_seconds(quota_info.get("resetTime")),
91
+ }
92
+ provider = model.get("apiProvider", "")
93
+ if provider == _GEMINI_PROVIDER:
94
+ buckets["gemini_5h"].append(entry)
95
+ elif provider in _THIRD_PARTY_PROVIDERS:
96
+ buckets["claude_gpt_5h"].append(entry)
97
+
98
+ result = {
99
+ key: (min(entries, key=lambda e: e["remaining_pct"]) if entries else None)
100
+ for key, entries in buckets.items()
101
+ }
102
+ return result, None
@@ -0,0 +1,148 @@
1
+ """Reads agy's cached quota/context snapshot and freshens the 5h quota
2
+ numbers live (see agy_live.py). See docs/status.md for the full rationale."""
3
+
4
+ import os
5
+ import time
6
+ from datetime import datetime, timezone
7
+ import json
8
+
9
+ from . import agy_live
10
+ from .protocol import AGENT_PEER_DIR
11
+
12
+ AGY_STATUS_FILE = os.path.join(AGENT_PEER_DIR, "agy_status.json")
13
+
14
+ STALE_AFTER_SECONDS = 15 * 60 # agy only writes this when it actually renders a statusline
15
+
16
+ # Raw payload keys -> the short, JSON-friendly names used in the trimmed dict.
17
+ _QUOTA_KEYS = {
18
+ "gemini_5h": "gemini-5h",
19
+ "gemini_weekly": "gemini-weekly",
20
+ "claude_gpt_5h": "3p-5h",
21
+ "claude_gpt_weekly": "3p-weekly",
22
+ }
23
+
24
+
25
+ def read_agy_status():
26
+ """Returns (payload: dict | None, age_seconds: float | None, error: str | None)."""
27
+ if not os.path.exists(AGY_STATUS_FILE):
28
+ return None, None, "No agy status captured yet — open an interactive `agy` session at least once."
29
+ try:
30
+ with open(AGY_STATUS_FILE, "r", encoding="utf-8") as f:
31
+ data = json.load(f)
32
+ except (OSError, json.JSONDecodeError) as e:
33
+ return None, None, f"Could not read {AGY_STATUS_FILE}: {e}"
34
+
35
+ captured_at = data.get("captured_at")
36
+ age_seconds = None
37
+ if captured_at:
38
+ try:
39
+ dt = datetime.strptime(captured_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
40
+ age_seconds = time.time() - dt.timestamp()
41
+ except ValueError:
42
+ pass
43
+
44
+ return data.get("payload"), age_seconds, None
45
+
46
+
47
+ def _fmt_age(seconds):
48
+ if seconds is None:
49
+ return "unknown"
50
+ if seconds < 60:
51
+ return f"{int(seconds)}s ago"
52
+ if seconds < 3600:
53
+ return f"{int(seconds // 60)}m ago"
54
+ return f"{seconds / 3600:.1f}h ago"
55
+
56
+
57
+ def fmt_duration(seconds):
58
+ if seconds is None:
59
+ return "?"
60
+ seconds = int(seconds)
61
+ h, m = seconds // 3600, (seconds % 3600) // 60
62
+ return f"{h}h{m}m" if h else f"{m}m"
63
+
64
+
65
+ def get_agy_status_dict() -> dict:
66
+ """JSON shape where remaining_pct is directly comparable to
67
+ get_claude_status_dict()'s, regardless of each provider's raw convention."""
68
+ payload, age_seconds, error = read_agy_status()
69
+ if error:
70
+ return {"error": error}
71
+
72
+ ctx = payload.get("context_window") or {}
73
+ raw_quota = payload.get("quota") or {}
74
+
75
+ quota = {}
76
+ for short_name, raw_key in _QUOTA_KEYS.items():
77
+ entry = raw_quota.get(raw_key)
78
+ quota[short_name] = (
79
+ {
80
+ "remaining_pct": round((entry.get("remaining_fraction") or 0) * 100, 1),
81
+ "resets_in_s": entry.get("reset_in_seconds"),
82
+ "source": "cached",
83
+ }
84
+ if entry
85
+ else None
86
+ )
87
+
88
+ # Freshen just the 5h numbers live; weekly + context have no API source.
89
+ live, live_error = agy_live.fetch_live_5h_quota()
90
+ if live:
91
+ for key in ("gemini_5h", "claude_gpt_5h"):
92
+ if live.get(key):
93
+ quota[key] = {**live[key], "source": "live"}
94
+
95
+ return {
96
+ "email": payload.get("email"),
97
+ "plan": payload.get("plan_tier"),
98
+ "model": (payload.get("model") or {}).get("display_name"),
99
+ "snapshot_age_s": round(age_seconds, 1) if age_seconds is not None else None,
100
+ "live_5h_fetch_error": live_error,
101
+ "context": {
102
+ "used_pct": ctx.get("used_percentage", 0),
103
+ "input_tokens": ctx.get("total_input_tokens", 0),
104
+ "output_tokens": ctx.get("total_output_tokens", 0),
105
+ "window_size": ctx.get("context_window_size", 0),
106
+ },
107
+ "quota": quota,
108
+ }
109
+
110
+
111
+ def format_agy_status(as_json: bool = False) -> str:
112
+ status = get_agy_status_dict()
113
+ if "error" in status:
114
+ return status["error"]
115
+ if as_json:
116
+ return json.dumps(status, indent=2)
117
+
118
+ age_s = status["snapshot_age_s"]
119
+ ctx = status["context"]
120
+ lines = [
121
+ f"agy status (email: {status['email'] or '?'} · plan: {status['plan'] or '?'} · model: {status['model'] or '?'})",
122
+ f" snapshot age: {_fmt_age(age_s)}"
123
+ + (" ⚠ stale (agy not run recently)" if (age_s or 0) > STALE_AFTER_SECONDS else ""),
124
+ "",
125
+ f" context: {ctx['used_pct']:.1f}% used "
126
+ f"({ctx['input_tokens']} in / {ctx['output_tokens']} out / {ctx['window_size']} window)"
127
+ " [cached, no live source]",
128
+ "",
129
+ " quota:",
130
+ ]
131
+ labels = {
132
+ "gemini_5h": "gemini 5h ",
133
+ "gemini_weekly": "gemini week",
134
+ "claude_gpt_5h": "claude/gpt 5h",
135
+ "claude_gpt_weekly": "claude/gpt wk",
136
+ }
137
+ for key, label in labels.items():
138
+ q = status["quota"].get(key)
139
+ if not q:
140
+ lines.append(f" {label} n/a")
141
+ continue
142
+ tag = "live" if q.get("source") == "live" else "cached"
143
+ lines.append(
144
+ f" {label} {q['remaining_pct']:>5.1f}% remaining (resets in {fmt_duration(q['resets_in_s'])}) [{tag}]"
145
+ )
146
+ if status.get("live_5h_fetch_error"):
147
+ lines.append(f"\n ⚠ live 5h refresh failed, showing cached numbers instead: {status['live_5h_fetch_error']}")
148
+ return "\n".join(lines)
@@ -0,0 +1,195 @@
1
+ """Fetches Claude Code's own usage/quota from Anthropic's API using its
2
+ OAuth token from the macOS Keychain. See docs/status.md for the rationale."""
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import unicodedata
9
+ import urllib.error
10
+ import urllib.request
11
+ from pathlib import Path
12
+
13
+ USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
14
+ TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
15
+ CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
16
+ DEFAULT_KEYCHAIN_SERVICE = "Claude Code-credentials"
17
+ CREDENTIALS_FILE = Path.home() / ".claude" / ".credentials.json"
18
+
19
+
20
+ def _hashed_keychain_service(config_dir: str) -> str:
21
+ """Mirrors Claude Code's own hashing of CLAUDE_CONFIG_DIR - must hash the
22
+ exact exported string, not a resolved path, or it names an empty keychain item."""
23
+ normalized = unicodedata.normalize("NFC", config_dir)
24
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:8]
25
+ return f"{DEFAULT_KEYCHAIN_SERVICE}-{digest}"
26
+
27
+
28
+ def _active_keychain_services() -> list[str]:
29
+ """Which keychain service holds this environment's active credential,
30
+ following the same CLAUDE_CONFIG_DIR resolution order Claude Code itself uses."""
31
+ secure_env = os.environ.get("CLAUDE_SECURESTORAGE_CONFIG_DIR")
32
+ if secure_env is not None:
33
+ return [DEFAULT_KEYCHAIN_SERVICE] if not secure_env else [_hashed_keychain_service(secure_env)]
34
+
35
+ config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
36
+ if not config_dir:
37
+ return [DEFAULT_KEYCHAIN_SERVICE]
38
+ return [_hashed_keychain_service(config_dir), DEFAULT_KEYCHAIN_SERVICE]
39
+
40
+
41
+ def _read_keychain(service: str) -> dict | None:
42
+ try:
43
+ user = os.environ.get("USER") or __import__("pwd").getpwuid(os.geteuid()).pw_name
44
+ raw = subprocess.run(
45
+ ["security", "find-generic-password", "-s", service, "-a", user, "-w"],
46
+ capture_output=True, text=True, timeout=5,
47
+ )
48
+ if raw.returncode == 0 and raw.stdout.strip():
49
+ return json.loads(raw.stdout).get("claudeAiOauth")
50
+ except Exception:
51
+ pass
52
+ return None
53
+
54
+
55
+ def _read_credentials() -> dict | None:
56
+ for service in _active_keychain_services():
57
+ oauth = _read_keychain(service)
58
+ if oauth:
59
+ return oauth
60
+ try:
61
+ return json.loads(CREDENTIALS_FILE.read_text()).get("claudeAiOauth")
62
+ except (OSError, json.JSONDecodeError):
63
+ return None
64
+
65
+
66
+ def _refresh(refresh_token: str) -> str | None:
67
+ body = json.dumps(
68
+ {"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CLIENT_ID}
69
+ ).encode()
70
+ req = urllib.request.Request(
71
+ TOKEN_URL, data=body, headers={"Content-Type": "application/json"}, method="POST"
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=10) as resp:
75
+ return json.loads(resp.read().decode()).get("access_token")
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def _request_usage(access_token: str) -> dict:
81
+ req = urllib.request.Request(
82
+ USAGE_URL,
83
+ headers={
84
+ "Authorization": f"Bearer {access_token}",
85
+ "anthropic-beta": "oauth-2025-04-20",
86
+ "User-Agent": "agent-peer/0.1.0",
87
+ },
88
+ )
89
+ with urllib.request.urlopen(req, timeout=10) as resp:
90
+ return json.loads(resp.read().decode())
91
+
92
+
93
+ def _fetch_email(access_token: str) -> str | None:
94
+ """Whose token this is — confirms which active account got read, rather
95
+ than trusting the keychain-slot resolution silently."""
96
+ req = urllib.request.Request(
97
+ "https://api.anthropic.com/api/oauth/profile",
98
+ headers={"Authorization": f"Bearer {access_token}", "User-Agent": "agent-peer/0.1.0"},
99
+ )
100
+ try:
101
+ with urllib.request.urlopen(req, timeout=5) as resp:
102
+ account = json.loads(resp.read().decode()).get("account") or {}
103
+ return account.get("email")
104
+ except Exception:
105
+ return None
106
+
107
+
108
+ def fetch_claude_usage():
109
+ """Returns (usage: dict | None, meta: dict | None, error: str | None)."""
110
+ oauth = _read_credentials()
111
+ if not oauth or not oauth.get("accessToken"):
112
+ return None, None, "No Claude Code credentials found (keychain or ~/.claude/.credentials.json)."
113
+
114
+ access_token = oauth["accessToken"]
115
+ try:
116
+ data = _request_usage(access_token)
117
+ except urllib.error.HTTPError as e:
118
+ if e.code == 401 and oauth.get("refreshToken"):
119
+ fresh = _refresh(oauth["refreshToken"])
120
+ if not fresh:
121
+ return None, None, "Access token expired and refresh failed."
122
+ access_token = fresh
123
+ try:
124
+ data = _request_usage(access_token)
125
+ except Exception as e2:
126
+ return None, None, f"Usage fetch failed after refresh: {e2}"
127
+ else:
128
+ return None, None, f"Usage fetch failed: HTTP {e.code}"
129
+ except Exception as e:
130
+ return None, None, f"Usage fetch failed: {e}"
131
+
132
+ meta = {"subscription_type": oauth.get("subscriptionType"), "email": _fetch_email(access_token)}
133
+ return data, meta, None
134
+
135
+
136
+ def _reset_in_seconds(resets_at: str | None) -> int | None:
137
+ if not resets_at:
138
+ return None
139
+ from datetime import datetime, timezone
140
+
141
+ try:
142
+ dt = datetime.fromisoformat(resets_at)
143
+ except ValueError:
144
+ return None
145
+ return max(0, int((dt - datetime.now(timezone.utc)).total_seconds()))
146
+
147
+
148
+ def get_claude_status_dict() -> dict:
149
+ """JSON shape normalized to remaining_pct, flipping Anthropic's raw
150
+ utilization% and dropping unreleased-feature placeholder fields."""
151
+ data, meta, error = fetch_claude_usage()
152
+ if error:
153
+ return {"error": error}
154
+
155
+ def window(entry: dict) -> dict:
156
+ used_pct = entry.get("utilization") or 0
157
+ return {
158
+ "remaining_pct": round(100 - used_pct, 1),
159
+ "resets_in_s": _reset_in_seconds(entry.get("resets_at")),
160
+ }
161
+
162
+ quota = {
163
+ "session_5h": window(data.get("five_hour") or {}),
164
+ "weekly_all": window(data.get("seven_day") or {}),
165
+ }
166
+ for lim in data.get("limits") or []:
167
+ if lim.get("kind") != "weekly_scoped":
168
+ continue
169
+ model = ((lim.get("scope") or {}).get("model") or {}).get("display_name")
170
+ if not model:
171
+ continue
172
+ quota[f"weekly_{model.lower()}"] = window({"utilization": lim.get("percent"), "resets_at": lim.get("resets_at")})
173
+
174
+ return {
175
+ "email": meta.get("email"),
176
+ "plan": meta.get("subscription_type"),
177
+ "quota": quota,
178
+ }
179
+
180
+
181
+ def format_claude_status(as_json: bool = False) -> str:
182
+ from .agy_status import fmt_duration
183
+
184
+ status = get_claude_status_dict()
185
+ if "error" in status:
186
+ return status["error"]
187
+ if as_json:
188
+ return json.dumps(status, indent=2)
189
+
190
+ lines = [f"claude status (email: {status['email'] or '?'} · plan: {status['plan'] or '?'})", "", " quota:"]
191
+ labels = {"session_5h": "session 5h ", "weekly_all": "weekly all "}
192
+ for key, q in status["quota"].items():
193
+ label = labels.get(key, f"weekly {key.removeprefix('weekly_'):<5}")
194
+ lines.append(f" {label} {q['remaining_pct']:>5.1f}% remaining (resets in {fmt_duration(q['resets_in_s'])})")
195
+ return "\n".join(lines)