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.
Files changed (35) hide show
  1. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/CHANGELOG.md +23 -0
  2. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/PKG-INFO +2 -2
  3. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/pyproject.toml +1 -1
  4. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/quotas.py +64 -41
  5. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_quotas_antigravity.py +109 -0
  6. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/.gitignore +0 -0
  7. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/LICENSE +0 -0
  8. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/README.md +0 -0
  9. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/skills/playmaker-coach/SKILL.md +0 -0
  10. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/__init__.py +0 -0
  11. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/__main__.py +0 -0
  12. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/__init__.py +0 -0
  13. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/agy.py +0 -0
  14. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/base.py +0 -0
  15. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/claude.py +0 -0
  16. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/codex.py +0 -0
  17. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/gemini.py +0 -0
  18. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/agents/opencode.py +0 -0
  19. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/cli.py +0 -0
  20. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/config.py +0 -0
  21. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/notify.py +0 -0
  22. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/registry.py +0 -0
  23. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/state.py +0 -0
  24. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/src/playmaker/watcher.py +0 -0
  25. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/__init__.py +0 -0
  26. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_agy.py +0 -0
  27. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_batch.py +0 -0
  28. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_claude.py +0 -0
  29. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_codex.py +0 -0
  30. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_opencode.py +0 -0
  31. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_permissions.py +0 -0
  32. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_quotas_zai.py +0 -0
  33. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_registry.py +0 -0
  34. {playmaker_cli-0.7.0 → playmaker_cli-0.7.1}/tests/test_skill.py +0 -0
  35. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: playmaker-cli
3
- Version: 0.7.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "playmaker-cli"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  description = "Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity and opencode sub-agents in parallel."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
- _ANTIGRAVITY_PROC_NAMES = ("agy", "language_server")
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 name in _ANTIGRAVITY_PROC_NAMES:
624
+ for pattern in _ANTIGRAVITY_PROC_PATTERNS:
622
625
  try:
623
626
  proc = subprocess.run(
624
- ["pgrep", "-f", name], capture_output=True, text=True, timeout=5
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 pid in pids:
633
- try:
634
- proc = subprocess.run(
635
- ["lsof", "-nP", "-p", str(pid), "-iTCP", "-sTCP:LISTEN"],
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
- except (urllib.error.URLError, ssl.SSLError, OSError, json.JSONDecodeError):
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
- ports = _antigravity_daemon_ports()
761
- if ports:
762
- payload = _antigravity_local_summary(ports)
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
- if windows:
766
- out = {
767
- "status": "ok",
768
- "account_email": None,
769
- "tier": None,
770
- "windows": windows,
771
- "source": "local",
772
- }
773
- # Enrich email/tier from the cheap OAuth loadCodeAssist call;
774
- # never let that failure sink the rich local windows.
775
- try:
776
- meta = _antigravity_account_meta()
777
- out["account_email"] = meta.get("email")
778
- out["tier"] = meta.get("tier")
779
- except Exception:
780
- pass
781
- return out
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