agent-shell-py 0.1.17__tar.gz → 0.2.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 (111) hide show
  1. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/AGENTS.md +25 -5
  2. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/PKG-INFO +19 -3
  3. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/README.md +18 -2
  4. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/agent-skills/delegating-code-review/SKILL.md +41 -9
  5. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/agent-skills/invoking-cli-agents/SKILL.md +47 -30
  6. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/agent-skills/invoking-cli-agents/api-reference.md +66 -9
  7. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/docs/development/agent_parameter_comparison.md +67 -37
  8. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/docs/development/disabled_tools.md +0 -1
  9. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/docs/development/total_token_count.md +5 -1
  10. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/_version.py +2 -2
  11. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/agent_adapter_protocol.py +7 -0
  12. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/claude_code_adapter.py +27 -24
  13. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/codex_adapter.py +27 -24
  14. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/copilot_cli_adapter.py +27 -24
  15. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/cursor_adapter.py +27 -24
  16. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/health.py +15 -22
  17. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/opencode_adapter.py +27 -24
  18. agent_shell_py-0.2.0/src/agent_shell/adapters/outcome.py +68 -0
  19. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/pi_adapter.py +74 -33
  20. agent_shell_py-0.2.0/src/agent_shell/adapters/response.py +70 -0
  21. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/models/agent.py +34 -1
  22. agent_shell_py-0.2.0/src/agent_shell/process_cleanup.py +213 -0
  23. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/shell.py +14 -10
  24. agent_shell_py-0.2.0/tests/conftest.py +50 -0
  25. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_claude_code_e2e.py +22 -11
  26. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_codex_e2e.py +20 -9
  27. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_copilot_cli_e2e.py +67 -12
  28. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_cursor_e2e.py +26 -10
  29. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_health_check_e2e.py +1 -1
  30. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_opencode_e2e.py +22 -10
  31. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/test_pi_e2e.py +28 -9
  32. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_health_check_integration.py +5 -3
  33. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_pi_integration.py +3 -1
  34. agent_shell_py-0.2.0/tests/integration/test_process_lifecycle.py +283 -0
  35. agent_shell_py-0.2.0/tests/unit/adapter_matrix.py +67 -0
  36. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/opencode_fixtures.py +10 -0
  37. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/pi_fixtures.py +103 -3
  38. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_adapter_transport.py +223 -6
  39. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_cursor_execute.py +16 -8
  40. agent_shell_py-0.2.0/tests/unit/test_execute_outcome.py +446 -0
  41. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_health_probe.py +17 -2
  42. agent_shell_py-0.2.0/tests/unit/test_models.py +97 -0
  43. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_pi_execute.py +15 -7
  44. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_pi_parse_event.py +132 -7
  45. agent_shell_py-0.2.0/tests/unit/test_process_cleanup.py +596 -0
  46. agent_shell_py-0.2.0/tests/unit/test_response_aggregation.py +131 -0
  47. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_shell.py +16 -3
  48. agent_shell_py-0.1.17/src/agent_shell/process_cleanup.py +0 -54
  49. agent_shell_py-0.1.17/tests/unit/test_models.py +0 -33
  50. agent_shell_py-0.1.17/tests/unit/test_process_cleanup.py +0 -137
  51. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/.github/workflows/build.yml +0 -0
  52. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/.github/workflows/ci.yml +0 -0
  53. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/.github/workflows/publish.yml +0 -0
  54. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/.gitignore +0 -0
  55. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/.python-version +0 -0
  56. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/LICENSE +0 -0
  57. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/docs/development/info.md +0 -0
  58. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/pyproject.toml +0 -0
  59. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/__init__.py +0 -0
  60. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/__init__.py +0 -0
  61. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/stderr_format.py +0 -0
  62. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/adapters/tool_denial.py +0 -0
  63. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/src/agent_shell/models/__init__.py +0 -0
  64. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/__init__.py +0 -0
  65. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/e2e/__init__.py +0 -0
  66. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/__init__.py +0 -0
  67. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_claude_code_integration.py +0 -0
  68. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_claude_code_mcp_integration.py +0 -0
  69. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_codex_integration.py +0 -0
  70. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_codex_mcp_integration.py +0 -0
  71. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_copilot_cli_integration.py +0 -0
  72. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_copilot_cli_mcp_integration.py +0 -0
  73. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_cursor_integration.py +0 -0
  74. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_cursor_mcp_integration.py +0 -0
  75. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_opencode_integration.py +0 -0
  76. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_opencode_mcp_integration.py +0 -0
  77. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/integration/test_pi_mcp_integration.py +0 -0
  78. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/__init__.py +0 -0
  79. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/codex_fixtures.py +0 -0
  80. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/copilot_fixtures.py +0 -0
  81. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/cursor_fixtures.py +0 -0
  82. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/fixtures.py +0 -0
  83. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_cancel.py +0 -0
  84. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_codex_cancel.py +0 -0
  85. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_codex_execute.py +0 -0
  86. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_codex_parse_event.py +0 -0
  87. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_codex_warnings.py +0 -0
  88. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_copilot_cli_cancel.py +0 -0
  89. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_copilot_cli_execute.py +0 -0
  90. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_copilot_cli_parse_event.py +0 -0
  91. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_copilot_cli_stream.py +0 -0
  92. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_cursor_cancel.py +0 -0
  93. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_cursor_parse_event.py +0 -0
  94. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_cursor_warnings.py +0 -0
  95. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_execute.py +0 -0
  96. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_mcp_server_spec.py +0 -0
  97. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_opencode_cancel.py +0 -0
  98. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_opencode_execute.py +0 -0
  99. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_opencode_parse_event.py +0 -0
  100. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_opencode_spawn.py +0 -0
  101. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_opencode_stream.py +0 -0
  102. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_parse_event.py +0 -0
  103. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_pi_cancel.py +0 -0
  104. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_pi_warnings.py +0 -0
  105. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_process_group_registration.py +0 -0
  106. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_shell_cancellation.py +0 -0
  107. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_shell_mcp.py +0 -0
  108. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_stderr_format.py +0 -0
  109. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_stream.py +0 -0
  110. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/tests/unit/test_tool_denial.py +0 -0
  111. {agent_shell_py-0.1.17 → agent_shell_py-0.2.0}/uv.lock +0 -0
