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/cli.py
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
"""Typer CLI app — entry point for `playmaker`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from playmaker import __version__, notify, state, watcher
|
|
18
|
+
from playmaker.registry import get_handler
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="playmaker",
|
|
22
|
+
help="Multi-agent orchestration CLI. Dispatch subtasks to "
|
|
23
|
+
"Claude/Codex/Antigravity and observe.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
err_console = Console(stderr=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version_callback(value: bool) -> None:
|
|
33
|
+
if value:
|
|
34
|
+
console.print(f"playmaker {__version__}")
|
|
35
|
+
raise typer.Exit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.callback()
|
|
39
|
+
def main(
|
|
40
|
+
version: bool = typer.Option(
|
|
41
|
+
False,
|
|
42
|
+
"--version",
|
|
43
|
+
"-V",
|
|
44
|
+
help="show the installed playmaker version and exit",
|
|
45
|
+
callback=_version_callback,
|
|
46
|
+
is_eager=True,
|
|
47
|
+
),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Multi-agent orchestration CLI."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command()
|
|
53
|
+
def init() -> None:
|
|
54
|
+
"""Bootstrap ~/.playmaker/ structure (config, state.db, dirs)."""
|
|
55
|
+
state.init_db()
|
|
56
|
+
if not state.CONFIG_PATH.exists():
|
|
57
|
+
state.CONFIG_PATH.write_text(_DEFAULT_CONFIG, encoding="utf-8")
|
|
58
|
+
console.print(f"[green]initialized[/green] {state.PLAYMAKER_HOME}")
|
|
59
|
+
console.print(f" db: {state.DB_PATH}")
|
|
60
|
+
console.print(f" logs: {state.LOGS_DIR}")
|
|
61
|
+
console.print(f" out: {state.OUTPUTS_DIR}")
|
|
62
|
+
console.print(f" config: {state.CONFIG_PATH}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
skill_app = typer.Typer(
|
|
66
|
+
help="Install the bundled playmaker-coach skill for Claude Code.",
|
|
67
|
+
no_args_is_help=True,
|
|
68
|
+
)
|
|
69
|
+
app.add_typer(skill_app, name="skill")
|
|
70
|
+
|
|
71
|
+
SKILL_NAME = "playmaker-coach"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bundled_skill_dir() -> Path:
|
|
75
|
+
"""Where the coach skill ships: inside the wheel, or `skills/` in a checkout."""
|
|
76
|
+
packaged = Path(__file__).resolve().parent / "_skills" / SKILL_NAME
|
|
77
|
+
if packaged.is_dir():
|
|
78
|
+
return packaged
|
|
79
|
+
return Path(__file__).resolve().parents[2] / "skills" / SKILL_NAME
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@skill_app.command("path")
|
|
83
|
+
def skill_path() -> None:
|
|
84
|
+
"""Print the path of the bundled skill inside this installation."""
|
|
85
|
+
console.print(str(_bundled_skill_dir()))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@skill_app.command("install")
|
|
89
|
+
def skill_install(
|
|
90
|
+
dest: Path = typer.Option(
|
|
91
|
+
Path("~/.claude/skills"),
|
|
92
|
+
"--dir",
|
|
93
|
+
help="skills directory to install into",
|
|
94
|
+
),
|
|
95
|
+
force: bool = typer.Option(False, "--force", help="overwrite an existing copy"),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Copy the playmaker-coach skill into your Claude Code skills directory."""
|
|
98
|
+
source = _bundled_skill_dir() / "SKILL.md"
|
|
99
|
+
if not source.exists():
|
|
100
|
+
err_console.print(f"[red]bundled skill not found at {source}[/red]")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
target_dir = dest.expanduser() / SKILL_NAME
|
|
104
|
+
target = target_dir / "SKILL.md"
|
|
105
|
+
if target.exists() and not force:
|
|
106
|
+
err_console.print(
|
|
107
|
+
f"[yellow]{target} already exists[/yellow] — pass --force to overwrite"
|
|
108
|
+
)
|
|
109
|
+
raise typer.Exit(1)
|
|
110
|
+
|
|
111
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
113
|
+
console.print(f"[green]installed[/green] {target}")
|
|
114
|
+
console.print(" Start a new Claude Code session and give it a multi-part task.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def dispatch(
|
|
119
|
+
agent: str = typer.Argument(..., help="agent name (claude|codex|agy|gemini)"),
|
|
120
|
+
prompt: str = typer.Option(..., "--prompt", "-p", help="initial prompt"),
|
|
121
|
+
cwd: Path = typer.Option(
|
|
122
|
+
Path.cwd(),
|
|
123
|
+
"--cwd",
|
|
124
|
+
help="working directory for the agent (defaults to current dir)",
|
|
125
|
+
),
|
|
126
|
+
files: list[Path] | None = typer.Option(
|
|
127
|
+
None, "--files", "-f", help="files to attach to prompt"
|
|
128
|
+
),
|
|
129
|
+
model: str | None = typer.Option(
|
|
130
|
+
None,
|
|
131
|
+
"--model",
|
|
132
|
+
"-m",
|
|
133
|
+
help="forwarded to the agent CLI's --model (e.g. claude 'opus'/'sonnet', "
|
|
134
|
+
"codex 'gpt-5-codex', agy 'Claude Opus 4.6 (Thinking)' / 'Gemini 3.5 Flash "
|
|
135
|
+
"(High)' — display names from `agy models`); omitted = agent default",
|
|
136
|
+
),
|
|
137
|
+
sync: bool = typer.Option(
|
|
138
|
+
False,
|
|
139
|
+
"--sync",
|
|
140
|
+
help="block until the agent finishes and print final output (default is detached)",
|
|
141
|
+
),
|
|
142
|
+
parent: str | None = typer.Option(
|
|
143
|
+
None, "--parent", help="parent session id (for delegation tree)"
|
|
144
|
+
),
|
|
145
|
+
batch: str | None = typer.Option(
|
|
146
|
+
None,
|
|
147
|
+
"--batch",
|
|
148
|
+
help="batch label: pass the same value to every dispatch in one fan-out. "
|
|
149
|
+
"Per-dispatch success pings are suppressed; one summary fires when the "
|
|
150
|
+
"whole batch finishes. Failures still ping immediately.",
|
|
151
|
+
),
|
|
152
|
+
json_out: bool = typer.Option(False, "--json", help="emit machine-readable result"),
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Run an agent non-interactively. Detached by default — prints session id
|
|
155
|
+
and returns immediately. Use --sync to block and print the final answer."""
|
|
156
|
+
state.init_db()
|
|
157
|
+
handler = get_handler(agent)
|
|
158
|
+
if not handler.is_available():
|
|
159
|
+
err_console.print(f"[red]agent {agent!r} binary not found on PATH[/red]")
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
cwd_resolved = cwd.expanduser().resolve()
|
|
163
|
+
sid = state.insert_session(
|
|
164
|
+
agent=agent,
|
|
165
|
+
prompt=prompt,
|
|
166
|
+
cwd=str(cwd_resolved),
|
|
167
|
+
files=[str(f) for f in (files or [])],
|
|
168
|
+
parent_id=parent,
|
|
169
|
+
model=model,
|
|
170
|
+
batch_id=batch,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if sync:
|
|
174
|
+
state.update_session(sid, status="running", pid=os.getpid())
|
|
175
|
+
_run_dispatch(sid)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
log_path = state.LOGS_DIR / f"{sid}.log"
|
|
179
|
+
log_fh = open(log_path, "wb")
|
|
180
|
+
cmd = [sys.executable, "-m", "playmaker", "_run-detached", sid]
|
|
181
|
+
proc = subprocess.Popen(
|
|
182
|
+
cmd,
|
|
183
|
+
stdout=log_fh,
|
|
184
|
+
stderr=subprocess.STDOUT,
|
|
185
|
+
stdin=subprocess.DEVNULL,
|
|
186
|
+
start_new_session=True,
|
|
187
|
+
close_fds=True,
|
|
188
|
+
)
|
|
189
|
+
log_fh.close()
|
|
190
|
+
state.update_session(sid, status="running", pid=proc.pid)
|
|
191
|
+
if json_out:
|
|
192
|
+
typer.echo(json.dumps({"session_id": sid, "pid": proc.pid, "status": "running"}))
|
|
193
|
+
else:
|
|
194
|
+
console.print(f"[dim]session: {sid} pid: {proc.pid} (detached)[/dim]")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _run_dispatch(sid: str) -> None:
|
|
198
|
+
"""Execute the dispatch for a pending session and update its row."""
|
|
199
|
+
row = state.get_session(sid)
|
|
200
|
+
if row is None:
|
|
201
|
+
err_console.print(f"[red]session {sid!r} vanished[/red]")
|
|
202
|
+
raise typer.Exit(1)
|
|
203
|
+
handler = get_handler(row["agent"])
|
|
204
|
+
cwd = Path(row["cwd"])
|
|
205
|
+
files = [Path(p) for p in json.loads(row["files"] or "[]")]
|
|
206
|
+
|
|
207
|
+
def _on_session_started(agent_session_id: str) -> None:
|
|
208
|
+
# Persist the id immediately so other commands (`get`, `thread`) can
|
|
209
|
+
# locate the session before the agent finishes.
|
|
210
|
+
state.update_session(sid, agent_session_id=agent_session_id)
|
|
211
|
+
|
|
212
|
+
# Pre-populated agent_session_id is the marker that this row is a resume
|
|
213
|
+
# of an existing agent thread (set by `continue` before spawning).
|
|
214
|
+
resume_target = row.get("agent_session_id")
|
|
215
|
+
model = row.get("model")
|
|
216
|
+
try:
|
|
217
|
+
if resume_target:
|
|
218
|
+
result = handler.resume(
|
|
219
|
+
row["prompt"],
|
|
220
|
+
cwd,
|
|
221
|
+
resume_target,
|
|
222
|
+
files,
|
|
223
|
+
on_session_started=_on_session_started,
|
|
224
|
+
model=model,
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
result = handler.dispatch(
|
|
228
|
+
row["prompt"],
|
|
229
|
+
cwd,
|
|
230
|
+
files,
|
|
231
|
+
on_session_started=_on_session_started,
|
|
232
|
+
model=model,
|
|
233
|
+
)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
state.update_session(sid, status="failed", finished_at=state.now_iso(), exit_code=1)
|
|
236
|
+
err_console.print(f"[red]dispatch failed:[/red] {exc}")
|
|
237
|
+
# Failures always ping immediately and loudly (Basso), even inside a
|
|
238
|
+
# batch — they're the actionable event. Click opens the log.
|
|
239
|
+
notify.notify(
|
|
240
|
+
f"playmaker — {row['agent']} FAILED",
|
|
241
|
+
_one_line(str(exc), 120),
|
|
242
|
+
sound_name="Basso",
|
|
243
|
+
open_path=str(state.LOGS_DIR / f"{sid}.log"),
|
|
244
|
+
group=f"playmaker-fail-{sid}",
|
|
245
|
+
)
|
|
246
|
+
_maybe_finalize_batch(row.get("batch_id"))
|
|
247
|
+
raise typer.Exit(1) from exc
|
|
248
|
+
|
|
249
|
+
output_path = _write_output(sid, result.initial_output)
|
|
250
|
+
|
|
251
|
+
state.update_session(
|
|
252
|
+
sid,
|
|
253
|
+
status="done",
|
|
254
|
+
finished_at=state.now_iso(),
|
|
255
|
+
agent_session_id=result.agent_session_id,
|
|
256
|
+
session_file_path=str(result.session_file) if result.session_file else None,
|
|
257
|
+
output_path=str(output_path),
|
|
258
|
+
cost_usd=result.cost_usd,
|
|
259
|
+
duration_seconds=result.duration_seconds,
|
|
260
|
+
exit_code=result.exit_code,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# In a batch, stay quiet per-dispatch — one summary fires when the whole
|
|
264
|
+
# batch drains (see _maybe_finalize_batch). Solo dispatches ping here.
|
|
265
|
+
if not row.get("batch_id"):
|
|
266
|
+
notify.notify(
|
|
267
|
+
f"playmaker — {row['agent']} done",
|
|
268
|
+
_one_line(result.initial_output),
|
|
269
|
+
sound_name="Blow",
|
|
270
|
+
open_path=str(output_path),
|
|
271
|
+
group=f"playmaker-{sid}",
|
|
272
|
+
)
|
|
273
|
+
_maybe_finalize_batch(row.get("batch_id"))
|
|
274
|
+
|
|
275
|
+
console.print(f"[dim]session: {sid} agent_session: {result.agent_session_id}[/dim]")
|
|
276
|
+
typer.echo(result.initial_output)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _write_output(sid: str, text: str) -> Path:
|
|
280
|
+
"""Persist an agent's final output. Most outputs are Markdown, so use `.md`
|
|
281
|
+
(renders in Quick Look / editors); detect genuine JSON and use `.json`."""
|
|
282
|
+
ext = "md"
|
|
283
|
+
stripped = (text or "").strip()
|
|
284
|
+
if stripped[:1] in "{[":
|
|
285
|
+
try:
|
|
286
|
+
json.loads(stripped)
|
|
287
|
+
ext = "json"
|
|
288
|
+
except ValueError:
|
|
289
|
+
pass
|
|
290
|
+
path = state.OUTPUTS_DIR / f"{sid}.{ext}"
|
|
291
|
+
path.write_text(text or "", encoding="utf-8")
|
|
292
|
+
return path
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _one_line(text: str, limit: int = 90) -> str:
|
|
296
|
+
"""Collapse whitespace, drop markdown markers, truncate (char-safe)."""
|
|
297
|
+
t = " ".join((text or "").split())
|
|
298
|
+
for ch in ("`", "*", "#", ">", "_", "~"):
|
|
299
|
+
t = t.replace(ch, "")
|
|
300
|
+
t = " ".join(t.split())
|
|
301
|
+
return (t[:limit] + "…") if len(t) > limit else t
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _batch_slug(batch_id: str) -> str:
|
|
305
|
+
return "".join(c if c.isalnum() or c in "-_" else "-" for c in batch_id)[:60]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _maybe_finalize_batch(batch_id: str | None) -> None:
|
|
309
|
+
"""Fire one summary notification when every session in a batch is terminal.
|
|
310
|
+
|
|
311
|
+
Cross-process safe: each detached dispatch calls this on completion; only
|
|
312
|
+
the finisher that wins the O_EXCL sentinel actually notifies.
|
|
313
|
+
"""
|
|
314
|
+
if not batch_id:
|
|
315
|
+
return
|
|
316
|
+
siblings = state.list_batch(batch_id)
|
|
317
|
+
if not siblings:
|
|
318
|
+
return
|
|
319
|
+
terminal = {"done", "failed", "killed"}
|
|
320
|
+
if any(s["status"] not in terminal for s in siblings):
|
|
321
|
+
return # not the last to finish
|
|
322
|
+
|
|
323
|
+
sentinel = state.LOGS_DIR / f".batch-{_batch_slug(batch_id)}.done"
|
|
324
|
+
try:
|
|
325
|
+
fd = os.open(str(sentinel), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
326
|
+
os.close(fd)
|
|
327
|
+
except FileExistsError:
|
|
328
|
+
return # another finisher already fired the summary
|
|
329
|
+
|
|
330
|
+
ok = [s for s in siblings if s["status"] == "done"]
|
|
331
|
+
marks = " · ".join(f"{s['agent']} {'✓' if s['status'] == 'done' else '✗'}" for s in siblings)
|
|
332
|
+
combined = _render_batch_file(batch_id, siblings)
|
|
333
|
+
notify.notify(
|
|
334
|
+
"playmaker — batch done",
|
|
335
|
+
f"{len(ok)}/{len(siblings)} done · {marks}",
|
|
336
|
+
sound_name="Blow" if len(ok) == len(siblings) else "Basso",
|
|
337
|
+
open_path=str(combined) if combined else None,
|
|
338
|
+
group=f"playmaker-batch-{_batch_slug(batch_id)}",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _render_batch_file(batch_id: str, siblings: list) -> Path | None:
|
|
343
|
+
"""Write a combined markdown view of all batch outputs to /tmp for review."""
|
|
344
|
+
lines = [f"# playmaker batch: {batch_id}", ""]
|
|
345
|
+
for s in siblings:
|
|
346
|
+
lines.append(f"## {s['agent']} — {s['status']} ({s['id'][:8]})")
|
|
347
|
+
out_path = s.get("output_path")
|
|
348
|
+
if not out_path:
|
|
349
|
+
matches = sorted(state.OUTPUTS_DIR.glob(f"{s['id']}.*"))
|
|
350
|
+
out_path = str(matches[0]) if matches else str(state.OUTPUTS_DIR / f"{s['id']}.md")
|
|
351
|
+
try:
|
|
352
|
+
content = Path(out_path).read_text(encoding="utf-8").strip()
|
|
353
|
+
except OSError:
|
|
354
|
+
content = "_(no output captured — see `playmaker logs " + s["id"][:8] + "`)_"
|
|
355
|
+
lines += ["", content or "_(empty)_", ""]
|
|
356
|
+
target = Path("/tmp") / f"playmaker-batch-{_batch_slug(batch_id)}.md"
|
|
357
|
+
try:
|
|
358
|
+
target.write_text("\n".join(lines), encoding="utf-8")
|
|
359
|
+
return target
|
|
360
|
+
except OSError:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@app.command("_run-detached", hidden=True)
|
|
365
|
+
def _run_detached(session_id: str) -> None:
|
|
366
|
+
"""Internal: run a pre-inserted session in the background."""
|
|
367
|
+
state.init_db()
|
|
368
|
+
_run_dispatch(session_id)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@app.command("continue")
|
|
372
|
+
def continue_(
|
|
373
|
+
session_id: str = typer.Argument(..., help="existing session id (or unique prefix) to resume"),
|
|
374
|
+
prompt: str = typer.Option(..., "--prompt", "-p", help="follow-up prompt"),
|
|
375
|
+
cwd: Path | None = typer.Option(
|
|
376
|
+
None,
|
|
377
|
+
"--cwd",
|
|
378
|
+
help="override working directory (defaults to the parent session's cwd)",
|
|
379
|
+
),
|
|
380
|
+
files: list[Path] | None = typer.Option(
|
|
381
|
+
None, "--files", "-f", help="files to attach to prompt"
|
|
382
|
+
),
|
|
383
|
+
model: str | None = typer.Option(
|
|
384
|
+
None,
|
|
385
|
+
"--model",
|
|
386
|
+
"-m",
|
|
387
|
+
help="override the model for this turn; defaults to the parent session's model",
|
|
388
|
+
),
|
|
389
|
+
sync: bool = typer.Option(
|
|
390
|
+
False, "--sync", help="block until done and print final output (default is detached)"
|
|
391
|
+
),
|
|
392
|
+
json_out: bool = typer.Option(False, "--json", help="emit machine-readable result"),
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Resume a previous agent session with a follow-up prompt — preserves the
|
|
395
|
+
sub-agent's prior reasoning and tool history. Use this for incremental
|
|
396
|
+
feedback; only fall back to a fresh `dispatch` if context is stale."""
|
|
397
|
+
state.init_db()
|
|
398
|
+
parent = state.get_session(session_id)
|
|
399
|
+
if parent is None:
|
|
400
|
+
err_console.print(f"[red]session {session_id!r} not found[/red]")
|
|
401
|
+
raise typer.Exit(1)
|
|
402
|
+
parent_agent_session_id = parent.get("agent_session_id")
|
|
403
|
+
if not parent_agent_session_id:
|
|
404
|
+
err_console.print(
|
|
405
|
+
f"[red]parent session {parent['id'][:8]} has no agent_session_id yet "
|
|
406
|
+
f"(status={parent['status']}); cannot resume[/red]"
|
|
407
|
+
)
|
|
408
|
+
raise typer.Exit(1)
|
|
409
|
+
|
|
410
|
+
handler = get_handler(parent["agent"])
|
|
411
|
+
if not handler.is_available():
|
|
412
|
+
err_console.print(f"[red]agent {parent['agent']!r} binary not found on PATH[/red]")
|
|
413
|
+
raise typer.Exit(1)
|
|
414
|
+
|
|
415
|
+
cwd_resolved = (cwd or Path(parent["cwd"])).expanduser().resolve()
|
|
416
|
+
effective_model = model if model is not None else parent.get("model")
|
|
417
|
+
|
|
418
|
+
# New playmaker session that targets the parent's live agent thread.
|
|
419
|
+
sid = state.insert_session(
|
|
420
|
+
agent=parent["agent"],
|
|
421
|
+
prompt=prompt,
|
|
422
|
+
cwd=str(cwd_resolved),
|
|
423
|
+
files=[str(f) for f in (files or [])],
|
|
424
|
+
parent_id=parent["id"],
|
|
425
|
+
model=effective_model,
|
|
426
|
+
)
|
|
427
|
+
# Pre-populating agent_session_id flips _run_dispatch into resume mode.
|
|
428
|
+
state.update_session(sid, agent_session_id=parent_agent_session_id)
|
|
429
|
+
|
|
430
|
+
if sync:
|
|
431
|
+
state.update_session(sid, status="running", pid=os.getpid())
|
|
432
|
+
_run_dispatch(sid)
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
log_path = state.LOGS_DIR / f"{sid}.log"
|
|
436
|
+
log_fh = open(log_path, "wb")
|
|
437
|
+
cmd = [sys.executable, "-m", "playmaker", "_run-detached", sid]
|
|
438
|
+
proc = subprocess.Popen(
|
|
439
|
+
cmd,
|
|
440
|
+
stdout=log_fh,
|
|
441
|
+
stderr=subprocess.STDOUT,
|
|
442
|
+
stdin=subprocess.DEVNULL,
|
|
443
|
+
start_new_session=True,
|
|
444
|
+
close_fds=True,
|
|
445
|
+
)
|
|
446
|
+
log_fh.close()
|
|
447
|
+
state.update_session(sid, status="running", pid=proc.pid)
|
|
448
|
+
if json_out:
|
|
449
|
+
typer.echo(
|
|
450
|
+
json.dumps(
|
|
451
|
+
{
|
|
452
|
+
"session_id": sid,
|
|
453
|
+
"pid": proc.pid,
|
|
454
|
+
"status": "running",
|
|
455
|
+
"resumes": parent_agent_session_id,
|
|
456
|
+
"parent_id": parent["id"],
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
console.print(
|
|
462
|
+
f"[dim]session: {sid} pid: {proc.pid} "
|
|
463
|
+
f"resumes {parent['agent']} thread {parent_agent_session_id[:8]} (detached)[/dim]"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@app.command("list")
|
|
468
|
+
def list_cmd(
|
|
469
|
+
status: str | None = typer.Option(None, "--status", help="pending|running|done|failed|killed"),
|
|
470
|
+
agent: str | None = typer.Option(None, "--agent", help="filter by agent"),
|
|
471
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
472
|
+
limit: int = typer.Option(50, "--limit"),
|
|
473
|
+
) -> None:
|
|
474
|
+
"""List recent sessions."""
|
|
475
|
+
state.init_db()
|
|
476
|
+
rows = state.list_sessions(status=status, agent=agent, limit=limit)
|
|
477
|
+
if json_out:
|
|
478
|
+
typer.echo(json.dumps(rows, default=str))
|
|
479
|
+
return
|
|
480
|
+
if not rows:
|
|
481
|
+
console.print("[dim]no sessions[/dim]")
|
|
482
|
+
return
|
|
483
|
+
table = Table(show_header=True, header_style="bold")
|
|
484
|
+
table.add_column("id", style="cyan")
|
|
485
|
+
table.add_column("agent")
|
|
486
|
+
table.add_column("status")
|
|
487
|
+
table.add_column("started")
|
|
488
|
+
table.add_column("prompt")
|
|
489
|
+
for r in rows:
|
|
490
|
+
prompt_preview = (r["prompt"] or "").replace("\n", " ")
|
|
491
|
+
if len(prompt_preview) > 60:
|
|
492
|
+
prompt_preview = prompt_preview[:57] + "..."
|
|
493
|
+
table.add_row(
|
|
494
|
+
r["id"][:8],
|
|
495
|
+
r["agent"],
|
|
496
|
+
_status_icon(r["status"]),
|
|
497
|
+
(r["started_at"] or "")[:19],
|
|
498
|
+
prompt_preview,
|
|
499
|
+
)
|
|
500
|
+
console.print(table)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@app.command()
|
|
504
|
+
def get(
|
|
505
|
+
session_id: str = typer.Argument(..., help="session id or unique prefix"),
|
|
506
|
+
wait: bool = typer.Option(False, "--wait", help="block until session reaches a terminal state"),
|
|
507
|
+
poll_seconds: float = typer.Option(1.0, "--poll", help="polling interval for --wait"),
|
|
508
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
509
|
+
) -> None:
|
|
510
|
+
"""Show session metadata + final output."""
|
|
511
|
+
state.init_db()
|
|
512
|
+
row = state.get_session(session_id)
|
|
513
|
+
if row is None:
|
|
514
|
+
err_console.print(f"[red]session {session_id!r} not found[/red]")
|
|
515
|
+
raise typer.Exit(1)
|
|
516
|
+
if wait:
|
|
517
|
+
terminal = {"done", "failed", "killed"}
|
|
518
|
+
while row["status"] not in terminal:
|
|
519
|
+
time.sleep(poll_seconds)
|
|
520
|
+
row = state.get_session(session_id)
|
|
521
|
+
if row is None:
|
|
522
|
+
err_console.print(f"[red]session {session_id!r} vanished while waiting[/red]")
|
|
523
|
+
raise typer.Exit(1)
|
|
524
|
+
|
|
525
|
+
output = ""
|
|
526
|
+
if row.get("output_path") and Path(row["output_path"]).exists():
|
|
527
|
+
output = Path(row["output_path"]).read_text(encoding="utf-8")
|
|
528
|
+
|
|
529
|
+
if json_out:
|
|
530
|
+
row["output"] = output
|
|
531
|
+
typer.echo(json.dumps(row, default=str))
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
console.print(f"[bold]{row['id']}[/bold] [dim]({row['agent']} · {row['status']})[/dim]")
|
|
535
|
+
console.print(f" started: {row['started_at']}")
|
|
536
|
+
if row["finished_at"]:
|
|
537
|
+
console.print(f" finished: {row['finished_at']}")
|
|
538
|
+
if row["cost_usd"] is not None:
|
|
539
|
+
console.print(f" cost: ${row['cost_usd']:.4f}")
|
|
540
|
+
if row["session_file_path"]:
|
|
541
|
+
console.print(f" thread: {row['session_file_path']}")
|
|
542
|
+
console.print(f"\n[bold]prompt[/bold]\n{row['prompt']}")
|
|
543
|
+
if output:
|
|
544
|
+
console.print("\n[bold]output[/bold]")
|
|
545
|
+
typer.echo(output)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
DEFAULT_THREAD_BYTES = 50_000
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@app.command()
|
|
552
|
+
def thread(
|
|
553
|
+
session_id: str = typer.Argument(..., help="session id or unique prefix"),
|
|
554
|
+
last: int = typer.Option(5, "--last", help="show last N turns (ignored with --all)"),
|
|
555
|
+
role: str | None = typer.Option(
|
|
556
|
+
None, "--role", help="filter to user|assistant|tool"
|
|
557
|
+
),
|
|
558
|
+
all_: bool = typer.Option(False, "--all", help="emit the entire thread"),
|
|
559
|
+
include_tools: bool = typer.Option(
|
|
560
|
+
False, "--include-tools", help="include tool_calls and tool_results in output"
|
|
561
|
+
),
|
|
562
|
+
max_bytes: int = typer.Option(
|
|
563
|
+
DEFAULT_THREAD_BYTES,
|
|
564
|
+
"--max-bytes",
|
|
565
|
+
help="hard cap on output bytes; 0 disables. Truncates with explicit warning.",
|
|
566
|
+
),
|
|
567
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
568
|
+
follow: bool = typer.Option(False, "--follow", help="poll and print new turns until done"),
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Read a normalized slice of an agent's session file."""
|
|
571
|
+
state.init_db()
|
|
572
|
+
row = state.get_session(session_id)
|
|
573
|
+
if row is None:
|
|
574
|
+
err_console.print(f"[red]session {session_id!r} has no resolvable session_file[/red]")
|
|
575
|
+
raise typer.Exit(1)
|
|
576
|
+
handler = get_handler(row["agent"])
|
|
577
|
+
|
|
578
|
+
def _parse_turns(current_row: dict, *, require_existing_file: bool) -> list | None:
|
|
579
|
+
session_file_path = current_row.get("session_file_path")
|
|
580
|
+
if not session_file_path:
|
|
581
|
+
return None
|
|
582
|
+
path = Path(session_file_path)
|
|
583
|
+
if require_existing_file and not path.exists():
|
|
584
|
+
return None
|
|
585
|
+
parsed = handler.parse_session_file(path)
|
|
586
|
+
if role:
|
|
587
|
+
parsed = [t for t in parsed if t.role == role]
|
|
588
|
+
return parsed
|
|
589
|
+
|
|
590
|
+
def _emit_turns(batch: list, *, limit_bytes: int | None = None) -> None:
|
|
591
|
+
if json_out:
|
|
592
|
+
payload = [
|
|
593
|
+
{
|
|
594
|
+
"role": t.role,
|
|
595
|
+
"content": t.content,
|
|
596
|
+
"tool_calls": t.tool_calls if include_tools else [],
|
|
597
|
+
"tool_results": t.tool_results if include_tools else [],
|
|
598
|
+
"timestamp": t.timestamp.isoformat() if t.timestamp else None,
|
|
599
|
+
}
|
|
600
|
+
for t in batch
|
|
601
|
+
]
|
|
602
|
+
text = json.dumps(payload, indent=2, ensure_ascii=False)
|
|
603
|
+
else:
|
|
604
|
+
text = _render_turns(batch, include_tools=include_tools)
|
|
605
|
+
if limit_bytes is not None:
|
|
606
|
+
text = _maybe_truncate(text, limit_bytes)
|
|
607
|
+
typer.echo(text)
|
|
608
|
+
|
|
609
|
+
def _emit_delta(current_row: dict, printed_count: int) -> int:
|
|
610
|
+
current_turns = _parse_turns(current_row, require_existing_file=True)
|
|
611
|
+
if current_turns is None:
|
|
612
|
+
return printed_count
|
|
613
|
+
current_count = len(current_turns)
|
|
614
|
+
if current_count > printed_count:
|
|
615
|
+
_emit_turns(current_turns[printed_count:])
|
|
616
|
+
return max(printed_count, current_count)
|
|
617
|
+
|
|
618
|
+
turns = _parse_turns(row, require_existing_file=False)
|
|
619
|
+
if turns is None:
|
|
620
|
+
if not follow:
|
|
621
|
+
err_console.print(f"[red]session {session_id!r} has no resolvable session_file[/red]")
|
|
622
|
+
raise typer.Exit(1)
|
|
623
|
+
printed_count = 0
|
|
624
|
+
else:
|
|
625
|
+
printed_count = len(turns)
|
|
626
|
+
initial_turns = turns
|
|
627
|
+
if not all_:
|
|
628
|
+
initial_turns = initial_turns[-last:] if last > 0 else initial_turns
|
|
629
|
+
_emit_turns(initial_turns, limit_bytes=max_bytes)
|
|
630
|
+
|
|
631
|
+
if not follow:
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
terminal = {"done", "failed", "killed"}
|
|
635
|
+
if row["status"] in terminal:
|
|
636
|
+
return
|
|
637
|
+
|
|
638
|
+
try:
|
|
639
|
+
while True:
|
|
640
|
+
time.sleep(0.5)
|
|
641
|
+
row = state.get_session(session_id)
|
|
642
|
+
if row is None:
|
|
643
|
+
err_console.print(f"[red]session {session_id!r} vanished while following[/red]")
|
|
644
|
+
raise typer.Exit(1)
|
|
645
|
+
printed_count = _emit_delta(row, printed_count)
|
|
646
|
+
if row["status"] in terminal:
|
|
647
|
+
time.sleep(0.5)
|
|
648
|
+
final_row = state.get_session(session_id)
|
|
649
|
+
if final_row is not None:
|
|
650
|
+
_emit_delta(final_row, printed_count)
|
|
651
|
+
return
|
|
652
|
+
except KeyboardInterrupt:
|
|
653
|
+
err_console.print("[dim]follow stopped[/dim]")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@app.command()
|
|
658
|
+
def summary(
|
|
659
|
+
session_id: str = typer.Argument(..., help="session id or unique prefix"),
|
|
660
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
661
|
+
) -> None:
|
|
662
|
+
"""Show last 2 assistant messages — sugar for `thread --last 2 --role assistant`."""
|
|
663
|
+
state.init_db()
|
|
664
|
+
row = state.get_session(session_id)
|
|
665
|
+
if row is None or not row.get("session_file_path"):
|
|
666
|
+
# fall back to stored output if session_file is missing
|
|
667
|
+
if row and row.get("output_path") and Path(row["output_path"]).exists():
|
|
668
|
+
txt = Path(row["output_path"]).read_text(encoding="utf-8")
|
|
669
|
+
typer.echo(txt if not json_out else json.dumps({"output": txt}))
|
|
670
|
+
return
|
|
671
|
+
err_console.print(f"[red]session {session_id!r} has no thread to summarize[/red]")
|
|
672
|
+
raise typer.Exit(1)
|
|
673
|
+
handler = get_handler(row["agent"])
|
|
674
|
+
turns = [
|
|
675
|
+
t for t in handler.parse_session_file(Path(row["session_file_path"]))
|
|
676
|
+
if t.role == "assistant"
|
|
677
|
+
][-2:]
|
|
678
|
+
if json_out:
|
|
679
|
+
typer.echo(
|
|
680
|
+
json.dumps(
|
|
681
|
+
[{"role": t.role, "content": t.content} for t in turns],
|
|
682
|
+
indent=2,
|
|
683
|
+
ensure_ascii=False,
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
return
|
|
687
|
+
typer.echo(_render_turns(turns, include_tools=False))
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _render_turns(turns: list, *, include_tools: bool) -> str:
|
|
691
|
+
out: list[str] = []
|
|
692
|
+
for t in turns:
|
|
693
|
+
ts = t.timestamp.strftime("%H:%M:%S") if t.timestamp else " - "
|
|
694
|
+
header = f"--- {t.role} @ {ts} ---"
|
|
695
|
+
out.append(header)
|
|
696
|
+
if t.content:
|
|
697
|
+
out.append(t.content)
|
|
698
|
+
if include_tools:
|
|
699
|
+
for tc in t.tool_calls:
|
|
700
|
+
out.append(f"[tool_call] {tc.get('name')}({tc.get('input')})")
|
|
701
|
+
for tr in t.tool_results:
|
|
702
|
+
content = tr.get("content", "")
|
|
703
|
+
if len(content) > 400:
|
|
704
|
+
content = content[:400] + "...[truncated]"
|
|
705
|
+
out.append(f"[tool_result {tr.get('tool_use_id', '')}] {content}")
|
|
706
|
+
out.append("")
|
|
707
|
+
return "\n".join(out).rstrip()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _maybe_truncate(text: str, max_bytes: int) -> str:
|
|
711
|
+
if max_bytes <= 0:
|
|
712
|
+
return text
|
|
713
|
+
raw = text.encode("utf-8")
|
|
714
|
+
if len(raw) <= max_bytes:
|
|
715
|
+
return text
|
|
716
|
+
truncated = raw[:max_bytes].decode("utf-8", errors="ignore")
|
|
717
|
+
return (
|
|
718
|
+
truncated
|
|
719
|
+
+ f"\n\n[!] truncated at {max_bytes} bytes (full size: {len(raw)}). "
|
|
720
|
+
"Use --all or higher --max-bytes to see more."
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@app.command()
|
|
725
|
+
def logs(
|
|
726
|
+
session_id: str = typer.Argument(..., help="session id or unique prefix"),
|
|
727
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="tail -f the log file"),
|
|
728
|
+
) -> None:
|
|
729
|
+
"""Show subprocess stdout/stderr for a detached session — typically the
|
|
730
|
+
final dispatch output and any spawn-time errors. For live agent progress
|
|
731
|
+
(turns, tool calls), use `playmaker thread <id> --follow` instead."""
|
|
732
|
+
state.init_db()
|
|
733
|
+
row = state.get_session(session_id)
|
|
734
|
+
if row is None:
|
|
735
|
+
err_console.print(f"[red]session {session_id!r} not found[/red]")
|
|
736
|
+
raise typer.Exit(1)
|
|
737
|
+
log_path = state.LOGS_DIR / f"{row['id']}.log"
|
|
738
|
+
if not log_path.exists():
|
|
739
|
+
err_console.print(f"[yellow]no log for {row['id']} (was it run with --detach?)[/yellow]")
|
|
740
|
+
raise typer.Exit(1)
|
|
741
|
+
|
|
742
|
+
if not follow:
|
|
743
|
+
typer.echo(log_path.read_text(encoding="utf-8", errors="replace"))
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
with log_path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
747
|
+
# Print existing content first.
|
|
748
|
+
typer.echo(fh.read(), nl=False)
|
|
749
|
+
terminal = {"done", "failed", "killed"}
|
|
750
|
+
while True:
|
|
751
|
+
chunk = fh.read()
|
|
752
|
+
if chunk:
|
|
753
|
+
typer.echo(chunk, nl=False)
|
|
754
|
+
row = state.get_session(row["id"])
|
|
755
|
+
if row is None or row["status"] in terminal:
|
|
756
|
+
# one final flush
|
|
757
|
+
tail = fh.read()
|
|
758
|
+
if tail:
|
|
759
|
+
typer.echo(tail, nl=False)
|
|
760
|
+
return
|
|
761
|
+
time.sleep(0.5)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@app.command()
|
|
765
|
+
def kill(
|
|
766
|
+
session_id: str = typer.Argument(..., help="session id or unique prefix"),
|
|
767
|
+
) -> None:
|
|
768
|
+
"""SIGTERM a running detached session."""
|
|
769
|
+
state.init_db()
|
|
770
|
+
row = state.get_session(session_id)
|
|
771
|
+
if row is None:
|
|
772
|
+
err_console.print(f"[red]session {session_id!r} not found[/red]")
|
|
773
|
+
raise typer.Exit(1)
|
|
774
|
+
if row["status"] not in ("running", "pending"):
|
|
775
|
+
err_console.print(f"[yellow]session is {row['status']}; nothing to kill[/yellow]")
|
|
776
|
+
raise typer.Exit(0)
|
|
777
|
+
pid = row.get("pid")
|
|
778
|
+
if not pid:
|
|
779
|
+
err_console.print(f"[red]no pid recorded for {row['id']}[/red]")
|
|
780
|
+
raise typer.Exit(1)
|
|
781
|
+
try:
|
|
782
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
783
|
+
except ProcessLookupError:
|
|
784
|
+
# Process already gone; just mark killed.
|
|
785
|
+
pass
|
|
786
|
+
except PermissionError as exc:
|
|
787
|
+
err_console.print(f"[red]cannot kill pid {pid}: {exc}[/red]")
|
|
788
|
+
raise typer.Exit(1) from exc
|
|
789
|
+
state.update_session(
|
|
790
|
+
row["id"], status="killed", finished_at=state.now_iso(), exit_code=143
|
|
791
|
+
)
|
|
792
|
+
console.print(f"[magenta]killed[/magenta] {row['id']} (pid {pid})")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@app.command()
|
|
796
|
+
def watch() -> None:
|
|
797
|
+
"""Live TUI of recent and active sessions. Ctrl-C to exit."""
|
|
798
|
+
watcher.run()
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@app.command()
|
|
802
|
+
def quotas(
|
|
803
|
+
refresh: bool = typer.Option(False, "--refresh", help="re-run probes before printing"),
|
|
804
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
805
|
+
) -> None:
|
|
806
|
+
"""Show ~/.playmaker/quotas.json. With --refresh, run probes first."""
|
|
807
|
+
state.init_db()
|
|
808
|
+
if refresh or not state.QUOTAS_PATH.exists():
|
|
809
|
+
from playmaker import quotas as quotas_mod
|
|
810
|
+
|
|
811
|
+
try:
|
|
812
|
+
quotas_mod.refresh_all(state.QUOTAS_PATH)
|
|
813
|
+
except Exception as exc:
|
|
814
|
+
err_console.print(f"[red]quota refresh failed at the top level:[/red] {exc}")
|
|
815
|
+
if not state.QUOTAS_PATH.exists():
|
|
816
|
+
err_console.print("[yellow]no quotas data yet — try `playmaker quotas --refresh`[/yellow]")
|
|
817
|
+
raise typer.Exit(1)
|
|
818
|
+
|
|
819
|
+
text = state.QUOTAS_PATH.read_text(encoding="utf-8")
|
|
820
|
+
if json_out:
|
|
821
|
+
typer.echo(text)
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
data = json.loads(text)
|
|
825
|
+
fetched = data.get("fetched_at", "?")
|
|
826
|
+
console.print(f"[dim]fetched: {fetched}[/dim]")
|
|
827
|
+
for name, info in (data.get("providers") or {}).items():
|
|
828
|
+
console.print()
|
|
829
|
+
_render_provider(name, info)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _render_provider(name: str, info: dict) -> None:
|
|
833
|
+
status = info.get("status")
|
|
834
|
+
label_color = {"codex": "blue", "claude": "magenta", "agy": "green", "gemini": "cyan"}.get(
|
|
835
|
+
name, "white"
|
|
836
|
+
)
|
|
837
|
+
display = {"agy": "Antigravity (agy)"}.get(name, name.capitalize())
|
|
838
|
+
title = f"[bold {label_color}]{display}[/bold {label_color}]"
|
|
839
|
+
suffix_parts: list[str] = []
|
|
840
|
+
if info.get("account_email"):
|
|
841
|
+
suffix_parts.append(info["account_email"])
|
|
842
|
+
if info.get("tier"):
|
|
843
|
+
suffix_parts.append(info["tier"])
|
|
844
|
+
# agy has two data sources; "remote" is the coarse Gemini-only fallback.
|
|
845
|
+
if info.get("source") == "remote":
|
|
846
|
+
suffix_parts.append("Gemini-only (daemon offline)")
|
|
847
|
+
suffix = " · ".join(suffix_parts)
|
|
848
|
+
if suffix:
|
|
849
|
+
console.print(f"{title} [dim]{suffix}[/dim]")
|
|
850
|
+
else:
|
|
851
|
+
console.print(title)
|
|
852
|
+
|
|
853
|
+
if status == "error":
|
|
854
|
+
console.print(f" [red]error[/red]: {info.get('error', '')}")
|
|
855
|
+
if info.get("last_success"):
|
|
856
|
+
console.print(f" [dim]last success: {info['last_success']}[/dim]")
|
|
857
|
+
return
|
|
858
|
+
if status == "unsupported":
|
|
859
|
+
console.print(f" [yellow]unsupported[/yellow]: {info.get('reason', '')}")
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
windows = info.get("windows") or []
|
|
863
|
+
if not windows:
|
|
864
|
+
console.print(" [dim]no quota windows reported[/dim]")
|
|
865
|
+
return
|
|
866
|
+
|
|
867
|
+
# Adaptive name column so longer categorized labels (e.g. "Claude/GPT
|
|
868
|
+
# weekly") keep the bars aligned instead of overflowing a fixed width.
|
|
869
|
+
name_width = max(11, *(len(w["name"]) for w in windows))
|
|
870
|
+
for w in windows:
|
|
871
|
+
pct = w.get("pct_left")
|
|
872
|
+
bar = _bar(pct) if isinstance(pct, int) else " " * 20
|
|
873
|
+
line = f" [bold]{w['name']:<{name_width}}[/bold] {bar} {pct}% left"
|
|
874
|
+
right_parts = []
|
|
875
|
+
if w.get("reset_relative"):
|
|
876
|
+
right_parts.append(f"resets in {w['reset_relative']}")
|
|
877
|
+
if w.get("forecast"):
|
|
878
|
+
right_parts.append(w["forecast"])
|
|
879
|
+
if w.get("reserve_pct") is not None:
|
|
880
|
+
right_parts.append(f"{w['reserve_pct']}% in reserve")
|
|
881
|
+
if right_parts:
|
|
882
|
+
line += f" [dim]{' · '.join(right_parts)}[/dim]"
|
|
883
|
+
console.print(line)
|
|
884
|
+
|
|
885
|
+
# Metered overage pool ("Extra usage" in Claude's UI) — the bucket that
|
|
886
|
+
# holds usage-credit / Agent-SDK spend. monthly_limit/used arrive in cents.
|
|
887
|
+
extra = info.get("extra_usage")
|
|
888
|
+
if extra and extra.get("monthly_limit_usd") is not None:
|
|
889
|
+
limit = (extra.get("monthly_limit_usd") or 0) / 100
|
|
890
|
+
used = (extra.get("used_credits_usd") or 0) / 100
|
|
891
|
+
util = extra.get("utilization_pct")
|
|
892
|
+
if util is None and limit > 0:
|
|
893
|
+
util = round(used / limit * 100)
|
|
894
|
+
util_str = f"{util}% used" if util is not None else ""
|
|
895
|
+
console.print(
|
|
896
|
+
f" [bold]{'Extra usage':<11}[/bold] ${used:.2f} / ${limit:.2f}"
|
|
897
|
+
f" [dim]{util_str}[/dim]"
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _bar(pct: int, width: int = 20) -> str:
|
|
902
|
+
pct = max(0, min(100, pct))
|
|
903
|
+
filled = round((pct / 100) * width)
|
|
904
|
+
if pct >= 50:
|
|
905
|
+
color = "green"
|
|
906
|
+
elif pct >= 20:
|
|
907
|
+
color = "yellow"
|
|
908
|
+
else:
|
|
909
|
+
color = "red"
|
|
910
|
+
return f"[{color}]{'█' * filled}[/{color}]{'░' * (width - filled)}"
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
@app.command()
|
|
914
|
+
def agents() -> None:
|
|
915
|
+
"""List registered agents — name, availability, profile path."""
|
|
916
|
+
from playmaker.registry import all_handlers, find_profile
|
|
917
|
+
|
|
918
|
+
state.init_db()
|
|
919
|
+
cwd = Path.cwd()
|
|
920
|
+
table = Table(show_header=True, header_style="bold")
|
|
921
|
+
table.add_column("name", style="cyan")
|
|
922
|
+
table.add_column("available")
|
|
923
|
+
table.add_column("profile")
|
|
924
|
+
for name, handler in all_handlers().items():
|
|
925
|
+
avail = "[green]yes[/green]" if handler.is_available() else "[red]no[/red]"
|
|
926
|
+
profile = find_profile(name, cwd)
|
|
927
|
+
profile_str = str(profile) if profile else "[dim]none[/dim]"
|
|
928
|
+
table.add_row(name, avail, profile_str)
|
|
929
|
+
console.print(table)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _status_icon(status: str) -> str:
|
|
933
|
+
return {
|
|
934
|
+
"pending": "[yellow]pending[/yellow]",
|
|
935
|
+
"running": "[blue]running[/blue]",
|
|
936
|
+
"done": "[green]done[/green]",
|
|
937
|
+
"failed": "[red]failed[/red]",
|
|
938
|
+
"killed": "[magenta]killed[/magenta]",
|
|
939
|
+
}.get(status, status)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
_DEFAULT_CONFIG = """\
|
|
943
|
+
# playmaker config
|
|
944
|
+
[notifications]
|
|
945
|
+
on_complete = true
|
|
946
|
+
on_fail = true
|
|
947
|
+
sound = true
|
|
948
|
+
# App that a clicked notification opens the agent's output file in
|
|
949
|
+
# (terminal-notifier only). Any app name `open -a` accepts.
|
|
950
|
+
editor = "Zed"
|
|
951
|
+
|
|
952
|
+
[agents.claude]
|
|
953
|
+
binary = "claude"
|
|
954
|
+
# Headless dispatch passes --dangerously-skip-permissions so unattended runs
|
|
955
|
+
# are not blocked by tool-permission prompts. Set to false to keep Claude
|
|
956
|
+
# Code's normal permission checks (detached runs will then stall on the first
|
|
957
|
+
# tool prompt and finish without writing anything).
|
|
958
|
+
skip_permissions = true
|
|
959
|
+
|
|
960
|
+
[agents.codex]
|
|
961
|
+
binary = "codex"
|
|
962
|
+
|
|
963
|
+
[agents.agy]
|
|
964
|
+
binary = "agy"
|
|
965
|
+
# Antigravity CLI. Model names are display strings from `agy models`,
|
|
966
|
+
# e.g. "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (High)".
|
|
967
|
+
skip_permissions = true
|
|
968
|
+
# Forwarded to agy --print-timeout; the CLI's own default (5m) is too short
|
|
969
|
+
# for real subtasks.
|
|
970
|
+
print_timeout = "60m"
|
|
971
|
+
|
|
972
|
+
[agents.gemini]
|
|
973
|
+
binary = "gemini"
|
|
974
|
+
"""
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
if __name__ == "__main__":
|
|
978
|
+
app()
|