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.
Files changed (31) hide show
  1. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/CHANGELOG.md +46 -0
  2. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/PKG-INFO +55 -25
  3. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/README.md +53 -23
  4. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/pyproject.toml +3 -3
  5. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/skills/playmaker-coach/SKILL.md +23 -17
  6. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/agy.py +14 -8
  7. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/claude.py +33 -11
  8. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/codex.py +14 -0
  9. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/cli.py +32 -10
  10. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/config.py +22 -0
  11. playmaker_cli-0.5.1/tests/test_claude.py +148 -0
  12. playmaker_cli-0.5.1/tests/test_permissions.py +127 -0
  13. playmaker_cli-0.5.1/tests/test_state.py +157 -0
  14. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/.gitignore +0 -0
  15. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/LICENSE +0 -0
  16. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/__init__.py +0 -0
  17. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/__main__.py +0 -0
  18. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/__init__.py +0 -0
  19. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/base.py +0 -0
  20. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/agents/gemini.py +0 -0
  21. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/notify.py +0 -0
  22. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/quotas.py +0 -0
  23. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/registry.py +0 -0
  24. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/state.py +0 -0
  25. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/src/playmaker/watcher.py +0 -0
  26. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/__init__.py +0 -0
  27. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_agy.py +0 -0
  28. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_codex.py +0 -0
  29. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_quotas_antigravity.py +0 -0
  30. {playmaker_cli-0.4.0 → playmaker_cli-0.5.1}/tests/test_registry.py +0 -0
  31. {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.4.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.12
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 --model gpt-5-codex -p "Add PATCH /users/:id …"
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 "Gemini 3.5 Flash (High)" -p "pytest coverage for …"
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
- ## ⚠️ Sub-agents skip permission prompts by default
90
+ ## Permissions
91
91
 
92
- By default, playmaker launches Claude and Antigravity sub-agents with
93
- `--dangerously-skip-permissions` (and Gemini with `--yolo`). This is
94
- deliberate: a headless agent has no human at the keyboard, so without these
95
- flags a detached run stalls at the first tool-approval prompt and finishes
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
- It also means a dispatched agent can run commands and edit files **without
99
- asking**. Only dispatch prompts you'd be comfortable running unattended, in
100
- working directories you trust.
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
- To opt out for Claude, set this in `~/.playmaker/config.toml`:
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
- skip_permissions = false
111
+ permission_mode = "acceptEdits"
112
+ allowed_tools = ["Read", "Edit", "Write", "Bash(pytest:*)"]
113
+ disallowed_tools = ["WebFetch"]
107
114
  ```
108
115
 
109
- with the caveat above: detached runs will then stall on the first permission
110
- prompt, so this only really makes sense alongside `--sync` workflows or
111
- allowlist rules you've configured in Claude Code itself.
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 gpt-5-codex` |
146
- | **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | models are display names: `--model "Claude Opus 4.6 (Thinking)"` see `agy models` |
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["🧑‍🏫 coach<br/>your Claude Code session"] -->|dispatch| P(("playmaker"))
207
- P --> A1["claude -p<br/><i>--model sonnet</i>"]
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 & A2 & A3 --> S[("state.db<br/>outputs/ · logs/")]
211
- S -->|list · thread · summary| C
212
- S -.->|batch drained| N["🔔 one ping"]
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 --model gpt-5-codex -p "Add PATCH /users/:id …"
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 "Gemini 3.5 Flash (High)" -p "pytest coverage for …"
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
- ## ⚠️ Sub-agents skip permission prompts by default
63
+ ## Permissions
64
64
 
65
- By default, playmaker launches Claude and Antigravity sub-agents with
66
- `--dangerously-skip-permissions` (and Gemini with `--yolo`). This is
67
- deliberate: a headless agent has no human at the keyboard, so without these
68
- flags a detached run stalls at the first tool-approval prompt and finishes
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
- It also means a dispatched agent can run commands and edit files **without
72
- asking**. Only dispatch prompts you'd be comfortable running unattended, in
73
- working directories you trust.
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
- To opt out for Claude, set this in `~/.playmaker/config.toml`:
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
- skip_permissions = false
84
+ permission_mode = "acceptEdits"
85
+ allowed_tools = ["Read", "Edit", "Write", "Bash(pytest:*)"]
86
+ disallowed_tools = ["WebFetch"]
80
87
  ```
81
88
 
82
- with the caveat above: detached runs will then stall on the first permission
83
- prompt, so this only really makes sense alongside `--sync` workflows or
84
- allowlist rules you've configured in Claude Code itself.
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 gpt-5-codex` |
119
- | **Antigravity (`agy`)** | bundled with [Antigravity](https://antigravity.google) | models are display names: `--model "Claude Opus 4.6 (Thinking)"` see `agy models` |
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["🧑‍🏫 coach<br/>your Claude Code session"] -->|dispatch| P(("playmaker"))
180
- P --> A1["claude -p<br/><i>--model sonnet</i>"]
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 & A2 & A3 --> S[("state.db<br/>outputs/ · logs/")]
184
- S -->|list · thread · summary| C
185
- S -.->|batch drained| N["🔔 one ping"]
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.4.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.12",
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>=8",
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: `agy models` exposes **Gemini 3.5 Flash (Low/Medium/High)**, **Gemini 3.1 Pro (Low/High)**, **Claude Sonnet 4.6 (Thinking)**, **Claude Opus 4.6 (Thinking)**, and **GPT-OSS 120B (Medium)**. 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 names are the exact display strings (quote them in bash): `--model "Claude Opus 4.6 (Thinking)"`.
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** (playmaker forwards `--dangerously-skip-permissions`; 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.
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 "Claude Opus 4.6 (Thinking)"`** | Opus quality on Google's pool |
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 "Gemini 3.5 Flash (Low)" --cwd $(pwd) --sync \
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 "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.
93
- - **Pattern-following implementation, well-scoped CRUD, mechanical refactor, test scaffolding, writing inside an existing convention:** mid tier (Claude Sonnet, agy "Claude Sonnet 4.6 (Thinking)" / "Gemini 3.5 Flash (High)" / "Gemini 3.1 Pro (Low)", mid-tier Codex). This is where the bulk of delegated implementation goes.
94
- - **Recon, summarization, mechanical loops over many files, name normalization:** cheap tier (agy "Gemini 3.5 Flash (Low)"/"(Medium)", **Claude Haiku**, Sonnet for recon-with-judgment).
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 "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.
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 (Gemini 3.5 Flash High): pytest tests + README
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`, `codex exec -m gpt-5-codex`, `agy --model "Gemini 3.5 Flash (High)"` — agy takes the exact display names from `agy models` and they contain spaces, so **always quote them**. Note: agy's own default is a top-tier model, another reason never to omit `--model` on agy dispatches meant to be cheap.
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" --model gpt-5-codex --prompt "..." --cwd $(pwd)
167
- playmaker dispatch agy --batch "$B" --model "Gemini 3.5 Flash (High)" --prompt "..." --cwd $(pwd)
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 --model gpt-5-codex --prompt "..." --cwd $(pwd) --sync # blocks, prints output
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** playmaker forwards `--dangerously-skip-permissions`, so external `claude -p` dispatch/resume runs headless with permissions skipped and *can* edit files in a detached session. (This is configurable; a user who set `skip_permissions = false` will see detached runs stall on the first tool prompt.)
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>`: a leftover "click Allow" pivot means the local `claude` is older than the skip-permissions wiring or overrides it fall back to an internal sub-agent or Codex/agy for that subtask. 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.
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`, `codex exec -m gpt-5-codex`, `agy --model "Claude Opus 4.6 (Thinking)"` (display names from `agy models`, always quoted). 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.
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 model roster (from `agy models`): `Gemini 3.5 Flash (Low)` / `(Medium)` / `(High)`, `Gemini 3.1 Pro (Low)` / `(High)`, `Claude Sonnet 4.6 (Thinking)`, `Claude Opus 4.6 (Thinking)`, `GPT-OSS 120B (Medium)`. Re-check the roster with `agy models` when in doubt it changes with Antigravity releases.
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 "<display name>"] [--dangerously-skip-permissions]
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
- - models are addressed by *display name* from `agy models`, e.g.
9
- "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (Low)".
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 display names agy accepts, from `agy models`.
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
- # Detached runs have no human to approve tool prompts (same rationale
150
- # as the claude handler). Opt out via [agents.agy] skip_permissions.
151
- if agent_setting("agy", "skip_permissions", True):
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
- # 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")
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
- if agent_setting("claude", "skip_permissions", True):
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
- "codex 'gpt-5-codex', agy 'Claude Opus 4.6 (Thinking)' / 'Gemini 3.5 Flash "
135
- "(High)' display names from `agy models`); omitted = agent default",
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
- # 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
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 are display strings from `agy models`,
966
- # e.g. "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (High)".
967
- skip_permissions = true
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