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