playmaker-cli 0.7.1__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.
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/CHANGELOG.md +50 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/PKG-INFO +13 -1
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/README.md +12 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/pyproject.toml +1 -1
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/skills/playmaker-coach/SKILL.md +5 -5
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/agy.py +13 -6
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/claude.py +9 -4
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/codex.py +4 -4
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/gemini.py +5 -4
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/opencode.py +8 -5
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/cli.py +33 -5
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/config.py +19 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/quotas.py +22 -8
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_agy.py +28 -0
- playmaker_cli-0.7.2/tests/test_binary.py +208 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_quotas_zai.py +53 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/.gitignore +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/LICENSE +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/registry.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/state.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/__init__.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_batch.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_claude.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_codex.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_opencode.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_permissions.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_quotas_antigravity.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_registry.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_skill.py +0 -0
- {playmaker_cli-0.7.1 → playmaker_cli-0.7.2}/tests/test_state.py +0 -0
|
@@ -5,6 +5,56 @@ 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
|
+
|
|
8
58
|
## [0.7.1] - 2026-08-18
|
|
9
59
|
|
|
10
60
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: playmaker-cli
|
|
3
|
-
Version: 0.7.
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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"],
|
|
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.
|
|
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
|
|
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"],
|
|
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(
|
|
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(
|
|
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 = {
|
|
850
|
-
|
|
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, {})
|
|
@@ -936,7 +936,15 @@ _ZAI_UNIT_NAMES = {3: "hour", 4: "day", 5: "month", 6: "week"}
|
|
|
936
936
|
|
|
937
937
|
# (type, unit, number) -> label. "Session"/"Weekly" deliberately match the
|
|
938
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.
|
|
939
945
|
_ZAI_WINDOW_LABELS = {
|
|
946
|
+
("CREDIT_LIMIT", 3, 5): "Session",
|
|
947
|
+
("CREDIT_LIMIT", 6, 1): "Weekly",
|
|
940
948
|
("TOKENS_LIMIT", 3, 5): "Session",
|
|
941
949
|
("TOKENS_LIMIT", 6, 1): "Weekly",
|
|
942
950
|
("TIME_LIMIT", 5, 1): "MCP tools",
|
|
@@ -979,16 +987,22 @@ def _zai_window_label(limit_type: object, unit: object, number: object) -> str:
|
|
|
979
987
|
def zai_probe() -> dict:
|
|
980
988
|
"""GLM Coding Plan quota, for work dispatched through `opencode`.
|
|
981
989
|
|
|
982
|
-
Verified response on a live plan (2026-
|
|
990
|
+
Verified response on a live Pro plan (2026-08):
|
|
983
991
|
|
|
984
992
|
{"code": 200, "success": true, "msg": "Operation successful",
|
|
985
|
-
"data": {"level": "
|
|
986
|
-
{"type": "
|
|
987
|
-
|
|
988
|
-
"nextResetTime":
|
|
989
|
-
{"type": "
|
|
990
|
-
"currentValue":
|
|
991
|
-
"nextResetTime":
|
|
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.
|
|
992
1006
|
|
|
993
1007
|
`percentage` is percent *used*, `nextResetTime` is epoch ms and is absent
|
|
994
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
|
|
@@ -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
|
|
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
|