playmaker-cli 0.4.0__tar.gz → 0.5.1__tar.gz
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-0.4.0 → playmaker_cli-0.5.1}/CHANGELOG.md +46 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/PKG-INFO +55 -25
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/README.md +53 -23
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/pyproject.toml +3 -3
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/skills/playmaker-coach/SKILL.md +23 -17
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/agy.py +14 -8
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/claude.py +33 -11
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/codex.py +14 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/cli.py +32 -10
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/config.py +22 -0
- playmaker_cli-0.5.1/tests/test_claude.py +148 -0
- playmaker_cli-0.5.1/tests/test_permissions.py +127 -0
- playmaker_cli-0.5.1/tests/test_state.py +157 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/.gitignore +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/LICENSE +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/gemini.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/quotas.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/registry.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/state.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/__init__.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_agy.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_codex.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_quotas_antigravity.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_registry.py +0 -0
- {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_skill.py +0 -0
|
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.1] - 2026-07-27
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **The bundled coach skill taught agy model names agy no longer accepts.**
|
|
13
|
+
`agy models` switched from quoted display strings (`"Claude Opus 4.6
|
|
14
|
+
(Thinking)"`) to bare slugs (`claude-opus-4-6-thinking`), so every agy
|
|
15
|
+
dispatch the skill proposed failed model validation. The skill now says to
|
|
16
|
+
read the live roster from `agy models` instead of spelling names from
|
|
17
|
+
memory. It also recommended `codex -m gpt-5-codex`, which fails on accounts
|
|
18
|
+
whose plan lacks that model; codex dispatches now default to the account's
|
|
19
|
+
own default model. Both fixes landed on main just after v0.5.0 was tagged —
|
|
20
|
+
this release exists because the skill ships inside the wheel, so doc fixes
|
|
21
|
+
are not live until published.
|
|
22
|
+
|
|
23
|
+
## [0.5.0] - 2026-07-27
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Sub-agent permissions are configurable, and Claude no longer skips them by
|
|
28
|
+
default.** 0.4 passed `--dangerously-skip-permissions` to every Claude
|
|
29
|
+
dispatch because the alternative was believed to be a run that stalls on the
|
|
30
|
+
first prompt. That is not what happens: a headless `claude -p` without
|
|
31
|
+
permissions answers "I need your permission" and returns immediately, having
|
|
32
|
+
changed nothing. There is also a middle tier, verified against claude 2.x:
|
|
33
|
+
`--permission-mode acceptEdits` lets the agent edit files and run commands
|
|
34
|
+
inside the dispatch `--cwd` while claude itself refuses anything outside it.
|
|
35
|
+
|
|
36
|
+
That is the new default. `[agents.claude]` now accepts `permission_mode`,
|
|
37
|
+
`allowed_tools` and `disallowed_tools`, and `yolo = true` restores the old
|
|
38
|
+
no-boundary behaviour in one line. The 0.4 spelling `skip_permissions = true`
|
|
39
|
+
is still honoured; `skip_permissions = false` now falls through to the
|
|
40
|
+
default mode instead of producing a run that does nothing.
|
|
41
|
+
|
|
42
|
+
**Upgrade note:** a subtask that needs to write outside its `--cwd` will now
|
|
43
|
+
be refused. Point `--cwd` at the right directory, or set `yolo = true`.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- `[agents.codex] sandbox` — forwarded to `codex exec -s`
|
|
48
|
+
(`read-only` / `workspace-write` / `danger-full-access`). Codex needs no
|
|
49
|
+
permission-skipping flag: `codex exec` is already non-interactive and
|
|
50
|
+
sandboxes the model's shell itself, and playmaker never passed it one.
|
|
51
|
+
- `[agents.agy] sandbox` — forwarded to `agy --sandbox`. agy has no per-mode
|
|
52
|
+
permission flag, so it keeps `yolo = true` as its default.
|
|
53
|
+
|
|
8
54
|
## [0.4.0] - 2026-07-27
|
|
9
55
|
|
|
10
56
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: playmaker-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Playing-coach CLI for orchestrating Claude Code, Codex and Antigravity sub-agents in parallel.
|
|
5
5
|
Project-URL: Homepage, https://github.com/vladsafedev/playmaker
|
|
6
6
|
Project-URL: Repository, https://github.com/vladsafedev/playmaker
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Software Development
|
|
|
22
22
|
Classifier: Topic :: Utilities
|
|
23
23
|
Requires-Python: >=3.11
|
|
24
24
|
Requires-Dist: rich>=13.7
|
|
25
|
-
Requires-Dist: typer>=0.
|
|
25
|
+
Requires-Dist: typer>=0.27.0
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
|
|
28
28
|
# playmaker
|
|
@@ -41,9 +41,9 @@ parses their native session files, and pings you when they land.
|
|
|
41
41
|
```console
|
|
42
42
|
$ B=dashboard # one label for the whole fan-out
|
|
43
43
|
|
|
44
|
-
$ playmaker dispatch codex --batch $B
|
|
44
|
+
$ playmaker dispatch codex --batch $B -p "Add PATCH /users/:id …"
|
|
45
45
|
session: 9f2c1a4e-… pid: 48211 (detached)
|
|
46
|
-
$ playmaker dispatch agy --batch $B --model
|
|
46
|
+
$ playmaker dispatch agy --batch $B --model gemini-3.6-flash-high -p "pytest coverage for …"
|
|
47
47
|
session: 4b1f9c02-… pid: 48219 (detached)
|
|
48
48
|
$ playmaker dispatch claude --batch $B --model sonnet -p "Update the API docs for …"
|
|
49
49
|
session: c07d5511-… pid: 48244 (detached)
|
|
@@ -87,28 +87,54 @@ The catch: doing this by hand — terminal tabs, jumping between tools,
|
|
|
87
87
|
copy-pasting context — is friction. `playmaker` removes the friction; the
|
|
88
88
|
[coach skill](#the-coach-skill) provides the discipline.
|
|
89
89
|
|
|
90
|
-
##
|
|
90
|
+
## Permissions
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
having written nothing.
|
|
92
|
+
A detached agent has nobody at the keyboard to approve a tool prompt, so you
|
|
93
|
+
decide up front what it may do. Left alone, a headless Claude simply answers
|
|
94
|
+
*"I need your permission"* and finishes having changed nothing — so the choice
|
|
95
|
+
is real, not a formality.
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
By default playmaker asks for the weakest setting that still lets the work
|
|
98
|
+
finish: **the agent is free inside the directory you dispatched it to, and
|
|
99
|
+
Claude itself refuses anything outside it.**
|
|
101
100
|
|
|
102
|
-
|
|
101
|
+
| `[agents.claude] permission_mode` | inside `--cwd` | outside `--cwd` |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `"plan"` | reads and plans, no writes | — |
|
|
104
|
+
| `"acceptEdits"` *(default)* | edits files, runs commands | refused |
|
|
105
|
+
| `"bypassPermissions"` | anything | anything |
|
|
106
|
+
|
|
107
|
+
Narrow it further with an allowlist — Claude Code's own tool syntax:
|
|
103
108
|
|
|
104
109
|
```toml
|
|
105
110
|
[agents.claude]
|
|
106
|
-
|
|
111
|
+
permission_mode = "acceptEdits"
|
|
112
|
+
allowed_tools = ["Read", "Edit", "Write", "Bash(pytest:*)"]
|
|
113
|
+
disallowed_tools = ["WebFetch"]
|
|
107
114
|
```
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
**Or skip the whole thing.** One line, no boundary, including the
|
|
117
|
+
working-directory one:
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
[agents.claude]
|
|
121
|
+
yolo = true
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
That is a reasonable trade for prompts and directories you'd run unattended
|
|
125
|
+
anyway — just make it a decision rather than a default you inherited.
|
|
126
|
+
|
|
127
|
+
The other agents differ, because their CLIs do:
|
|
128
|
+
|
|
129
|
+
- **codex** needs none of this. `codex exec` is already non-interactive and
|
|
130
|
+
sandboxes the model's shell itself, so playmaker passes no bypass flag at
|
|
131
|
+
all. Override its policy with `sandbox = "read-only" | "workspace-write" |
|
|
132
|
+
"danger-full-access"`.
|
|
133
|
+
- **agy** has no middle tier — no per-mode flag exists, so a detached run
|
|
134
|
+
either auto-approves or comes back having done nothing. It therefore
|
|
135
|
+
defaults to `yolo = true`; layer `sandbox = true` on top for agy's own
|
|
136
|
+
terminal restrictions.
|
|
137
|
+
- **gemini** (legacy) runs with `--yolo`.
|
|
112
138
|
|
|
113
139
|
## Install
|
|
114
140
|
|
|
@@ -142,8 +168,8 @@ playmaker agents # which agent CLIs are reachable
|
|
|
142
168
|
| Agent | Install | Notes |
|
|
143
169
|
|---|---|---|
|
|
144
170
|
| **Claude Code** | `npm i -g @anthropic-ai/claude-code` | `--model sonnet` / `opus` / `haiku` |
|
|
145
|
-
| **Codex CLI** | `npm i -g @openai/codex` | `--model
|
|
146
|
-
| **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) |
|
|
171
|
+
| **Codex CLI** | `npm i -g @openai/codex` | the model roster depends on your plan; omit `--model` to use the account default |
|
|
172
|
+
| **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | `--model claude-opus-4-6-thinking` — the roster moves, so read it from `agy models` |
|
|
147
173
|
| **Gemini CLI** (legacy) | `npm i -g @google/gemini-cli` | still supported, superseded by `agy` |
|
|
148
174
|
|
|
149
175
|
At least one is required; `playmaker agents` tells you which it can see.
|
|
@@ -203,13 +229,15 @@ still live — that's the cheap path for "almost right, fix Y". Start fresh with
|
|
|
203
229
|
|
|
204
230
|
```mermaid
|
|
205
231
|
flowchart LR
|
|
206
|
-
C["
|
|
207
|
-
P --> A1["claude -p<br
|
|
232
|
+
C["coach<br/>your Claude Code session"] -->|dispatch| P(("playmaker"))
|
|
233
|
+
P --> A1["claude -p<br/>--model sonnet"]
|
|
208
234
|
P --> A2["codex exec"]
|
|
209
235
|
P --> A3["agy -p"]
|
|
210
|
-
A1
|
|
211
|
-
|
|
212
|
-
|
|
236
|
+
A1 --> S[("state.db<br/>outputs/ + logs/")]
|
|
237
|
+
A2 --> S
|
|
238
|
+
A3 --> S
|
|
239
|
+
S -->|"list / thread / summary"| C
|
|
240
|
+
S -.->|"batch drained"| N["one ping"]
|
|
213
241
|
```
|
|
214
242
|
|
|
215
243
|
Each dispatch is a detached OS process with its own quota; playmaker owns the
|
|
@@ -324,6 +352,8 @@ input the coach skill uses to route each subtask.
|
|
|
324
352
|
Handlers for other agent CLIs are especially welcome — see
|
|
325
353
|
[CONTRIBUTING.md](CONTRIBUTING.md) for the `AgentHandler` contract and what you
|
|
326
354
|
need to know about a CLI before writing one.
|
|
355
|
+
[SECURITY.md](SECURITY.md) covers which credentials the quota probes read and
|
|
356
|
+
what a dispatched agent is allowed to do.
|
|
327
357
|
|
|
328
358
|
```bash
|
|
329
359
|
uv run pytest
|
|
@@ -14,9 +14,9 @@ parses their native session files, and pings you when they land.
|
|
|
14
14
|
```console
|
|
15
15
|
$ B=dashboard # one label for the whole fan-out
|
|
16
16
|
|
|
17
|
-
$ playmaker dispatch codex --batch $B
|
|
17
|
+
$ playmaker dispatch codex --batch $B -p "Add PATCH /users/:id …"
|
|
18
18
|
session: 9f2c1a4e-… pid: 48211 (detached)
|
|
19
|
-
$ playmaker dispatch agy --batch $B --model
|
|
19
|
+
$ playmaker dispatch agy --batch $B --model gemini-3.6-flash-high -p "pytest coverage for …"
|
|
20
20
|
session: 4b1f9c02-… pid: 48219 (detached)
|
|
21
21
|
$ playmaker dispatch claude --batch $B --model sonnet -p "Update the API docs for …"
|
|
22
22
|
session: c07d5511-… pid: 48244 (detached)
|
|
@@ -60,28 +60,54 @@ The catch: doing this by hand — terminal tabs, jumping between tools,
|
|
|
60
60
|
copy-pasting context — is friction. `playmaker` removes the friction; the
|
|
61
61
|
[coach skill](#the-coach-skill) provides the discipline.
|
|
62
62
|
|
|
63
|
-
##
|
|
63
|
+
## Permissions
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
having written nothing.
|
|
65
|
+
A detached agent has nobody at the keyboard to approve a tool prompt, so you
|
|
66
|
+
decide up front what it may do. Left alone, a headless Claude simply answers
|
|
67
|
+
*"I need your permission"* and finishes having changed nothing — so the choice
|
|
68
|
+
is real, not a formality.
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
By default playmaker asks for the weakest setting that still lets the work
|
|
71
|
+
finish: **the agent is free inside the directory you dispatched it to, and
|
|
72
|
+
Claude itself refuses anything outside it.**
|
|
74
73
|
|
|
75
|
-
|
|
74
|
+
| `[agents.claude] permission_mode` | inside `--cwd` | outside `--cwd` |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `"plan"` | reads and plans, no writes | — |
|
|
77
|
+
| `"acceptEdits"` *(default)* | edits files, runs commands | refused |
|
|
78
|
+
| `"bypassPermissions"` | anything | anything |
|
|
79
|
+
|
|
80
|
+
Narrow it further with an allowlist — Claude Code's own tool syntax:
|
|
76
81
|
|
|
77
82
|
```toml
|
|
78
83
|
[agents.claude]
|
|
79
|
-
|
|
84
|
+
permission_mode = "acceptEdits"
|
|
85
|
+
allowed_tools = ["Read", "Edit", "Write", "Bash(pytest:*)"]
|
|
86
|
+
disallowed_tools = ["WebFetch"]
|
|
80
87
|
```
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
**Or skip the whole thing.** One line, no boundary, including the
|
|
90
|
+
working-directory one:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
[agents.claude]
|
|
94
|
+
yolo = true
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
That is a reasonable trade for prompts and directories you'd run unattended
|
|
98
|
+
anyway — just make it a decision rather than a default you inherited.
|
|
99
|
+
|
|
100
|
+
The other agents differ, because their CLIs do:
|
|
101
|
+
|
|
102
|
+
- **codex** needs none of this. `codex exec` is already non-interactive and
|
|
103
|
+
sandboxes the model's shell itself, so playmaker passes no bypass flag at
|
|
104
|
+
all. Override its policy with `sandbox = "read-only" | "workspace-write" |
|
|
105
|
+
"danger-full-access"`.
|
|
106
|
+
- **agy** has no middle tier — no per-mode flag exists, so a detached run
|
|
107
|
+
either auto-approves or comes back having done nothing. It therefore
|
|
108
|
+
defaults to `yolo = true`; layer `sandbox = true` on top for agy's own
|
|
109
|
+
terminal restrictions.
|
|
110
|
+
- **gemini** (legacy) runs with `--yolo`.
|
|
85
111
|
|
|
86
112
|
## Install
|
|
87
113
|
|
|
@@ -115,8 +141,8 @@ playmaker agents # which agent CLIs are reachable
|
|
|
115
141
|
| Agent | Install | Notes |
|
|
116
142
|
|---|---|---|
|
|
117
143
|
| **Claude Code** | `npm i -g @anthropic-ai/claude-code` | `--model sonnet` / `opus` / `haiku` |
|
|
118
|
-
| **Codex CLI** | `npm i -g @openai/codex` | `--model
|
|
119
|
-
| **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) |
|
|
144
|
+
| **Codex CLI** | `npm i -g @openai/codex` | the model roster depends on your plan; omit `--model` to use the account default |
|
|
145
|
+
| **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | `--model claude-opus-4-6-thinking` — the roster moves, so read it from `agy models` |
|
|
120
146
|
| **Gemini CLI** (legacy) | `npm i -g @google/gemini-cli` | still supported, superseded by `agy` |
|
|
121
147
|
|
|
122
148
|
At least one is required; `playmaker agents` tells you which it can see.
|
|
@@ -176,13 +202,15 @@ still live — that's the cheap path for "almost right, fix Y". Start fresh with
|
|
|
176
202
|
|
|
177
203
|
```mermaid
|
|
178
204
|
flowchart LR
|
|
179
|
-
C["
|
|
180
|
-
P --> A1["claude -p<br
|
|
205
|
+
C["coach<br/>your Claude Code session"] -->|dispatch| P(("playmaker"))
|
|
206
|
+
P --> A1["claude -p<br/>--model sonnet"]
|
|
181
207
|
P --> A2["codex exec"]
|
|
182
208
|
P --> A3["agy -p"]
|
|
183
|
-
A1
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
A1 --> S[("state.db<br/>outputs/ + logs/")]
|
|
210
|
+
A2 --> S
|
|
211
|
+
A3 --> S
|
|
212
|
+
S -->|"list / thread / summary"| C
|
|
213
|
+
S -.->|"batch drained"| N["one ping"]
|
|
186
214
|
```
|
|
187
215
|
|
|
188
216
|
Each dispatch is a detached OS process with its own quota; playmaker owns the
|
|
@@ -297,6 +325,8 @@ input the coach skill uses to route each subtask.
|
|
|
297
325
|
Handlers for other agent CLIs are especially welcome — see
|
|
298
326
|
[CONTRIBUTING.md](CONTRIBUTING.md) for the `AgentHandler` contract and what you
|
|
299
327
|
need to know about a CLI before writing one.
|
|
328
|
+
[SECURITY.md](SECURITY.md) covers which credentials the quota probes read and
|
|
329
|
+
what a dispatched agent is allowed to do.
|
|
300
330
|
|
|
301
331
|
```bash
|
|
302
332
|
uv run pytest
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "playmaker-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.1"
|
|
4
4
|
description = "Playing-coach CLI for orchestrating Claude Code, Codex and Antigravity sub-agents in parallel."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -35,7 +35,7 @@ classifiers = [
|
|
|
35
35
|
"Topic :: Utilities",
|
|
36
36
|
]
|
|
37
37
|
dependencies = [
|
|
38
|
-
"typer>=0.
|
|
38
|
+
"typer>=0.27.0",
|
|
39
39
|
"rich>=13.7",
|
|
40
40
|
]
|
|
41
41
|
|
|
@@ -50,7 +50,7 @@ playmaker = "playmaker.cli:app"
|
|
|
50
50
|
[dependency-groups]
|
|
51
51
|
dev = [
|
|
52
52
|
"ruff>=0.4",
|
|
53
|
-
"pytest>=
|
|
53
|
+
"pytest>=9.1.1",
|
|
54
54
|
]
|
|
55
55
|
|
|
56
56
|
[build-system]
|
|
@@ -7,7 +7,9 @@ description: Playing-coach orchestration of Claude/Codex/Antigravity (agy) sub-a
|
|
|
7
7
|
|
|
8
8
|
Use the `playmaker` CLI as a facade to dispatch sub-tasks to Codex / Antigravity (`agy`) / a sibling Claude, monitor them, read their threads, review their diffs, and feed back. The coach (this thread) does its own portion of the work in parallel.
|
|
9
9
|
|
|
10
|
-
> **`agy` (Antigravity CLI)** does not only serve Google models:
|
|
10
|
+
> **`agy` (Antigravity CLI)** does not only serve Google models: alongside the Gemini Flash and Pro tiers, `agy models` carries **Claude Sonnet and Opus** and a **GPT-OSS** mid-tier. Opus via agy runs on *Google's* quota pool — top-tier Claude work that does not touch the Anthropic subscription's scarce Opus weekly bucket: `--model claude-opus-4-6-thinking`.
|
|
11
|
+
>
|
|
12
|
+
> **Never write an agy model name from memory — run `agy models` and copy a line.** Both the roster *and its spelling* move with Antigravity releases: names used to be quoted display strings like `"Claude Opus 4.6 (Thinking)"` and are now bare slugs. playmaker validates `--model` against the live roster and fails the dispatch on a stale name, so a wrong guess costs you a round-trip.
|
|
11
13
|
|
|
12
14
|
**The point of the coach pattern:** all available models do useful work in parallel under your direction. The coach's job is *orchestration*, not *production*. A coach that does its own implementation, runs its own codebase recon, and reviews everything line by line burns the same budget delegation was meant to save. Treat your own context window as the most expensive resource on the table — every byte you read or generate yourself is a byte that could have come from a cheaper model. Push out as much as you can: implementation, recon, summarization, even drafting the per-subtask prompts when the task is large enough.
|
|
13
15
|
|
|
@@ -20,7 +22,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
|
|
|
20
22
|
2. **Internal sub-agents — the `Task`/`Agent` tool in THIS session.** Run inside this session, **write files** (they inherit the coach's permission mode), return their result straight into the coach's context, and run in parallel. They draw on the same subscription, so the gate is "is weekly quota healthy?" — glance at `playmaker quotas`. **Use them freely** for Claude-side chunks the coach will fold back in directly; spawn several at once, don't be shy.
|
|
21
23
|
|
|
22
24
|
3. **External dispatch — `playmaker dispatch <agent>`.** Separate OS processes, tracked in playmaker (`list`/`watch`/`thread`/`continue`):
|
|
23
|
-
- **`claude -p` (sibling Claude):** same subscription. Its key lever is the model bucket — **Sonnet is a separate weekly bucket from Opus**, usually idle while Opus depletes, so default **`--model sonnet`** to spare the scarce Opus bucket (**`--model haiku`** for trivial mechanical work). It **can write files**
|
|
25
|
+
- **`claude -p` (sibling Claude):** same subscription. Its key lever is the model bucket — **Sonnet is a separate weekly bucket from Opus**, usually idle while Opus depletes, so default **`--model sonnet`** to spare the scarce Opus bucket (**`--model haiku`** for trivial mechanical work). It **can write files** — playmaker runs it in `acceptEdits`, so it edits and runs commands freely inside `--cwd` and is refused outside it (see §10). Use it over an internal sub-agent when you want a **tracked, detached work-stream** you can monitor/continue independently of the coach's turn.
|
|
24
26
|
- **`codex` / `agy`:** each on its own subscription/quota — the right home for **write-heavy** parallel implementation that can leave the Anthropic subscription. `agy` is special: besides Gemini tiers it carries **Claude Sonnet/Opus 4.6 (Thinking)** on Google's pool, so even "must be Claude-quality" work can leave the Anthropic quota.
|
|
25
27
|
|
|
26
28
|
**Routing cheat-sheet for Claude-side work:**
|
|
@@ -31,7 +33,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
|
|
|
31
33
|
| is an independent stream you'll monitor / continue separately | **external `dispatch claude --model sonnet`** | tracked & detached; Sonnet's own weekly bucket spares Opus |
|
|
32
34
|
| is heavy reasoning only the coach can do | **coach** | top-tier Opus, serial |
|
|
33
35
|
| is write-heavy and can leave Claude | **codex / agy** | their own quotas |
|
|
34
|
-
| needs top-tier Claude but the Anthropic Opus weekly is precious | **`dispatch agy --model
|
|
36
|
+
| needs top-tier Claude but the Anthropic Opus weekly is precious | **`dispatch agy --model claude-opus-4-6-thinking`** | Opus quality on Google's pool |
|
|
35
37
|
|
|
36
38
|
**Sonnet is your cheap parallel Claude worker** — a separate weekly bucket that usually sits idle while Opus depletes. Reach for it (via `dispatch claude --model sonnet`, or by pointing an internal sub-agent at Sonnet) instead of burning Opus on mid-tier work.
|
|
37
39
|
|
|
@@ -69,7 +71,7 @@ Codebase exploration ("find where the User entity lives, list its existing field
|
|
|
69
71
|
Pattern: dispatch a recon subtask with an explicit deliverable.
|
|
70
72
|
|
|
71
73
|
```bash
|
|
72
|
-
playmaker dispatch agy --model
|
|
74
|
+
playmaker dispatch agy --model gemini-3.6-flash-low --cwd $(pwd) --sync \
|
|
73
75
|
--prompt "Recon only — do not edit any files. In apps/backend/, locate: (a) the User entity/schema and the migration tooling used (Prisma vs TypeORM vs other), (b) the auth middleware that resolves the current user, (c) where DTOs are defined for user PATCH endpoints if any. Report under 200 words as a numbered list with file paths and line ranges."
|
|
74
76
|
```
|
|
75
77
|
|
|
@@ -89,9 +91,9 @@ Break the task into 2-5 subtasks (don't go finer-grained than that on first run
|
|
|
89
91
|
|
|
90
92
|
1. Sort *models* (not agents) by remaining capacity — freshest at the top. A provider with one fresh model and one depleted model is two separate buckets.
|
|
91
93
|
2. **Tier-match each subtask to the cheapest model that can finish it cleanly:**
|
|
92
|
-
- **Architectural / spec judgment / cross-module integration:** top tier (Opus, agy
|
|
93
|
-
- **Pattern-following implementation, well-scoped CRUD, mechanical refactor, test scaffolding, writing inside an existing convention:** mid tier (Claude Sonnet, agy
|
|
94
|
-
- **Recon, summarization, mechanical loops over many files, name normalization:** cheap tier (agy
|
|
94
|
+
- **Architectural / spec judgment / cross-module integration:** top tier (Opus, agy `claude-opus-4-6-thinking` / `gemini-3.1-pro-high`, Codex top-tier). The coach lives here; agy-Opus is the overflow lane when the Anthropic Opus weekly is precious.
|
|
95
|
+
- **Pattern-following implementation, well-scoped CRUD, mechanical refactor, test scaffolding, writing inside an existing convention:** mid tier (Claude Sonnet, agy `claude-sonnet-4-6` / `gemini-3.5-flash-high` / `gemini-3.1-pro-low`, mid-tier Codex). This is where the bulk of delegated implementation goes.
|
|
96
|
+
- **Recon, summarization, mechanical loops over many files, name normalization:** cheap tier (agy's lowest Flash tiers, **Claude Haiku**, Sonnet for recon-with-judgment).
|
|
95
97
|
|
|
96
98
|
For Claude specifically, tier is orthogonal to **lane** (see "Execution lanes"): pick the model tier here, then decide *internal sub-agent* (coach integrates the result) vs *external `-p`* (tracked detached stream).
|
|
97
99
|
3. Pass the model explicitly: `playmaker dispatch <agent> --model <name> ...`. Without `--model`, the agent CLI uses its default — which is usually fine but means the coach gives up control over tier-matching. **For sibling Claude, default to `--model sonnet`** (Haiku for trivial mechanical work); never omit it and let the CLI pick Opus — that burns the scarce shared Opus weekly bucket.
|
|
@@ -112,7 +114,7 @@ To make a subtask finishable, every dispatch must carry:
|
|
|
112
114
|
2. **Acceptance criteria as a check the agent runs itself.** A green command — `npx prisma validate`, `pnpm test users.spec.ts`, `tsc --noEmit`, an `eslint` pass on a specific file. The agent is told to keep iterating until that command exits 0 and to surface its output in the final answer. This replaces coach-side review for ~80% of the work.
|
|
113
115
|
3. **Done definition in one sentence**, written so the coach can confirm it in seconds without reading the diff line by line. "Column added, migration generated, prisma validate green." If the coach can't write such a sentence, the subtask isn't sized right yet — split or specify further before dispatching.
|
|
114
116
|
4. **Context the agent needs but doesn't have.** Spec section excerpts, naming conventions, the one related file it should mirror. Paste these into the prompt; don't make the agent find them by reading half the repo. If your team keeps durable notes (a docs folder, a wiki, a notes vault), pass the *exact* paths worth reading rather than asking the agent to go looking.
|
|
115
|
-
5. **Match to capability.** Codex / agy-Gemini do well on pattern-following, well-scoped CRUD, test scaffolding, mechanical refactors, and writing within an existing convention. They do poorly on architectural decisions across files they haven't been pointed at, novel API design, and judging whether a spec rule applies. Keep those for the coach — or for agy's
|
|
117
|
+
5. **Match to capability.** Codex / agy-Gemini do well on pattern-following, well-scoped CRUD, test scaffolding, mechanical refactors, and writing within an existing convention. They do poorly on architectural decisions across files they haven't been pointed at, novel API design, and judging whether a spec rule applies. Keep those for the coach — or for agy's `claude-opus-4-6-thinking` when the subtask genuinely needs top-tier judgment but should not burn the Anthropic Opus weekly. If a profile in `.playmaker/agents/<name>.md` exists, trust its guidance over these defaults.
|
|
116
118
|
|
|
117
119
|
A useful smell test before dispatching: *"If this came back done, would I review by running one command and reading one paragraph — or would I need to read the whole diff and think hard about whether it's right?"* If the latter, re-scope before sending.
|
|
118
120
|
|
|
@@ -121,7 +123,7 @@ Output the plan as a short proposal:
|
|
|
121
123
|
Plan:
|
|
122
124
|
- Coach (me): schema design + integration glue
|
|
123
125
|
- Codex: FastAPI handlers in apps/api/
|
|
124
|
-
- agy (
|
|
126
|
+
- agy (gemini-3.6-flash-high): pytest tests + README
|
|
125
127
|
|
|
126
128
|
Quotas: Claude session 93% / weekly 80%, Codex 100%,
|
|
127
129
|
agy Gemini 5h 100% / weekly 96%, Claude/GPT 5h 88% / weekly 71%.
|
|
@@ -151,7 +153,11 @@ playmaker dispatch <agent> --model <name> --prompt "<scoped prompt>" --cwd $(pwd
|
|
|
151
153
|
|
|
152
154
|
Always pass `--cwd $(pwd)` — `playmaker`'s default is the *coach process's* current dir, which is not always what you want.
|
|
153
155
|
|
|
154
|
-
Always pass `--model` when you've made a tier-matching decision in step 3. Without it, the agent CLI uses its own default (which may be its top-tier model, defeating the load-distribution effort). Model name is what the agent's native CLI accepts: `claude --model sonnet`, `
|
|
156
|
+
Always pass `--model` when you've made a tier-matching decision in step 3. Without it, the agent CLI uses its own default (which may be its top-tier model, defeating the load-distribution effort). Model name is what the agent's native CLI accepts: `claude --model sonnet`, `agy --model gemini-3.6-flash-high` — for agy, copy the line from `agy models` rather than typing it.
|
|
157
|
+
|
|
158
|
+
Two exceptions to "always pass `--model`":
|
|
159
|
+
- **codex** — its model roster depends on the account plan, and an unavailable name fails the whole dispatch (`codex turn failed: … not supported when using Codex with a ChatGPT account`). Omitting `--model` uses whatever that account actually has, which is usually what you want.
|
|
160
|
+
- **agy** — its own default is a top-tier model, so omitting `--model` on a dispatch meant to be cheap silently spends the expensive bucket. Always pass it here.
|
|
155
161
|
|
|
156
162
|
**agy prompt discipline:** the agy agent's shell lives in a private scratch directory, not the workspace. playmaker automatically prepends a workspace preamble to every agy dispatch, but reinforce it: word file instructions with paths relative to the workspace root or absolute paths, never "in the current directory".
|
|
157
163
|
|
|
@@ -163,8 +169,8 @@ Always pass `--model` when you've made a tier-matching decision in step 3. Witho
|
|
|
163
169
|
|
|
164
170
|
```bash
|
|
165
171
|
B=admin-dashboard # any short label shared across this fan-out
|
|
166
|
-
playmaker dispatch codex --batch "$B" --
|
|
167
|
-
playmaker dispatch agy --batch "$B" --model
|
|
172
|
+
playmaker dispatch codex --batch "$B" --prompt "..." --cwd $(pwd)
|
|
173
|
+
playmaker dispatch agy --batch "$B" --model gemini-3.6-flash-high --prompt "..." --cwd $(pwd)
|
|
168
174
|
```
|
|
169
175
|
|
|
170
176
|
`playmaker continue <id> --model <name>` overrides the model for one follow-up turn while keeping the live session; without `--model` it inherits the parent session's model.
|
|
@@ -173,7 +179,7 @@ For sequential delegation (e.g. "I'll do schema first, then Codex builds on top"
|
|
|
173
179
|
|
|
174
180
|
```bash
|
|
175
181
|
git commit -am "checkpoint: schema before backend dispatch"
|
|
176
|
-
playmaker dispatch codex --
|
|
182
|
+
playmaker dispatch codex --prompt "..." --cwd $(pwd) --sync # blocks, prints output
|
|
177
183
|
```
|
|
178
184
|
|
|
179
185
|
### 6. Coach's own work
|
|
@@ -219,14 +225,14 @@ Read `summary` first. If you need more context to judge: `playmaker thread <id>`
|
|
|
219
225
|
|
|
220
226
|
If `playmaker dispatch` returns an error (binary missing, auth bad, agent unavailable), don't silently retry. Surface it, propose a re-routed plan ("Codex unavailable; want me to give the backend to Claude instead?"), and wait for user confirmation.
|
|
221
227
|
|
|
222
|
-
**Sibling-Claude writes are enabled
|
|
228
|
+
**Sibling-Claude writes are enabled, but bounded.** playmaker dispatches `claude -p` with `--permission-mode acceptEdits` by default: it edits files and runs commands inside the dispatch `--cwd` without asking, and claude itself refuses anything outside that directory. So **keep every path in the prompt inside `--cwd`** — a subtask that legitimately needs to touch a sibling repo or a dotfile in `$HOME` will come back refused, and the fix is a different `--cwd` (or the user's `yolo = true`), not a re-prompt.
|
|
223
229
|
|
|
224
230
|
Both Claude lanes are on the subscription, so pick by **where the work lives**, not cost:
|
|
225
231
|
- **Coach folds the result in directly → internal sub-agent (Task tool).** In-session, write-capable, returns into context. Default choice for "more Claude."
|
|
226
232
|
- **Independent stream you'll monitor / continue separately → `playmaker dispatch claude --model sonnet`.** Tracked, detached; Sonnet's separate weekly bucket spares Opus.
|
|
227
233
|
- **Work that can leave the Claude family → Codex / agy** (their own quotas).
|
|
228
234
|
|
|
229
|
-
If a dispatch comes back with zero file changes, check `playmaker summary <id
|
|
235
|
+
If a dispatch comes back with zero file changes, check `playmaker summary <id>`. Two common causes for Claude: the subtask tried to write outside `--cwd` and was refused (re-dispatch with the right `--cwd`), or the run needed a permission the configured mode doesn't grant — a "I need your permission" answer, not a crash. For **agy** specifically, a "done" with no file changes usually means the files landed in agy's private scratch dir (`~/.gemini/antigravity-cli/scratch/`) — the prompt referred to "the current directory" instead of workspace paths; re-dispatch with explicit paths.
|
|
230
236
|
|
|
231
237
|
## Reading discipline
|
|
232
238
|
|
|
@@ -263,9 +269,9 @@ playmaker watch # Rich live TUI of sessions
|
|
|
263
269
|
|
|
264
270
|
Every command takes `--json` for machine-readable output.
|
|
265
271
|
|
|
266
|
-
`--model NAME` is forwarded to the agent's native CLI: `claude --model sonnet`, `
|
|
272
|
+
`--model NAME` is forwarded to the agent's native CLI: `claude --model sonnet`, `agy --model claude-opus-4-6-thinking`, `codex -m <whatever that account has>`. Without it the agent CLI uses its own default. Model is stored on the session row, so detached re-runs and `continue` inherit it; `continue --model X` overrides for that one turn.
|
|
267
273
|
|
|
268
|
-
agy
|
|
274
|
+
**The agy roster is not documented here on purpose** — it changes with Antigravity releases, and so does the spelling convention. Run `agy models` and copy a line. At the time of writing it returns bare slugs in the shape `gemini-3.6-flash-{low,medium,high}`, `gemini-3.1-pro-{low,high}`, `claude-sonnet-4-6`, `claude-opus-4-6-thinking`, `gpt-oss-120b-medium` — but treat that as an example of the *shape*, not a list to copy from.
|
|
269
275
|
|
|
270
276
|
## Anti-patterns
|
|
271
277
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""Antigravity CLI (`agy`) handler.
|
|
2
2
|
|
|
3
3
|
Empirically (agy 1.1.1):
|
|
4
|
-
- oneshot: `agy -p "<prompt>" [--model
|
|
4
|
+
- oneshot: `agy -p "<prompt>" [--model <name>] [--dangerously-skip-permissions]
|
|
5
5
|
[--print-timeout 60m] [--log-file <path>]`; stdout is the final response as
|
|
6
6
|
plain text (no JSON envelope).
|
|
7
7
|
- resume: `agy --conversation <uuid> -p "..."` — keeps the same conversation id.
|
|
8
|
-
-
|
|
9
|
-
"
|
|
8
|
+
- `--model` takes a name from `agy models` verbatim, e.g.
|
|
9
|
+
"claude-opus-4-6-thinking", "gemini-3.6-flash-high". Both the roster and its
|
|
10
|
+
*spelling* move with Antigravity releases — earlier builds took display names
|
|
11
|
+
like "Claude Opus 4.6 (Thinking)" — which is why _validate_model() reads the
|
|
12
|
+
live list instead of trusting a hardcoded one.
|
|
10
13
|
- the conversation id is NOT printed to stdout; it is recovered from the CLI
|
|
11
14
|
debug log (`--log-file`) via the `Created conversation <uuid>` line, which
|
|
12
15
|
appears a few seconds in — that is our early on_session_started signal.
|
|
@@ -34,7 +37,7 @@ import time
|
|
|
34
37
|
from pathlib import Path
|
|
35
38
|
|
|
36
39
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
37
|
-
from playmaker.config import agent_setting
|
|
40
|
+
from playmaker.config import agent_setting, yolo_enabled
|
|
38
41
|
|
|
39
42
|
AGY_BRAIN_ROOT = Path("~/.gemini/antigravity-cli/brain").expanduser()
|
|
40
43
|
|
|
@@ -53,7 +56,7 @@ class AgyHandler:
|
|
|
53
56
|
@staticmethod
|
|
54
57
|
@functools.lru_cache(maxsize=1)
|
|
55
58
|
def available_models() -> tuple[str, ...]:
|
|
56
|
-
"""Exact model
|
|
59
|
+
"""Exact model names agy accepts, from `agy models`.
|
|
57
60
|
|
|
58
61
|
agy resolves an unknown `--model` to its default *silently* (no error,
|
|
59
62
|
wrong model runs), so we validate against this list before dispatch.
|
|
@@ -146,10 +149,13 @@ class AgyHandler:
|
|
|
146
149
|
cmd = ["agy", "-p", full_prompt, "--log-file", str(log_path)]
|
|
147
150
|
if conversation_id:
|
|
148
151
|
cmd += ["--conversation", conversation_id]
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
+
# agy has no middle tier: unlike claude there is no per-mode permission
|
|
153
|
+
# flag, so a detached run either skips the prompts or answers nothing.
|
|
154
|
+
# `--sandbox` is orthogonal and can be layered on top.
|
|
155
|
+
if yolo_enabled("agy", default=True):
|
|
152
156
|
cmd.append("--dangerously-skip-permissions")
|
|
157
|
+
if agent_setting("agy", "sandbox", False):
|
|
158
|
+
cmd.append("--sandbox")
|
|
153
159
|
# agy's built-in print timeout is 5m — too short for real subtasks.
|
|
154
160
|
cmd += ["--print-timeout", str(agent_setting("agy", "print_timeout", "60m"))]
|
|
155
161
|
if model:
|
|
@@ -17,7 +17,36 @@ import subprocess
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
19
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
20
|
-
from playmaker.config import agent_setting
|
|
20
|
+
from playmaker.config import agent_list_setting, agent_setting, yolo_enabled
|
|
21
|
+
|
|
22
|
+
# What a sub-agent is allowed to do without a human at the keyboard.
|
|
23
|
+
# Verified against claude 2.x in `-p` mode:
|
|
24
|
+
# default -> the agent writes nothing and answers "I need permission";
|
|
25
|
+
# it does NOT hang, it just returns having done nothing.
|
|
26
|
+
# acceptEdits -> edits and commands proceed inside the working directory,
|
|
27
|
+
# and anything outside it is refused by claude itself.
|
|
28
|
+
# bypassPermissions / --dangerously-skip-permissions -> no boundary at all.
|
|
29
|
+
# acceptEdits is the default here because it is the weakest mode that still
|
|
30
|
+
# lets a detached run finish its work.
|
|
31
|
+
DEFAULT_PERMISSION_MODE = "acceptEdits"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def permission_args() -> list[str]:
|
|
35
|
+
"""Permission flags for a headless run, from [agents.claude] in config.toml."""
|
|
36
|
+
if yolo_enabled("claude"):
|
|
37
|
+
return ["--dangerously-skip-permissions"]
|
|
38
|
+
|
|
39
|
+
mode = agent_setting("claude", "permission_mode", DEFAULT_PERMISSION_MODE)
|
|
40
|
+
args = ["--permission-mode", str(mode)]
|
|
41
|
+
# Comma-separated rather than variadic: `--allowedTools A B` would swallow
|
|
42
|
+
# the positional prompt that follows.
|
|
43
|
+
allowed = agent_list_setting("claude", "allowed_tools")
|
|
44
|
+
if allowed:
|
|
45
|
+
args += ["--allowedTools", ",".join(allowed)]
|
|
46
|
+
disallowed = agent_list_setting("claude", "disallowed_tools")
|
|
47
|
+
if disallowed:
|
|
48
|
+
args += ["--disallowedTools", ",".join(disallowed)]
|
|
49
|
+
return args
|
|
21
50
|
|
|
22
51
|
|
|
23
52
|
class ClaudeHandler:
|
|
@@ -53,13 +82,7 @@ class ClaudeHandler:
|
|
|
53
82
|
"stream-json",
|
|
54
83
|
"--verbose",
|
|
55
84
|
]
|
|
56
|
-
|
|
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")
|
|
85
|
+
cmd += permission_args()
|
|
63
86
|
if model:
|
|
64
87
|
cmd += ["--model", model]
|
|
65
88
|
cmd.append(full_prompt)
|
|
@@ -169,9 +192,8 @@ class ClaudeHandler:
|
|
|
169
192
|
"--output-format",
|
|
170
193
|
"json",
|
|
171
194
|
]
|
|
172
|
-
# See dispatch(): detached resume has no human to approve prompts.
|
|
173
|
-
|
|
174
|
-
cmd.append("--dangerously-skip-permissions")
|
|
195
|
+
# See dispatch(): detached resume has no human to approve prompts either.
|
|
196
|
+
cmd += permission_args()
|
|
175
197
|
if model:
|
|
176
198
|
cmd += ["--model", model]
|
|
177
199
|
cmd.append(full_prompt)
|
|
@@ -23,10 +23,23 @@ import time
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
25
|
from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
|
|
26
|
+
from playmaker.config import agent_setting
|
|
26
27
|
|
|
27
28
|
CODEX_SESSIONS_ROOT = Path("~/.codex/sessions").expanduser()
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def sandbox_args() -> list[str]:
|
|
32
|
+
"""Codex's own sandbox policy, from [agents.codex] sandbox in config.toml.
|
|
33
|
+
|
|
34
|
+
Unlike claude and agy, codex needs no permission-skipping flag: `codex exec`
|
|
35
|
+
is already non-interactive and confines the model's shell commands with its
|
|
36
|
+
own sandbox. We pass `-s` only when a specific policy was configured;
|
|
37
|
+
otherwise codex's own default applies.
|
|
38
|
+
"""
|
|
39
|
+
policy = agent_setting("codex", "sandbox")
|
|
40
|
+
return ["-s", str(policy)] if policy else []
|
|
41
|
+
|
|
42
|
+
|
|
30
43
|
class CodexHandler:
|
|
31
44
|
name = "codex"
|
|
32
45
|
|
|
@@ -58,6 +71,7 @@ class CodexHandler:
|
|
|
58
71
|
"-o",
|
|
59
72
|
str(last_msg_path),
|
|
60
73
|
]
|
|
74
|
+
cmd += sandbox_args()
|
|
61
75
|
if model:
|
|
62
76
|
cmd += ["-m", model]
|
|
63
77
|
cmd.append(full_prompt)
|
|
@@ -131,8 +131,8 @@ def dispatch(
|
|
|
131
131
|
"--model",
|
|
132
132
|
"-m",
|
|
133
133
|
help="forwarded to the agent CLI's --model (e.g. claude 'opus'/'sonnet', "
|
|
134
|
-
"
|
|
135
|
-
"
|
|
134
|
+
"agy 'claude-opus-4-6-thinking' — exact names from `agy models`); "
|
|
135
|
+
"omitted = agent default, which is the safe choice for codex",
|
|
136
136
|
),
|
|
137
137
|
sync: bool = typer.Option(
|
|
138
138
|
False,
|
|
@@ -949,22 +949,44 @@ sound = true
|
|
|
949
949
|
# (terminal-notifier only). Any app name `open -a` accepts.
|
|
950
950
|
editor = "Zed"
|
|
951
951
|
|
|
952
|
+
# How much each sub-agent may do while nobody is watching. A detached agent
|
|
953
|
+
# cannot answer a permission prompt, so every agent needs *some* answer here.
|
|
954
|
+
|
|
952
955
|
[agents.claude]
|
|
953
956
|
binary = "claude"
|
|
954
|
-
#
|
|
955
|
-
#
|
|
956
|
-
#
|
|
957
|
-
#
|
|
958
|
-
|
|
957
|
+
# "acceptEdits" (default): the agent edits files and runs commands freely
|
|
958
|
+
# inside the dispatch --cwd, and claude itself refuses anything outside it.
|
|
959
|
+
# "plan": read and plan only, no writes.
|
|
960
|
+
# "bypassPermissions": no boundary at all — same as yolo below.
|
|
961
|
+
permission_mode = "acceptEdits"
|
|
962
|
+
# Optional allowlist/denylist, narrower than the mode. Tool syntax is Claude
|
|
963
|
+
# Code's own, e.g. "Bash(pytest:*)" to permit one command family.
|
|
964
|
+
# allowed_tools = ["Read", "Edit", "Write", "Bash(pytest:*)"]
|
|
965
|
+
# disallowed_tools = ["WebFetch"]
|
|
966
|
+
#
|
|
967
|
+
# Escape hatch: skip every check, including the working-directory boundary.
|
|
968
|
+
# Only for prompts and directories you would run unattended.
|
|
969
|
+
# yolo = true
|
|
959
970
|
|
|
960
971
|
[agents.codex]
|
|
961
972
|
binary = "codex"
|
|
973
|
+
# codex exec is already non-interactive and sandboxes the model's shell itself,
|
|
974
|
+
# so it needs no permission flag. Set a policy to override its default:
|
|
975
|
+
# "read-only" | "workspace-write" | "danger-full-access"
|
|
976
|
+
# sandbox = "workspace-write"
|
|
962
977
|
|
|
963
978
|
[agents.agy]
|
|
964
979
|
binary = "agy"
|
|
965
|
-
# Antigravity CLI. Model names
|
|
966
|
-
#
|
|
967
|
-
|
|
980
|
+
# Antigravity CLI. Model names come from `agy models` verbatim, e.g.
|
|
981
|
+
# "claude-opus-4-6-thinking", "gemini-3.6-flash-high". The roster changes
|
|
982
|
+
# with Antigravity releases, so read it rather than copying from docs.
|
|
983
|
+
#
|
|
984
|
+
# agy has no per-mode permission flag — a detached run either auto-approves or
|
|
985
|
+
# comes back having done nothing — so this defaults to on.
|
|
986
|
+
yolo = true
|
|
987
|
+
# Layer agy's own terminal restrictions on top.
|
|
988
|
+
# sandbox = true
|
|
989
|
+
#
|
|
968
990
|
# Forwarded to agy --print-timeout; the CLI's own default (5m) is too short
|
|
969
991
|
# for real subtasks.
|
|
970
992
|
print_timeout = "60m"
|
|
@@ -28,3 +28,25 @@ def setting(section: str, key: str, default: Any = None) -> Any:
|
|
|
28
28
|
"""Look up [<section>] <key>, falling back to `default`."""
|
|
29
29
|
value = load_config().get(section, {})
|
|
30
30
|
return value.get(key, default) if isinstance(value, dict) else default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def yolo_enabled(agent: str, *, default: bool = False) -> bool:
|
|
34
|
+
"""Whether this agent is configured to skip its permission checks entirely.
|
|
35
|
+
|
|
36
|
+
`yolo` is the current spelling; `skip_permissions` is the 0.4 name and is
|
|
37
|
+
still honoured so existing configs keep working.
|
|
38
|
+
"""
|
|
39
|
+
value = agent_setting(agent, "yolo")
|
|
40
|
+
if value is None:
|
|
41
|
+
value = agent_setting(agent, "skip_permissions")
|
|
42
|
+
return default if value is None else bool(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def agent_list_setting(agent: str, key: str) -> list[str]:
|
|
46
|
+
"""A list-valued agent setting, tolerating a plain string in the TOML."""
|
|
47
|
+
value = agent_setting(agent, key)
|
|
48
|
+
if value is None:
|
|
49
|
+
return []
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
52
|
+
return [str(v) for v in value]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
import playmaker.config as config
|
|
12
|
+
from playmaker.agents.claude import ClaudeHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeStream:
|
|
16
|
+
def __init__(self, lines: list[str]) -> None:
|
|
17
|
+
self._lines = lines
|
|
18
|
+
|
|
19
|
+
def __iter__(self):
|
|
20
|
+
return iter(self._lines)
|
|
21
|
+
|
|
22
|
+
def read(self) -> str:
|
|
23
|
+
return "".join(self._lines)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _FakePopen:
|
|
27
|
+
"""Stand-in for subprocess.Popen over claude's stream-json output."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, lines: list[str], returncode: int = 0, stderr: str = "") -> None:
|
|
30
|
+
self._lines = lines
|
|
31
|
+
self.returncode = returncode
|
|
32
|
+
self._stderr = stderr
|
|
33
|
+
self.cmd: list[str] = []
|
|
34
|
+
self.stdout = None
|
|
35
|
+
self.stderr = None
|
|
36
|
+
|
|
37
|
+
def __call__(self, cmd, **kwargs):
|
|
38
|
+
self.cmd = cmd
|
|
39
|
+
self.stdout = _FakeStream(self._lines)
|
|
40
|
+
self.stderr = _FakeStream([self._stderr])
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def wait(self) -> int:
|
|
44
|
+
return self.returncode
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _events(*objs: dict) -> list[str]:
|
|
48
|
+
return [json.dumps(o) + "\n" for o in objs]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
INIT = {"type": "system", "subtype": "init", "session_id": "sess-123"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_dispatch_returns_the_result_event_text(monkeypatch, tmp_path) -> None:
|
|
55
|
+
fake = _FakePopen(
|
|
56
|
+
_events(
|
|
57
|
+
INIT,
|
|
58
|
+
{"type": "assistant", "message": {"content": [{"type": "text", "text": "hi"}]}},
|
|
59
|
+
{"type": "result", "subtype": "success", "result": "DONE", "total_cost_usd": 0.12},
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
63
|
+
|
|
64
|
+
result = ClaudeHandler().dispatch(prompt="do it", cwd=tmp_path)
|
|
65
|
+
|
|
66
|
+
assert result.agent_session_id == "sess-123"
|
|
67
|
+
assert result.initial_output == "DONE"
|
|
68
|
+
assert result.cost_usd == 0.12
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_dispatch_fires_the_session_callback_before_the_run_ends(monkeypatch, tmp_path) -> None:
|
|
72
|
+
# The early callback is what lets `playmaker get` find a detached session
|
|
73
|
+
# within a second instead of after the agent finishes.
|
|
74
|
+
fake = _FakePopen(_events(INIT, {"type": "result", "subtype": "success", "result": "ok"}))
|
|
75
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
76
|
+
seen: list[str] = []
|
|
77
|
+
|
|
78
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path, on_session_started=seen.append)
|
|
79
|
+
|
|
80
|
+
assert seen == ["sess-123"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_dispatch_surfaces_the_error_from_the_result_event(monkeypatch, tmp_path) -> None:
|
|
84
|
+
# claude -p reports overload/rate-limit/refusal in the final result event
|
|
85
|
+
# with an EMPTY stderr, so without this the failure was a blank message.
|
|
86
|
+
fake = _FakePopen(
|
|
87
|
+
_events(
|
|
88
|
+
INIT,
|
|
89
|
+
{"type": "result", "subtype": "error_during_execution", "is_error": True,
|
|
90
|
+
"result": "Claude AI usage limit reached"},
|
|
91
|
+
),
|
|
92
|
+
returncode=1,
|
|
93
|
+
)
|
|
94
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
95
|
+
|
|
96
|
+
with pytest.raises(RuntimeError, match="usage limit reached"):
|
|
97
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_dispatch_falls_back_to_raw_stdout_rather_than_failing_blank(
|
|
101
|
+
monkeypatch, tmp_path
|
|
102
|
+
) -> None:
|
|
103
|
+
# No stderr, no result event: the raw first lines are the only diagnostic
|
|
104
|
+
# left, and they beat the blank "claude failed (exit 1):" this replaced.
|
|
105
|
+
fake = _FakePopen(_events(INIT), returncode=1)
|
|
106
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
107
|
+
|
|
108
|
+
with pytest.raises(RuntimeError, match="sess-123"):
|
|
109
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_dispatch_prefers_stderr_over_the_stream_events(monkeypatch, tmp_path) -> None:
|
|
113
|
+
fake = _FakePopen(_events(INIT), returncode=1, stderr="command not found: claude")
|
|
114
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
115
|
+
|
|
116
|
+
with pytest.raises(RuntimeError, match="command not found"):
|
|
117
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_dispatch_without_a_session_id_is_an_error(monkeypatch, tmp_path) -> None:
|
|
121
|
+
fake = _FakePopen(_events({"type": "result", "subtype": "success", "result": "ok"}))
|
|
122
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
123
|
+
|
|
124
|
+
with pytest.raises(RuntimeError, match="missing session_id"):
|
|
125
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_dispatch_bounds_the_agent_to_the_cwd_by_default(monkeypatch, tmp_path) -> None:
|
|
129
|
+
fake = _FakePopen(_events(INIT, {"type": "result", "subtype": "success", "result": "ok"}))
|
|
130
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
131
|
+
monkeypatch.setattr(config, "load_config", dict)
|
|
132
|
+
|
|
133
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path, model="sonnet")
|
|
134
|
+
|
|
135
|
+
assert "--dangerously-skip-permissions" not in fake.cmd
|
|
136
|
+
assert fake.cmd[fake.cmd.index("--permission-mode") + 1] == "acceptEdits"
|
|
137
|
+
assert fake.cmd[fake.cmd.index("--model") + 1] == "sonnet"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_dispatch_yolo_is_opt_in(monkeypatch, tmp_path) -> None:
|
|
141
|
+
fake = _FakePopen(_events(INIT, {"type": "result", "subtype": "success", "result": "ok"}))
|
|
142
|
+
monkeypatch.setattr("playmaker.agents.claude.subprocess.Popen", fake)
|
|
143
|
+
monkeypatch.setattr(config, "load_config", lambda: {"agents": {"claude": {"yolo": True}}})
|
|
144
|
+
|
|
145
|
+
ClaudeHandler().dispatch(prompt="p", cwd=tmp_path)
|
|
146
|
+
|
|
147
|
+
assert "--dangerously-skip-permissions" in fake.cmd
|
|
148
|
+
assert "--permission-mode" not in fake.cmd
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""How each handler answers 'what may this agent do unattended?'."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
import playmaker.config as config
|
|
13
|
+
from playmaker.agents.claude import permission_args
|
|
14
|
+
from playmaker.agents.codex import sandbox_args
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def configured(monkeypatch):
|
|
19
|
+
"""Install a fake config.toml body for the duration of a test."""
|
|
20
|
+
|
|
21
|
+
def _set(cfg: dict) -> None:
|
|
22
|
+
monkeypatch.setattr(config, "load_config", lambda: cfg)
|
|
23
|
+
|
|
24
|
+
return _set
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_claude_defaults_to_accept_edits(configured) -> None:
|
|
28
|
+
configured({})
|
|
29
|
+
|
|
30
|
+
assert permission_args() == ["--permission-mode", "acceptEdits"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_claude_permission_mode_is_configurable(configured) -> None:
|
|
34
|
+
configured({"agents": {"claude": {"permission_mode": "plan"}}})
|
|
35
|
+
|
|
36
|
+
assert permission_args() == ["--permission-mode", "plan"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_claude_yolo_replaces_the_mode_entirely(configured) -> None:
|
|
40
|
+
configured({"agents": {"claude": {"yolo": True, "permission_mode": "plan"}}})
|
|
41
|
+
|
|
42
|
+
assert permission_args() == ["--dangerously-skip-permissions"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_claude_honours_the_legacy_skip_permissions_name(configured) -> None:
|
|
46
|
+
# 0.4 configs in the wild say skip_permissions; they must keep working.
|
|
47
|
+
configured({"agents": {"claude": {"skip_permissions": True}}})
|
|
48
|
+
|
|
49
|
+
assert permission_args() == ["--dangerously-skip-permissions"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_claude_skip_permissions_false_no_longer_means_do_nothing(configured) -> None:
|
|
53
|
+
# In 0.4 this produced a run that wrote nothing. It now falls through to
|
|
54
|
+
# the default mode, which can actually finish the work.
|
|
55
|
+
configured({"agents": {"claude": {"skip_permissions": False}}})
|
|
56
|
+
|
|
57
|
+
assert permission_args() == ["--permission-mode", "acceptEdits"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_claude_tool_lists_are_comma_joined(configured) -> None:
|
|
61
|
+
# Comma-joined on purpose: claude's --allowedTools is variadic and would
|
|
62
|
+
# otherwise swallow the positional prompt that follows it.
|
|
63
|
+
configured(
|
|
64
|
+
{
|
|
65
|
+
"agents": {
|
|
66
|
+
"claude": {
|
|
67
|
+
"allowed_tools": ["Read", "Edit", "Bash(pytest:*)"],
|
|
68
|
+
"disallowed_tools": ["WebFetch"],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
args = permission_args()
|
|
75
|
+
|
|
76
|
+
assert args[args.index("--allowedTools") + 1] == "Read,Edit,Bash(pytest:*)"
|
|
77
|
+
assert args[args.index("--disallowedTools") + 1] == "WebFetch"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_claude_tool_list_accepts_a_plain_string(configured) -> None:
|
|
81
|
+
configured({"agents": {"claude": {"allowed_tools": "Read, Edit"}}})
|
|
82
|
+
|
|
83
|
+
args = permission_args()
|
|
84
|
+
|
|
85
|
+
assert args[args.index("--allowedTools") + 1] == "Read,Edit"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_codex_passes_no_sandbox_flag_by_default(configured) -> None:
|
|
89
|
+
configured({})
|
|
90
|
+
|
|
91
|
+
assert sandbox_args() == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_codex_sandbox_policy_is_forwarded(configured) -> None:
|
|
95
|
+
configured({"agents": {"codex": {"sandbox": "read-only"}}})
|
|
96
|
+
|
|
97
|
+
assert sandbox_args() == ["-s", "read-only"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_agy_yolo_defaults_on_because_it_has_no_middle_tier(configured) -> None:
|
|
101
|
+
configured({})
|
|
102
|
+
|
|
103
|
+
assert config.yolo_enabled("agy", default=True) is True
|
|
104
|
+
assert config.yolo_enabled("claude") is False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_agy_yolo_can_be_turned_off(configured) -> None:
|
|
108
|
+
configured({"agents": {"agy": {"yolo": False}}})
|
|
109
|
+
|
|
110
|
+
assert config.yolo_enabled("agy", default=True) is False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_shipped_config_template_is_valid_toml_and_matches_the_defaults() -> None:
|
|
114
|
+
# `playmaker init` writes this verbatim, so a typo here reaches every new
|
|
115
|
+
# user, and a drift from the handler defaults is a documentation lie.
|
|
116
|
+
import tomllib
|
|
117
|
+
|
|
118
|
+
from playmaker.agents.claude import DEFAULT_PERMISSION_MODE
|
|
119
|
+
from playmaker.cli import _DEFAULT_CONFIG
|
|
120
|
+
|
|
121
|
+
cfg = tomllib.loads(_DEFAULT_CONFIG)
|
|
122
|
+
|
|
123
|
+
assert cfg["agents"]["claude"]["permission_mode"] == DEFAULT_PERMISSION_MODE
|
|
124
|
+
assert cfg["agents"]["agy"]["yolo"] is True
|
|
125
|
+
assert "yolo" not in cfg["agents"]["claude"] # the escape hatch stays commented out
|
|
126
|
+
assert "sandbox" not in cfg["agents"]["codex"]
|
|
127
|
+
assert cfg["notifications"]["editor"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
import playmaker.state as state
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def db(monkeypatch, tmp_path: Path):
|
|
16
|
+
"""Point the module-level paths at a throwaway home and initialise it."""
|
|
17
|
+
home = tmp_path / ".playmaker"
|
|
18
|
+
monkeypatch.setattr(state, "PLAYMAKER_HOME", home)
|
|
19
|
+
monkeypatch.setattr(state, "DB_PATH", home / "state.db")
|
|
20
|
+
monkeypatch.setattr(state, "LOGS_DIR", home / "logs")
|
|
21
|
+
monkeypatch.setattr(state, "OUTPUTS_DIR", home / "outputs")
|
|
22
|
+
monkeypatch.setattr(state, "AGENTS_DIR", home / "agents")
|
|
23
|
+
state.init_db()
|
|
24
|
+
return home
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_init_db_creates_the_directory_layout(db: Path) -> None:
|
|
28
|
+
assert (db / "state.db").is_file()
|
|
29
|
+
assert (db / "logs").is_dir()
|
|
30
|
+
assert (db / "outputs").is_dir()
|
|
31
|
+
assert (db / "agents").is_dir()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_init_db_is_idempotent(db: Path) -> None:
|
|
35
|
+
sid = state.insert_session(agent="codex", prompt="p", cwd="/tmp")
|
|
36
|
+
|
|
37
|
+
state.init_db()
|
|
38
|
+
|
|
39
|
+
assert state.get_session(sid) is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_insert_and_get_round_trip(db: Path) -> None:
|
|
43
|
+
sid = state.insert_session(
|
|
44
|
+
agent="agy",
|
|
45
|
+
prompt="do the thing",
|
|
46
|
+
cwd="/repo",
|
|
47
|
+
files=["a.py", "b.py"],
|
|
48
|
+
model="Claude Opus 4.6 (Thinking)",
|
|
49
|
+
batch_id="dash",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
row = state.get_session(sid)
|
|
53
|
+
|
|
54
|
+
assert row is not None
|
|
55
|
+
assert row["agent"] == "agy"
|
|
56
|
+
assert row["status"] == "pending"
|
|
57
|
+
assert row["model"] == "Claude Opus 4.6 (Thinking)"
|
|
58
|
+
assert row["batch_id"] == "dash"
|
|
59
|
+
assert row["files"] == '["a.py", "b.py"]'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_get_session_accepts_the_short_id_the_cli_prints(db: Path) -> None:
|
|
63
|
+
# `playmaker list` shows only the first 8 characters, so lookup by prefix
|
|
64
|
+
# is the normal way a user refers to a session.
|
|
65
|
+
sid = state.insert_session(agent="codex", prompt="p", cwd="/repo")
|
|
66
|
+
|
|
67
|
+
row = state.get_session(sid[:8])
|
|
68
|
+
|
|
69
|
+
assert row is not None
|
|
70
|
+
assert row["id"] == sid
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_get_session_unknown_id_returns_none(db: Path) -> None:
|
|
74
|
+
assert state.get_session("nope-not-here") is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_update_session_writes_only_named_fields(db: Path) -> None:
|
|
78
|
+
sid = state.insert_session(agent="claude", prompt="p", cwd="/repo")
|
|
79
|
+
|
|
80
|
+
state.update_session(sid, status="done", exit_code=0, cost_usd=0.42)
|
|
81
|
+
|
|
82
|
+
row = state.get_session(sid)
|
|
83
|
+
assert row is not None
|
|
84
|
+
assert (row["status"], row["exit_code"], row["cost_usd"]) == ("done", 0, 0.42)
|
|
85
|
+
assert row["prompt"] == "p"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_update_session_with_no_fields_is_a_noop(db: Path) -> None:
|
|
89
|
+
sid = state.insert_session(agent="claude", prompt="p", cwd="/repo")
|
|
90
|
+
|
|
91
|
+
state.update_session(sid)
|
|
92
|
+
|
|
93
|
+
assert state.get_session(sid)["status"] == "pending"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_list_sessions_filters_and_orders_newest_first(db: Path) -> None:
|
|
97
|
+
old = state.insert_session(agent="codex", prompt="old", cwd="/repo")
|
|
98
|
+
new = state.insert_session(agent="codex", prompt="new", cwd="/repo")
|
|
99
|
+
other = state.insert_session(agent="agy", prompt="other", cwd="/repo")
|
|
100
|
+
state.update_session(old, status="done", started_at="2020-01-01T00:00:00")
|
|
101
|
+
state.update_session(new, status="running", started_at="2030-01-01T00:00:00")
|
|
102
|
+
|
|
103
|
+
assert [r["id"] for r in state.list_sessions(agent="codex")] == [new, old]
|
|
104
|
+
assert [r["id"] for r in state.list_sessions(status="done")] == [old]
|
|
105
|
+
assert {r["id"] for r in state.list_sessions()} == {old, new, other}
|
|
106
|
+
assert len(state.list_sessions(limit=1)) == 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_list_batch_returns_only_that_batch_oldest_first(db: Path) -> None:
|
|
110
|
+
first = state.insert_session(agent="codex", prompt="a", cwd="/r", batch_id="dash")
|
|
111
|
+
second = state.insert_session(agent="agy", prompt="b", cwd="/r", batch_id="dash")
|
|
112
|
+
state.insert_session(agent="claude", prompt="c", cwd="/r", batch_id="other")
|
|
113
|
+
state.update_session(first, started_at="2020-01-01T00:00:00")
|
|
114
|
+
state.update_session(second, started_at="2030-01-01T00:00:00")
|
|
115
|
+
|
|
116
|
+
assert [r["id"] for r in state.list_batch("dash")] == [first, second]
|
|
117
|
+
assert state.list_batch("nothing") == []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_init_db_migrates_a_database_without_model_and_batch_columns(
|
|
121
|
+
monkeypatch, tmp_path: Path
|
|
122
|
+
) -> None:
|
|
123
|
+
# Databases created before 0.3/0.4 lack these columns; init_db must add
|
|
124
|
+
# them in place rather than leaving every query broken.
|
|
125
|
+
home = tmp_path / ".playmaker"
|
|
126
|
+
home.mkdir()
|
|
127
|
+
monkeypatch.setattr(state, "PLAYMAKER_HOME", home)
|
|
128
|
+
monkeypatch.setattr(state, "DB_PATH", home / "state.db")
|
|
129
|
+
monkeypatch.setattr(state, "LOGS_DIR", home / "logs")
|
|
130
|
+
monkeypatch.setattr(state, "OUTPUTS_DIR", home / "outputs")
|
|
131
|
+
monkeypatch.setattr(state, "AGENTS_DIR", home / "agents")
|
|
132
|
+
legacy = sqlite3.connect(home / "state.db")
|
|
133
|
+
legacy.execute(
|
|
134
|
+
"""
|
|
135
|
+
CREATE TABLE sessions (
|
|
136
|
+
id TEXT PRIMARY KEY, agent TEXT NOT NULL, agent_session_id TEXT,
|
|
137
|
+
prompt TEXT NOT NULL, cwd TEXT NOT NULL, files TEXT,
|
|
138
|
+
status TEXT NOT NULL, started_at TEXT NOT NULL, finished_at TEXT,
|
|
139
|
+
exit_code INTEGER, cost_usd REAL, duration_seconds REAL,
|
|
140
|
+
output_path TEXT, session_file_path TEXT, parent_id TEXT, pid INTEGER
|
|
141
|
+
)
|
|
142
|
+
"""
|
|
143
|
+
)
|
|
144
|
+
legacy.execute(
|
|
145
|
+
"INSERT INTO sessions (id, agent, prompt, cwd, status, started_at) "
|
|
146
|
+
"VALUES ('old-1', 'codex', 'p', '/repo', 'done', '2020-01-01T00:00:00')"
|
|
147
|
+
)
|
|
148
|
+
legacy.commit()
|
|
149
|
+
legacy.close()
|
|
150
|
+
|
|
151
|
+
state.init_db()
|
|
152
|
+
|
|
153
|
+
row = state.get_session("old-1")
|
|
154
|
+
assert row is not None
|
|
155
|
+
assert row["model"] is None
|
|
156
|
+
assert row["batch_id"] is None
|
|
157
|
+
assert state.insert_session(agent="agy", prompt="p", cwd="/r", batch_id="b")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|