playmaker-cli 0.7.0__tar.gz → 0.7.2__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 (36) hide show
  1. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/CHANGELOG.md +73 -0
  2. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/PKG-INFO +14 -2
  3. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/README.md +12 -0
  4. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/pyproject.toml +1 -1
  5. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/skills/playmaker-coach/SKILL.md +5 -5
  6. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/agy.py +13 -6
  7. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/claude.py +9 -4
  8. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/codex.py +4 -4
  9. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/gemini.py +5 -4
  10. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/opencode.py +8 -5
  11. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/cli.py +33 -5
  12. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/config.py +19 -0
  13. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/quotas.py +86 -49
  14. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_agy.py +28 -0
  15. playmaker_cli-0.7.2/tests/test_binary.py +208 -0
  16. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_quotas_antigravity.py +109 -0
  17. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_quotas_zai.py +53 -0
  18. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/.gitignore +0 -0
  19. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/LICENSE +0 -0
  20. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/__init__.py +0 -0
  21. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/__main__.py +0 -0
  22. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/__init__.py +0 -0
  23. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/agents/base.py +0 -0
  24. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/notify.py +0 -0
  25. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/registry.py +0 -0
  26. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/state.py +0 -0
  27. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/src/playmaker/watcher.py +0 -0
  28. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/__init__.py +0 -0
  29. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_batch.py +0 -0
  30. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_claude.py +0 -0
  31. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_codex.py +0 -0
  32. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_opencode.py +0 -0
  33. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_permissions.py +0 -0
  34. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_registry.py +0 -0
  35. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_skill.py +0 -0
  36. {playmaker_cli-0.7.0 → playmaker_cli-0.7.2}/tests/test_state.py +0 -0
@@ -5,6 +5,79 @@ 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.2] - 2026-08-18
9
+
10
+ ### Fixed
11
+
12
+ - **`[agents.<name>] binary` was documented, written into every config `init`
13
+ produced, and read by nothing.** All five handlers hardcoded the executable —
14
+ `shutil.which("opencode")` for the availability check, `cmd = ["opencode", …]`
15
+ for the dispatch — so a config pointing at an absolute path or an alternate
16
+ build changed exactly nothing, and the agent still had to be on `PATH` under
17
+ its own name. That is the wrong assumption for how these CLIs install:
18
+ opencode lands in `~/.opencode/bin`, which reaches `PATH` only through a line
19
+ in an interactive `.zshrc`, so every non-interactive dispatch — cron, an
20
+ editor-spawned run, the coach itself — reported the agent unavailable unless
21
+ you'd worked around it with a symlink. The setting is now honoured everywhere
22
+ an executable is named: `is_available()`, both dispatch and resume, and the
23
+ roster probes (`agy models`, `opencode models`, `gemini --list-sessions`). A
24
+ bare name still resolves on `PATH`; an absolute path is used as-is and a
25
+ leading `~` is expanded, since subprocess does no shell expansion of its own.
26
+ When the lookup fails, the error now names the executable it actually tried
27
+ and where the setting came from, instead of insisting it is "not on PATH".
28
+
29
+ - **The Z.ai windows lost their names when z.ai renamed them.** `playmaker
30
+ quotas` had started printing the GLM rows as `5 hours` and `1 week` instead
31
+ of `Session` and `Weekly`: the plans moved to weekly Credits and the API now
32
+ types the inference windows `CREDIT_LIMIT` where it sent `TOKENS_LIMIT` in
33
+ July, so the label lookup missed and fell back to rendering the bare span.
34
+ The labels are shared with the claude probe on purpose — the two providers
35
+ are meant to read like-for-like down the table — so the fallback quietly cost
36
+ the comparison. Both spellings are mapped now. The monthly `MCP tools` pool
37
+ is simply absent from the response on a current plan; nothing to do, it just
38
+ stops appearing.
39
+
40
+ - **The coach skill named a stale GLM model and lied about where the opencode
41
+ default lives.** The skill ships in the wheel, so its facts are the coach's
42
+ facts. It routed bulk work to `zai-coding-plan/glm-5.2` when the plan's
43
+ current flagship is `glm-5.3`, described the Z.ai quota as token windows plus
44
+ a monthly `MCP tools` pool (the windows are credits now; the pool is gone),
45
+ and told the coach that omitting `--model` falls back to whatever
46
+ `~/.config/opencode/opencode.json` names. That last one sends you looking in
47
+ the wrong file: opencode keeps the interactively-picked default in its own
48
+ state, not in that config, so a machine with no `model` key there still
49
+ resolves to something — and the only way to see what an unqualified dispatch
50
+ will run is the `providerID`/`modelID` on a past session in `opencode.db`.
51
+
52
+ - **`--model` for agy validated against the wrong column.** agy 1.1.14 prints
53
+ `agy models` as `<slug>\t<Display Name>` after a `Fetching available
54
+ models...` line; the roster was read as whole lines, so every real slug was
55
+ rejected as unknown. Only the first token counts now (older builds printed
56
+ the bare slug — same first token).
57
+
58
+ ## [0.7.1] - 2026-08-18
59
+
60
+ ### Fixed
61
+
62
+ - **The Antigravity quota probe knocked on every port on the machine, and one
63
+ of them answered in TLS.** `playmaker quotas` had shown
64
+ `Antigravity (agy) error: BadStatusLine` for a week: the daemon lookup ran
65
+ `lsof -p <pid> -iTCP -sTCP:LISTEN` without `-a`, and lsof ORs its selectors
66
+ unless told otherwise — so "agy's listening sockets" was actually every
67
+ LISTEN socket on the box (hence the old `!= 5432` Postgres carve-out). The
68
+ probe then POSTed the quota RPC to Steam, chromedriver, a Logi plugin, …
69
+ until a TLS-only listener sorted below agy's ports answered a plaintext
70
+ request with a TLS alert record. urllib raises that as
71
+ `http.client.BadStatusLine`, which is not an `OSError`, so it escaped the
72
+ per-port `except`, escaped `antigravity_probe`, and the aggregator recorded
73
+ the whole provider as an error — with the working local daemon two ports
74
+ away. lsof now gets `-a` (one call, all pids), the `pgrep` for `agy` is
75
+ anchored so playmaker's own dispatches with `playmaker-agy-*.log` in their
76
+ arguments don't count as the daemon, anything a port says only disqualifies
77
+ that port, and any failure on the local path falls back to the remote
78
+ Gemini-only probe rather than to `error`. The refresh also stopped spending
79
+ ~30 s in timeouts on strangers' ports (1.3 s now).
80
+
8
81
  ## [0.7.0] - 2026-08-05
