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
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Codex CLI handler.
|
|
2
|
+
|
|
3
|
+
Empirically (codex 0.125.0):
|
|
4
|
+
- oneshot: `codex exec [--json] [--skip-git-repo-check] [--cd <DIR>] "<PROMPT>"`
|
|
5
|
+
- json output: `--json` (JSONL event stream on stdout)
|
|
6
|
+
- last assistant text: `--output-last-message <FILE>` writes it cleanly
|
|
7
|
+
- session file: ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO-ts>-<thread_id>.jsonl
|
|
8
|
+
|
|
9
|
+
stdout event types observed: thread.started, turn.started, item.completed, turn.completed
|
|
10
|
+
session-file event types: session_meta, turn_context, event_msg, response_item
|
|
11
|
+
The final assistant message is in `task_complete.payload.last_agent_message`
|
|
12
|
+
inside the rollout file, OR via stdout `item.completed` events with
|
|
13
|
+
`item.type == "agent_message"`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
26
|
+
|
|
27
|
+
CODEX_SESSIONS_ROOT = Path("~/.codex/sessions").expanduser()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CodexHandler:
|
|
31
|
+
name = "codex"
|
|
32
|
+
|
|
33
|
+
def is_available(self) -> bool:
|
|
34
|
+
return shutil.which("codex") is not None
|
|
35
|
+
|
|
36
|
+
def dispatch(
|
|
37
|
+
self,
|
|
38
|
+
prompt: str,
|
|
39
|
+
cwd: Path,
|
|
40
|
+
files: list[Path] | None = None,
|
|
41
|
+
on_session_started: SessionStartedCallback | None = None,
|
|
42
|
+
model: str | None = None,
|
|
43
|
+
) -> DispatchResult:
|
|
44
|
+
full_prompt = self._build_prompt(prompt, files or [])
|
|
45
|
+
|
|
46
|
+
with tempfile.NamedTemporaryFile(
|
|
47
|
+
"w+", suffix=".txt", delete=False, prefix="codex-last-"
|
|
48
|
+
) as tmp:
|
|
49
|
+
last_msg_path = Path(tmp.name)
|
|
50
|
+
|
|
51
|
+
cmd = [
|
|
52
|
+
"codex",
|
|
53
|
+
"exec",
|
|
54
|
+
"--json",
|
|
55
|
+
"--skip-git-repo-check",
|
|
56
|
+
"--cd",
|
|
57
|
+
str(cwd),
|
|
58
|
+
"-o",
|
|
59
|
+
str(last_msg_path),
|
|
60
|
+
]
|
|
61
|
+
if model:
|
|
62
|
+
cmd += ["-m", model]
|
|
63
|
+
cmd.append(full_prompt)
|
|
64
|
+
t0 = time.monotonic()
|
|
65
|
+
proc = subprocess.Popen(
|
|
66
|
+
cmd,
|
|
67
|
+
stdout=subprocess.PIPE,
|
|
68
|
+
stderr=subprocess.PIPE,
|
|
69
|
+
text=True,
|
|
70
|
+
bufsize=1, # line-buffered
|
|
71
|
+
)
|
|
72
|
+
thread_id, last_message_streamed, error_text, stdout_buf = self._consume_stream(
|
|
73
|
+
proc, on_session_started
|
|
74
|
+
)
|
|
75
|
+
stderr_buf = proc.stderr.read() if proc.stderr else ""
|
|
76
|
+
proc.wait()
|
|
77
|
+
duration = time.monotonic() - t0
|
|
78
|
+
|
|
79
|
+
last_message = ""
|
|
80
|
+
if last_msg_path.exists():
|
|
81
|
+
last_message = last_msg_path.read_text(encoding="utf-8").strip()
|
|
82
|
+
last_msg_path.unlink(missing_ok=True)
|
|
83
|
+
if not last_message:
|
|
84
|
+
last_message = last_message_streamed
|
|
85
|
+
|
|
86
|
+
# Codex reports invalid-model / auth / server failures via a `turn.failed`
|
|
87
|
+
# or `error` stream event while still exiting 0 and writing an EMPTY
|
|
88
|
+
# last-message file — without this the dispatch would look "done" with no
|
|
89
|
+
# output. Surface it as a failure so the coach reroutes instead of
|
|
90
|
+
# silently getting nothing back. (A non-empty message means the turn
|
|
91
|
+
# recovered, so only raise when we truly have no answer.)
|
|
92
|
+
if error_text and not last_message:
|
|
93
|
+
raise RuntimeError(f"codex turn failed: {error_text}")
|
|
94
|
+
|
|
95
|
+
# Codex emits a non-fatal "failed to record rollout items" warning when the
|
|
96
|
+
# agent shuts down; the answer is still written. Gate on the message we
|
|
97
|
+
# actually hold, not on the file — it has been consumed and unlinked above.
|
|
98
|
+
if proc.returncode != 0 and not last_message:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"codex failed (exit {proc.returncode}): "
|
|
101
|
+
f"{stderr_buf.strip() or error_text or stdout_buf[:500]}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if thread_id is None:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"codex stdout missing thread.started event:\n{stdout_buf[:500]}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return DispatchResult(
|
|
110
|
+
agent_session_id=thread_id,
|
|
111
|
+
cwd=str(cwd),
|
|
112
|
+
session_file=self.find_session_file(thread_id, cwd),
|
|
113
|
+
initial_output=last_message,
|
|
114
|
+
cost_usd=None, # Codex JSON does not expose USD cost
|
|
115
|
+
duration_seconds=duration,
|
|
116
|
+
exit_code=proc.returncode,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _consume_stream(
|
|
121
|
+
proc: subprocess.Popen,
|
|
122
|
+
on_session_started: SessionStartedCallback | None,
|
|
123
|
+
) -> tuple[str | None, str, str, str]:
|
|
124
|
+
"""Read stdout line-by-line, fire callback on thread.started, capture
|
|
125
|
+
the latest agent_message item and any failure event.
|
|
126
|
+
|
|
127
|
+
Returns (thread_id, last_message, error_text, raw_stdout). `error_text`
|
|
128
|
+
is the message from a `turn.failed`/`error`/`item.type == "error"` event
|
|
129
|
+
(invalid model, auth, upstream 4xx/5xx) — codex emits these while still
|
|
130
|
+
exiting 0, so callers must inspect it, not just the return code.
|
|
131
|
+
"""
|
|
132
|
+
thread_id: str | None = None
|
|
133
|
+
last_message = ""
|
|
134
|
+
error_text = ""
|
|
135
|
+
buf_parts: list[str] = []
|
|
136
|
+
assert proc.stdout is not None
|
|
137
|
+
for raw in proc.stdout:
|
|
138
|
+
buf_parts.append(raw)
|
|
139
|
+
line = raw.strip()
|
|
140
|
+
if not line:
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
obj = json.loads(line)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
continue
|
|
146
|
+
etype = obj.get("type")
|
|
147
|
+
if thread_id is None and etype == "thread.started":
|
|
148
|
+
tid = obj.get("thread_id")
|
|
149
|
+
if isinstance(tid, str) and tid:
|
|
150
|
+
thread_id = tid
|
|
151
|
+
if on_session_started is not None:
|
|
152
|
+
try:
|
|
153
|
+
on_session_started(tid)
|
|
154
|
+
except Exception:
|
|
155
|
+
# callback failures must not break dispatch
|
|
156
|
+
pass
|
|
157
|
+
elif etype == "item.completed":
|
|
158
|
+
item = obj.get("item") or {}
|
|
159
|
+
if item.get("type") == "agent_message" and item.get("text"):
|
|
160
|
+
last_message = item["text"]
|
|
161
|
+
elif etype in ("error", "turn.failed"):
|
|
162
|
+
error_text = CodexHandler._extract_error(obj) or error_text
|
|
163
|
+
return thread_id, last_message, error_text, "".join(buf_parts)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _extract_error(obj: dict) -> str:
|
|
167
|
+
"""Pull a human-readable message out of a codex error/turn.failed event.
|
|
168
|
+
|
|
169
|
+
The message field often nests a JSON string
|
|
170
|
+
({"error":{"message": "..."}}); unwrap it to the innermost message.
|
|
171
|
+
"""
|
|
172
|
+
raw = ""
|
|
173
|
+
err = obj.get("error")
|
|
174
|
+
if isinstance(err, dict):
|
|
175
|
+
raw = err.get("message") or err.get("type") or ""
|
|
176
|
+
elif isinstance(err, str):
|
|
177
|
+
raw = err
|
|
178
|
+
if not raw:
|
|
179
|
+
raw = obj.get("message") or ""
|
|
180
|
+
candidate = raw.strip()
|
|
181
|
+
for _ in range(3):
|
|
182
|
+
if not (candidate.startswith("{") and candidate.endswith("}")):
|
|
183
|
+
break
|
|
184
|
+
try:
|
|
185
|
+
inner = json.loads(candidate)
|
|
186
|
+
except (json.JSONDecodeError, ValueError):
|
|
187
|
+
break
|
|
188
|
+
nested = inner.get("error") if isinstance(inner, dict) else None
|
|
189
|
+
if isinstance(nested, dict) and nested.get("message"):
|
|
190
|
+
candidate = str(nested["message"]).strip()
|
|
191
|
+
elif isinstance(inner, dict) and inner.get("message"):
|
|
192
|
+
candidate = str(inner["message"]).strip()
|
|
193
|
+
else:
|
|
194
|
+
break
|
|
195
|
+
return candidate
|
|
196
|
+
|
|
197
|
+
def resume(
|
|
198
|
+
self,
|
|
199
|
+
prompt: str,
|
|
200
|
+
cwd: Path,
|
|
201
|
+
agent_session_id: str,
|
|
202
|
+
files: list[Path] | None = None,
|
|
203
|
+
on_session_started: SessionStartedCallback | None = None,
|
|
204
|
+
model: str | None = None,
|
|
205
|
+
) -> DispatchResult:
|
|
206
|
+
full_prompt = self._build_prompt(prompt, files or [])
|
|
207
|
+
|
|
208
|
+
with tempfile.NamedTemporaryFile(
|
|
209
|
+
"w+", suffix=".txt", delete=False, prefix="codex-last-"
|
|
210
|
+
) as tmp:
|
|
211
|
+
last_msg_path = Path(tmp.name)
|
|
212
|
+
|
|
213
|
+
# `codex exec resume` has no --cd; cwd flows through subprocess.
|
|
214
|
+
cmd = [
|
|
215
|
+
"codex",
|
|
216
|
+
"exec",
|
|
217
|
+
"resume",
|
|
218
|
+
"--json",
|
|
219
|
+
"--skip-git-repo-check",
|
|
220
|
+
"-o",
|
|
221
|
+
str(last_msg_path),
|
|
222
|
+
]
|
|
223
|
+
if model:
|
|
224
|
+
cmd += ["-m", model]
|
|
225
|
+
cmd += [agent_session_id, full_prompt]
|
|
226
|
+
t0 = time.monotonic()
|
|
227
|
+
proc = subprocess.Popen(
|
|
228
|
+
cmd,
|
|
229
|
+
cwd=str(cwd),
|
|
230
|
+
stdout=subprocess.PIPE,
|
|
231
|
+
stderr=subprocess.PIPE,
|
|
232
|
+
text=True,
|
|
233
|
+
bufsize=1,
|
|
234
|
+
)
|
|
235
|
+
thread_id, last_message_streamed, error_text, stdout_buf = self._consume_stream(
|
|
236
|
+
proc, on_session_started
|
|
237
|
+
)
|
|
238
|
+
stderr_buf = proc.stderr.read() if proc.stderr else ""
|
|
239
|
+
proc.wait()
|
|
240
|
+
duration = time.monotonic() - t0
|
|
241
|
+
|
|
242
|
+
last_message = ""
|
|
243
|
+
if last_msg_path.exists():
|
|
244
|
+
last_message = last_msg_path.read_text(encoding="utf-8").strip()
|
|
245
|
+
last_msg_path.unlink(missing_ok=True)
|
|
246
|
+
if not last_message:
|
|
247
|
+
last_message = last_message_streamed
|
|
248
|
+
|
|
249
|
+
# See dispatch(): codex reports failures via a stream event while exiting 0.
|
|
250
|
+
if error_text and not last_message:
|
|
251
|
+
raise RuntimeError(f"codex resume turn failed: {error_text}")
|
|
252
|
+
|
|
253
|
+
if proc.returncode != 0 and not last_message:
|
|
254
|
+
raise RuntimeError(
|
|
255
|
+
f"codex resume failed (exit {proc.returncode}): "
|
|
256
|
+
f"{stderr_buf.strip() or error_text or stdout_buf[:500]}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# On resume, codex re-emits thread.started with the SAME thread_id.
|
|
260
|
+
# If the event was missed (older codex builds, transport hiccup), fall
|
|
261
|
+
# back to the input id and fire the callback so the contract holds.
|
|
262
|
+
effective_id = thread_id or agent_session_id
|
|
263
|
+
if thread_id is None and on_session_started is not None:
|
|
264
|
+
try:
|
|
265
|
+
on_session_started(effective_id)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
return DispatchResult(
|
|
270
|
+
agent_session_id=effective_id,
|
|
271
|
+
cwd=str(cwd),
|
|
272
|
+
session_file=self.find_session_file(effective_id, cwd),
|
|
273
|
+
initial_output=last_message,
|
|
274
|
+
cost_usd=None,
|
|
275
|
+
duration_seconds=duration,
|
|
276
|
+
exit_code=proc.returncode,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def find_session_file(self, agent_session_id: str, cwd: Path) -> Path | None:
|
|
280
|
+
# filename: rollout-<ISO timestamp>-<thread_id>.jsonl
|
|
281
|
+
matches = sorted(
|
|
282
|
+
CODEX_SESSIONS_ROOT.rglob(f"rollout-*-{agent_session_id}.jsonl"),
|
|
283
|
+
key=lambda p: p.stat().st_mtime,
|
|
284
|
+
reverse=True,
|
|
285
|
+
)
|
|
286
|
+
return matches[0] if matches else None
|
|
287
|
+
|
|
288
|
+
def parse_session_file(self, path: Path) -> list[Turn]:
|
|
289
|
+
"""Read Codex rollout jsonl and yield user/assistant/tool turns.
|
|
290
|
+
|
|
291
|
+
Codex line shape: {timestamp, type, payload}. Relevant types:
|
|
292
|
+
- response_item with payload.type == "message" → user/assistant text
|
|
293
|
+
- response_item with payload.type == "function_call" → tool_call
|
|
294
|
+
- response_item with payload.type == "function_call_output" → tool_result
|
|
295
|
+
- event_msg with payload.type == "task_complete" → carries the
|
|
296
|
+
last_agent_message (already produced by some 'message' item)
|
|
297
|
+
|
|
298
|
+
Reasoning items are ignored by default — they're verbose and the
|
|
299
|
+
coach reads them only when actively debugging.
|
|
300
|
+
"""
|
|
301
|
+
from datetime import datetime
|
|
302
|
+
|
|
303
|
+
turns: list[Turn] = []
|
|
304
|
+
if not path.exists():
|
|
305
|
+
return turns
|
|
306
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
307
|
+
for raw in fh:
|
|
308
|
+
raw = raw.strip()
|
|
309
|
+
if not raw:
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
obj = json.loads(raw)
|
|
313
|
+
except json.JSONDecodeError:
|
|
314
|
+
continue
|
|
315
|
+
if obj.get("type") != "response_item":
|
|
316
|
+
continue
|
|
317
|
+
payload = obj.get("payload") or {}
|
|
318
|
+
ptype = payload.get("type")
|
|
319
|
+
|
|
320
|
+
ts = None
|
|
321
|
+
ts_raw = obj.get("timestamp")
|
|
322
|
+
if isinstance(ts_raw, str):
|
|
323
|
+
try:
|
|
324
|
+
ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
|
|
325
|
+
except ValueError:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
if ptype == "message":
|
|
329
|
+
role = payload.get("role") or "user"
|
|
330
|
+
if role == "developer":
|
|
331
|
+
# System scaffolding from Codex (skills/permissions); skip.
|
|
332
|
+
continue
|
|
333
|
+
content_blocks = payload.get("content") or []
|
|
334
|
+
texts: list[str] = []
|
|
335
|
+
for block in content_blocks:
|
|
336
|
+
if not isinstance(block, dict):
|
|
337
|
+
continue
|
|
338
|
+
text = block.get("text") or ""
|
|
339
|
+
if text:
|
|
340
|
+
texts.append(text)
|
|
341
|
+
turns.append(
|
|
342
|
+
Turn(
|
|
343
|
+
role=role,
|
|
344
|
+
content="\n".join(texts),
|
|
345
|
+
timestamp=ts,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
elif ptype == "function_call":
|
|
349
|
+
turns.append(
|
|
350
|
+
Turn(
|
|
351
|
+
role="assistant",
|
|
352
|
+
content="",
|
|
353
|
+
tool_calls=[
|
|
354
|
+
{
|
|
355
|
+
"id": payload.get("call_id") or payload.get("id"),
|
|
356
|
+
"name": payload.get("name"),
|
|
357
|
+
"input": payload.get("arguments"),
|
|
358
|
+
}
|
|
359
|
+
],
|
|
360
|
+
timestamp=ts,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
elif ptype == "function_call_output":
|
|
364
|
+
output = payload.get("output")
|
|
365
|
+
turns.append(
|
|
366
|
+
Turn(
|
|
367
|
+
role="tool",
|
|
368
|
+
content="",
|
|
369
|
+
tool_results=[
|
|
370
|
+
{
|
|
371
|
+
"tool_use_id": payload.get("call_id"),
|
|
372
|
+
"content": output
|
|
373
|
+
if isinstance(output, str)
|
|
374
|
+
else json.dumps(output) if output else "",
|
|
375
|
+
}
|
|
376
|
+
],
|
|
377
|
+
timestamp=ts,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return turns
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def _build_prompt(prompt: str, files: list[Path]) -> str:
|
|
384
|
+
if not files:
|
|
385
|
+
return prompt
|
|
386
|
+
refs = "\n".join(f"@{p}" for p in files)
|
|
387
|
+
return f"{prompt}\n\n{refs}"
|