@@ -38,6 +38,16 @@ classDiagram
38
38
  -_parse_event(event, include_thinking) list~StreamEvent~
39
39
  }
40
40
 
41
+ class AgentExecutionError {
42
+ <<Exception>>
43
+ +str reason
44
+ +str response
45
+ +float cost
46
+ +str session_id
47
+ +float duration
48
+ +int output_tokens
49
+ }
50
+
41
51
  class MCPServerSpec {
42
52
  +str name
43
53
  +MCPServerType type
@@ -74,13 +84,13 @@ classDiagram
74
84
  +float duration
75
85
  +str session_id
76
86
  +int output_tokens
87
+ +str error
77
88
  }
78
89
 
79
90
  class AgentType {
80
91
  <<StrEnum>>
81
92
  CLAUDE_CODE
82
93
  OPENCODE
83
- GEMINI_CLI
84
94
  COPILOT_CLI
85
95
  CODEX
86
96
  PI
@@ -90,7 +100,8 @@ classDiagram
90
100
  AgentShell --> AgentAdapter : delegates to
91
101
  AgentShell --> AgentType : resolves via
92
102
  ClaudeCodeAdapter ..|> AgentAdapter : satisfies
93
- AgentShell ..> AgentResponse : returns
103
+ AgentShell ..> AgentResponse : returns on success
104
+ AgentShell ..> AgentExecutionError : raises on failure
94
105
  AgentShell ..> HealthCheckResult : returns
95
106
  AgentShell ..> StreamEvent : yields
96
107
  AgentShell ..> MCPServerSpec : accepts/returns
@@ -102,7 +113,17 @@ The adapter pattern uses Python's `Protocol` (structural typing) rather than ABC
102
113
 
103
114
  `output_tokens` is a cost measure — the billed output-token count, which **includes reasoning tokens** (billed at the output rate). Each adapter normalises this so the value is consistent across agents (e.g. OpenCode reports reasoning in a sibling field, so its adapter adds it back).
104
115
 
105
- `health_check(cwd, model, timeout)` probes an agent + model combination with a trivial prompt and returns `HealthCheckResult(healthy, exception)`. The verdict is derived from the normalised event stream (healthy = a `result` event with no `error`), not exit codes — which are unreliable, since some CLIs exit 0 on failure. The shared logic lives once in `adapters/health.py`; each adapter delegates to it.
116
+ `health_check(cwd, model, timeout)` probes an agent + model combination with a trivial prompt
117
+ and returns `HealthCheckResult(healthy, exception)`. The verdict is derived from the normalised
118
+ event stream (healthy = the LAST `result` event says `content == "ok"` and no `error` event
119
+ arrived — pi emits one `result` per agent loop, and its auto-retry runs more than one), not exit
120
+ codes — which are unreliable, since some CLIs exit 0 on failure. `execute()` judges a run by the
121
+ same rule and raises `AgentExecutionError` when it fails, carrying whatever partial
122
+ response/cost/session/token data the run produced. The success/failure verdict lives once in
123
+ `adapters/outcome.py`, shared by both surfaces so they report identical reasons for the same
124
+ stream; `adapters/health.py` wraps it for the health probe, and `adapters/response.py` wraps it
125
+ for `execute()`'s stream-to-`AgentResponse` collection (used by every adapter — each `execute()`
126
+ is a single delegating call into it).
106
127
 
107
128
  ## Supported Agents
108
129
 
@@ -112,14 +133,13 @@ The adapter pattern uses Python's `Protocol` (structural typing) rather than ABC
112
133
  - [x] Codex
113
134
  - [x] Pi
114
135
  - [x] Cursor
115
- - [ ] Gemini CLI
116
136
 
117
137
  ## MCP Server Configuration
118
138
 
119
139
  `AgentShell` exposes a unified API for registering MCP servers across all supported agents:
120
140
 