9
82
 
10
83
  ### 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.2
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
@@ -309,6 +309,18 @@ reads `process.env.PWD`, which a subprocess `cwd` does not update, so left
309
309
  alone it would ignore `--cwd` and write into the directory *you* were standing
310
310
  in. playmaker passes `--dir` and fixes up `PWD`, so `--cwd` means what it says.
311
311
 
312
+ And it is the agent most likely to need `binary`, which every lane accepts:
313
+
314
+ ```toml
315
+ [agents.opencode]
316
+ binary = "~/.opencode/bin/opencode"
317
+ ```
318
+
319
+ A bare name is resolved on `PATH`; a path is used as-is. opencode installs to
320
+ `~/.opencode/bin`, which reaches `PATH` only via a line in an interactive
321
+ `.zshrc` — so a dispatch from cron, an editor, or the coach can't find it
322
+ otherwise, and playmaker would report the agent as unavailable.
323
+
312
324
  ## Notifications
313
325
 
314
326
  Every detached dispatch pings when it finishes. With
@@ -282,6 +282,18 @@ reads `process.env.PWD`, which a subprocess `cwd` does not update, so left
282
282
  alone it would ignore `--cwd` and write into the directory *you* were standing
283
283
  in. playmaker passes `--dir` and fixes up `PWD`, so `--cwd` means what it says.
284
284
 
285
+ And it is the agent most likely to need `binary`, which every lane accepts:
286
+
287
+ ```toml
288
+ [agents.opencode]
289
+ binary = "~/.opencode/bin/opencode"
290
+ ```
291
+
292
+ A bare name is resolved on `PATH`; a path is used as-is. opencode installs to
293
+ `~/.opencode/bin`, which reaches `PATH` only via a line in an interactive
294
+ `.zshrc` — so a dispatch from cron, an editor, or the coach can't find it
295
+ otherwise, and playmaker would report the agent as unavailable.
296
+
285
297
  ## Notifications
286
298
 
287
299
  Every detached dispatch pings when it finishes. With
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "playmaker-cli"
3
- version = "0.7.0"
3
+ version = "0.7.2"
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"
@@ -23,7 +23,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
23
23
 
24
24
  3. **External dispatch — `playmaker dispatch <agent>`.** Separate OS processes, tracked in playmaker (`list`/`watch`/`thread`/`continue`):
25
25
  - **`claude -p` (sibling Claude):** same subscription. Its key lever is the model bucket — **Sonnet is a separate weekly bucket from Opus**, usually idle while Opus depletes, so default **`--model sonnet`** to spare the scarce Opus bucket (**`--model haiku`** for trivial mechanical work). It **can write files** — playmaker runs it in `acceptEdits`, so it edits and runs commands freely inside `--cwd` and is refused outside it (see §10). Use it over an internal sub-agent when you want a **tracked, detached work-stream** you can monitor/continue independently of the coach's turn.
26
- - **`codex` / `agy` / `opencode`:** each on its own subscription/quota — the right home for **write-heavy** parallel implementation that can leave the Anthropic subscription. `agy` is special: besides Gemini tiers it carries **Claude Sonnet/Opus 4.6 (Thinking)** on Google's pool, so even "must be Claude-quality" work can leave the Anthropic quota. `opencode` is the widest lane: one CLI over ~75 providers, addressed as `provider/model` — a GLM coding plan (`zai-coding-plan/glm-5.2`), or a model running locally on this machine, which spends **no** subscription quota at all.
26
+ - **`codex` / `agy` / `opencode`:** each on its own subscription/quota — the right home for **write-heavy** parallel implementation that can leave the Anthropic subscription. `agy` is special: besides Gemini tiers it carries **Claude Sonnet/Opus 4.6 (Thinking)** on Google's pool, so even "must be Claude-quality" work can leave the Anthropic quota. `opencode` is the widest lane: one CLI over ~75 providers, addressed as `provider/model` — a GLM coding plan (`zai-coding-plan/glm-5.3`), or a model running locally on this machine, which spends **no** subscription quota at all.
27
27
 
28
28
  **Routing cheat-sheet for Claude-side work:**
29
29
 
@@ -34,7 +34,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
34
34
  | is heavy reasoning only the coach can do | **coach** | top-tier Opus, serial |
35
35
  | is write-heavy and can leave Claude | **codex / agy / opencode** | their own quotas |
36
36
  | needs top-tier Claude but the Anthropic Opus weekly is precious | **`dispatch agy --model claude-opus-4-6-thinking`** | Opus quality on Google's pool |
37
- | is bulk work and every subscription is running low | **`dispatch opencode --model zai-coding-plan/glm-5.2`** | a separate GLM plan, untouched by the others |
37
+ | is bulk work and every subscription is running low | **`dispatch opencode --model zai-coding-plan/glm-5.3`** | a separate GLM plan, untouched by the others |
38
38
  | is mechanical and privacy-sensitive, or all quotas are spent | **`dispatch opencode --model <local provider>/<model>`** | runs on this machine; costs no quota, just wall-clock |
39
39
 
40
40
  **Sonnet is your cheap parallel Claude worker** — a separate weekly bucket that usually sits idle while Opus depletes. Reach for it (via `dispatch claude --model sonnet`, or by pointing an internal sub-agent at Sonnet) instead of burning Opus on mid-tier work.
