playmaker-cli 0.7.0__tar.gz → 0.7.1__tar.gz
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.
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/CHANGELOG.md +23 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/PKG-INFO +2 -2
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/pyproject.toml +1 -1
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/quotas.py +64 -41
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_quotas_antigravity.py +109 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/.gitignore +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/LICENSE +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/README.md +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/skills/playmaker-coach/SKILL.md +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/agy.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/claude.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/codex.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/gemini.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/opencode.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/cli.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/config.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/registry.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/state.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/__init__.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_agy.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_batch.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_claude.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_codex.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_opencode.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_permissions.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_quotas_zai.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_registry.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_skill.py +0 -0
- {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_state.py +0 -0
|
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.7.1] - 2026-08-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The Antigravity quota probe knocked on every port on the machine, and one
|
|
13
|
+
of them answered in TLS.** `playmaker quotas` had shown
|
|
14
|
+
`Antigravity (agy) error: BadStatusLine` for a week: the daemon lookup ran
|
|
15
|
+
`lsof -p <pid> -iTCP -sTCP:LISTEN` without `-a`, and lsof ORs its selectors
|
|
16
|
+
unless told otherwise — so "agy's listening sockets" was actually every
|
|
17
|
+
LISTEN socket on the box (hence the old `!= 5432` Postgres carve-out). The
|
|
18
|
+
probe then POSTed the quota RPC to Steam, chromedriver, a Logi plugin, …
|
|
19
|
+
until a TLS-only listener sorted below agy's ports answered a plaintext
|
|
20
|
+
request with a TLS alert record. urllib raises that as
|
|
21
|
+
`http.client.BadStatusLine`, which is not an `OSError`, so it escaped the
|
|
22
|
+
per-port `except`, escaped `antigravity_probe`, and the aggregator recorded
|
|
23
|
+
the whole provider as an error — with the working local daemon two ports
|
|
24
|
+
away. lsof now gets `-a` (one call, all pids), the `pgrep` for `agy` is
|
|
25
|
+
anchored so playmaker's own dispatches with `playmaker-agy-*.log` in their
|
|
26
|
+
arguments don't count as the daemon, anything a port says only disqualifies
|
|
27
|
+
that port, and any failure on the local path falls back to the remote
|
|
28
|
+
Gemini-only probe rather than to `error`. The refresh also stopped spending
|
|
29
|
+
~30 s in timeouts on strangers' ports (1.3 s now).
|
|
30
|
+
|
|
8
31
|
## [0.7.0] - 2026-08-05
|
|
9
32
|
|
|
10
33
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: playmaker-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel.
|
|
5
5
|
Project-URL: Homepage, https://github.com/vladsafedev/playmaker
|
|
6
6
|
Project-URL: Repository, https://github.com/vladsafedev/playmaker
|
|
@@ -612,39 +612,48 @@ def gemini_probe() -> dict:
|
|
|
612
612
|
_ANTIGRAVITY_QUOTA_SUMMARY_PATH = (
|
|
613
613
|
"/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary"
|
|
614
614
|
)
|
|
615
|
-
|
|
615
|
+
# `pgrep -f` regexes over the full command line. The agy one is anchored so a
|
|
616
|
+
# process whose *arguments* merely mention agy — playmaker's own dispatch, with
|
|
617
|
+
# its `--log-file .../playmaker-agy-*.log` — doesn't count as the daemon.
|
|
618
|
+
_ANTIGRAVITY_PROC_PATTERNS = (r"(^|/)agy( |$)", r"language_server")
|
|
616
619
|
|
|
617
620
|
|
|
618
621
|
def _antigravity_daemon_ports() -> list[int]:
|
|
619
622
|
"""Local TCP ports that a running agy/language_server daemon is listening on."""
|
|
620
623
|
pids: set[int] = set()
|
|
621
|
-
for
|
|
624
|
+
for pattern in _ANTIGRAVITY_PROC_PATTERNS:
|
|
622
625
|
try:
|
|
623
626
|
proc = subprocess.run(
|
|
624
|
-
["pgrep", "-f",
|
|
627
|
+
["pgrep", "-f", pattern], capture_output=True, text=True, timeout=5
|
|
625
628
|
)
|
|
626
629
|
except (OSError, subprocess.SubprocessError):
|
|
627
630
|
continue
|
|
628
631
|
for line in proc.stdout.split():
|
|
629
632
|
if line.isdigit():
|
|
630
633
|
pids.add(int(line))
|
|
634
|
+
if not pids:
|
|
635
|
+
return []
|
|
636
|
+
# `-a` ANDs lsof's selectors. Without it they are ORed, and the listing is
|
|
637
|
+
# every LISTEN socket on the machine — Steam, chromedriver, whatever else
|
|
638
|
+
# sits on 127.0.0.1 — so the probe went knocking on strangers' ports.
|
|
639
|
+
try:
|
|
640
|
+
proc = subprocess.run(
|
|
641
|
+
[
|
|
642
|
+
"lsof", "-nP", "-a",
|
|
643
|
+
"-p", ",".join(str(pid) for pid in sorted(pids)),
|
|
644
|
+
"-iTCP", "-sTCP:LISTEN",
|
|
645
|
+
],
|
|
646
|
+
capture_output=True,
|
|
647
|
+
text=True,
|
|
648
|
+
timeout=5,
|
|
649
|
+
)
|
|
650
|
+
except (OSError, subprocess.SubprocessError):
|
|
651
|
+
return []
|
|
631
652
|
ports: set[int] = set()
|
|
632
|
-
for
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
capture_output=True,
|
|
637
|
-
text=True,
|
|
638
|
-
timeout=5,
|
|
639
|
-
)
|
|
640
|
-
except (OSError, subprocess.SubprocessError):
|
|
641
|
-
continue
|
|
642
|
-
for line in proc.stdout.splitlines():
|
|
643
|
-
m = re.search(r"127\.0\.0\.1:(\d+)", line)
|
|
644
|
-
if m:
|
|
645
|
-
port = int(m.group(1))
|
|
646
|
-
if port != 5432: # skip an unrelated Postgres LISTEN
|
|
647
|
-
ports.add(port)
|
|
653
|
+
for line in proc.stdout.splitlines():
|
|
654
|
+
m = re.search(r"127\.0\.0\.1:(\d+)", line)
|
|
655
|
+
if m:
|
|
656
|
+
ports.add(int(m.group(1)))
|
|
648
657
|
return sorted(ports)
|
|
649
658
|
|
|
650
659
|
|
|
@@ -664,12 +673,19 @@ def _antigravity_local_summary(ports: list[int], timeout: float = 5.0) -> dict |
|
|
|
664
673
|
method="POST",
|
|
665
674
|
headers={"Content-Type": "application/json", "Connect-Protocol-Version": "1"},
|
|
666
675
|
)
|
|
676
|
+
# Whatever a port says, it only ever disqualifies that port. Not
|
|
677
|
+
# every refusal is an OSError: a TLS-only listener answers a
|
|
678
|
+
# plaintext POST with a TLS alert record, which urllib raises as
|
|
679
|
+
# http.client.BadStatusLine — an HTTPException that used to escape
|
|
680
|
+
# this loop and sink the whole probe.
|
|
667
681
|
try:
|
|
668
682
|
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
669
683
|
data = json.loads(resp.read().decode("utf-8"))
|
|
670
|
-
|
|
684
|
+
if not isinstance(data, dict):
|
|
685
|
+
continue
|
|
686
|
+
payload = data.get("response") or data.get("summary") or data
|
|
687
|
+
except Exception:
|
|
671
688
|
continue
|
|
672
|
-
payload = data.get("response") or data.get("summary") or data
|
|
673
689
|
if isinstance(payload, dict) and payload.get("groups"):
|
|
674
690
|
return payload
|
|
675
691
|
return None
|
|
@@ -756,29 +772,36 @@ def antigravity_probe() -> dict:
|
|
|
756
772
|
back to the OAuth retrieveUserQuota, which only surfaces coarse Gemini daily
|
|
757
773
|
buckets (source: "remote") — the Claude/GPT windows are simply not available
|
|
758
774
|
to a plain OAuth token. The local path needs a running agy/CodexBar daemon.
|
|
775
|
+
|
|
776
|
+
The local path is a bonus, so it degrades to remote rather than to an
|
|
777
|
+
error: whatever goes wrong there — a stray listener, a daemon mid-start,
|
|
778
|
+
a payload we don't recognise — the user still gets a quota row.
|
|
759
779
|
"""
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
780
|
+
windows: list[dict] = []
|
|
781
|
+
try:
|
|
782
|
+
ports = _antigravity_daemon_ports()
|
|
783
|
+
payload = _antigravity_local_summary(ports) if ports else None
|
|
763
784
|
if payload:
|
|
764
785
|
windows = _antigravity_windows_from_summary(payload)
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
786
|
+
except Exception:
|
|
787
|
+
windows = []
|
|
788
|
+
if windows:
|
|
789
|
+
out = {
|
|
790
|
+
"status": "ok",
|
|
791
|
+
"account_email": None,
|
|
792
|
+
"tier": None,
|
|
793
|
+
"windows": windows,
|
|
794
|
+
"source": "local",
|
|
795
|
+
}
|
|
796
|
+
# Enrich email/tier from the cheap OAuth loadCodeAssist call;
|
|
797
|
+
# never let that failure sink the rich local windows.
|
|
798
|
+
try:
|
|
799
|
+
meta = _antigravity_account_meta()
|
|
800
|
+
out["account_email"] = meta.get("email")
|
|
801
|
+
out["tier"] = meta.get("tier")
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
return out
|
|
782
805
|
|
|
783
806
|
result = _google_code_assist_probe(_ANTIGRAVITY_CLOUDCODE_BASE, "ANTIGRAVITY")
|
|
784
807
|
result["source"] = "remote"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import http.client
|
|
4
|
+
import json
|
|
3
5
|
import sys
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
|
|
@@ -98,3 +100,110 @@ def test_local_summary_returns_none_when_no_port_answers(monkeypatch) -> None:
|
|
|
98
100
|
monkeypatch.setattr(quotas.urllib.request, "urlopen", boom)
|
|
99
101
|
|
|
100
102
|
assert quotas._antigravity_local_summary([49999]) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _Response:
|
|
106
|
+
def __init__(self, body: bytes) -> None:
|
|
107
|
+
self._body = body
|
|
108
|
+
|
|
109
|
+
def read(self) -> bytes:
|
|
110
|
+
return self._body
|
|
111
|
+
|
|
112
|
+
def __enter__(self):
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *exc) -> None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_local_summary_skips_a_port_that_does_not_speak_http(monkeypatch) -> None:
|
|
120
|
+
# A TLS-only listener answers a plaintext POST with a TLS alert record;
|
|
121
|
+
# urllib surfaces that as http.client.BadStatusLine, which is not an
|
|
122
|
+
# OSError. It disqualifies that port — it must not sink the probe.
|
|
123
|
+
tls_alert = "\x15\x03\x03\x00\x02\x022"
|
|
124
|
+
|
|
125
|
+
def urlopen(req, timeout=None, context=None):
|
|
126
|
+
if ":50652/" in req.full_url:
|
|
127
|
+
if req.full_url.startswith("https"):
|
|
128
|
+
raise TimeoutError("The read operation timed out")
|
|
129
|
+
raise http.client.BadStatusLine(tls_alert)
|
|
130
|
+
return _Response(json.dumps({"response": _SUMMARY}).encode())
|
|
131
|
+
|
|
132
|
+
monkeypatch.setattr(quotas.urllib.request, "urlopen", urlopen)
|
|
133
|
+
|
|
134
|
+
assert quotas._antigravity_local_summary([50652, 51802]) == _SUMMARY
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_local_summary_ignores_a_port_that_answers_non_object_json(monkeypatch) -> None:
|
|
138
|
+
def urlopen(req, timeout=None, context=None):
|
|
139
|
+
return _Response(b"[]")
|
|
140
|
+
|
|
141
|
+
monkeypatch.setattr(quotas.urllib.request, "urlopen", urlopen)
|
|
142
|
+
|
|
143
|
+
assert quotas._antigravity_local_summary([49999]) is None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_daemon_ports_lists_only_the_daemon_sockets(monkeypatch) -> None:
|
|
147
|
+
# lsof ORs its selectors unless told otherwise, so `-p PID -iTCP` without
|
|
148
|
+
# `-a` is every LISTEN socket on the machine — that is how the probe ended
|
|
149
|
+
# up POSTing to Steam and chromedriver. The lookup must AND them and ask
|
|
150
|
+
# for exactly the pids pgrep found.
|
|
151
|
+
calls: list[list[str]] = []
|
|
152
|
+
|
|
153
|
+
class _Proc:
|
|
154
|
+
def __init__(self, stdout: str) -> None:
|
|
155
|
+
self.stdout = stdout
|
|
156
|
+
self.returncode = 0
|
|
157
|
+
|
|
158
|
+
def run(cmd, **kwargs):
|
|
159
|
+
calls.append(cmd)
|
|
160
|
+
if cmd[0] == "pgrep":
|
|
161
|
+
return _Proc("8577\n" if "agy" in cmd[-1] else "")
|
|
162
|
+
assert cmd[0] == "lsof"
|
|
163
|
+
return _Proc(
|
|
164
|
+
"COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
|
|
165
|
+
"agy 8577 me 10u IPv4 0x1 0t0 TCP 127.0.0.1:51802 (LISTEN)\n"
|
|
166
|
+
"agy 8577 me 11u IPv4 0x2 0t0 TCP 127.0.0.1:51803 (LISTEN)\n"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(quotas.subprocess, "run", run)
|
|
170
|
+
|
|
171
|
+
assert quotas._antigravity_daemon_ports() == [51802, 51803]
|
|
172
|
+
lsof = [c for c in calls if c[0] == "lsof"]
|
|
173
|
+
assert len(lsof) == 1
|
|
174
|
+
assert "-a" in lsof[0]
|
|
175
|
+
assert lsof[0][lsof[0].index("-p") + 1] == "8577"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_daemon_ports_is_empty_without_a_daemon(monkeypatch) -> None:
|
|
179
|
+
calls: list[list[str]] = []
|
|
180
|
+
|
|
181
|
+
class _Proc:
|
|
182
|
+
stdout = ""
|
|
183
|
+
returncode = 1
|
|
184
|
+
|
|
185
|
+
def run(cmd, **kwargs):
|
|
186
|
+
calls.append(cmd)
|
|
187
|
+
return _Proc()
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr(quotas.subprocess, "run", run)
|
|
190
|
+
|
|
191
|
+
assert quotas._antigravity_daemon_ports() == []
|
|
192
|
+
# No pids → no lsof at all (an unfiltered lsof is the whole-machine listing).
|
|
193
|
+
assert all(c[0] == "pgrep" for c in calls)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_antigravity_probe_falls_back_to_remote_when_the_local_path_blows_up(
|
|
197
|
+
monkeypatch,
|
|
198
|
+
) -> None:
|
|
199
|
+
def boom():
|
|
200
|
+
raise http.client.BadStatusLine("\x15\x03\x03\x00\x02\x022")
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(quotas, "_antigravity_daemon_ports", boom)
|
|
203
|
+
remote = {"status": "ok", "windows": [{"name": "Flash", "pct_left": 100}]}
|
|
204
|
+
monkeypatch.setattr(quotas, "_google_code_assist_probe", lambda base_url, ide_type: remote)
|
|
205
|
+
|
|
206
|
+
result = quotas.antigravity_probe()
|
|
207
|
+
|
|
208
|
+
assert result["status"] == "ok"
|
|
209
|
+
assert result["source"] == "remote"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|