121
141
  ```python
122
- from agent_shell import AgentShell
142
+ from agent_shell.shell import AgentShell
123
143
  from agent_shell.models.agent import AgentType, MCPServerSpec, MCPServerType
124
144
 
125
145
  shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-shell-py
3
- Version: 0.1.17
3
+ Version: 0.2.0
4
4
  Summary: A lightweight abstraction for executing CLI coding agents headlessly
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -17,8 +17,8 @@ and returning the output that can be used programatically as a unified contract
17
17
  agent; swap the backend without changing a line of consuming code.
18
18
  - **Six CLI agents** — Claude Code, OpenCode, Copilot CLI, Codex, Pi, and Cursor behind a
19
19
  common adapter protocol.
20
- - **Execute or stream** — get one `AgentResponse`, or async-iterate normalized `StreamEvent`s
21
- with optional thinking/reasoning.
20
+ - **Execute or stream** — get one `AgentResponse` (raises `AgentExecutionError` on a failed run),
21
+ or async-iterate normalized `StreamEvent`s with optional thinking/reasoning.
22
22
  - **Session resumption** — continue any conversation by passing back its `session_id`.
23
23
  - **Normalized cost & tokens** — consistent `cost` and `output_tokens` (reasoning included)
24
24
  regardless of how each CLI reports them.
@@ -77,6 +77,22 @@ follow_up = await shell.execute(
77
77
  > `output_tokens` is a cost measure: the billed output-token count, which **includes reasoning
78
78
  > tokens** (they are billed at the output rate). It is reported consistently across all adapters.
79
79
 
80
+ ### Failure handling
81
+
82
+ `execute()` raises `AgentExecutionError` instead of returning when a run failed — an `error`
83
+ event was emitted, the terminal `result` had `content == "error"`, or no terminal `result`
84
+ arrived at all. `str(e)` is the bare reason; the exception also carries whatever partial
85
+ `response`/`cost`/`session_id`/`duration`/`output_tokens` the run produced before failing.
86
+
87
+ ```python
88
+ from agent_shell.models.agent import AgentExecutionError
89
+
90
+ try:
91
+ response = await shell.execute(cwd="/path/to/project", prompt="Fix the failing test")
92
+ except AgentExecutionError as e:
93
+ print(f"run failed: {e}") # e.g. "500 model name=qwen3.6-27b-8Q failed to load"
94
+ ```
95
+
80
96
  ### Stream
81
97
 
82
98
  ```python
@@ -8,8 +8,8 @@ and returning the output that can be used programatically as a unified contract
8
8
  agent; swap the backend without changing a line of consuming code.
9
9
  - **Six CLI agents** — Claude Code, OpenCode, Copilot CLI, Codex, Pi, and Cursor behind a
10
10
  common adapter protocol.
11
- - **Execute or stream** — get one `AgentResponse`, or async-iterate normalized `StreamEvent`s
12
- with optional thinking/reasoning.
11
+ - **Execute or stream** — get one `AgentResponse` (raises `AgentExecutionError` on a failed run),
12
+ or async-iterate normalized `StreamEvent`s with optional thinking/reasoning.
13
13
  - **Session resumption** — continue any conversation by passing back its `session_id`.
14
14
  - **Normalized cost & tokens** — consistent `cost` and `output_tokens` (reasoning included)
15
15
  regardless of how each CLI reports them.
