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/agents/base.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Common interfaces and types for agent handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
# Called by a handler the moment it learns the agent's session id, before the
|
|
12
|
+
# dispatch finishes. Codex emits it from a `thread.started` stdout event ~1s in;
|
|
13
|
+
# Claude/Gemini have no early signal in non-interactive mode and call it at the
|
|
14
|
+
# end. Failures inside the callback must not abort dispatch.
|
|
15
|
+
SessionStartedCallback = Callable[[str], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class DispatchResult:
|
|
20
|
+
"""Outcome of a synchronous one-shot dispatch."""
|
|
21
|
+
|
|
22
|
+
agent_session_id: str
|
|
23
|
+
cwd: str
|
|
24
|
+
session_file: Path | None
|
|
25
|
+
initial_output: str
|
|
26
|
+
cost_usd: float | None = None
|
|
27
|
+
duration_seconds: float | None = None
|
|
28
|
+
exit_code: int = 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Turn:
|
|
33
|
+
"""One normalized turn extracted from an agent's session file."""
|
|
34
|
+
|
|
35
|
+
role: str # user | assistant | tool | system
|
|
36
|
+
content: str
|
|
37
|
+
tool_calls: list[dict] = field(default_factory=list)
|
|
38
|
+
tool_results: list[dict] = field(default_factory=list)
|
|
39
|
+
timestamp: datetime | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AgentHandler(Protocol):
|
|
43
|
+
"""Each supported agent (claude/codex/agy/gemini) implements this Protocol."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
def is_available(self) -> bool:
|
|
48
|
+
"""Binary present and reachable on PATH."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def dispatch(
|
|
52
|
+
self,
|
|
53
|
+
prompt: str,
|
|
54
|
+
cwd: Path,
|
|
55
|
+
files: list[Path] | None = None,
|
|
56
|
+
on_session_started: SessionStartedCallback | None = None,
|
|
57
|
+
model: str | None = None,
|
|
58
|
+
) -> DispatchResult:
|
|
59
|
+
"""Run agent non-interactively, await first turn, return metadata.
|
|
60
|
+
|
|
61
|
+
Implementations MUST call `on_session_started(agent_session_id)` exactly
|
|
62
|
+
once as soon as the id is known — early if the agent's protocol exposes
|
|
63
|
+
it mid-stream, otherwise just before returning.
|
|
64
|
+
|
|
65
|
+
`model` is forwarded to the agent CLI's own `--model` flag when set;
|
|
66
|
+
when None the agent picks its configured default.
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def resume(
|
|
71
|
+
self,
|
|
72
|
+
prompt: str,
|
|
73
|
+
cwd: Path,
|
|
74
|
+
agent_session_id: str,
|
|
75
|
+
files: list[Path] | None = None,
|
|
76
|
+
on_session_started: SessionStartedCallback | None = None,
|
|
77
|
+
model: str | None = None,
|
|
78
|
+
) -> DispatchResult:
|
|
79
|
+
"""Continue a previously-started agent session with a new prompt.
|
|
80
|
+
|
|
81
|
+
The returned `DispatchResult.agent_session_id` may equal the input id
|
|
82
|
+
(most agents keep the same thread on resume) or may differ if the
|
|
83
|
+
agent's protocol mints a new continuation id — callers should rely on
|
|
84
|
+
whatever the result reports.
|
|
85
|
+
|
|
86
|
+
Like `dispatch`, MUST call `on_session_started` exactly once.
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
def find_session_file(self, agent_session_id: str, cwd: Path) -> Path | None:
|
|
91
|
+
"""Locate the agent's native session file for a given id+cwd."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
def parse_session_file(self, path: Path) -> list[Turn]:
|
|
95
|
+
"""Parse the session file into a normalized list of turns."""
|
|
96
|
+
...
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Claude Code CLI handler.
|
|
2
|
+
|
|
3
|
+
Empirically:
|
|
4
|
+
- oneshot: `claude -p "..."`
|
|
5
|
+
- json output: `--output-format json`
|
|
6
|
+
- primary cwd: subprocess cwd; `--add-dir` is variadic (multiple dirs) and would
|
|
7
|
+
swallow the positional prompt, so we omit it and rely on subprocess cwd alone
|
|
8
|
+
- session file: ~/.claude/projects/<cwd-with-slashes-as-dashes>/<session-id>.jsonl
|
|
9
|
+
- json result fields: session_id, result, total_cost_usd, duration_ms, usage
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
20
|
+
from playmaker.config import agent_setting
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClaudeHandler:
|
|
24
|
+
name = "claude"
|
|
25
|
+
|
|
26
|
+
def is_available(self) -> bool:
|
|
27
|
+
return shutil.which("claude") is not None
|
|
28
|
+
|
|
29
|
+
def dispatch(
|
|
30
|
+
self,
|
|
31
|
+
prompt: str,
|
|
32
|
+
cwd: Path,
|
|
33
|
+
files: list[Path] | None = None,
|
|
34
|
+
on_session_started: SessionStartedCallback | None = None,
|
|
35
|
+
model: str | None = None,
|
|
36
|
+
) -> DispatchResult:
|
|
37
|
+
"""Streaming dispatch — emits on_session_started early.
|
|
38
|
+
|
|
39
|
+
`claude -p --output-format stream-json --verbose` produces JSONL
|
|
40
|
+
on stdout where the first line is a `system/init` event carrying
|
|
41
|
+
`session_id`. We catch that within the first poll and fire the
|
|
42
|
+
callback so `playmaker dispatch` persists the id to state.db BEFORE
|
|
43
|
+
the agent finishes — other commands (`get`, `thread`) can locate
|
|
44
|
+
the session within ~1s instead of waiting for the full run.
|
|
45
|
+
"""
|
|
46
|
+
full_prompt = self._build_prompt(prompt, files or [])
|
|
47
|
+
# `--verbose` is required by claude-cli when stream-json is used
|
|
48
|
+
# without partial-messages; without it we get a parse-time refusal.
|
|
49
|
+
cmd = [
|
|
50
|
+
"claude",
|
|
51
|
+
"-p",
|
|
52
|
+
"--output-format",
|
|
53
|
+
"stream-json",
|
|
54
|
+
"--verbose",
|
|
55
|
+
]
|
|
56
|
+
# Detached runs have no human to approve tool prompts; without this
|
|
57
|
+
# the agent stalls on the first file write and ends its turn with
|
|
58
|
+
# zero changes. Skipping permissions is what makes sibling-Claude
|
|
59
|
+
# usable for write-heavy subtasks in headless mode. Opt out via
|
|
60
|
+
# [agents.claude] skip_permissions = false in ~/.playmaker/config.toml.
|
|
61
|
+
if agent_setting("claude", "skip_permissions", True):
|
|
62
|
+
cmd.append("--dangerously-skip-permissions")
|
|
63
|
+
if model:
|
|
64
|
+
cmd += ["--model", model]
|
|
65
|
+
cmd.append(full_prompt)
|
|
66
|
+
import time as _time
|
|
67
|
+
t0 = _time.monotonic()
|
|
68
|
+
proc = subprocess.Popen(
|
|
69
|
+
cmd,
|
|
70
|
+
cwd=str(cwd),
|
|
71
|
+
stdout=subprocess.PIPE,
|
|
72
|
+
stderr=subprocess.PIPE,
|
|
73
|
+
text=True,
|
|
74
|
+
bufsize=1, # line-buffered
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
agent_session_id: str | None = None
|
|
78
|
+
last_text = ""
|
|
79
|
+
error_text = "" # error reported in the stream-json result event
|
|
80
|
+
cost_usd: float | None = None
|
|
81
|
+
duration_seconds: float | None = None
|
|
82
|
+
first_lines: list[str] = [] # for diagnostics if no session_id
|
|
83
|
+
|
|
84
|
+
assert proc.stdout is not None
|
|
85
|
+
for raw in proc.stdout:
|
|
86
|
+
if len(first_lines) < 3:
|
|
87
|
+
first_lines.append(raw)
|
|
88
|
+
line = raw.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
obj = json.loads(line)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
# Early callback on first event carrying session_id (init).
|
|
97
|
+
if agent_session_id is None and obj.get("session_id"):
|
|
98
|
+
agent_session_id = obj["session_id"]
|
|
99
|
+
if on_session_started is not None:
|
|
100
|
+
try:
|
|
101
|
+
on_session_started(agent_session_id)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# Capture last assistant text + cost for the result.
|
|
106
|
+
etype = obj.get("type")
|
|
107
|
+
if etype == "assistant":
|
|
108
|
+
content = (obj.get("message") or {}).get("content") or []
|
|
109
|
+
for block in content:
|
|
110
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
111
|
+
last_text = block.get("text", "") or last_text
|
|
112
|
+
elif etype == "result":
|
|
113
|
+
last_text = obj.get("result", last_text)
|
|
114
|
+
# claude -p reports failures here (overload, rate-limit, refusal)
|
|
115
|
+
# with is_error=true and exit 1 but an EMPTY stderr — capture the
|
|
116
|
+
# message so the failure isn't surfaced as a blank "failed".
|
|
117
|
+
if obj.get("is_error") or (obj.get("subtype") not in (None, "success")):
|
|
118
|
+
error_text = (
|
|
119
|
+
obj.get("result") or obj.get("error") or obj.get("subtype") or ""
|
|
120
|
+
)
|
|
121
|
+
if obj.get("total_cost_usd") is not None:
|
|
122
|
+
cost_usd = obj["total_cost_usd"]
|
|
123
|
+
if obj.get("duration_ms") is not None:
|
|
124
|
+
duration_seconds = obj["duration_ms"] / 1000.0
|
|
125
|
+
|
|
126
|
+
stderr_buf = proc.stderr.read() if proc.stderr else ""
|
|
127
|
+
proc.wait()
|
|
128
|
+
|
|
129
|
+
if proc.returncode != 0:
|
|
130
|
+
detail = stderr_buf.strip() or error_text or last_text or "".join(first_lines)[:500]
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
f"claude failed (exit {proc.returncode}): {detail or '(no error output)'}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if agent_session_id is None:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"claude stream-json missing session_id in first events:\n"
|
|
138
|
+
f"{''.join(first_lines)[:500]}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if duration_seconds is None:
|
|
142
|
+
duration_seconds = _time.monotonic() - t0
|
|
143
|
+
|
|
144
|
+
return DispatchResult(
|
|
145
|
+
agent_session_id=agent_session_id,
|
|
146
|
+
cwd=str(cwd),
|
|
147
|
+
session_file=self.find_session_file(agent_session_id, cwd),
|
|
148
|
+
initial_output=last_text,
|
|
149
|
+
cost_usd=cost_usd,
|
|
150
|
+
duration_seconds=duration_seconds,
|
|
151
|
+
exit_code=proc.returncode,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def resume(
|
|
155
|
+
self,
|
|
156
|
+
prompt: str,
|
|
157
|
+
cwd: Path,
|
|
158
|
+
agent_session_id: str,
|
|
159
|
+
files: list[Path] | None = None,
|
|
160
|
+
on_session_started: SessionStartedCallback | None = None,
|
|
161
|
+
model: str | None = None,
|
|
162
|
+
) -> DispatchResult:
|
|
163
|
+
full_prompt = self._build_prompt(prompt, files or [])
|
|
164
|
+
cmd = [
|
|
165
|
+
"claude",
|
|
166
|
+
"-p",
|
|
167
|
+
"--resume",
|
|
168
|
+
agent_session_id,
|
|
169
|
+
"--output-format",
|
|
170
|
+
"json",
|
|
171
|
+
]
|
|
172
|
+
# See dispatch(): detached resume has no human to approve prompts.
|
|
173
|
+
if agent_setting("claude", "skip_permissions", True):
|
|
174
|
+
cmd.append("--dangerously-skip-permissions")
|
|
175
|
+
if model:
|
|
176
|
+
cmd += ["--model", model]
|
|
177
|
+
cmd.append(full_prompt)
|
|
178
|
+
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
|
|
179
|
+
if proc.returncode != 0:
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"claude resume failed (exit {proc.returncode}): "
|
|
182
|
+
f"{proc.stderr.strip() or proc.stdout.strip()}"
|
|
183
|
+
)
|
|
184
|
+
try:
|
|
185
|
+
data = json.loads(proc.stdout)
|
|
186
|
+
except json.JSONDecodeError as e:
|
|
187
|
+
raise RuntimeError(
|
|
188
|
+
f"claude resume returned non-JSON output: {proc.stdout[:500]}"
|
|
189
|
+
) from e
|
|
190
|
+
# Claude keeps the same session_id on --resume.
|
|
191
|
+
new_session_id = data.get("session_id", agent_session_id)
|
|
192
|
+
if on_session_started is not None:
|
|
193
|
+
try:
|
|
194
|
+
on_session_started(new_session_id)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
return DispatchResult(
|
|
198
|
+
agent_session_id=new_session_id,
|
|
199
|
+
cwd=str(cwd),
|
|
200
|
+
session_file=self.find_session_file(new_session_id, cwd),
|
|
201
|
+
initial_output=data.get("result", ""),
|
|
202
|
+
cost_usd=data.get("total_cost_usd"),
|
|
203
|
+
duration_seconds=data.get("duration_ms", 0) / 1000.0,
|
|
204
|
+
exit_code=proc.returncode,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def find_session_file(self, agent_session_id: str, cwd: Path) -> Path | None:
|
|
208
|
+
cwd_hash = self._hash_cwd(cwd)
|
|
209
|
+
path = Path("~/.claude/projects").expanduser() / cwd_hash / f"{agent_session_id}.jsonl"
|
|
210
|
+
return path if path.exists() else None
|
|
211
|
+
|
|
212
|
+
def parse_session_file(self, path: Path) -> list[Turn]:
|
|
213
|
+
"""Read Claude jsonl and yield user/assistant turns.
|
|
214
|
+
|
|
215
|
+
Each line is a top-level dict with `type` and `timestamp`. Relevant
|
|
216
|
+
types are `user` and `assistant`; `message.content` is either a
|
|
217
|
+
plain string (simple user input) or an array of typed blocks
|
|
218
|
+
(`text`, `thinking`, `tool_use`, `tool_result`).
|
|
219
|
+
"""
|
|
220
|
+
from datetime import datetime
|
|
221
|
+
|
|
222
|
+
turns: list[Turn] = []
|
|
223
|
+
if not path.exists():
|
|
224
|
+
return turns
|
|
225
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
226
|
+
for raw in fh:
|
|
227
|
+
raw = raw.strip()
|
|
228
|
+
if not raw:
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
obj = json.loads(raw)
|
|
232
|
+
except json.JSONDecodeError:
|
|
233
|
+
continue
|
|
234
|
+
kind = obj.get("type")
|
|
235
|
+
if kind not in ("user", "assistant"):
|
|
236
|
+
continue
|
|
237
|
+
msg = obj.get("message") or {}
|
|
238
|
+
role = msg.get("role") or kind
|
|
239
|
+
content_raw = msg.get("content")
|
|
240
|
+
|
|
241
|
+
texts: list[str] = []
|
|
242
|
+
tool_calls: list[dict] = []
|
|
243
|
+
tool_results: list[dict] = []
|
|
244
|
+
if isinstance(content_raw, str):
|
|
245
|
+
texts.append(content_raw)
|
|
246
|
+
elif isinstance(content_raw, list):
|
|
247
|
+
for block in content_raw:
|
|
248
|
+
if not isinstance(block, dict):
|
|
249
|
+
continue
|
|
250
|
+
btype = block.get("type")
|
|
251
|
+
if btype == "text":
|
|
252
|
+
texts.append(block.get("text", ""))
|
|
253
|
+
elif btype == "thinking":
|
|
254
|
+
# surface thinking for debugging when --include-tools
|
|
255
|
+
# is on; coach skill controls when to ask for it
|
|
256
|
+
texts.append(f"[thinking] {block.get('thinking', '')}")
|
|
257
|
+
elif btype == "tool_use":
|
|
258
|
+
tool_calls.append(
|
|
259
|
+
{
|
|
260
|
+
"id": block.get("id"),
|
|
261
|
+
"name": block.get("name"),
|
|
262
|
+
"input": block.get("input"),
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
elif btype == "tool_result":
|
|
266
|
+
content = block.get("content")
|
|
267
|
+
tool_results.append(
|
|
268
|
+
{
|
|
269
|
+
"tool_use_id": block.get("tool_use_id"),
|
|
270
|
+
"content": content
|
|
271
|
+
if isinstance(content, str)
|
|
272
|
+
else json.dumps(content) if content else "",
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
ts_raw = obj.get("timestamp")
|
|
277
|
+
ts = None
|
|
278
|
+
if isinstance(ts_raw, str):
|
|
279
|
+
try:
|
|
280
|
+
ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
|
|
281
|
+
except ValueError:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
turns.append(
|
|
285
|
+
Turn(
|
|
286
|
+
role=role,
|
|
287
|
+
content="\n".join(t for t in texts if t),
|
|
288
|
+
tool_calls=tool_calls,
|
|
289
|
+
tool_results=tool_results,
|
|
290
|
+
timestamp=ts,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return turns
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _hash_cwd(cwd: Path) -> str:
|
|
297
|
+
# Empirical: literal `/` -> `-`, applied to the absolute resolved path.
|
|
298
|
+
# e.g. /Users/x/Sites/foo -> -Users-x-Sites-foo
|
|
299
|
+
absolute = str(cwd.expanduser().resolve())
|
|
300
|
+
return absolute.replace("/", "-")
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _build_prompt(prompt: str, files: list[Path]) -> str:
|
|
304
|
+
if not files:
|
|
305
|
+
return prompt
|
|
306
|
+
refs = " ".join(f"@{p}" for p in files)
|
|
307
|
+
return f"{prompt}\n\n{refs}"
|