@@ -63,7 +63,7 @@ playmaker quotas --refresh # current capacity, broken out per model
63
63
  - Claude: two non-coach ways to run it — an **internal sub-agent** (Task tool; in-session, write-capable, result returns to the coach) and an **external `claude -p` dispatch** (tracked, detached stream). Both draw on the subscription; the difference is where results land, not cost. See "Execution lanes". Default external Claude to `--model sonnet` — its weekly bucket is separate from Opus and usually idle, so it spares the scarce coach (Opus) bucket.
64
64
  - Antigravity (`agy`): one Google pool split across model families — Gemini Flash tiers for cheap bulk work, Gemini 3.1 Pro for hard Gemini work, **Claude Sonnet/Opus 4.6 (Thinking)** as Anthropic-quality capacity that spends *Google's* quota, GPT-OSS 120B as a spare mid-tier. `playmaker quotas` shows the **full categorized breakdown** — `Gemini 5h` / `Gemini weekly` and `Claude/GPT 5h` / `Claude/GPT weekly`. Two things share a bucket: all Gemini models draw the Gemini bucket, and Claude *and* GPT-OSS share the Claude/GPT bucket. So dispatching Opus 4.6 via agy spends the same `Claude/GPT` bucket as Sonnet or GPT-OSS — watch the `Claude/GPT 5h` window when fanning out several agy-Claude jobs. (This needs agy's local daemon up — normally true when any agy process is running; if `playmaker quotas` tags agy "daemon offline" it fell back to a coarse Gemini-only view.)
65
65
  - Codex: top-tier vs lighter modes (where applicable).
66
- - Z.ai (GLM): shown as its own provider because the plan is what has the quota, not the CLI — `Session` (5h) and `Weekly` windows plus a monthly `MCP tools` pool that only web-search/reader calls touch. It appears whenever `opencode auth login` has a Z.AI credential, and reads *unsupported* when it doesn't. An `opencode` dispatch pointed at a **local** model spends none of it, so local lanes never show up in this table at all.
66
+ - Z.ai (GLM): shown as its own provider because the plan is what has the quota, not the CLI — `Session` (5h) and `Weekly` windows, denominated in the plan's **credits** (Pro: 12k per session, 60k per week). It appears whenever `opencode auth login` has a Z.AI credential, and reads *unsupported* when it doesn't. An `opencode` dispatch pointed at a **local** model spends none of it, so local lanes never show up in this table at all.
67
67
 
68
68
  If `quotas.json` is more than 1h old (or shows errors), say so before relying on the numbers.
69
69
 
@@ -162,7 +162,7 @@ Two exceptions to "always pass `--model`":
162
162
  - **codex** — its model roster depends on the account plan, and an unavailable name fails the whole dispatch (`codex turn failed: … not supported when using Codex with a ChatGPT account`). Omitting `--model` uses whatever that account actually has, which is usually what you want.
163
163
  - **agy** — its own default is a top-tier model, so omitting `--model` on a dispatch meant to be cheap silently spends the expensive bucket. Always pass it here.
164
164
 
165
- **opencode is `provider/model`, and the default is a trap.** Names look like `zai-coding-plan/glm-5.2` or `lmstudio/qwen/qwen3-coder-30b` — **run `opencode models` and copy a line** rather than writing one from memory; playmaker validates against that roster and fails the dispatch on a name it doesn't contain. Omitting `--model` does *not* fall back to something sensible: it uses whatever is set in the user's own `~/.config/opencode/opencode.json`, which is typically the last model they picked interactively often a small local one. So for opencode, always pass `--model` unless `[agents.opencode] model` is already set in `~/.playmaker/config.toml`.
165
+ **opencode is `provider/model`, and the default is invisible.** Names look like `zai-coding-plan/glm-5.3` or `lmstudio/qwen/qwen3-coder-30b` — **run `opencode models` and copy a line** rather than writing one from memory; playmaker validates against that roster and fails the dispatch on a name it doesn't contain. Omitting `--model` falls through to opencode's own default, which is whatever the user last picked interactively — and it is *not* necessarily written to `~/.config/opencode/opencode.json`, so you cannot read it back from a config file. The only way to know what an unqualified dispatch will run is to look at the `providerID`/`modelID` on a past session in `opencode.db`. So for opencode, always pass `--model` unless `[agents.opencode] model` is set in `~/.playmaker/config.toml` — which is the cheap fix: pin it there once and the lane becomes deterministic.
166
166
 
167
167
  **agy prompt discipline:** the agy agent's shell lives in a private scratch directory, not the workspace. playmaker automatically prepends a workspace preamble to every agy dispatch, but reinforce it: word file instructions with paths relative to the workspace root or absolute paths, never "in the current directory".
168
168
 
@@ -274,7 +274,7 @@ playmaker watch # Rich live TUI of sessions
274
274
 
275
275
  Every command takes `--json` for machine-readable output.
276
276
 
277
- `--model NAME` is forwarded to the agent's native CLI: `claude --model sonnet`, `agy --model claude-opus-4-6-thinking`, `codex -m <whatever that account has>`, `opencode -m zai-coding-plan/glm-5.2`. Without it the agent CLI uses its own default. Model is stored on the session row, so detached re-runs and `continue` inherit it; `continue --model X` overrides for that one turn.
277
+ `--model NAME` is forwarded to the agent's native CLI: `claude --model sonnet`, `agy --model claude-opus-4-6-thinking`, `codex -m <whatever that account has>`, `opencode -m zai-coding-plan/glm-5.3`. Without it the agent CLI uses its own default. Model is stored on the session row, so detached re-runs and `continue` inherit it; `continue --model X` overrides for that one turn.
278
278
 
279
279
  **The agy roster is not documented here on purpose** — it changes with Antigravity releases, and so does the spelling convention. Run `agy models` and copy a line. At the time of writing it returns bare slugs in the shape `gemini-3.6-flash-{low,medium,high}`, `gemini-3.1-pro-{low,high}`, `claude-sonnet-4-6`, `claude-opus-4-6-thinking`, `gpt-oss-120b-medium` — but treat that as an example of the *shape*, not a list to copy from.
280
280
 
@@ -37,7 +37,7 @@ import time
37
37
  from pathlib import Path
38
38
 
39
39
  from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
