playmaker-cli 0.4.0__tar.gz → 0.5.0__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.0}/CHANGELOG.md +31 -0
  2. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/PKG-INFO +51 -21
  3. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/README.md +49 -19
  4. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/pyproject.toml +3 -3
  5. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/skills/playmaker-coach/SKILL.md +3 -3
  6. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/agents/agy.py +7 -4
  7. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/agents/claude.py +33 -11
  8. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/agents/codex.py +14 -0
  9. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/cli.py +27 -6
  10. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/config.py +22 -0
  11. playmaker_cli-0.5.0/tests/test_claude.py +148 -0
  12. playmaker_cli-0.5.0/tests/test_permissions.py +127 -0
  13. playmaker_cli-0.5.0/tests/test_state.py +157 -0
  14. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/.gitignore +0 -0
  15. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/LICENSE +0 -0
  16. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/__init__.py +0 -0
  17. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/__main__.py +0 -0
  18. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/agents/__init__.py +0 -0
  19. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/agents/base.py +0 -0
  20. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/agents/gemini.py +0 -0
  21. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/notify.py +0 -0
  22. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/quotas.py +0 -0
  23. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/registry.py +0 -0
  24. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/state.py +0 -0
  25. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/src/playmaker/watcher.py +0 -0
  26. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/tests/__init__.py +0 -0
  27. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/tests/test_agy.py +0 -0
  28. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/tests/test_codex.py +0 -0
  29. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/tests/test_quotas_antigravity.py +0 -0
  30. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/tests/test_registry.py +0 -0
  31. {playmaker_cli-0.4.0 → playmaker_cli-0.5.0}/tests/test_skill.py +0 -0
@@ -5,6 +5,37 @@ 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.0] - 2026-07-27
9
+
10
+ ### Changed
11
+
12
+ - **Sub-agent permissions are configurable, and Claude no longer skips them by
13
+ default.** 0.4 passed `--dangerously-skip-permissions` to every Claude
14
+ dispatch because the alternative was believed to be a run that stalls on the
15
+ first prompt. That is not what happens: a headless `claude -p` without
16
+ permissions answers "I need your permission" and returns immediately, having
17
+ changed nothing. There is also a middle tier, verified against claude 2.x:
18
+ `--permission-mode acceptEdits` lets the agent edit files and run commands
19
+ inside the dispatch `--cwd` while claude itself refuses anything outside it.
20
+
21
+ That is the new default. `[agents.claude]` now accepts `permission_mode`,
22
+ `allowed_tools` and `disallowed_tools`, and `yolo = true` restores the old
23
+ no-boundary behaviour in one line. The 0.4 spelling `skip_permissions = true`
24
+ is still honoured; `skip_permissions = false` now falls through to the
25
+ default mode instead of producing a run that does nothing.
26
+
27
+ **Upgrade note:** a subtask that needs to write outside its `--cwd` will now
28
+ be refused. Point `--cwd` at the right directory, or set `yolo = true`.
29
+
30
+ ### Added
31
+
32
+ - `[agents.codex] sandbox` — forwarded to `codex exec -s`
33
+ (`read-only` / `workspace-write` / `danger-full-access`). Codex needs no
34
+ permission-skipping flag: `codex exec` is already non-interactive and
35
+ sandboxes the model's shell itself, and playmaker never passed it one.
36
+ - `[agents.agy] sandbox` — forwarded to `agy --sandbox`. agy has no per-mode
37
+ permission flag, so it keeps `yolo = true` as its default.
38
+
8
39
  ## [0.4.0] - 2026-07-27
9
40
 
10
41
  ### 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.0
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
@@ -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
 
@@ -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
@@ -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
 
@@ -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.0"
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]
@@ -20,7 +20,7 @@ All Claude work — coach, internal sub-agents, external `claude -p` — draws f
20
20
  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
21
 
22
22
  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.
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 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
24
  - **`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
25
 
26
26
  **Routing cheat-sheet for Claude-side work:**
@@ -219,14 +219,14 @@ Read `summary` first. If you need more context to judge: `playmaker thread <id>`
219
219
 
220
220
  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
221
 
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.)
222
+ **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
223
 
224
224
  Both Claude lanes are on the subscription, so pick by **where the work lives**, not cost:
225
225
  - **Coach folds the result in directly → internal sub-agent (Task tool).** In-session, write-capable, returns into context. Default choice for "more Claude."
226
226
  - **Independent stream you'll monitor / continue separately → `playmaker dispatch claude --model sonnet`.** Tracked, detached; Sonnet's separate weekly bucket spares Opus.
227
227
  - **Work that can leave the Claude family → Codex / agy** (their own quotas).
228
228
 
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.
229
+ 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
230
 
231
231
  ## Reading discipline
232
232
 
@@ -34,7 +34,7 @@ import time
34
34
  from pathlib import Path
35
35
 
36
36
  from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
37
- from playmaker.config import agent_setting
37
+ from playmaker.config import agent_setting, yolo_enabled
38
38
 
39
39
  AGY_BRAIN_ROOT = Path("~/.gemini/antigravity-cli/brain").expanduser()
40
40
 
@@ -146,10 +146,13 @@ class AgyHandler:
146
146
  cmd = ["agy", "-p", full_prompt, "--log-file", str(log_path)]
147
147
  if conversation_id:
148
148
  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):
149
+ # agy has no middle tier: unlike claude there is no per-mode permission
150
+ # flag, so a detached run either skips the prompts or answers nothing.
151
+ # `--sandbox` is orthogonal and can be layered on top.
152
+ if yolo_enabled("agy", default=True):
152
153
  cmd.append("--dangerously-skip-permissions")
154
+ if agent_setting("agy", "sandbox", False):
155
+ cmd.append("--sandbox")
153
156
  # agy's built-in print timeout is 5m — too short for real subtasks.
154
157
  cmd += ["--print-timeout", str(agent_setting("agy", "print_timeout", "60m"))]
155
158
  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)
@@ -949,22 +949,43 @@ 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
980
  # Antigravity CLI. Model names are display strings from `agy models`,
966
981
  # e.g. "Claude Opus 4.6 (Thinking)", "Gemini 3.5 Flash (High)".
967
- skip_permissions = true
982
+ #
983
+ # agy has no per-mode permission flag — a detached run either auto-approves or
984
+ # comes back having done nothing — so this defaults to on.
985
+ yolo = true
986
+ # Layer agy's own terminal restrictions on top.
987
+ # sandbox = true
988
+ #
968
989
  # Forwarded to agy --print-timeout; the CLI's own default (5m) is too short
969
990
  # for real subtasks.
970
991
  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