agent-shell-py 0.2.1__tar.gz → 0.2.3__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 (118) hide show
  1. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/PKG-INFO +1 -1
  2. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/docs/development/agent_parameter_comparison.md +25 -16
  3. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/_version.py +2 -2
  4. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/claude_code_adapter.py +16 -13
  5. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/codex_adapter.py +16 -12
  6. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/copilot_cli_adapter.py +33 -14
  7. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/cursor_adapter.py +15 -13
  8. agent_shell_py-0.2.3/src/agent_shell/adapters/model_discovery.py +81 -0
  9. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/opencode_adapter.py +20 -15
  10. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/pi_adapter.py +15 -13
  11. agent_shell_py-0.2.3/src/agent_shell/process_cleanup.py +158 -0
  12. agent_shell_py-0.2.3/tests/conftest.py +20 -0
  13. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_model_discovery_integration.py +6 -16
  14. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_process_lifecycle.py +282 -54
  15. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_adapter_transport.py +8 -19
  16. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_cancel.py +1 -1
  17. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_codex_cancel.py +1 -1
  18. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_copilot_cli_cancel.py +1 -1
  19. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_copilot_cli_stream.py +52 -0
  20. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_cursor_cancel.py +1 -1
  21. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_opencode_cancel.py +1 -1
  22. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_pi_cancel.py +1 -1
  23. agent_shell_py-0.2.3/tests/unit/test_process_cleanup.py +201 -0
  24. agent_shell_py-0.2.3/tests/unit/test_process_group_registration.py +57 -0
  25. agent_shell_py-0.2.1/src/agent_shell/adapters/model_discovery.py +0 -117
  26. agent_shell_py-0.2.1/src/agent_shell/process_cleanup.py +0 -215
  27. agent_shell_py-0.2.1/tests/conftest.py +0 -50
  28. agent_shell_py-0.2.1/tests/unit/test_process_cleanup.py +0 -596
  29. agent_shell_py-0.2.1/tests/unit/test_process_group_registration.py +0 -208
  30. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/.github/workflows/build.yml +0 -0
  31. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/.github/workflows/ci.yml +0 -0
  32. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/.github/workflows/publish.yml +0 -0
  33. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/.gitignore +0 -0
  34. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/.python-version +0 -0
  35. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/AGENTS.md +0 -0
  36. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/LICENSE +0 -0
  37. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/README.md +0 -0
  38. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/docs/assets/skill_banner.png +0 -0
  39. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/docs/development/disabled_tools.md +0 -0
  40. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/docs/development/info.md +0 -0
  41. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/docs/development/total_token_count.md +0 -0
  42. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/pyproject.toml +0 -0
  43. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/skills/delegating-code-review/SKILL.md +0 -0
  44. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/skills/invoking-cli-agents/SKILL.md +0 -0
  45. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/skills/invoking-cli-agents/api-reference.md +0 -0
  46. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/__init__.py +0 -0
  47. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/__init__.py +0 -0
  48. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/agent_adapter_protocol.py +0 -0
  49. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/health.py +0 -0
  50. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/outcome.py +0 -0
  51. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/response.py +0 -0
  52. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/stderr_format.py +0 -0
  53. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/adapters/tool_denial.py +0 -0
  54. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/models/__init__.py +0 -0
  55. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/models/agent.py +0 -0
  56. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/src/agent_shell/shell.py +0 -0
  57. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/__init__.py +0 -0
  58. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/__init__.py +0 -0
  59. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_claude_code_e2e.py +0 -0
  60. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_codex_e2e.py +0 -0
  61. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_copilot_cli_e2e.py +0 -0
  62. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_cursor_e2e.py +0 -0
  63. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_health_check_e2e.py +0 -0
  64. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_model_discovery_e2e.py +0 -0
  65. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_opencode_e2e.py +0 -0
  66. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/e2e/test_pi_e2e.py +0 -0
  67. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/__init__.py +0 -0
  68. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_claude_code_integration.py +0 -0
  69. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_claude_code_mcp_integration.py +0 -0
  70. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_codex_integration.py +0 -0
  71. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_codex_mcp_integration.py +0 -0
  72. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_copilot_cli_integration.py +0 -0
  73. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_copilot_cli_mcp_integration.py +0 -0
  74. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_cursor_integration.py +0 -0
  75. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_cursor_mcp_integration.py +0 -0
  76. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_health_check_integration.py +0 -0
  77. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_opencode_integration.py +0 -0
  78. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_opencode_mcp_integration.py +0 -0
  79. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_pi_integration.py +0 -0
  80. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/integration/test_pi_mcp_integration.py +0 -0
  81. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/__init__.py +0 -0
  82. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/adapter_matrix.py +0 -0
  83. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/codex_fixtures.py +0 -0
  84. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/copilot_fixtures.py +0 -0
  85. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/cursor_fixtures.py +0 -0
  86. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/fixtures.py +0 -0
  87. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/opencode_fixtures.py +0 -0
  88. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/pi_fixtures.py +0 -0
  89. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_codex_execute.py +0 -0
  90. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_codex_parse_event.py +0 -0
  91. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_codex_warnings.py +0 -0
  92. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_copilot_cli_execute.py +0 -0
  93. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_copilot_cli_parse_event.py +0 -0
  94. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_cursor_execute.py +0 -0
  95. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_cursor_parse_event.py +0 -0
  96. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_cursor_warnings.py +0 -0
  97. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_execute.py +0 -0
  98. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_execute_outcome.py +0 -0
  99. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_health_probe.py +0 -0
  100. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_mcp_server_spec.py +0 -0
  101. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_model_discovery.py +0 -0
  102. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_models.py +0 -0
  103. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_opencode_execute.py +0 -0
  104. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_opencode_parse_event.py +0 -0
  105. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_opencode_spawn.py +0 -0
  106. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_opencode_stream.py +0 -0
  107. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_parse_event.py +0 -0
  108. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_pi_execute.py +0 -0
  109. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_pi_parse_event.py +0 -0
  110. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_pi_warnings.py +0 -0
  111. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_response_aggregation.py +0 -0
  112. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_shell.py +0 -0
  113. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_shell_cancellation.py +0 -0
  114. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_shell_mcp.py +0 -0
  115. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_stderr_format.py +0 -0
  116. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_stream.py +0 -0
  117. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/tests/unit/test_tool_denial.py +0 -0
  118. {agent_shell_py-0.2.1 → agent_shell_py-0.2.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-shell-py
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: A lightweight abstraction for executing CLI coding agents headlessly
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,7 +1,7 @@
1
1
  # Agent CLI Parameter Comparison
2
2
 
3
3
  Comparison of headless/non-interactive configuration across supported CLI coding agents.
4
- Last updated: 2026-08-02
4
+ Last updated: 2026-08-05
5
5
 
6
6
  > The summary matrix below has no Pi or Cursor column (it predates both adapters); see the
7
7
  > per-agent detail sections, the model-discovery section, and the `disallowed_tools` table.
@@ -20,9 +20,9 @@ Last updated: 2026-08-02
20
20
  | **Allowed tools** | `--allowed-tools` | No direct flag | `--allow-tool`, `--available-tools` | `tools` in config |
21
21
  | **Disallowed tools** | `--disallowed-tools` | `web_search` config only | `--deny-tool` | `OPENCODE_PERMISSION` env / `permission` config |
22
22
  | **Stream output** | `--output-format stream-json` | `--json` (NDJSON) | `--output-format=json` (JSONL) | `--format json` |
23
- | **Working dir** | cwd + `--add-dir` | `--cd` / `-C` | cwd (no flag) | cwd (no flag) |
23
+ | **Working dir** | cwd + `--add-dir` | `--cd` / `-C` | cwd / `-C` | cwd |
24
24
  | **System prompt** | `--system-prompt` / `--append-system-prompt` | No flag (files only) | No flag (files only) | `instructions` in config |
25
- | **Budget** | `--max-budget-usd` | No direct flag | No direct flag | No direct flag |
25
+ | **Budget** | `--max-budget-usd` | No direct flag | `--max-ai-credits` | No direct flag |
26
26
  | **Auto-approve** | `--dangerously-skip-permissions` | `--yolo` | `--yolo` / `--allow-all` | Auto in `run` mode |
27
27
  | **Session resume** | `--resume` | `exec resume <id>` | `--resume` | `-s <id>` |
28
28
 
@@ -123,23 +123,32 @@ real discovery commands.
123
123
 
124
124
  ## Copilot CLI (GitHub)
125
125
 
126
- - **Headless mode**: `-p` / `--prompt` for one-shot, `--acp --stdio` for programmatic JSON-RPC
127
- - **Model**: `--model` (default `claude-sonnet-4.5`)
128
- - **Effort**: `--effort low|medium|high` or `--reasoning-effort low|medium|high`
129
- - **Allowed tools**: `--allow-tool`, `--deny-tool`, `--available-tools`, `--excluded-tools`, `--allow-all-tools`
130
- - **Auto-approve**: `--allow-all` / `--yolo`, `--autopilot`
126
+ - **Headless mode**: `-p` / `--prompt` for one-shot; `--acp` for Agent Client Protocol
127
+ - **Model**: `--model <model>`; use `auto` to let Copilot choose
128
+ - **Effort**: `--effort` / `--reasoning-effort` with choices
129
+ `none|minimal|low|medium|high|xhigh|max`. AgentShell accepts effort values
130
+ case-insensitively, validates them against these choices, and passes the normalized lowercase
131
+ value to Copilot via `--effort`.
132
+ - **Allowed tools**: `--allow-tool`, `--deny-tool`, `--available-tools`,
133
+ `--excluded-tools`, `--allow-all-tools`
134
+ - **Auto-approve**: `--allow-all-tools`; `--allow-all` / `--yolo` also grant path and URL
135
+ permissions
136
+ - **Agent mode**: `--mode interactive|plan|autopilot`; `--autopilot` is the shortcut
131
137
  - **Output format**: `--output-format=json` (JSONL)
132
138
  - **Silent mode**: `--silent` suppresses stats, prints only response
133
- - **Working directory**: Uses cwd (no flag), ACP mode uses `newSession` parameter
134
- - **System prompt**: No CLI flag, uses `.github/copilot-instructions.md` and `AGENTS.md` files
135
- - **Budget**: No per-run flag, auto-compacts at 95% token limit
139
+ - **Working directory**: CLI supports `-C <directory>`; AgentShell sets subprocess `cwd`
140
+ - **System prompt**: No CLI flag; uses `.github/copilot-instructions.md` and `AGENTS.md` files
141
+ - **AI credit budget**: `--max-ai-credits <credits>` limits credits for the session
142
+ - **Token budget**: No per-run flag; auto-compacts at 95% of the token limit
136
143
  - **Path permissions**: `--allow-all-paths`, `--disallow-temp-dir`
137
144
  - **URL permissions**: `--allow-all-urls`, `--allow-url`, `--deny-url`
138
- - **Session**: `--resume`, `--continue`, plus `--session-id=<uuid>` to start a NEW session under
139
- a chosen id. The adapter resumes with `--resume <id>`. The resumed run reports the SAME
140
- `sessionId`, and an unknown id is rejected ("No session, task, or name matched"), so id
141
- identity is real evidence the CLI continued that session (measured 2026-07-26)
142
- - **ACP mode**: `--acp --stdio` or `--acp --port 3000` for JSON-RPC integration
145
+ - **Session**: `--resume`, `--continue`, and `--session-id <id>`. The latter resumes an existing
146
+ session or task, or assigns a UUID to a new session. AgentShell resumes with `--resume <id>`.
147
+ The resumed run reports the SAME `sessionId`, and an unknown id is rejected
148
+ ("No session, task, or name matched"), so id identity is real evidence the CLI continued that
149
+ session (measured 2026-07-26).
150
+ - **ACP mode**: `--acp` uses stdio by default. Copilot CLI 1.0.78 also accepts hidden
151
+ `--stdio` and `--port <port>` transport options; they are not shown by `copilot --help`.
143
152
 
144
153
  ## OpenCode
145
154
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.1'
22
- __version_tuple__ = version_tuple = (0, 2, 1)
21
+ __version__ = version = '0.2.3'
22
+ __version_tuple__ = version_tuple = (0, 2, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -7,9 +7,18 @@ import warnings
7
7
  from pathlib import Path
8
8
  from typing import AsyncIterator
9
9
 
10
- from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, MCPServerType, HealthCheckResult
11
- from agent_shell.process_cleanup import (register_process_group, kill_process_group,
12
- release_process)
10
+ from agent_shell.models.agent import (
11
+ AgentResponse,
12
+ HealthCheckResult,
13
+ MCPServerSpec,
14
+ MCPServerType,
15
+ StreamEvent,
16
+ )
17
+ from agent_shell.process_cleanup import (
18
+ create_grouped_process,
19
+ kill_process_group,
20
+ release_process,
21
+ )
13
22
  from agent_shell.adapters.health import run_health_probe
14
23
  from agent_shell.adapters.model_discovery import decode_model_output, run_model_command
15
24
  from agent_shell.adapters.response import collect_response
@@ -105,18 +114,12 @@ class ClaudeCodeAdapter():
105
114
  logger.debug("Command: %s", cmd)
106
115
  logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
107
116
 
108
- process = await asyncio.create_subprocess_exec(
109
- *cmd,
110
- stdin=asyncio.subprocess.DEVNULL,
111
- stdout=asyncio.subprocess.PIPE,
112
- stderr=asyncio.subprocess.PIPE,
113
- cwd=os.path.abspath(cwd),
114
- preexec_fn=os.setsid,
117
+ process = await create_grouped_process(
118
+ cmd,
119
+ cwd=os.path.abspath(cwd),
115
120
  )
116
121
 
117
122
  self._active_processes.append(process)
118
- # setsid makes the child a session leader, so pgid == pid
119
- register_process_group(process.pid)
120
123
 
121
124
  # Drain stderr concurrently with stdout. Reading it only after the stdout loop can
122
125
  # deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
@@ -227,7 +230,7 @@ class ClaudeCodeAdapter():
227
230
 
228
231
  async def cancel(self) -> None:
229
232
  for process in self._active_processes:
230
- kill_process_group(process.pid)
233
+ kill_process_group(process)
231
234
  self._active_processes.clear()
232
235
 
233
236
  async def health_check(
@@ -6,9 +6,18 @@ import os
6
6
  import warnings
7
7
  from typing import AsyncIterator
8
8
 
9
- from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, MCPServerType, HealthCheckResult
10
- from agent_shell.process_cleanup import (register_process_group, kill_process_group,
11
- release_process)
9
+ from agent_shell.models.agent import (
10
+ AgentResponse,
11
+ HealthCheckResult,
12
+ MCPServerSpec,
13
+ MCPServerType,
14
+ StreamEvent,
15
+ )
16
+ from agent_shell.process_cleanup import (
17
+ create_grouped_process,
18
+ kill_process_group,
19
+ release_process,
20
+ )
12
21
  from agent_shell.adapters.health import run_health_probe
13
22
  from agent_shell.adapters.model_discovery import decode_model_output, run_model_command
14
23
  from agent_shell.adapters.response import collect_response
@@ -117,17 +126,12 @@ class CodexAdapter:
117
126
  logger.debug("Command: %s", cmd)
118
127
  logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
119
128
 
120
- process = await asyncio.create_subprocess_exec(
121
- *cmd,
122
- stdin=asyncio.subprocess.DEVNULL,
123
- stdout=asyncio.subprocess.PIPE,
124
- stderr=asyncio.subprocess.PIPE,
125
- cwd=os.path.abspath(cwd),
126
- preexec_fn=os.setsid,
129
+ process = await create_grouped_process(
130
+ cmd,
131
+ cwd=os.path.abspath(cwd),
127
132
  )
128
133
 
129
134
  self._active_processes.append(process)
130
- register_process_group(process.pid)
131
135
 
132
136
  # Drain stderr concurrently with stdout. Reading it only after the stdout loop can
133
137
  # deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
@@ -279,7 +283,7 @@ class CodexAdapter:
279
283
 
280
284
  async def cancel(self) -> None:
281
285
  for process in self._active_processes:
282
- kill_process_group(process.pid)
286
+ kill_process_group(process)
283
287
  self._active_processes.clear()
284
288
 
285
289
  async def health_check(
@@ -7,10 +7,16 @@ import warnings
7
7
  from pathlib import Path
8
8
  from typing import AsyncIterator
9
9
 
10
- from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, MCPServerType, HealthCheckResult
10
+ from agent_shell.models.agent import (
11
+ AgentResponse,
12
+ HealthCheckResult,
13
+ MCPServerSpec,
14
+ MCPServerType,
15
+ StreamEvent,
16
+ )
11
17
  from agent_shell.process_cleanup import (
18
+ create_grouped_process,
12
19
  kill_process_group,
13
- register_process_group,
14
20
  release_process,
15
21
  )
16
22
  from agent_shell.adapters.health import run_health_probe
@@ -38,6 +44,21 @@ _DISALLOWED_TOOL_MAP = {
38
44
  "edit": ["write"],
39
45
  }
40
46
 
47
+ _COPILOT_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
48
+
49
+
50
+ def _normalize_effort(effort: str | None) -> str | None:
51
+ if effort is None:
52
+ return None
53
+
54
+ normalized = effort.lower() if isinstance(effort, str) else None
55
+ if normalized not in _COPILOT_EFFORTS:
56
+ choices = ", ".join(_COPILOT_EFFORTS)
57
+ raise ValueError(
58
+ f"Unsupported Copilot effort {effort!r}; accepted choices: {choices}"
59
+ )
60
+ return normalized
61
+
41
62
 
42
63
  def _json_rpc_frame(message: dict) -> bytes:
43
64
  payload = json.dumps(message, separators=(",", ":")).encode("utf-8")
@@ -154,6 +175,7 @@ class CopilotCLIAdapter:
154
175
  session_id: str | None = None,
155
176
  disallowed_tools: list[str] | None = None,
156
177
  ) -> AsyncIterator[StreamEvent]:
178
+ effort = _normalize_effort(effort)
157
179
  cmd = [
158
180
  "copilot", "-p", prompt,
159
181
  "--output-format", "json",
@@ -195,17 +217,12 @@ class CopilotCLIAdapter:
195
217
  logger.debug("Command: %s", cmd)
196
218
  logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
197
219
 
198
- process = await asyncio.create_subprocess_exec(
199
- *cmd,
200
- stdin=asyncio.subprocess.DEVNULL,
201
- stdout=asyncio.subprocess.PIPE,
202
- stderr=asyncio.subprocess.PIPE,
203
- cwd=os.path.abspath(cwd),
204
- preexec_fn=os.setsid,
220
+ process = await create_grouped_process(
221
+ cmd,
222
+ cwd=os.path.abspath(cwd),
205
223
  )
206
224
 
207
225
  self._active_processes.append(process)
208
- register_process_group(process.pid)
209
226
 
210
227
  # Drain stderr concurrently with stdout. Reading it only after the stdout loop can
211
228
  # deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
@@ -348,7 +365,7 @@ class CopilotCLIAdapter:
348
365
 
349
366
  async def cancel(self) -> None:
350
367
  for process in self._active_processes:
351
- kill_process_group(process.pid)
368
+ kill_process_group(process)
352
369
  self._active_processes.clear()
353
370
 
354
371
  async def health_check(
@@ -365,7 +382,7 @@ class CopilotCLIAdapter:
365
382
  timeout: float = 30.0,
366
383
  ) -> list[str]:
367
384
  cmd = ["copilot", "--headless", "--no-auto-update", "--stdio"]
368
- process, group_leader = await start_model_process(
385
+ process = await start_model_process(
369
386
  cmd,
370
387
  cwd,
371
388
  stdin=asyncio.subprocess.PIPE,
@@ -401,7 +418,7 @@ class CopilotCLIAdapter:
401
418
  ) from error
402
419
  finally:
403
420
  try:
404
- await stop_model_processes(process, group_leader)
421
+ await stop_model_processes(process)
405
422
  finally:
406
423
  if not stderr_task.done():
407
424
  stderr_task.cancel()
@@ -480,8 +497,10 @@ class CopilotCLIAdapter:
480
497
 
481
498
  for name, entry in servers.items():
482
499
  if not isinstance(entry, dict):
500
+ actual_type = type(entry).__name__
483
501
  warnings.warn(
484
- f"Skipping malformed MCP entry '{name}': expected object, got {type(entry).__name__}",
502
+ f"Skipping malformed MCP entry '{name}': "
503
+ f"expected object, got {actual_type}",
485
504
  UserWarning,
486
505
  stacklevel=2,
487
506
  )
@@ -6,9 +6,17 @@ import os
6
6
  import warnings
7
7
  from typing import AsyncIterator
8
8
 
9
- from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, HealthCheckResult
10
- from agent_shell.process_cleanup import (register_process_group, kill_process_group,
11
- release_process)
9
+ from agent_shell.models.agent import (
10
+ AgentResponse,
11
+ HealthCheckResult,
12
+ MCPServerSpec,
13
+ StreamEvent,
14
+ )
15
+ from agent_shell.process_cleanup import (
16
+ create_grouped_process,
17
+ kill_process_group,
18
+ release_process,
19
+ )
12
20
  from agent_shell.adapters.health import run_health_probe
13
21
  from agent_shell.adapters.model_discovery import decode_model_output, run_model_command
14
22
  from agent_shell.adapters.response import collect_response
@@ -105,18 +113,12 @@ class CursorAdapter:
105
113
  logger.debug("Command: %s", cmd)
106
114
  logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
107
115
 
108
- process = await asyncio.create_subprocess_exec(
109
- *cmd,
110
- stdin=asyncio.subprocess.DEVNULL,
111
- stdout=asyncio.subprocess.PIPE,
112
- stderr=asyncio.subprocess.PIPE,
113
- cwd=os.path.abspath(cwd),
114
- preexec_fn=os.setsid,
116
+ process = await create_grouped_process(
117
+ cmd,
118
+ cwd=os.path.abspath(cwd),
115
119
  )
116
120
 
117
121
  self._active_processes.append(process)
118
- # setsid makes the child a session leader, so pgid == pid
119
- register_process_group(process.pid)
120
122
 
121
123
  # Drain stderr concurrently with stdout. Reading it only after the stdout loop can
122
124
  # deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
@@ -281,7 +283,7 @@ class CursorAdapter:
281
283
 
282
284
  async def cancel(self) -> None:
283
285
  for process in self._active_processes:
284
- kill_process_group(process.pid)
286
+ kill_process_group(process)
285
287
  self._active_processes.clear()
286
288
 
287
289
  async def health_check(
@@ -0,0 +1,81 @@
1
+ import asyncio
2
+ import os
3
+
4
+ from agent_shell.process_cleanup import create_grouped_process, kill_process_group
5
+
6
+
7
+ def decode_model_output(output: bytes, source: str) -> str:
8
+ """Decode model metadata without leaking a low-level codec error."""
9
+ try:
10
+ return output.decode("utf-8")
11
+ except UnicodeDecodeError as error:
12
+ raise RuntimeError(f"{source} returned invalid UTF-8 output") from error
13
+
14
+
15
+ async def _reap_process(process: asyncio.subprocess.Process) -> None:
16
+ try:
17
+ await asyncio.wait_for(process.wait(), timeout=2.0)
18
+ except (TimeoutError, ProcessLookupError):
19
+ pass
20
+
21
+
22
+ async def stop_model_processes(process: asyncio.subprocess.Process) -> None:
23
+ """Ask the exact guardian to kill the discovery group, then reap the CLI."""
24
+ kill_process_group(process)
25
+ await _reap_process(process)
26
+
27
+
28
+ async def start_model_process(
29
+ command: list[str],
30
+ cwd: str,
31
+ *,
32
+ env: dict[str, str] | None = None,
33
+ stdin: int = asyncio.subprocess.DEVNULL,
34
+ ) -> asyncio.subprocess.Process:
35
+ """Start a discovery CLI in a group owned by an exact guardian handle."""
36
+ return await create_grouped_process(
37
+ command,
38
+ cwd=os.path.abspath(cwd),
39
+ env=env,
40
+ stdin=stdin,
41
+ )
42
+
43
+
44
+ async def run_model_command(
45
+ command: list[str],
46
+ cwd: str,
47
+ timeout: float,
48
+ *,
49
+ env: dict[str, str] | None = None,
50
+ input_data: bytes | None = None,
51
+ ) -> tuple[int, bytes, bytes]:
52
+ """Run a short-lived model-discovery command and clean up its whole group."""
53
+ process = await start_model_process(
54
+ command,
55
+ cwd,
56
+ env=env,
57
+ stdin=(
58
+ asyncio.subprocess.PIPE
59
+ if input_data is not None
60
+ else asyncio.subprocess.DEVNULL
61
+ ),
62
+ )
63
+
64
+ try:
65
+ stdout, stderr = await asyncio.wait_for(
66
+ process.communicate(input_data),
67
+ timeout=timeout,
68
+ )
69
+ except TimeoutError as error:
70
+ await stop_model_processes(process)
71
+ rendered = " ".join(command)
72
+ raise RuntimeError(
73
+ f"`{rendered}` timed out after {timeout:g} seconds"
74
+ ) from error
75
+ except BaseException:
76
+ await stop_model_processes(process)
77
+ raise
78
+
79
+ returncode = process.returncode
80
+ await stop_model_processes(process)
81
+ return returncode, stdout, stderr
@@ -7,9 +7,18 @@ import warnings
7
7
  from pathlib import Path
8
8
  from typing import AsyncIterator
9
9
 
10
- from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, MCPServerType, HealthCheckResult
11
- from agent_shell.process_cleanup import (register_process_group, kill_process_group,
12
- release_process)
10
+ from agent_shell.models.agent import (
11
+ AgentResponse,
12
+ HealthCheckResult,
13
+ MCPServerSpec,
14
+ MCPServerType,
15
+ StreamEvent,
16
+ )
17
+ from agent_shell.process_cleanup import (
18
+ create_grouped_process,
19
+ kill_process_group,
20
+ release_process,
21
+ )
13
22
  from agent_shell.adapters.health import run_health_probe
14
23
  from agent_shell.adapters.model_discovery import decode_model_output, run_model_command
15
24
  from agent_shell.adapters.response import collect_response
@@ -103,19 +112,13 @@ class OpenCodeAdapter():
103
112
  logger.debug("Command: %s", cmd)
104
113
  logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
105
114
 
106
- process = await asyncio.create_subprocess_exec(
107
- *cmd,
108
- stdin=asyncio.subprocess.DEVNULL,
109
- stdout=asyncio.subprocess.PIPE,
110
- stderr=asyncio.subprocess.PIPE,
111
- cwd=os.path.abspath(cwd),
112
- preexec_fn=os.setsid,
113
- env=env,
115
+ process = await create_grouped_process(
116
+ cmd,
117
+ cwd=os.path.abspath(cwd),
118
+ env=env,
114
119
  )
115
120
 
116
121
  self._active_processes.append(process)
117
- # setsid makes the child a session leader, so pgid == pid
118
- register_process_group(process.pid)
119
122
 
120
123
  # Drain stderr concurrently with stdout. Reading it only after the stdout loop can
121
124
  # deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
@@ -344,7 +347,7 @@ class OpenCodeAdapter():
344
347
 
345
348
  async def cancel(self) -> None:
346
349
  for process in self._active_processes:
347
- kill_process_group(process.pid)
350
+ kill_process_group(process)
348
351
  self._active_processes.clear()
349
352
 
350
353
  async def health_check(
@@ -432,8 +435,10 @@ class OpenCodeAdapter():
432
435
 
433
436
  for name, entry in servers.items():
434
437
  if not isinstance(entry, dict):
438
+ actual_type = type(entry).__name__
435
439
  warnings.warn(
436
- f"Skipping malformed MCP entry '{name}': expected object, got {type(entry).__name__}",
440
+ f"Skipping malformed MCP entry '{name}': "
441
+ f"expected object, got {actual_type}",
437
442
  UserWarning,
438
443
  stacklevel=2,
439
444
  )
@@ -7,9 +7,17 @@ import re
7
7
  import warnings
8
8
  from typing import AsyncIterator
9
9
 
10
- from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, HealthCheckResult
11
- from agent_shell.process_cleanup import (register_process_group, kill_process_group,
12
- release_process)
10
+ from agent_shell.models.agent import (
11
+ AgentResponse,
12
+ HealthCheckResult,
13
+ MCPServerSpec,
14
+ StreamEvent,
15
+ )
16
+ from agent_shell.process_cleanup import (
17
+ create_grouped_process,
18
+ kill_process_group,
19
+ release_process,
20
+ )
13
21
  from agent_shell.adapters.health import run_health_probe
14
22
  from agent_shell.adapters.model_discovery import decode_model_output, run_model_command
15
23
  from agent_shell.adapters.response import collect_response
@@ -115,18 +123,12 @@ class PiAdapter:
115
123
  logger.debug("Command: %s", cmd)
116
124
  logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
117
125
 
118
- process = await asyncio.create_subprocess_exec(
119
- *cmd,
120
- stdin=asyncio.subprocess.DEVNULL,
121
- stdout=asyncio.subprocess.PIPE,
122
- stderr=asyncio.subprocess.PIPE,
123
- cwd=os.path.abspath(cwd),
124
- preexec_fn=os.setsid,
126
+ process = await create_grouped_process(
127
+ cmd,
128
+ cwd=os.path.abspath(cwd),
125
129
  )
126
130
 
127
131
  self._active_processes.append(process)
128
- # setsid makes the child a session leader, so pgid == pid
129
- register_process_group(process.pid)
130
132
 
131
133
  # Drain stderr concurrently with stdout. Reading it only after the stdout loop can
132
134
  # deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
@@ -307,7 +309,7 @@ class PiAdapter:
307
309
 
308
310
  async def cancel(self) -> None:
309
311
  for process in self._active_processes:
310
- kill_process_group(process.pid)
312
+ kill_process_group(process)
311
313
  self._active_processes.clear()
312
314
 
313
315
  async def health_check(