playmaker-cli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- playmaker/__init__.py +10 -0
- playmaker/__main__.py +4 -0
- playmaker/_skills/playmaker-coach/SKILL.md +282 -0
- playmaker/agents/__init__.py +0 -0
- playmaker/agents/agy.py +342 -0
- playmaker/agents/base.py +96 -0
- playmaker/agents/claude.py +307 -0
- playmaker/agents/codex.py +387 -0
- playmaker/agents/gemini.py +352 -0
- playmaker/cli.py +978 -0
- playmaker/config.py +30 -0
- playmaker/notify.py +82 -0
- playmaker/quotas.py +940 -0
- playmaker/registry.py +41 -0
- playmaker/state.py +173 -0
- playmaker/watcher.py +145 -0
- playmaker_cli-0.4.0.dist-info/METADATA +335 -0
- playmaker_cli-0.4.0.dist-info/RECORD +21 -0
- playmaker_cli-0.4.0.dist-info/WHEEL +4 -0
- playmaker_cli-0.4.0.dist-info/entry_points.txt +2 -0
- playmaker_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
playmaker/registry.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Map agent name -> handler instance + profile lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from playmaker.agents.agy import AgyHandler
|
|
8
|
+
from playmaker.agents.base import AgentHandler
|
|
9
|
+
from playmaker.agents.claude import ClaudeHandler
|
|
10
|
+
from playmaker.agents.codex import CodexHandler
|
|
11
|
+
from playmaker.agents.gemini import GeminiHandler
|
|
12
|
+
from playmaker.state import AGENTS_DIR
|
|
13
|
+
|
|
14
|
+
_HANDLERS: dict[str, AgentHandler] = {
|
|
15
|
+
"claude": ClaudeHandler(),
|
|
16
|
+
"codex": CodexHandler(),
|
|
17
|
+
"agy": AgyHandler(),
|
|
18
|
+
"gemini": GeminiHandler(),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_handler(name: str) -> AgentHandler:
|
|
23
|
+
if name not in _HANDLERS:
|
|
24
|
+
raise KeyError(f"unknown agent {name!r}; available: {', '.join(_HANDLERS)}")
|
|
25
|
+
return _HANDLERS[name]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def all_handlers() -> dict[str, AgentHandler]:
|
|
29
|
+
return dict(_HANDLERS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_profile(agent_name: str, project_cwd: Path | None = None) -> Path | None:
|
|
33
|
+
"""Project-local profile takes precedence over global."""
|
|
34
|
+
candidates: list[Path] = []
|
|
35
|
+
if project_cwd is not None:
|
|
36
|
+
candidates.append(project_cwd / ".playmaker" / "agents" / f"{agent_name}.md")
|
|
37
|
+
candidates.append(AGENTS_DIR / f"{agent_name}.md")
|
|
38
|
+
for path in candidates:
|
|
39
|
+
if path.exists():
|
|
40
|
+
return path
|
|
41
|
+
return None
|
playmaker/state.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""SQLite-backed run state for `playmaker`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import uuid
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
PLAYMAKER_HOME = Path("~/.playmaker").expanduser()
|
|
14
|
+
DB_PATH = PLAYMAKER_HOME / "state.db"
|
|
15
|
+
LOGS_DIR = PLAYMAKER_HOME / "logs"
|
|
16
|
+
OUTPUTS_DIR = PLAYMAKER_HOME / "outputs"
|
|
17
|
+
AGENTS_DIR = PLAYMAKER_HOME / "agents"
|
|
18
|
+
CONFIG_PATH = PLAYMAKER_HOME / "config.toml"
|
|
19
|
+
QUOTAS_PATH = PLAYMAKER_HOME / "quotas.json"
|
|
20
|
+
|
|
21
|
+
SCHEMA = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
agent TEXT NOT NULL,
|
|
25
|
+
agent_session_id TEXT,
|
|
26
|
+
prompt TEXT NOT NULL,
|
|
27
|
+
cwd TEXT NOT NULL,
|
|
28
|
+
files TEXT,
|
|
29
|
+
status TEXT NOT NULL,
|
|
30
|
+
started_at TEXT NOT NULL,
|
|
31
|
+
finished_at TEXT,
|
|
32
|
+
exit_code INTEGER,
|
|
33
|
+
cost_usd REAL,
|
|
34
|
+
duration_seconds REAL,
|
|
35
|
+
output_path TEXT,
|
|
36
|
+
session_file_path TEXT,
|
|
37
|
+
parent_id TEXT,
|
|
38
|
+
pid INTEGER,
|
|
39
|
+
model TEXT,
|
|
40
|
+
batch_id TEXT
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_status ON sessions(status);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_agent ON sessions(agent);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def now_iso() -> str:
|
|
49
|
+
return datetime.now(UTC).isoformat()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def new_session_id() -> str:
|
|
53
|
+
return str(uuid.uuid4())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def connect():
|
|
58
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
59
|
+
conn.row_factory = sqlite3.Row
|
|
60
|
+
try:
|
|
61
|
+
yield conn
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_dirs() -> None:
|
|
67
|
+
for d in (PLAYMAKER_HOME, LOGS_DIR, OUTPUTS_DIR, AGENTS_DIR):
|
|
68
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def init_db() -> None:
|
|
72
|
+
ensure_dirs()
|
|
73
|
+
with connect() as c:
|
|
74
|
+
c.executescript(SCHEMA)
|
|
75
|
+
# Migration: pre-existing databases may lack newer columns.
|
|
76
|
+
existing_cols = {row["name"] for row in c.execute("PRAGMA table_info(sessions)")}
|
|
77
|
+
if "model" not in existing_cols:
|
|
78
|
+
c.execute("ALTER TABLE sessions ADD COLUMN model TEXT")
|
|
79
|
+
if "batch_id" not in existing_cols:
|
|
80
|
+
c.execute("ALTER TABLE sessions ADD COLUMN batch_id TEXT")
|
|
81
|
+
# Created after migration so it works on pre-existing tables too.
|
|
82
|
+
c.execute("CREATE INDEX IF NOT EXISTS idx_batch ON sessions(batch_id)")
|
|
83
|
+
c.commit()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def insert_session(
|
|
87
|
+
*,
|
|
88
|
+
agent: str,
|
|
89
|
+
prompt: str,
|
|
90
|
+
cwd: str,
|
|
91
|
+
files: list[str] | None = None,
|
|
92
|
+
parent_id: str | None = None,
|
|
93
|
+
model: str | None = None,
|
|
94
|
+
batch_id: str | None = None,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Insert a new pending session, return its id."""
|
|
97
|
+
sid = new_session_id()
|
|
98
|
+
with connect() as c:
|
|
99
|
+
c.execute(
|
|
100
|
+
"""
|
|
101
|
+
INSERT INTO sessions (
|
|
102
|
+
id, agent, prompt, cwd, files,
|
|
103
|
+
status, started_at, parent_id, model, batch_id
|
|
104
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
""",
|
|
106
|
+
(
|
|
107
|
+
sid,
|
|
108
|
+
agent,
|
|
109
|
+
prompt,
|
|
110
|
+
cwd,
|
|
111
|
+
json.dumps(files) if files else None,
|
|
112
|
+
"pending",
|
|
113
|
+
now_iso(),
|
|
114
|
+
parent_id,
|
|
115
|
+
model,
|
|
116
|
+
batch_id,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
c.commit()
|
|
120
|
+
return sid
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_batch(batch_id: str) -> list[dict[str, Any]]:
|
|
124
|
+
"""All sessions in a dispatch batch, oldest first."""
|
|
125
|
+
with connect() as c:
|
|
126
|
+
rows = c.execute(
|
|
127
|
+
"SELECT * FROM sessions WHERE batch_id = ? ORDER BY started_at ASC",
|
|
128
|
+
(batch_id,),
|
|
129
|
+
).fetchall()
|
|
130
|
+
return [dict(r) for r in rows]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def update_session(session_id: str, **fields: Any) -> None:
|
|
134
|
+
if not fields:
|
|
135
|
+
return
|
|
136
|
+
cols = ", ".join(f"{k} = ?" for k in fields)
|
|
137
|
+
values = list(fields.values()) + [session_id]
|
|
138
|
+
with connect() as c:
|
|
139
|
+
c.execute(f"UPDATE sessions SET {cols} WHERE id = ?", values)
|
|
140
|
+
c.commit()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_session(session_id: str) -> dict[str, Any] | None:
|
|
144
|
+
with connect() as c:
|
|
145
|
+
row = c.execute(
|
|
146
|
+
"SELECT * FROM sessions WHERE id = ? OR id LIKE ?",
|
|
147
|
+
(session_id, f"{session_id}%"),
|
|
148
|
+
).fetchone()
|
|
149
|
+
return dict(row) if row else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def list_sessions(
|
|
153
|
+
*,
|
|
154
|
+
status: str | None = None,
|
|
155
|
+
agent: str | None = None,
|
|
156
|
+
limit: int = 50,
|
|
157
|
+
) -> list[dict[str, Any]]:
|
|
158
|
+
sql = "SELECT * FROM sessions"
|
|
159
|
+
where = []
|
|
160
|
+
params: list[Any] = []
|
|
161
|
+
if status:
|
|
162
|
+
where.append("status = ?")
|
|
163
|
+
params.append(status)
|
|
164
|
+
if agent:
|
|
165
|
+
where.append("agent = ?")
|
|
166
|
+
params.append(agent)
|
|
167
|
+
if where:
|
|
168
|
+
sql += " WHERE " + " AND ".join(where)
|
|
169
|
+
sql += " ORDER BY started_at DESC LIMIT ?"
|
|
170
|
+
params.append(limit)
|
|
171
|
+
with connect() as c:
|
|
172
|
+
rows = c.execute(sql, params).fetchall()
|
|
173
|
+
return [dict(r) for r in rows]
|
playmaker/watcher.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Rich Live TUI for `playmaker watch`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from playmaker import state
|
|
15
|
+
from playmaker.registry import get_handler
|
|
16
|
+
|
|
17
|
+
_ICONS = {
|
|
18
|
+
"pending": "โณ",
|
|
19
|
+
"running": "๐",
|
|
20
|
+
"done": "โ
",
|
|
21
|
+
"failed": "โ",
|
|
22
|
+
"killed": "๐",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_table() -> Table:
|
|
27
|
+
rows = state.list_sessions(limit=50)
|
|
28
|
+
# Keep non-terminal + recently terminated (last 5 minutes).
|
|
29
|
+
now = datetime.now(UTC)
|
|
30
|
+
keep = []
|
|
31
|
+
for r in rows:
|
|
32
|
+
if r["status"] in ("pending", "running"):
|
|
33
|
+
keep.append(r)
|
|
34
|
+
elif r["finished_at"]:
|
|
35
|
+
try:
|
|
36
|
+
finished = datetime.fromisoformat(r["finished_at"])
|
|
37
|
+
except ValueError:
|
|
38
|
+
continue
|
|
39
|
+
if (now - finished).total_seconds() < 300:
|
|
40
|
+
keep.append(r)
|
|
41
|
+
|
|
42
|
+
table = Table(
|
|
43
|
+
title=f"playmaker โ live sessions ({len(keep)})",
|
|
44
|
+
show_header=True,
|
|
45
|
+
header_style="bold",
|
|
46
|
+
)
|
|
47
|
+
table.add_column("id", style="cyan", no_wrap=True)
|
|
48
|
+
table.add_column("agent", no_wrap=True)
|
|
49
|
+
table.add_column("status", no_wrap=True)
|
|
50
|
+
table.add_column("age", no_wrap=True)
|
|
51
|
+
table.add_column("activity")
|
|
52
|
+
table.add_column("prompt")
|
|
53
|
+
|
|
54
|
+
for r in keep:
|
|
55
|
+
try:
|
|
56
|
+
started = datetime.fromisoformat(r["started_at"])
|
|
57
|
+
age_s = (now - started).total_seconds()
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
age_s = 0
|
|
60
|
+
age_str = _format_age(age_s)
|
|
61
|
+
icon = _ICONS.get(r["status"], "?")
|
|
62
|
+
prompt = (r["prompt"] or "").replace("\n", " ")
|
|
63
|
+
if len(prompt) > 60:
|
|
64
|
+
prompt = prompt[:57] + "..."
|
|
65
|
+
activity = _get_activity(r)
|
|
66
|
+
table.add_row(
|
|
67
|
+
r["id"][:8],
|
|
68
|
+
r["agent"],
|
|
69
|
+
f"{icon} {r['status']}",
|
|
70
|
+
age_str,
|
|
71
|
+
activity,
|
|
72
|
+
prompt,
|
|
73
|
+
)
|
|
74
|
+
return table
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_activity(row: dict[str, Any]) -> str:
|
|
78
|
+
status = row["status"]
|
|
79
|
+
if status == "running":
|
|
80
|
+
path_str = row.get("session_file_path")
|
|
81
|
+
if not path_str:
|
|
82
|
+
return "starting..."
|
|
83
|
+
path = Path(path_str)
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return "starting..."
|
|
86
|
+
try:
|
|
87
|
+
handler = get_handler(row["agent"])
|
|
88
|
+
turns = handler.parse_session_file(path)
|
|
89
|
+
if not turns:
|
|
90
|
+
return "starting..."
|
|
91
|
+
last = turns[-1]
|
|
92
|
+
if last.tool_calls:
|
|
93
|
+
# show "tool: <name>" of the LAST tool_call's 'name' field
|
|
94
|
+
name = last.tool_calls[-1].get("name", "")
|
|
95
|
+
text = f"tool: {name}"
|
|
96
|
+
elif last.content:
|
|
97
|
+
text = last.content
|
|
98
|
+
else:
|
|
99
|
+
return "starting..."
|
|
100
|
+
|
|
101
|
+
text = text.replace("\n", " ")
|
|
102
|
+
if len(text) > 60:
|
|
103
|
+
text = text[:57] + "..."
|
|
104
|
+
return text
|
|
105
|
+
except Exception:
|
|
106
|
+
return "-"
|
|
107
|
+
|
|
108
|
+
if status == "done":
|
|
109
|
+
out_path_str = row.get("output_path")
|
|
110
|
+
if not out_path_str:
|
|
111
|
+
return "-"
|
|
112
|
+
path = Path(out_path_str)
|
|
113
|
+
if not path.exists():
|
|
114
|
+
return "-"
|
|
115
|
+
try:
|
|
116
|
+
text = path.read_text().replace("\n", " ")
|
|
117
|
+
if len(text) > 60:
|
|
118
|
+
text = text[:57] + "..."
|
|
119
|
+
return text
|
|
120
|
+
except Exception:
|
|
121
|
+
return "-"
|
|
122
|
+
|
|
123
|
+
return "-"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _format_age(seconds: float) -> str:
|
|
127
|
+
seconds = int(seconds)
|
|
128
|
+
if seconds < 60:
|
|
129
|
+
return f"{seconds}s"
|
|
130
|
+
if seconds < 3600:
|
|
131
|
+
return f"{seconds // 60}m{seconds % 60:02d}s"
|
|
132
|
+
h, rem = divmod(seconds, 3600)
|
|
133
|
+
return f"{h}h{rem // 60:02d}m"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run() -> None:
|
|
137
|
+
state.init_db()
|
|
138
|
+
console = Console()
|
|
139
|
+
try:
|
|
140
|
+
with Live(_build_table(), console=console, refresh_per_second=1) as live:
|
|
141
|
+
while True:
|
|
142
|
+
time.sleep(1)
|
|
143
|
+
live.update(_build_table())
|
|
144
|
+
except KeyboardInterrupt:
|
|
145
|
+
console.print("[dim]watch stopped[/dim]")
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: playmaker-cli
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Playing-coach CLI for orchestrating Claude Code, Codex and Antigravity sub-agents in parallel.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vladsafedev/playmaker
|
|
6
|
+
Project-URL: Repository, https://github.com/vladsafedev/playmaker
|
|
7
|
+
Project-URL: Issues, https://github.com/vladsafedev/playmaker/issues
|
|
8
|
+
Author-email: Vladislav Shulyugin <vladislav.shulyugin@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,ai,antigravity,claude,claude-code,cli,codex,gemini,orchestration,subagents
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: rich>=13.7
|
|
25
|
+
Requires-Dist: typer>=0.12
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# playmaker
|
|
29
|
+
|
|
30
|
+
[](https://github.com/vladsafedev/playmaker/actions/workflows/ci.yml)
|
|
31
|
+
[](https://pypi.org/project/playmaker-cli/)
|
|
32
|
+
[](https://pypi.org/project/playmaker-cli/)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
**Run Claude Code, Codex and Antigravity as parallel sub-agents from one terminal โ and spend three separate quotas instead of one.**
|
|
36
|
+
|
|
37
|
+
You stay in your Claude Code session doing the part only you can do. `playmaker`
|
|
38
|
+
fans the rest out to other agent CLIs as detached processes, tracks them,
|
|
39
|
+
parses their native session files, and pings you when they land.
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ B=dashboard # one label for the whole fan-out
|
|
43
|
+
|
|
44
|
+
$ playmaker dispatch codex --batch $B --model gpt-5-codex -p "Add PATCH /users/:id โฆ"
|
|
45
|
+
session: 9f2c1a4e-โฆ pid: 48211 (detached)
|
|
46
|
+
$ playmaker dispatch agy --batch $B --model "Gemini 3.5 Flash (High)" -p "pytest coverage for โฆ"
|
|
47
|
+
session: 4b1f9c02-โฆ pid: 48219 (detached)
|
|
48
|
+
$ playmaker dispatch claude --batch $B --model sonnet -p "Update the API docs for โฆ"
|
|
49
|
+
session: c07d5511-โฆ pid: 48244 (detached)
|
|
50
|
+
|
|
51
|
+
# โฆyou keep working in your own session while those three run
|
|
52
|
+
|
|
53
|
+
$ playmaker list
|
|
54
|
+
โโโโโโโโโโโโณโโโโโโโโโณโโโโโโโโโโณโโโโโโโโโโโโโโโโโโโโโโณโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
55
|
+
โ id โ agent โ status โ started โ prompt โ
|
|
56
|
+
โกโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฉ
|
|
57
|
+
โ 9f2c1a4e โ codex โ running โ 2026-07-27T18:02:09 โ Add PATCH /users/:id โฆ โ
|
|
58
|
+
โ 4b1f9c02 โ agy โ done โ 2026-07-27T18:02:11 โ pytest coverage for โฆ โ
|
|
59
|
+
โ c07d5511 โ claude โ done โ 2026-07-27T18:02:14 โ Update the API docs โฆ โ
|
|
60
|
+
โโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
61
|
+
|
|
62
|
+
๐ playmaker โ batch done # one ping for the whole fan-outโฆ
|
|
63
|
+
3/3 done ยท codex โ ยท agy โ ยท claude โ # โฆclick it to open every output
|
|
64
|
+
|
|
65
|
+
$ playmaker summary 9f2c1a4e # what codex says it did
|
|
66
|
+
$ playmaker continue 9f2c1a4e -p "the 404 branch is missing a test"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Why
|
|
70
|
+
|
|
71
|
+
Three reasons to fan work out across agent CLIs instead of grinding through it
|
|
72
|
+
in one serial session:
|
|
73
|
+
|
|
74
|
+
1. **Wall-clock speed.** A task that decomposes into 3โ5 independent
|
|
75
|
+
work-streams (schema, backend, frontend, tests, docs) finishes 2โ4ร faster
|
|
76
|
+
when each stream runs as its own parallel agent.
|
|
77
|
+
2. **Provider arbitrage.** Codex and Antigravity quotas are entirely separate
|
|
78
|
+
pools from your Anthropic plan. Every slice you hand them is capacity your
|
|
79
|
+
main session never spends โ and Antigravity's roster includes Claude
|
|
80
|
+
Sonnet/Opus, so even Claude-quality work can run on Google's pool.
|
|
81
|
+
3. **Bucket arbitrage inside one plan.** Headless `claude -p` draws on the same
|
|
82
|
+
subscription as your interactive session, but per-model weekly buckets are
|
|
83
|
+
separate โ dispatching `--model sonnet` spends Sonnet's usually-idle bucket
|
|
84
|
+
instead of the scarce Opus one.
|
|
85
|
+
|
|
86
|
+
The catch: doing this by hand โ terminal tabs, jumping between tools,
|
|
87
|
+
copy-pasting context โ is friction. `playmaker` removes the friction; the
|
|
88
|
+
[coach skill](#the-coach-skill) provides the discipline.
|
|
89
|
+
|
|
90
|
+
## โ ๏ธ Sub-agents skip permission prompts by default
|
|
91
|
+
|
|
92
|
+
By default, playmaker launches Claude and Antigravity sub-agents with
|
|
93
|
+
`--dangerously-skip-permissions` (and Gemini with `--yolo`). This is
|
|
94
|
+
deliberate: a headless agent has no human at the keyboard, so without these
|
|
95
|
+
flags a detached run stalls at the first tool-approval prompt and finishes
|
|
96
|
+
having written nothing.
|
|
97
|
+
|
|
98
|
+
It also means a dispatched agent can run commands and edit files **without
|
|
99
|
+
asking**. Only dispatch prompts you'd be comfortable running unattended, in
|
|
100
|
+
working directories you trust.
|
|
101
|
+
|
|
102
|
+
To opt out for Claude, set this in `~/.playmaker/config.toml`:
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[agents.claude]
|
|
106
|
+
skip_permissions = false
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
โ with the caveat above: detached runs will then stall on the first permission
|
|
110
|
+
prompt, so this only really makes sense alongside `--sync` workflows or
|
|
111
|
+
allowlist rules you've configured in Claude Code itself.
|
|
112
|
+
|
|
113
|
+
## Install
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv tool install playmaker-cli # or: pipx install playmaker-cli
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>From source</summary>
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
git clone https://github.com/vladsafedev/playmaker
|
|
124
|
+
cd playmaker
|
|
125
|
+
uv tool install --editable .
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
</details>
|
|
129
|
+
|
|
130
|
+
Then set up the data directory and install the coach skill:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
playmaker init # creates ~/.playmaker/ (state.db, logs/, outputs/, config.toml)
|
|
134
|
+
playmaker skill install # drops playmaker-coach into ~/.claude/skills/
|
|
135
|
+
playmaker agents # which agent CLIs are reachable
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Prerequisites
|
|
139
|
+
|
|
140
|
+
`playmaker` orchestrates external CLIs โ install whichever you have access to:
|
|
141
|
+
|
|
142
|
+
| Agent | Install | Notes |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| **Claude Code** | `npm i -g @anthropic-ai/claude-code` | `--model sonnet` / `opus` / `haiku` |
|
|
145
|
+
| **Codex CLI** | `npm i -g @openai/codex` | `--model gpt-5-codex` |
|
|
146
|
+
| **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | models are display names: `--model "Claude Opus 4.6 (Thinking)"` โ see `agy models` |
|
|
147
|
+
| **Gemini CLI** (legacy) | `npm i -g @google/gemini-cli` | still supported, superseded by `agy` |
|
|
148
|
+
|
|
149
|
+
At least one is required; `playmaker agents` tells you which it can see.
|
|
150
|
+
|
|
151
|
+
## The coach skill
|
|
152
|
+
|
|
153
|
+
`playmaker` is the runner. The decision-making โ *when* delegation beats doing
|
|
154
|
+
it yourself, *which* agent gets *which* slice, how to size a subtask so
|
|
155
|
+
reviewing it is cheap โ lives in
|
|
156
|
+
[**playmaker-coach**](skills/playmaker-coach/SKILL.md), a Claude Code skill
|
|
157
|
+
that ships with the package:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
playmaker skill install # ~/.claude/skills/playmaker-coach/SKILL.md
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Then give any Claude Code session a multi-component task and it activates:
|
|
164
|
+
proposes a split with per-model quota rationale, waits for your approval, fans
|
|
165
|
+
out, and reviews what comes back.
|
|
166
|
+
|
|
167
|
+
## Commands
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
playmaker agents # who's installed
|
|
171
|
+
playmaker quotas [--refresh] # capacity per provider, per model
|
|
172
|
+
|
|
173
|
+
playmaker dispatch <agent> --prompt "..." # detached by default
|
|
174
|
+
[--model NAME] # forwarded to the agent's own CLI
|
|
175
|
+
[--cwd DIR]
|
|
176
|
+
[--files PATH...]
|
|
177
|
+
[--sync] # block and print the final answer
|
|
178
|
+
[--parent ID] # link lineage to an earlier session
|
|
179
|
+
[--batch LABEL] # group a fan-out; one summary ping
|
|
180
|
+
playmaker continue <id> --prompt "..." # follow-up inside the live session
|
|
181
|
+
[--model NAME] [--files PATH...] [--sync]
|
|
182
|
+
|
|
183
|
+
playmaker list [--status running|done|failed] [--agent NAME] [--limit N]
|
|
184
|
+
playmaker get <id> [--wait] [--poll SECONDS]
|
|
185
|
+
playmaker summary <id> # last 2 assistant messages
|
|
186
|
+
playmaker thread <id> [--last N] [--all] [--role assistant|user|tool]
|
|
187
|
+
[--include-tools] [--max-bytes N] [--follow]
|
|
188
|
+
playmaker logs <id> [--follow] # subprocess stdout for detached runs
|
|
189
|
+
playmaker kill <id>
|
|
190
|
+
playmaker watch # live TUI of sessions
|
|
191
|
+
playmaker skill install [--dir PATH] [--force]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`dispatch`, `continue`, `list`, `get`, `thread` and `quotas` all take `--json`
|
|
195
|
+
for scripting.
|
|
196
|
+
|
|
197
|
+
**`continue` vs a fresh `dispatch`.** `continue` sends a follow-up into the
|
|
198
|
+
agent's existing session, so its reasoning, tool history and file context are
|
|
199
|
+
still live โ that's the cheap path for "almost right, fix Y". Start fresh with
|
|
200
|
+
`--parent <id>` only when the old context has become a liability.
|
|
201
|
+
|
|
202
|
+
## How it works
|
|
203
|
+
|
|
204
|
+
```mermaid
|
|
205
|
+
flowchart LR
|
|
206
|
+
C["๐งโ๐ซ coach<br/>your Claude Code session"] -->|dispatch| P(("playmaker"))
|
|
207
|
+
P --> A1["claude -p<br/><i>--model sonnet</i>"]
|
|
208
|
+
P --> A2["codex exec"]
|
|
209
|
+
P --> A3["agy -p"]
|
|
210
|
+
A1 & A2 & A3 --> S[("state.db<br/>outputs/ ยท logs/")]
|
|
211
|
+
S -->|list ยท thread ยท summary| C
|
|
212
|
+
S -.->|batch drained| N["๐ one ping"]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Each dispatch is a detached OS process with its own quota; playmaker owns the
|
|
216
|
+
bookkeeping in between.
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
~/.playmaker/
|
|
220
|
+
โโโ state.db SQLite โ sessions, status, pids, models, output paths
|
|
221
|
+
โโโ config.toml
|
|
222
|
+
โโโ agents/ optional agent profile markdown (claude.md, codex.md, agy.mdโฆ)
|
|
223
|
+
โโโ outputs/ final output per session โ .md, or .json if the agent returned JSON
|
|
224
|
+
โโโ logs/ subprocess stdout for detached runs
|
|
225
|
+
โโโ quotas.json latest capacity snapshot
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`dispatch` runs the agent's CLI non-interactively, parses its native output,
|
|
229
|
+
and locates the session file the tool writes locally. Empirically:
|
|
230
|
+
|
|
231
|
+
| Agent | Session transcript |
|
|
232
|
+
|---|---|
|
|
233
|
+
| Claude | `~/.claude/projects/<cwd-with-slashes-as-dashes>/<id>.jsonl` |
|
|
234
|
+
| Codex | `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<thread_id>.jsonl` |
|
|
235
|
+
| Antigravity | `~/.gemini/antigravity-cli/brain/<conversation-id>/.system_generated/logs/transcript_full.jsonl` |
|
|
236
|
+
| Gemini | `~/.gemini/tmp/<cwd-basename>/chats/session-<ts>-<short_id>.{json,jsonl}` |
|
|
237
|
+
|
|
238
|
+
`thread` and `summary` normalize all of them into the same turn list, so every
|
|
239
|
+
agent reads back in one shape.
|
|
240
|
+
|
|
241
|
+
Profiles are optional and discovered, not shipped: drop
|
|
242
|
+
`~/.playmaker/agents/<name>.md` (or `./.playmaker/agents/<name>.md` next to a
|
|
243
|
+
repo, which wins) and it is prepended to every dispatch for that agent.
|
|
244
|
+
|
|
245
|
+
Two quirks worth knowing: **agy**'s shell cwd is a private scratch dir rather
|
|
246
|
+
than your workspace, so playmaker prepends a workspace preamble to every agy
|
|
247
|
+
dispatch; and a wrong `--model` is a silent failure on both **codex** (reports
|
|
248
|
+
`turn.failed` while exiting 0) and **agy** (runs its default model instead) โ
|
|
249
|
+
playmaker turns both into real errors.
|
|
250
|
+
|
|
251
|
+
## Notifications
|
|
252
|
+
|
|
253
|
+
Every detached dispatch pings when it finishes. With
|
|
254
|
+
[`terminal-notifier`](https://github.com/julienXX/terminal-notifier) installed
|
|
255
|
+
(`brew install terminal-notifier`), the notification is **clickable** and opens
|
|
256
|
+
the agent's output file in your editor:
|
|
257
|
+
|
|
258
|
+
```toml
|
|
259
|
+
[notifications]
|
|
260
|
+
editor = "Zed" # any app name `open -a` accepts
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Without terminal-notifier, playmaker falls back to plain `osascript` banners,
|
|
264
|
+
which can't be clicked.
|
|
265
|
+
|
|
266
|
+
**Batches.** Pass the same `--batch <label>` to every dispatch in a fan-out and
|
|
267
|
+
per-dispatch success pings are suppressed โ one "N/N done" summary fires when
|
|
268
|
+
the whole batch drains. Failures are the actionable event, so they still ping
|
|
269
|
+
immediately, with a distinct sound (Basso vs. the usual Blow).
|
|
270
|
+
|
|
271
|
+
Each session's final output lands in `~/.playmaker/outputs/<id>.md` (or `.json`
|
|
272
|
+
when the agent returned genuine JSON), so the notification click โ and
|
|
273
|
+
`playmaker get` โ always have a stable file to open.
|
|
274
|
+
|
|
275
|
+
## Quotas
|
|
276
|
+
|
|
277
|
+
`playmaker quotas` is token-based: it reads the credentials each CLI already
|
|
278
|
+
stores, no scraping and no browser.
|
|
279
|
+
|
|
280
|
+
```console
|
|
281
|
+
$ playmaker quotas --refresh
|
|
282
|
+
Claude Max 20x
|
|
283
|
+
Session โโโโโโโโโโโโโโโโโโโโ 80% left resets in 3h 12m
|
|
284
|
+
Weekly โโโโโโโโโโโโโโโโโโโโ 55% left resets in 4d 6h
|
|
285
|
+
Sonnet โโโโโโโโโโโโโโโโโโโโ 95% left resets in 4d 6h
|
|
286
|
+
|
|
287
|
+
Codex Plus
|
|
288
|
+
Session โโโโโโโโโโโโโโโโโโโโ 90% left resets in 1h 40m
|
|
289
|
+
Weekly โโโโโโโโโโโโโโโโโโโโ 65% left resets in 2d 9h
|
|
290
|
+
|
|
291
|
+
Antigravity (agy)
|
|
292
|
+
Gemini 5h โโโโโโโโโโโโโโโโโโโโ 100% left
|
|
293
|
+
Gemini weekly โโโโโโโโโโโโโโโโโโโโ 95% left
|
|
294
|
+
Claude/GPT 5h โโโโโโโโโโโโโโโโโโโโ 90% left resets in 2h 05m
|
|
295
|
+
Claude/GPT weekly โโโโโโโโโโโโโโโโโโโโ 70% left resets in 5d 1h
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The `Weekly` and `Sonnet` rows above are the point: they are **separate
|
|
299
|
+
buckets**. So is every agy row. Routing a subtask is choosing which of them to
|
|
300
|
+
spend.
|
|
301
|
+
|
|
302
|
+
- **Claude** โ OAuth usage API; token from the Claude Code Keychain entry.
|
|
303
|
+
- **Codex** โ ChatGPT `wham/usage` API; token from `~/.codex/auth.json`.
|
|
304
|
+
- **Antigravity** โ prefers agy's **local daemon**
|
|
305
|
+
(`RetrieveUserQuotaSummary` over its embedded gRPC-web endpoint), which is
|
|
306
|
+
the only source for the full categorized breakdown above. Works whenever any
|
|
307
|
+
agy process has the singleton daemon up. Falls back to the OAuth
|
|
308
|
+
`retrieveUserQuota` on the Antigravity backend, which surfaces only coarse
|
|
309
|
+
Gemini buckets and is flagged *daemon offline*. Approach ported from
|
|
310
|
+
[steipete/CodexBar](https://github.com/steipete/CodexBar).
|
|
311
|
+
|
|
312
|
+
Reading these at *model* granularity is the point: they are the load-balancing
|
|
313
|
+
input the coach skill uses to route each subtask.
|
|
314
|
+
|
|
315
|
+
## Limitations
|
|
316
|
+
|
|
317
|
+
- macOS-only for the Claude quota probe (reads the Claude Code Keychain entry)
|
|
318
|
+
and for notifications. Everything else works on Linux.
|
|
319
|
+
- No remote agents โ everything runs as a local subprocess on your machine.
|
|
320
|
+
- Quota probes read undocumented endpoints the vendors can change at any time.
|
|
321
|
+
|
|
322
|
+
## Contributing
|
|
323
|
+
|
|
324
|
+
Handlers for other agent CLIs are especially welcome โ see
|
|
325
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for the `AgentHandler` contract and what you
|
|
326
|
+
need to know about a CLI before writing one.
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
uv run pytest
|
|
330
|
+
uv run ruff check .
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
MIT. See [LICENSE](LICENSE).
|