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/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()