@@ -68,6 +68,22 @@ follow_up = await shell.execute(
68
68
  > `output_tokens` is a cost measure: the billed output-token count, which **includes reasoning
69
69
  > tokens** (they are billed at the output rate). It is reported consistently across all adapters.
70
70
 
71
+ ### Failure handling
72
+
73
+ `execute()` raises `AgentExecutionError` instead of returning when a run failed — an `error`
74
+ event was emitted, the terminal `result` had `content == "error"`, or no terminal `result`
75
+ arrived at all. `str(e)` is the bare reason; the exception also carries whatever partial
76
+ `response`/`cost`/`session_id`/`duration`/`output_tokens` the run produced before failing.
77
+
78
+ ```python
79
+ from agent_shell.models.agent import AgentExecutionError
80
+
81
+ try:
82
+ response = await shell.execute(cwd="/path/to/project", prompt="Fix the failing test")
83
+ except AgentExecutionError as e:
84
+ print(f"run failed: {e}") # e.g. "500 model name=qwen3.6-27b-8Q failed to load"
85
+ ```
86
+
71
87
  ### Stream
72
88
 
73
89
  ```python
@@ -29,8 +29,17 @@ instruction in the prompt — restrict the tools. But mind how the two controls
29
29
  The whitelist is *inert under the default `auto_approve=True`* (`--dangerously-skip-permissions`
30
30
  auto-approves everything), so the `auto_approve=False` is what makes it bite:
31
31
  `allowed_tools=["Read", "Glob", "Grep", "Bash"], auto_approve=False`.
32
- - **OpenCode / Codex** — they ignore `allowed_tools`; use the enforced denylist instead:
33
- `disallowed_tools=["edit"]`.
32
+ - **OpenCode** — it ignores `allowed_tools`, but its denylist **is** genuinely enforced (a
33
+ per-subprocess `OPENCODE_PERMISSION` env var), and deny beats `auto_approve`, so keep the
34
+ default `auto_approve=True`: `disallowed_tools=["edit"]`.
35
+ - **Codex / Cursor** — **in-library tool scoping gives you nothing here.** They ignore
36
+ `allowed_tools`, *and* they cannot enforce `disallowed_tools=["edit"]`. Codex can only deny
37
+ `web_search`; anything else prints
38
+ `UserWarning: Codex can only deny web_search; ignoring ['edit']` and the reviewer then runs
39
+ with unrestricted write access. Cursor has no per-call deny at all (its tool policy lives in
40
+ `.cursor/cli.json`). If a Codex/Cursor reviewer must not write, enforce it outside the
41
+ library: capture `git diff` yourself and pass it in the prompt, run the reviewer under an
42
+ OS-level read-only sandbox, and/or use a different agent type when read-only really matters.
34
43
 
35
44
  **This is defence-in-depth, not a sandbox.** Any reviewer that keeps `bash` (for `git diff`) can
36
45
  still write via the shell, and in-library scoping doesn't cover MCP-provided tools. For a *hard*
@@ -60,6 +69,27 @@ Flag issues by severity: CRITICAL, WARNING, or SUGGESTION.""",
60
69
 
61
70
  The reviewer will run `git diff` itself, read surrounding code for context, and report findings.
62
71
 
72
+ ## Handling a Failed Review
73
+
74
+ A failed reviewer run raises `AgentExecutionError` instead of returning a response — catch it
75
+ so a crashed or rate-limited reviewer doesn't look like a silent "no issues found":
76
+
77
+ ```python
78
+ from agent_shell.models.agent import AgentExecutionError
79
+
80
+ try:
81
+ review = await reviewer.execute(
82
+ cwd="/path/to/project",
83
+ prompt="Review the uncommitted changes in this repository.",
84
+ allowed_tools=["Read", "Glob", "Grep", "Bash"],
85
+ auto_approve=False,
86
+ model="sonnet",
87
+ )
88
+ except AgentExecutionError as e:
89
+ print(f"review failed: {e}") # e.g. "500 model name=... failed to load"
90
+ raise
91
+ ```
92
+
63
93
  ## Review a Specific Commit or Range
64
94
 
65
95
  Point the reviewer at a particular changeset — only the prompt changes.
@@ -96,7 +126,9 @@ clarification = await reviewer.execute(
96
126
  ## Cross-Agent Review
97
127
 
98
128
  Use a different agent or model than the one that wrote the code for genuine independence.
99
- OpenCode ignores `allowed_tools`, so restrict it with `disallowed_tools` instead.
129
+ OpenCode ignores `allowed_tools`, so restrict it with `disallowed_tools` instead — that deny
130
+ *is* enforced. Don't swap `AgentType.CODEX` or `AgentType.CURSOR` into this example expecting
131
+ the same protection: they drop the deny with only a `UserWarning`.
100
132
 
101
133
  ```python
102
134
  reviewer = AgentShell(agent_type=AgentType.OPENCODE)
@@ -112,10 +144,9 @@ Flag issues by severity: CRITICAL, WARNING, or SUGGESTION.""",
112
144
  ```
113
145
 
114
146
  > OpenCode reports `cost` as `0.0` for many models — use `output_tokens` if you need a usage
115
- > figure. And `execute()` gives no failure signal: an empty `review.response` likely means the
116
- > reviewer failed. If a reliable verdict matters, `stream()` and require a `result` event with
117
- > `content == "ok"` — OpenCode can truncate a review with no terminal event and no error (see the
118
- > core skill's Error Handling).
147
+ > figure. And a failed reviewer run raises `AgentExecutionError` rather than returning — wrap
148
+ > the call (see [Handling a Failed Review](#handling-a-failed-review) below), since OpenCode can
149
+ > truncate a review with no terminal event and no error (see the core skill's Error Handling).
119
150
 
120
151
  ## Prompt Patterns
121
152
 
@@ -155,9 +186,10 @@ For each gap, describe the test case that should exist and why it matters.
155
186
  |---------|-----|
156
187
  | Relying on a "do not modify files" prompt instruction for safety | Enforce it (whitelist + `auto_approve=False`, or `disallowed_tools`) |
157
188
  | Whitelisting tools but leaving the default `auto_approve=True` | `--dangerously-skip-permissions` makes the whitelist inert — set `auto_approve=False` |
158
- | Assuming `allowed_tools` restricts OpenCode | OpenCode ignores it use `disallowed_tools` |
189
+ | Assuming `allowed_tools` restricts OpenCode, Codex, or Cursor | All three ignore it |
190
+ | `disallowed_tools` to keep a Codex/Cursor reviewer read-only | It only warns; sandbox instead |
159
191
  | Believing `disallowed_tools=["edit"]` makes the reviewer read-only | It doesn't — the model writes via `bash`; also deny `bash` (and OS-sandbox for a hard guarantee) |
160
192
  | Not giving the reviewer `Bash`/git access | Without it the reviewer can't run `git diff` — keep `bash`, or pass the diff in the prompt |
161
- | Not checking for empty responses from `execute()` | `execute()` gives no failure signal an empty `review.response` likely means failure; use `stream()` to detect it |
193
+ | Not catching `AgentExecutionError` | A failed reviewer run raises, it does not return |
162
194
  | Reviewing with the same model that wrote the code | Use a different model or agent type for independence |
163
195
  | Ignoring the review and committing anyway | At minimum, address all CRITICAL items first |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: invoking-cli-agents
3
- description: Use when programmatically invoking a CLI coding agent (Claude Code, OpenCode, Copilot CLI, Codex, Pi) from Python, delegating a task to a sub-agent, orchestrating several agents, streaming an agent's output, resuming an agent session, restricting which tools an agent may use, or checking whether an agent/model is healthy. Keywords: AgentShell, headless agent, subprocess, allowed_tools, disallowed_tools, read-only agent, session_id, cost, output_tokens.
3
+ description: Use when programmatically invoking a CLI coding agent (Claude Code, OpenCode, Copilot CLI, Codex, Pi, Cursor) from Python, delegating a task to a sub-agent, orchestrating several agents, streaming an agent's output, resuming an agent session, restricting which tools an agent may use, or checking whether an agent/model is healthy. Keywords: AgentShell, headless agent, subprocess, allowed_tools, disallowed_tools, read-only agent, session_id, cost, output_tokens.
4
4
  ---
5
5
 
6
6
  # Invoking CLI Agents with AgentShell
@@ -15,7 +15,7 @@ passes `model` strings through verbatim and does not manage credentials.
15
15
 
16
16
  ## When to Use
17
17
 
18
- - You need to invoke Claude Code, OpenCode, Copilot CLI, Codex, or Pi from Python
18
+ - You need to invoke Claude Code, OpenCode, Copilot CLI, Codex, Pi, or Cursor from Python
19
19
  - You want to delegate a coding task to a sub-agent and collect the result
20
20
  - You need to orchestrate multi-step workflows across agents
21
21
  - You want to stream agent output in real-time
@@ -120,11 +120,11 @@ Both `execute()` and `stream()` take the same parameters.
120
120
  |-----------|------|---------|---------|
121
121
  | `cwd` | `str` | required | Working directory (must exist, else `ValueError`) |
122
122
  | `prompt` | `str` | required | Task or question for the agent |
123
- | `allowed_tools` | `list[str] \| None` | `None` | Whitelist of tools (agent-native names). `None` = all tools. Honoured by Claude Code, Copilot CLI, Pi; ignored by OpenCode and Codex. **Only actually enforced when `auto_approve=False`** (see Tool Restriction). |
123
+ | `allowed_tools` | `list[str] \| None` | `None` | Whitelist of tools (agent-native names). `None` = all tools. Honoured by Claude Code, Copilot CLI, Pi; ignored by OpenCode, Codex and Cursor. **Only actually enforced when `auto_approve=False`** (see Tool Restriction). |
124
124
  | `disallowed_tools` | `list[str] \| None` | `None` | Denylist using a canonical vocabulary (see Tool Restriction). Deny takes precedence over allow **and** over `auto_approve`, but covers only built-in tools. Enforcement varies per agent; unenforceable denies emit a `UserWarning`. |
125
125
  | `model` | `str \| None` | `None` | Model alias or name, passed to the CLI verbatim (e.g. `"sonnet"`, `"opencode/big-pickle"`) |
126
- | `effort` | `str \| None` | `None` | Reasoning effort: `"low"`, `"medium"`, `"high"`, etc. Claude Code, Copilot, Codex, Pi. **Ignored by OpenCode.** |
127
- | `include_thinking` | `bool` | `False` | Yield `thinking` events in `stream()`. Claude Code, Copilot, Pi. **Dropped by `execute()`** (which keeps only text). |
126
+ | `effort` | `str \| None` | `None` | Reasoning effort: `"low"`, `"medium"`, `"high"`, etc. Claude Code, Copilot, Codex, Pi. **Ignored by OpenCode** (silently) **and Cursor** (warns). |
127
+ | `include_thinking` | `bool` | `False` | Yield `thinking` events in `stream()`. Claude Code, Copilot, Pi, Cursor. **Dropped by `execute()`** (which keeps only text). |
128
128
  | `auto_approve` | `bool` | `True` | Skip tool permission prompts. Mapped on every adapter (Pi *requires* a trust decision — the default avoids a hang). **On Claude Code the default `True` sends `--dangerously-skip-permissions`, which bypasses `allowed_tools`.** |
129
129
  | `session_id` | `str \| None` | `None` | Resume a previous session |
130
130
 
@@ -143,7 +143,7 @@ AgentType.OPENCODE # OpenCode CLI
143
143
  AgentType.COPILOT_CLI # GitHub Copilot CLI
144
144
  AgentType.CODEX # OpenAI Codex CLI
145
145
  AgentType.PI # Pi coding agent
146
- AgentType.GEMINI_CLI # enum value only — NO adapter (raises ValueError at construction)
146
+ AgentType.CURSOR # Cursor CLI (cursor-agent)
147
147
  ```
148
148
 
149
149
  Capabilities differ by agent. `output_tokens` is populated on all of them; the rest varies:
@@ -155,6 +155,7 @@ Capabilities differ by agent. `output_tokens` is populated on all of them; the r
155
155
  | Copilot CLI | ✅ | ⚠️ `bash`, `edit` only | ✅ | ❌ `0.0` | ✅ real | ✅ |
156
156
  | Codex | ❌ | ⚠️ `web_search` only | ✅ | ❌ `0.0` | ❌ `0.0` | ✅ |
157
157
  | Pi | ✅ | ⚠️ `bash`, `edit`, `read` | ✅ | ⚠️ paid providers only | ❌ `0.0` | ❌ raises |
158
+ | Cursor | ❌ warns | ❌ none — warns | ❌ warns | ❌ `0.0` | ✅ real | ❌ raises |
158
159
 
159
160
  A `✅` for `allowed_tools` means the flag is passed — but it only *enforces* with
160
161
  `auto_approve=False`; `disallowed_tools` covers only built-in tools. See Tool Restriction.
@@ -188,7 +189,9 @@ response = await shell.execute(
188
189
  auto_approve=False,
189
190
  )
190
191
 
191
- # OpenCode / Codex ignore allowed_tools — use the enforced denylist instead.
192
+ # OpenCode ignores allowed_tools, but its denylist IS enforced — use that instead. (Codex and
193
+ # Cursor ignore BOTH controls: Codex can deny only web_search, Cursor nothing at all, so for
194
+ # those two in-library scoping buys you nothing — restrict them outside the library.)
192
195
  # Keep auto_approve at its default True: on OpenCode, auto_approve=False makes `opencode run`
193
196
  # auto-reject prompts and can silently abort the turn. (auto_approve=False is only for the
194
197
  # whitelist path above, on Claude Code / Copilot / Pi.)
@@ -211,28 +214,41 @@ response = await shell.execute(
211
214
 
212
215
  ## Error Handling
213
216
 
214
- AgentShell raises exceptions for its own preconditions, but **CLI agent failures are reported
215
- as events, not exceptions.**
217
+ AgentShell raises exceptions for its own preconditions **and** for a failed agent run:
216
218
 
217
219
  ```python
218
220
  # cwd validation - raises ValueError if directory doesn't exist
219
- # Unsupported agent (e.g. AgentType.GEMINI_CLI) - raises ValueError at construction
221
+ # Unsupported agent - an AgentType with no registered adapter raises ValueError
220
222
  # KeyboardInterrupt - AgentShell cancels the subprocess cleanly, then re-raises
221
223
  ```
222
224
 
223
- `execute()` returns an `AgentResponse` with whatever text accumulated (possibly empty) and
224
- **no failure signal** an empty `response` usually means the agent failed. To detect failures,
225
- use `stream()` and treat success as a **positive** signal, the way the library's own
226
- `health_check` does: a `result` event with `content == "ok"` arrived **and** no `error` event.
225
+ A run counts as failed when any of: an `error` event was emitted; the terminal `result` event
226
+ has `content == "error"`; or no terminal `result` event arrived at all (a killed, truncated, or
227
+ aborted run OpenCode in particular can drop the tail and still exit 0). `execute()` raises
228
+ `AgentExecutionError` for all three; `str(e)` is the bare reason (e.g. `"500 model
229
+ name=qwen3.6-27b-8Q failed to load"`), and the exception also carries whatever partial data the
230
+ run produced before failing:
227
231
 
228
- Failure shows up three ways, and the positive-signal check below catches all three: (1) an
229
- `error` event; (2) a `result` event with `content == "error"` — how a bad model or usage limit
230
- often surfaces, sometimes with *no* separate `error` event; (3) *no* `result` event at all —
231
- a turn can truncate with no terminal event and no error (OpenCode in particular can drop the
232
- tail and still exit 0), leaving a partial response that looks fine. Never infer success from
233
- "got some text and no error event." Treat a missing `result` event as failure (and consider a
234
- retry). Neither `execute()` nor `stream()` has a timeout, so wrap the call in `asyncio.wait_for`
235
- to guard against a hang.
232
+ ```python
233
+ from agent_shell.models.agent import AgentExecutionError
234
+
235
+ try:
236
+ response = await shell.execute(cwd=project_path, prompt="do something")
237
+ except AgentExecutionError as e:
238
+ print(f"failed: {e}") # str(e) == e.reason, the bare cause
239
+ print(e.response) # text produced before the failure, if any
240
+ print(e.cost, e.session_id, e.duration, e.output_tokens)
241
+ else:
242
+ print(response.response)
243
+ ```
244
+
245
+ `stream()` has no exception equivalent — it yields the raw events and leaves the verdict to the
246
+ caller. Apply the same rule as a **positive** signal, the way the library's own `health_check`
247
+ does: success is the LAST `result` event carrying `content == "ok"` **and** no `error` event —
248
+ the loop below already does this, since each `result` overwrites the verdict. (An agent that
249
+ retries a transient fault emits a failing `result` and then a good one.) Never infer
250
+ success from "got some text and no error event." Neither `execute()` nor `stream()` has a
251
+ timeout, so wrap the call in `asyncio.wait_for` to guard against a hang.
236
252
 
237
253
  ```python
238
254
  saw_ok = False
@@ -254,8 +270,9 @@ succeeded = saw_ok and error is None # absent result => succeeded stays
254
270
  event of every adapter. It reads `0` when the `result` event never arrives (a truncated turn),
255
271
  so it is not a standalone liveness check — pair it with the success check above.
256
272
  - `cost` — real for Claude Code and paid Pi providers; frequently `0.0` for OpenCode,
257
- Copilot, and Codex (they don't report it). Don't treat `cost == 0` as "the call failed".
258
- - `duration` real only for Claude Code and Copilot CLI; `0.0` elsewhere.
273
+ Copilot, Codex, and always `0.0` for Cursor (they don't report it). Don't treat
274
+ `cost == 0` as "the call failed".
275
+ - `duration` — real for Claude Code, Copilot CLI, and Cursor; `0.0` elsewhere.
259
276
 
260
277
  ## Other Capabilities
261
278
 
@@ -264,7 +281,7 @@ succeeded = saw_ok and error is None # absent result => succeeded stays
264
281
  **not** run your prompt, so use the stream-based check above when you care about a specific
265
282
  call's outcome.
266
283
  - **MCP server management** — `add_mcp_server`, `remove_mcp_server`, `list_mcp_servers` manage
267
- the underlying CLI's MCP config (Pi raises `NotImplementedError`).
284
+ the underlying CLI's MCP config (Pi and Cursor raise `NotImplementedError`).
268
285
 
269
286
  See [api-reference.md](api-reference.md) for their full signatures and the `MCPServerSpec` model.
270
287
 
@@ -286,10 +303,10 @@ logging.getLogger("agent_shell").addHandler(logging.StreamHandler())
286
303
  | Stream events live | `async for event in shell.stream(cwd, prompt)` |
287
304
  | Continue a conversation | Pass `session_id=response.session_id` |
288
305
  | Whitelist tools (Claude/Copilot/Pi) | `allowed_tools=["Read", "Glob"]` |
289
- | Deny tools on any agent (enforced) | `disallowed_tools=["edit", "bash"]` |
306
+ | Deny tools (Claude/OpenCode/Copilot/Pi) | `disallowed_tools=["edit", "bash"]` |
290
307
  | Track usage | Read `response.output_tokens` (portable) or `response.cost` |
291
308
  | Use a specific model | `model="sonnet"` |
292
- | Increase reasoning depth | `effort="high"` (not OpenCode) |
309
+ | Increase reasoning depth | `effort="high"` (not OpenCode or Cursor) |
293
310
  | See agent thinking | `include_thinking=True` in `stream()` |
294
311
  | Check an agent/model works | `await shell.health_check(cwd, model=...)` |
295
312
  | Cancel a running agent | `KeyboardInterrupt` (handled automatically) |
@@ -302,14 +319,14 @@ logging.getLogger("agent_shell").addHandler(logging.StreamHandler())
302
319
  | Forgetting `await` | Both `execute()` and the `stream()` iterator are async |
303
320
  | `allowed_tools=[]` to disable tools | Empty list is falsy → full access. Use a non-empty list or `disallowed_tools` |
304
321
  | `allowed_tools` with the default `auto_approve=True` as a safety boundary | `--dangerously-skip-permissions` bypasses it. Set `auto_approve=False`, or use `disallowed_tools` |
305
- | Relying on `allowed_tools` with OpenCode/Codex | They ignore it. Use `disallowed_tools` |
322
+ | Relying on `allowed_tools` with OpenCode/Codex/Cursor | Ignored; only OpenCode's deny works |
306
323
  | Trusting a `prompt` instruction ("don't edit files") as a guarantee | Enforce it (`disallowed_tools`, or whitelist + `auto_approve=False`) |
307
324
  | Assuming `disallowed_tools` sandboxes the agent | It covers only built-in tools; MCP/other-named tools bypass it. OS-sandbox for a hard guarantee |
308
325
  | Treating `cost == 0` as failure | Many agents don't report cost; use `output_tokens` |
309
- | Expecting `execute()` to expose thinking or detect failure | Use `stream()` for both |
326
+ | Expecting `execute()` to expose thinking | Use `stream()` with `include_thinking=True` |
327
+ | Not catching `AgentExecutionError` | `execute()` raises on a failed run — catch it |
310
328
  | Ignoring `UserWarning` on a deny | An unenforceable deny is warned, not applied — the tool is NOT blocked |
311
329
  | Ignoring `session_id` for multi-step work | Without it, each call starts fresh |
312
- | Using `AgentType.GEMINI_CLI` | No adapter — raises `ValueError` at construction |
313
330
 
314
331
  ## API Reference
315
332
 
@@ -1,6 +1,7 @@
1
1
  # AgentShell API Reference
2
2
 
3
- - [Models](#models) — `AgentType`, `AgentResponse`, `StreamEvent`, `MCPServerSpec`, `HealthCheckResult`
3
+ - [Models](#models) — `AgentType`, `AgentResponse`, `AgentExecutionError`, `StreamEvent`,
4
+ `MCPServerSpec`, `HealthCheckResult`
4
5
  - [StreamEvent types](#event-types)
5
6
  - [AgentShell class](#agentshell-class) — `execute`, `stream`, `health_check`, MCP management
6
7
  - [AgentAdapter protocol](#agentadapter-protocol)
@@ -16,15 +17,15 @@ from agent_shell.models.agent import AgentType
16
17
  class AgentType(StrEnum):
17
18
  CLAUDE_CODE = "claude_code"
18
19
  OPENCODE = "opencode"
19
- GEMINI_CLI = "gemini_cli" # enum only — NO adapter (raises ValueError)
20
20
  COPILOT_CLI = "copilot_cli"
21
21
  CODEX = "codex"
22
22
  PI = "pi"
23
+ CURSOR = "cursor"
23
24
  ```
24
25
 
25
26
  ### AgentResponse
26
27
 
27
- Returned by `execute()`.
28
+ Returned by `execute()` on success.
28
29
 
29
30
  ```python
30
31
  @dataclass
@@ -36,6 +37,29 @@ class AgentResponse:
36
37
  output_tokens: int = 0 # Generated tokens (reasoning-inclusive; populated on all agents)
37
38
  ```
38
39
 
40
+ ### AgentExecutionError
41
+
42
+ Raised by `execute()` — never returned — when the run failed: an `error` event was emitted, the
43
+ terminal `result` event had `content == "error"`, or no terminal `result` event arrived at all
44
+ (a killed, truncated, or aborted run). `str(e)` is the bare reason (e.g. `"500 model
45
+ name=qwen3.6-27b-8Q failed to load"`). The constructor arguments double as attributes, carrying
46
+ whatever partial data the run produced before failing.
47
+
48
+ ```python
49
+ from agent_shell.models.agent import AgentExecutionError
50
+
51
+ class AgentExecutionError(Exception):
52
+ def __init__(
53
+ self,
54
+ reason: str, # str(e) == reason; also e.reason
55
+ response: str = "", # text produced before the failure, if any
56
+ cost: float = 0.0,
57
+ session_id: str | None = None,
58
+ duration: float = 0.0,
59
+ output_tokens: int = 0,
60
+ ): ...
61
+ ```
62
+
39
63
  ### StreamEvent
40
64
 
41
65
  Yielded by `stream()`.
@@ -49,6 +73,7 @@ class StreamEvent:
49
73
  duration: float = 0.0 # Elapsed seconds (on "result" events, where supported)
50
74
  session_id: str | None = None # On session-start and "result" events
51
75
  output_tokens: int = 0 # Cumulative generated tokens (on "result" events)
76
+ error: str | None = None # Why a failing "result" failed, when recoverable (Pi)
52
77
  ```
53
78
 
54
79
  ### MCPServerSpec
@@ -91,14 +116,23 @@ Canonical event types emitted by `stream()`:
91
116
  |------|------|---------|--------------|
92
117
  | `system` | Session starts | `""` | `session_id` is set |
93
118
  | `text` | Agent produces output | Response text (may arrive in chunks) | |
94
- | `thinking` | Agent reasons (requires `include_thinking=True`; Claude Code / Copilot / Pi) | Chain-of-thought text | |
95
- | `tool_use` | Agent invokes a tool | Tool name (Codex: the command string) | |
96
- | `result` | Agent finishes | `"ok"` on success, `"error"` on agent-level failure | `cost`, `duration`, `output_tokens`, `session_id` populated where supported |
97
- | `error` | Agent or CLI process fails | Error message / stderr tail (last 500 chars) | |
119
+ | `thinking` | Agent reasons (requires `include_thinking=True`; Claude Code / Copilot / Pi / Cursor) | Chain-of-thought text | |
120
+ | `tool_use` | Agent invokes a tool | Tool name (Codex, and Cursor shell calls: the command) | |
121
+ | `result` | Agent finishes | `"ok"` or `"error"` | see the note below |
122
+ | `error` | Agent or CLI process fails | Error message, or stderr head+tail (500 chars each) | |
123
+
124
+ > A `result` event carries `cost`, `duration`, `output_tokens` and `session_id` on the agents
125
+ > that report them. On a failing result, `error` holds the reason when the adapter recovered a
126
+ > structured one (Pi); it is `None` otherwise.
98
127
 
99
128
  > Codex emits the session-start event as `type="session"` (not `"system"`). If you branch on
100
129
  > the session event across agents, match both.
101
130
 
131
+ > Long stderr is **not** tail-only. `format_stderr` keeps the first 500 *and* the last 500
132
+ > characters, joined by a `... [truncated] ...` marker, so a reason stated up front
133
+ > (cursor-agent's `Cannot use this model: <name>`) survives alongside a trailing stack trace.
134
+ > A stderr of 1000 characters or fewer is passed through whole, with no marker.
135
+
102
136
  ## AgentShell Class
103
137
 
104
138
  ```python
@@ -106,7 +140,7 @@ from agent_shell.shell import AgentShell
106
140
 
107
141
  class AgentShell:
108
142
  def __init__(self, agent_type: AgentType): ...
109
- # raises ValueError for an AgentType with no adapter (e.g. GEMINI_CLI)
143
+ # raises ValueError for an AgentType with no registered adapter
110
144
 
111
145
  async def execute(
112
146
  self,
@@ -120,13 +154,14 @@ class AgentShell:
120
154
  session_id: str | None = None, # Resume previous session
121
155
  disallowed_tools: list[str] | None = None, # Canonical denylist (enforced; deny > allow)
122
156
  ) -> AgentResponse: ...
157
+ # Raises AgentExecutionError if the run failed; see the Models section above.
123
158
 
124
159
  def stream(self, ...) -> AsyncIterator[StreamEvent]: ... # same parameters as execute()
125
160
 
126
161
  async def health_check(
127
162
  self, cwd: str, model: str | None = None, timeout: float = 60.0,
128
163
  ) -> HealthCheckResult: ...
129
- # Sends a trivial no-tool prompt; healthy iff a result=="ok" event arrives and no error.
164
+ # Sends a trivial no-tool prompt; healthy iff the LAST result event is "ok" and no error.
130
165
 
131
166
  async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: ...
132
167
  async def remove_mcp_server(self, mcp_server_name: str) -> None: ...
@@ -156,6 +191,7 @@ class AgentAdapter(Protocol):
156
191
  async def execute(self, cwd, prompt, allowed_tools=None, model=None, effort=None,
157
192
  include_thinking=False, auto_approve=True, session_id=None,
158
193
  disallowed_tools=None) -> AgentResponse: ...
194
+ # Raises AgentExecutionError if the run failed.
159
195
 
160
196
  def stream(self, ...) -> AsyncIterator[StreamEvent]: ... # same signature as execute()
161
197
 
@@ -199,3 +235,24 @@ class AgentAdapter(Protocol):
199
235
  - `effort` → `--thinking` (levels: off/minimal/low/medium/high/xhigh); `auto_approve` → `--approve` / `--no-approve` (one is always sent, else `pi -p` hangs on a trust prompt).
200
236
  - `cost` is real for paid providers (`0.0` on local); `duration` is `0.0`.
201
237
  - MCP-management methods raise `NotImplementedError`.
238
+
239
+ ### Cursor
240
+ - `model` → `--model` (e.g. `"sonnet-4-thinking"`, `"gpt-5"`); parameterised models take bracket
241
+ overrides, e.g. `"claude-opus-4-8[context=1m,effort=high]"`. A Free plan exposes only `auto`.
242
+ - `allowed_tools` **and** `disallowed_tools` are both **ignored** (both warn). Cursor has no
243
+ per-call tool policy of any kind — it lives in `.cursor/cli.json` — so *nothing* in this library
244
+ can scope a Cursor agent; restrict it outside (read-only mount, container). `disallowed_tools`
245
+ warns on every call; `allowed_tools` and `effort` warn once per adapter instance.
246
+ - `effort` is **ignored** (warns): Cursor has no effort flag, only the model bracket-override
247
+ above, which the adapter does not inject. `include_thinking` **is** honoured — Cursor streams
248
+ reasoning as `thinking` deltas.
249
+ - `auto_approve` → `--force`; without it tools auto-*reject* but the run still completes (exit 0).
250
+ `--trust` is always sent regardless: untrusted, `cursor-agent` exits 1 with zero stdout and a
251
+ plain-text "Workspace Trust Required" on stderr.
252
+ - Session resume is `--resume=<id>` (the `=` form binds the id, whose CLI arg is optional). The
253
+ resumed run reports the SAME id — but an unknown id is **accepted**, creating a session under
254
+ it rather than failing (like Pi; unlike Claude Code, OpenCode, Copilot and Codex, which all
255
+ reject one). A matching id is therefore not proof a prior transcript was continued.
256
+ - `duration` and `output_tokens` are real (`usage.outputTokens`); `cost` is always `0.0` — Cursor
257
+ reports no cost. MCP-management methods raise `NotImplementedError` (`cursor-agent mcp` has no
258
+ add/remove, and its `list` returns only name+status, which cannot rebuild an `MCPServerSpec`).