40
- from playmaker.config import agent_setting, yolo_enabled
40
+ from playmaker.config import agent_binary, agent_setting, yolo_enabled
41
41
 
42
42
  AGY_BRAIN_ROOT = Path("~/.gemini/antigravity-cli/brain").expanduser()
43
43
 
@@ -51,7 +51,7 @@ class AgyHandler:
51
51
  name = "agy"
52
52
 
53
53
  def is_available(self) -> bool:
54
- return shutil.which("agy") is not None
54
+ return shutil.which(agent_binary("agy")) is not None
55
55
 
56
56
  @staticmethod
57
57
  @functools.lru_cache(maxsize=1)
@@ -63,18 +63,25 @@ class AgyHandler:
63
63
  Cached per-process; returns () if the roster can't be read (then we skip
64
64
  validation rather than block a dispatch on a probe failure).
65
65
  """
66
- if shutil.which("agy") is None:
66
+ if shutil.which(agent_binary("agy")) is None:
67
67
  return ()
68
68
  try:
69
69
  proc = subprocess.run(
70
- ["agy", "models"], capture_output=True, text=True, timeout=15
70
+ [agent_binary("agy"), "models"],
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=15,
71
74
  )
72
75
  except (OSError, subprocess.SubprocessError):
73
76
  return ()
74
77
  if proc.returncode != 0:
75
78
  return ()
79
+ # `agy models` output is columnar: "<slug>\t<Display Name>" (older
80
+ # releases printed the bare slug only) — the slug is the first token.
76
81
  return tuple(
77
- line.strip() for line in proc.stdout.splitlines() if line.strip()
82
+ line.split()[0]
83
+ for line in proc.stdout.splitlines()
84
+ if line.strip() and not line.startswith("Fetching")
78
85
  )
79
86
 
80
87
  def _validate_model(self, model: str | None) -> None:
@@ -146,7 +153,7 @@ class AgyHandler:
146
153
  os.close(fd_log)
147
154
  log_path = Path(log_name)
148
155
 
149
- cmd = ["agy", "-p", full_prompt, "--log-file", str(log_path)]
156
+ cmd = [agent_binary("agy"), "-p", full_prompt, "--log-file", str(log_path)]
150
157
  if conversation_id:
151
158
  cmd += ["--conversation", conversation_id]
152
159
  # agy has no middle tier: unlike claude there is no per-mode permission
@@ -17,7 +17,12 @@ import subprocess
17
17
  from pathlib import Path
18
18
 
19
19
  from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
20
- from playmaker.config import agent_list_setting, agent_setting, yolo_enabled
20
+ from playmaker.config import (
21
+ agent_binary,
22
+ agent_list_setting,
23
+ agent_setting,
24
+ yolo_enabled,
25
+ )
21
26
 
22
27
  # What a sub-agent is allowed to do without a human at the keyboard.
23
28
  # Verified against claude 2.x in `-p` mode:
@@ -53,7 +58,7 @@ class ClaudeHandler:
53
58
  name = "claude"
54
59
 
55
60
  def is_available(self) -> bool:
56
- return shutil.which("claude") is not None
61
+ return shutil.which(agent_binary("claude")) is not None
57
62
 
58
63
  def dispatch(
59
64
  self,
@@ -76,7 +81,7 @@ class ClaudeHandler:
76
81
  # `--verbose` is required by claude-cli when stream-json is used
77
82
  # without partial-messages; without it we get a parse-time refusal.
78
83
  cmd = [
79
- "claude",
84
+ agent_binary("claude"),
80
85
  "-p",
81
86
  "--output-format",
82
87
  "stream-json",
@@ -185,7 +190,7 @@ class ClaudeHandler:
185
190
  ) -> DispatchResult:
186
191
  full_prompt = self._build_prompt(prompt, files or [])
187
192
  cmd = [
188
- "claude",
193
+ agent_binary("claude"),
189
194
  "-p",
190
195
  "--resume",
191
196
  agent_session_id,
@@ -23,7 +23,7 @@ import time
23
23
  from pathlib import Path
24
24
 
25
25
  from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
26
- from playmaker.config import agent_setting
26
+ from playmaker.config import agent_binary, agent_setting
27
27
 
28
28
  CODEX_SESSIONS_ROOT = Path("~/.codex/sessions").expanduser()
29
29
 
@@ -44,7 +44,7 @@ class CodexHandler:
44
44
  name = "codex"
45
45
 
46
46
  def is_available(self) -> bool:
47
- return shutil.which("codex") is not None
47
+ return shutil.which(agent_binary("codex")) is not None
48
48
 
49
49
  def dispatch(
50
50
  self,
@@ -62,7 +62,7 @@ class CodexHandler:
62
62
  last_msg_path = Path(tmp.name)
63
63
 
64
64
  cmd = [
65
- "codex",
65
+ agent_binary("codex"),
66
66
  "exec",
67
67
  "--json",
68
68
  "--skip-git-repo-check",
@@ -226,7 +226,7 @@ class CodexHandler:
226
226
 
227
227
  # `codex exec resume` has no --cd; cwd flows through subprocess.
228
228
  cmd = [
229
- "codex",
229
+ agent_binary("codex"),
230
230
  "exec",
231
231
  "resume",
232
232
  "--json",
@@ -21,6 +21,7 @@ import time
21
21
  from pathlib import Path
22
22
 
23
23
  from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
24
+ from playmaker.config import agent_binary
24
25
 
25
26
  GEMINI_CHATS_ROOT = Path("~/.gemini/tmp").expanduser()
26
27
 
@@ -29,7 +30,7 @@ class GeminiHandler:
29
30
  name = "gemini"
30
31
 
31
32
  def is_available(self) -> bool:
32
- return shutil.which("gemini") is not None
33
+ return shutil.which(agent_binary("gemini")) is not None
33
34
 
34
35
  def dispatch(
35
36
  self,
@@ -48,7 +49,7 @@ class GeminiHandler:
48
49
  """
49
50
  full_prompt = self._build_prompt(prompt, files or [])
50
51
  cmd = [
51
- "gemini",
52
+ agent_binary("gemini"),
52
53
  "-p",
53
54
  full_prompt,
54
55
  "-o",
@@ -150,7 +151,7 @@ class GeminiHandler:
150
151
 
151
152
  full_prompt = self._build_prompt(prompt, files or [])
152
153
  cmd = [
153
- "gemini",
154
+ agent_binary("gemini"),
154
155
  "--resume",
155
156
  str(index),
156
157
  "-p",
@@ -220,7 +221,7 @@ class GeminiHandler:
220
221
  " <N>. <title> (<age>) [<uuid>]"
221
222
  """
222
223
  proc = subprocess.run(
223
- ["gemini", "--list-sessions"],
224
+ [agent_binary("gemini"), "--list-sessions"],
224
225
  cwd=str(cwd),
225
226
  capture_output=True,
226
227
  text=True,
@@ -36,7 +36,7 @@ from datetime import UTC, datetime
36
36
  from pathlib import Path
37
37
 
38
38
  from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
39
- from playmaker.config import agent_setting, yolo_enabled
39
+ from playmaker.config import agent_binary, agent_setting, yolo_enabled
40
40
  from playmaker.state import PLAYMAKER_HOME
41
41
 
42
42
 
@@ -55,7 +55,7 @@ class OpencodeHandler:
55
55
  name = "opencode"
56
56
 
57
57
  def is_available(self) -> bool:
58
- return shutil.which("opencode") is not None
58
+ return shutil.which(agent_binary("opencode")) is not None
59
59
 
60
60
  @staticmethod
61
61
  @functools.lru_cache(maxsize=1)
@@ -67,11 +67,14 @@ class OpencodeHandler:
67
67
  per-process; () if the roster can't be read (then validation is skipped
68
68
  rather than blocking a dispatch on a probe failure).
69
69
  """
70
- if shutil.which("opencode") is None:
70
+ if shutil.which(agent_binary("opencode")) is None:
71
71
  return ()
72
72
  try:
73
73
  proc = subprocess.run(
74
- ["opencode", "models"], capture_output=True, text=True, timeout=15
74
+ [agent_binary("opencode"), "models"],
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=15,
75
78
  )
76
79
  except (OSError, subprocess.SubprocessError):
77
80
  return ()
@@ -157,7 +160,7 @@ class OpencodeHandler:
157
160
  # directory from process.env.PWD, which Popen(cwd=…) leaves pointing at
158
161
  # the parent. Without it a dispatch writes into whatever directory the
159
162
  # coach happened to be in. We set both and let them agree.
160
- cmd = ["opencode", "run", "--format", "json", "--dir", str(cwd)]
163
+ cmd = [agent_binary("opencode"), "run", "--format", "json", "--dir", str(cwd)]
161
164
  if session_id:
162
165
  cmd += ["-s", session_id]
163
166
  if effective_model:
@@ -158,7 +158,7 @@ def dispatch(
158
158
  state.init_db()
159
159
  handler = get_handler(agent)
160
160
  if not handler.is_available():
161
- err_console.print(f"[red]agent {agent!r} binary not found on PATH[/red]")
161
+ err_console.print(_unavailable(agent))
162
162
  raise typer.Exit(1)
163
163
 
164
164
  cwd_resolved = cwd.expanduser().resolve()
@@ -414,7 +414,7 @@ def continue_(
414
414
 
415
415
  handler = get_handler(parent["agent"])
416
416
  if not handler.is_available():
417
- err_console.print(f"[red]agent {parent['agent']!r} binary not found on PATH[/red]")
417
+ err_console.print(_unavailable(parent["agent"]))
418
418
  raise typer.Exit(1)
419
419
 
420
420
  cwd_resolved = (cwd or Path(parent["cwd"])).expanduser().resolve()
@@ -845,10 +845,13 @@ def _render_provider(name: str, info: dict) -> None:
845
845
  "agy": "green",
846
846
  "gemini": "cyan",
847
847
  "zai": "yellow",
848
+ "ollama": "bright_cyan",
848
849
  }.get(name, "white")
849
- display = {"agy": "Antigravity (agy)", "zai": "Z.ai (GLM, via opencode)"}.get(
850
- name, name.capitalize()
851
- )
850
+ display = {
851
+ "agy": "Antigravity (agy)",
852
+ "zai": "Z.ai (GLM, via opencode)",
853
+ "ollama": "Ollama (local, via opencode)",
854
+ }.get(name, name.capitalize())
852
855
  title = f"[bold {label_color}]{display}[/bold {label_color}]"
853
856
  suffix_parts: list[str] = []
854
857
  if info.get("account_email"):
@@ -896,6 +899,12 @@ def _render_provider(name: str, info: dict) -> None:
896
899
  line += f" [dim]{' · '.join(right_parts)}[/dim]"
897
900
  console.print(line)
898
901
 
902
+ # A local provider has models where the metered ones have tiers; naming them
903
+ # saves the coach a round-trip to `ollama list` before it can route here.
904
+ models = info.get("models")
905
+ if models:
906
+ console.print(f" [dim]models: {', '.join(models)}[/dim]")
907
+
899
908
  # Metered overage pool ("Extra usage" in Claude's UI) — the bucket that
900
909
  # holds usage-credit / Agent-SDK spend. monthly_limit/used arrive in cents.
901
910
  extra = info.get("extra_usage")
@@ -943,6 +952,19 @@ def agents() -> None:
943
952
  console.print(table)
944
953
 
945
954
 
955
+ def _unavailable(agent: str) -> str:
956
+ """Why a dispatch is refused, naming the executable we actually looked for.
957
+
958
+ Without the name this reads as "not on PATH" even when the config points
959
+ `binary` at a path that simply does not exist.
960
+ """
961
+ from playmaker.config import agent_binary
962
+
963
+ executable = agent_binary(agent)
964
+ hint = "" if executable == agent else f" (agents.{agent}.binary in {state.CONFIG_PATH})"
965
+ return f"[red]agent {agent!r}: {executable!r} not found{hint}[/red]"
966
+
967
+
946
968
  def _status_icon(status: str) -> str:
947
969
  return {
948
970
  "pending": "[yellow]pending[/yellow]",
@@ -965,6 +987,12 @@ editor = "Zed"
965
987
 
966
988
  # How much each sub-agent may do while nobody is watching. A detached agent
967
989
  # cannot answer a permission prompt, so every agent needs *some* answer here.
990
+ #
991
+ # `binary` is the executable playmaker launches for that agent. A bare name is
992
+ # resolved on PATH; an absolute path (~ allowed) is used as-is. Reach for the
993
+ # path form when the CLI installs somewhere only an interactive shell knows
994
+ # about — opencode's ~/.opencode/bin is added by a line in .zshrc, so a cron or
995
+ # editor-spawned dispatch cannot find it otherwise.
968
996
 
969
997
  [agents.claude]
970
998
  binary = "claude"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import tomllib
6
7
  from functools import lru_cache
7
8
  from typing import Any
@@ -24,6 +25,24 @@ def agent_setting(agent: str, key: str, default: Any = None) -> Any:
24
25
  return load_config().get("agents", {}).get(agent, {}).get(key, default)
25
26
 
26
27
 
28
+ def agent_binary(agent: str) -> str:
29
+ """The executable to launch for this agent.
30
+
31
+ `[agents.<agent>] binary` when set, else the agent's own name. A bare name
32
+ and an absolute path both work: every call site feeds this to `shutil.which`
33
+ or straight to `subprocess`, and `which` returns a path containing a
34
+ separator as-is. `~` is expanded here because these CLIs install under the
35
+ home directory often enough — opencode lands in ~/.opencode/bin, which only
36
+ an interactive shell puts on PATH, so a non-interactive dispatch (cron, an
37
+ editor-spawned run, the coach) cannot find it without this setting.
38
+ """
39
+ value = agent_setting(agent, "binary")
40
+ if value is None:
41
+ return agent
42
+ text = os.path.expanduser(str(value).strip())
43
+ return text or agent
44
+
45
+
27
46
  def setting(section: str, key: str, default: Any = None) -> Any:
28
47
  """Look up [<section>] <key>, falling back to `default`."""
29
48
  value = load_config().get(section, {})
@@ -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"
@@ -913,7 +936,15 @@ _ZAI_UNIT_NAMES = {3: "hour", 4: "day", 5: "month", 6: "week"}
913
936
 
914
937
  # (type, unit, number) -> label. "Session"/"Weekly" deliberately match the
915
938
  # claude probe's labels so the two providers read as like-for-like in the table.
939
+ #
940
+ # z.ai renamed the inference windows TOKENS_LIMIT -> CREDIT_LIMIT when the plans
941
+ # moved to weekly Credits (observed 2026-08 on a Pro plan; the same account read
942
+ # TOKENS_LIMIT in 2026-07). Both spellings stay mapped: an unrecognised type
943
+ # still renders, but as the bare span ("5 hours"), which breaks the like-for-
944
+ # like reading against the claude rows.
916
945
  _ZAI_WINDOW_LABELS = {
946
+ ("CREDIT_LIMIT", 3, 5): "Session",
947
+ ("CREDIT_LIMIT", 6, 1): "Weekly",
917
948
  ("TOKENS_LIMIT", 3, 5): "Session",
918
949
  ("TOKENS_LIMIT", 6, 1): "Weekly",
919
950
  ("TIME_LIMIT", 5, 1): "MCP tools",
@@ -956,16 +987,22 @@ def _zai_window_label(limit_type: object, unit: object, number: object) -> str:
956
987
  def zai_probe() -> dict:
957
988
  """GLM Coding Plan quota, for work dispatched through `opencode`.
958
989
 
959
- Verified response on a live plan (2026-07):
990
+ Verified response on a live Pro plan (2026-08):
960
991
 
961
992
  {"code": 200, "success": true, "msg": "Operation successful",
962
- "data": {"level": "max", "limits": [
963
- {"type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 0},
964
- {"type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 5,
965
- "nextResetTime": 1785339213993},
966
- {"type": "TIME_LIMIT", "unit": 5, "number": 1, "usage": 4000,
967
- "currentValue": 10, "remaining": 3990, "percentage": 1,
968
- "nextResetTime": 1787412813994, "usageDetails": [...]}]}}
993
+ "data": {"level": "pro", "limits": [
994
+ {"type": "CREDIT_LIMIT", "unit": 3, "number": 5, "usage": 12000,
995
+ "currentValue": 21, "remaining": 11978, "percentage": 1,
996
+ "nextResetTime": 1787084800394},
997
+ {"type": "CREDIT_LIMIT", "unit": 6, "number": 1, "usage": 60000,
998
+ "currentValue": 21, "remaining": 59978, "percentage": 1,
999
+ "nextResetTime": 1787671183998}]}}
1000
+
1001
+ Two things moved since 2026-07: the inference windows are typed
1002
+ CREDIT_LIMIT rather than TOKENS_LIMIT (`usage` is the plan's credit
1003
+ allowance — 12k per 5h / 60k per week on Pro), and the monthly TIME_LIMIT
1004
+ pool for MCP tools no longer appears at all. Older accounts may still send
1005
+ the previous shape, so both are mapped.
969
1006
 
970
1007
  `percentage` is percent *used*, `nextResetTime` is epoch ms and is absent
971
1008
  until a window has been touched. TIME_LIMIT is the monthly MCP tool pool
@@ -110,6 +110,34 @@ def test_validate_model_skips_when_roster_unavailable(monkeypatch) -> None:
110
110
  handler._validate_model("Anything At All")
111
111
 
112
112
 
113
+ def test_available_models_takes_the_slug_column(monkeypatch) -> None:
114
+ # agy 1.1.14 prints a progress line and then `<slug>\t<Display Name>`;
115
+ # `--model` wants the slug. Older builds printed the bare slug — same
116
+ # first token, so both shapes parse.
117
+ import playmaker.agents.agy as agy_mod
118
+
119
+ class _Proc:
120
+ returncode = 0
121
+ stdout = (
122
+ "Fetching available models...\n"
123
+ "gemini-3.7-flash-high\tGemini 3.7 Flash (High)\n"
124
+ "claude-opus-4-6-thinking\tClaude Opus 4.6 (Thinking)\n"
125
+ "gpt-oss-120b-medium\n"
126
+ )
127
+
128
+ monkeypatch.setattr(agy_mod.shutil, "which", lambda name: "/usr/local/bin/agy")
129
+ monkeypatch.setattr(agy_mod.subprocess, "run", lambda *a, **kw: _Proc())
130
+ AgyHandler.available_models.cache_clear()
131
+ try:
132
+ assert AgyHandler.available_models() == (
133
+ "gemini-3.7-flash-high",
134
+ "claude-opus-4-6-thinking",
135
+ "gpt-oss-120b-medium",
136
+ )
137
+ finally:
138
+ AgyHandler.available_models.cache_clear()
139
+
140
+
113
141
  def test_find_session_file_prefers_full_transcript(tmp_path: Path, monkeypatch) -> None:
114
142
  import playmaker.agents.agy as agy_mod
115
143
 
@@ -0,0 +1,208 @@
1
+ """Which executable each handler actually launches: [agents.<name>] binary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
11
+
12
+ import playmaker.config as config
13
+ from playmaker.config import agent_binary
14
+ from playmaker.registry import all_handlers
15
+
16
+ AGENTS = ("claude", "codex", "agy", "gemini", "opencode")
17
+
18
+
19
+ @pytest.fixture
20
+ def configured(monkeypatch):
21
+ """Install a fake config.toml body for the duration of a test."""
22
+
23
+ def _set(cfg: dict) -> None:
24
+ monkeypatch.setattr(config, "load_config", lambda: cfg)
25
+
26
+ return _set
27
+
28
+
29
+ # ---- resolution -------------------------------------------------------------
30
+
31
+
32
+ @pytest.mark.parametrize("agent", AGENTS)
33
+ def test_an_unconfigured_agent_runs_its_own_name(configured, agent) -> None:
34
+ configured({})
35
+
36
+ assert agent_binary(agent) == agent
37
+
38
+
39
+ @pytest.mark.parametrize("agent", AGENTS)
40
+ def test_the_configured_binary_wins(configured, agent) -> None:
41
+ configured({"agents": {agent: {"binary": f"/opt/{agent}-nightly/bin/{agent}"}}})
42
+
43
+ assert agent_binary(agent) == f"/opt/{agent}-nightly/bin/{agent}"
44
+
45
+
46
+ def test_a_leading_tilde_is_expanded(configured) -> None:
47
+ # The whole point of the setting is pointing at a CLI that installs under
48
+ # $HOME; subprocess does no shell expansion, so a literal "~" would ENOENT.
49
+ configured({"agents": {"opencode": {"binary": "~/.opencode/bin/opencode"}}})
50
+
51
+ resolved = agent_binary("opencode")
52
+
53
+ assert not resolved.startswith("~")
54
+ assert resolved == str(Path("~/.opencode/bin/opencode").expanduser())
55
+
56
+
57
+ def test_an_empty_binary_falls_back_to_the_agent_name(configured) -> None:
58
+ # `binary = ""` is a config typo, not a request to exec the empty string.
59
+ configured({"agents": {"agy": {"binary": " "}}})
60
+
61
+ assert agent_binary("agy") == "agy"
62
+
63
+
64
+ def test_one_agents_binary_does_not_leak_into_another(configured) -> None:
65
+ configured({"agents": {"claude": {"binary": "/opt/claude-nightly"}}})
66
+
67
+ assert agent_binary("claude") == "/opt/claude-nightly"
68
+ assert agent_binary("codex") == "codex"
69
+
70
+
71
+ # ---- availability -----------------------------------------------------------
72
+
73
+
74
+ @pytest.mark.parametrize("agent", AGENTS)
75
+ def test_availability_probes_the_configured_binary(monkeypatch, configured, agent) -> None:
76
+ # `playmaker dispatch` refuses to start when this says no, so a handler that
77
+ # ignored the setting would report "not installed" for a working CLI.
78
+ configured({"agents": {agent: {"binary": f"/opt/{agent}/bin/{agent}"}}})
79
+ probed: list[str] = []
80
+
81
+ def fake_which(cmd, *args, **kwargs):
82
+ probed.append(cmd)
83
+ return cmd
84
+
85
+ for module in ("claude", "codex", "agy", "gemini", "opencode"):
86
+ monkeypatch.setattr(f"playmaker.agents.{module}.shutil.which", fake_which)
87
+
88
+ assert all_handlers()[agent].is_available() is True
89
+ assert probed == [f"/opt/{agent}/bin/{agent}"]
90
+
91
+
92
+ @pytest.mark.parametrize("agent", AGENTS)
93
+ def test_availability_falls_back_to_the_bare_name(monkeypatch, configured, agent) -> None:
94
+ configured({})
95
+ probed: list[str] = []
96
+
97
+ def fake_which(cmd, *args, **kwargs):
98
+ probed.append(cmd)
99
+ return None
100
+
101
+ for module in ("claude", "codex", "agy", "gemini", "opencode"):
102
+ monkeypatch.setattr(f"playmaker.agents.{module}.shutil.which", fake_which)
103
+
104
+ assert all_handlers()[agent].is_available() is False
105
+ assert probed == [agent]
106
+
107
+
108
+ # ---- the launched command ---------------------------------------------------
109
+
110
+
111
+ def test_claude_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
112
+ configured({"agents": {"claude": {"binary": "/opt/claude-nightly/bin/claude"}}})
113
+
114
+ assert _dispatch_argv0("claude", monkeypatch, tmp_path) == "/opt/claude-nightly/bin/claude"
115
+
116
+
117
+ def test_codex_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
118
+ configured({"agents": {"codex": {"binary": "/opt/codex/bin/codex"}}})
119
+
120
+ assert _dispatch_argv0("codex", monkeypatch, tmp_path) == "/opt/codex/bin/codex"
121
+
122
+
123
+ def test_gemini_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
124
+ configured({"agents": {"gemini": {"binary": "/opt/gemini/bin/gemini"}}})
125
+
126
+ assert _dispatch_argv0("gemini", monkeypatch, tmp_path) == "/opt/gemini/bin/gemini"
127
+
128
+
129
+ def test_agy_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
130
+ configured({"agents": {"agy": {"binary": "/opt/agy/bin/agy"}}})
131
+
132
+ assert _dispatch_argv0("agy", monkeypatch, tmp_path) == "/opt/agy/bin/agy"
133
+
134
+
135
+ def test_opencode_dispatch_execs_the_configured_binary(monkeypatch, tmp_path, configured) -> None:
136
+ # The motivating case: opencode installs to ~/.opencode/bin, which only an
137
+ # interactive .zshrc puts on PATH — a detached dispatch needs this setting.
138
+ configured({"agents": {"opencode": {"binary": "~/.opencode/bin/opencode"}}})
139
+ expected = str(Path("~/.opencode/bin/opencode").expanduser())
140
+
141
+ assert _dispatch_argv0("opencode", monkeypatch, tmp_path) == expected
142
+
143
+
144
+ def _dispatch_argv0(agent: str, monkeypatch, tmp_path: Path) -> str:
145
+ """argv[0] of the process a dispatch would have spawned.
146
+
147
+ Every handler streams its child's stdout, so the fake has to be a Popen
148
+ stand-in rather than a plain recorder; we let each dispatch fail after the
149
+ exec and read the recorded command out of the closure.
150
+ """
151
+ recorded: list[list[str]] = []
152
+
153
+ class _FakePopen:
154
+ """Enough of Popen for every handler's read loop: agy polls and writes
155
+ to file handles, the others iterate stdout and read stderr."""
156
+
157
+ returncode = 1
158
+
159
+ def __init__(self, cmd, **kwargs):
160
+ recorded.append(cmd)
161
+ self.stdout = iter(())
162
+ self.stderr = _Reader()
163
+
164
+ def wait(self) -> int:
165
+ return self.returncode
166
+
167
+ def poll(self) -> int:
168
+ return self.returncode
169
+
170
+ def kill(self) -> None:
171
+ pass
172
+
173
+ class _Reader:
174
+ @staticmethod
175
+ def read() -> str:
176
+ return "stubbed"
177
+
178
+ monkeypatch.setattr(f"playmaker.agents.{agent}.subprocess.Popen", _FakePopen)
179
+ if agent == "opencode":
180
+ # Model validation shells out to `opencode models`; keep it out of the way.
181
+ from playmaker.agents.opencode import OpencodeHandler
182
+
183
+ monkeypatch.setattr(OpencodeHandler, "available_models", staticmethod(tuple))
184
+ if agent == "agy":
185
+ from playmaker.agents.agy import AgyHandler
186
+
187
+ monkeypatch.setattr(AgyHandler, "available_models", staticmethod(tuple))
188
+
189
+ with pytest.raises(RuntimeError):
190
+ all_handlers()[agent].dispatch(prompt="p", cwd=tmp_path)
191
+
192
+ assert len(recorded) == 1
193
+ return recorded[0][0]
194
+
195
+
196
+ # ---- the shipped template ---------------------------------------------------
197
+
198
+
199
+ def test_every_agent_in_the_template_declares_the_binary_it_defaults_to() -> None:
200
+ # The template is a contract: a key it lists has to be one a handler reads.
201
+ import tomllib
202
+
203
+ from playmaker.cli import _DEFAULT_CONFIG
204
+
205
+ cfg = tomllib.loads(_DEFAULT_CONFIG)
206
+
207
+ for agent in AGENTS:
208
+ assert cfg["agents"][agent]["binary"] == agent
@@ -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"
@@ -163,3 +163,56 @@ def test_the_environment_is_a_fallback_for_env_injected_configs(monkeypatch, tmp
163
163
 
164
164
  assert result["status"] == "ok"
165
165
  assert seen["headers"]["Authorization"] == "sk-from-env"
166
+
167
+
168
+ # The same endpoint on a live Pro plan, 2026-08. z.ai renamed the inference
169
+ # windows TOKENS_LIMIT -> CREDIT_LIMIT when plans moved to weekly Credits, and
170
+ # stopped returning the monthly TIME_LIMIT pool altogether.
171
+ _PAYLOAD_CREDITS = {
172
+ "code": 200,
173
+ "msg": "Operation successful",
174
+ "success": True,
175
+ "data": {
176
+ "level": "pro",
177
+ "limits": [
178
+ {
179
+ "type": "CREDIT_LIMIT",
180
+ "unit": 3,
181
+ "number": 5,
182
+ "usage": 12000,
183
+ "currentValue": 21,
184
+ "remaining": 11978,
185
+ "percentage": 1,
186
+ "nextResetTime": 1787084800394,
187
+ },
188
+ {
189
+ "type": "CREDIT_LIMIT",
190
+ "unit": 6,
191
+ "number": 1,
192
+ "usage": 60000,
193
+ "currentValue": 21,
194
+ "remaining": 59978,
195
+ "percentage": 1,
196
+ "nextResetTime": 1787671183998,
197
+ },
198
+ ],
199
+ },
200
+ }
201
+
202
+
203
+ def test_credit_limit_windows_read_like_the_token_ones(monkeypatch, keyed) -> None:
204
+ """The rename must not demote the rows to bare spans ("5 hours"/"1 week").
205
+
206
+ The labels are shared with the claude probe on purpose, so the two providers
207
+ line up in the table; an unmapped type still renders but loses that.
208
+ """
209
+ _respond(monkeypatch, _PAYLOAD_CREDITS)
210
+
211
+ result = quotas.zai_probe()
212
+
213
+ assert result["status"] == "ok"
214
+ assert result["tier"] == "Pro"
215
+ assert [(w["name"], w["pct_left"]) for w in result["windows"]] == [
216
+ ("Session", 99),
217
+ ("Weekly", 99),
218
+ ]
File without changes
File without changes