agent-shell-py 0.2.1__tar.gz → 0.2.2__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.
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/PKG-INFO +1 -1
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/_version.py +2 -2
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/claude_code_adapter.py +16 -13
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/codex_adapter.py +16 -12
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/copilot_cli_adapter.py +17 -14
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/cursor_adapter.py +15 -13
- agent_shell_py-0.2.2/src/agent_shell/adapters/model_discovery.py +81 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/opencode_adapter.py +20 -15
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/pi_adapter.py +15 -13
- agent_shell_py-0.2.2/src/agent_shell/process_cleanup.py +158 -0
- agent_shell_py-0.2.2/tests/conftest.py +20 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_model_discovery_integration.py +6 -16
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_process_lifecycle.py +282 -54
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_adapter_transport.py +8 -19
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cancel.py +1 -1
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_cancel.py +1 -1
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_cancel.py +1 -1
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_cancel.py +1 -1
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_cancel.py +1 -1
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_cancel.py +1 -1
- agent_shell_py-0.2.2/tests/unit/test_process_cleanup.py +201 -0
- agent_shell_py-0.2.2/tests/unit/test_process_group_registration.py +57 -0
- agent_shell_py-0.2.1/src/agent_shell/adapters/model_discovery.py +0 -117
- agent_shell_py-0.2.1/src/agent_shell/process_cleanup.py +0 -215
- agent_shell_py-0.2.1/tests/conftest.py +0 -50
- agent_shell_py-0.2.1/tests/unit/test_process_cleanup.py +0 -596
- agent_shell_py-0.2.1/tests/unit/test_process_group_registration.py +0 -208
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.github/workflows/build.yml +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.github/workflows/ci.yml +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.github/workflows/publish.yml +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.gitignore +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.python-version +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/AGENTS.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/LICENSE +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/README.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/assets/skill_banner.png +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/agent_parameter_comparison.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/disabled_tools.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/info.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/total_token_count.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/pyproject.toml +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/skills/delegating-code-review/SKILL.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/skills/invoking-cli-agents/SKILL.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/skills/invoking-cli-agents/api-reference.md +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/agent_adapter_protocol.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/health.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/outcome.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/response.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/stderr_format.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/tool_denial.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/models/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/models/agent.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/shell.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_claude_code_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_codex_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_copilot_cli_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_cursor_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_health_check_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_model_discovery_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_opencode_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_pi_e2e.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_claude_code_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_claude_code_mcp_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_codex_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_codex_mcp_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_copilot_cli_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_copilot_cli_mcp_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_cursor_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_cursor_mcp_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_health_check_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_opencode_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_opencode_mcp_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_pi_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_pi_mcp_integration.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/__init__.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/adapter_matrix.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/codex_fixtures.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/copilot_fixtures.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/cursor_fixtures.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/fixtures.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/opencode_fixtures.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/pi_fixtures.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_execute.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_parse_event.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_warnings.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_execute.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_parse_event.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_stream.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_execute.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_parse_event.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_warnings.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_execute.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_execute_outcome.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_health_probe.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_mcp_server_spec.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_model_discovery.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_models.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_execute.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_parse_event.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_spawn.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_stream.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_parse_event.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_execute.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_parse_event.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_warnings.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_response_aggregation.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_shell.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_shell_cancellation.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_shell_mcp.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_stderr_format.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_stream.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_tool_denial.py +0 -0
- {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/uv.lock +0 -0
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 2)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
{agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/claude_code_adapter.py
RENAMED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
286
|
+
kill_process_group(process)
|
|
283
287
|
self._active_processes.clear()
|
|
284
288
|
|
|
285
289
|
async def health_check(
|
{agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/copilot_cli_adapter.py
RENAMED
|
@@ -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
|
|
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
|
|
@@ -195,17 +201,12 @@ class CopilotCLIAdapter:
|
|
|
195
201
|
logger.debug("Command: %s", cmd)
|
|
196
202
|
logger.info("Process started (cwd=%s)", os.path.abspath(cwd))
|
|
197
203
|
|
|
198
|
-
process = await
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
stdout=asyncio.subprocess.PIPE,
|
|
202
|
-
stderr=asyncio.subprocess.PIPE,
|
|
203
|
-
cwd=os.path.abspath(cwd),
|
|
204
|
-
preexec_fn=os.setsid,
|
|
204
|
+
process = await create_grouped_process(
|
|
205
|
+
cmd,
|
|
206
|
+
cwd=os.path.abspath(cwd),
|
|
205
207
|
)
|
|
206
208
|
|
|
207
209
|
self._active_processes.append(process)
|
|
208
|
-
register_process_group(process.pid)
|
|
209
210
|
|
|
210
211
|
# Drain stderr concurrently with stdout. Reading it only after the stdout loop can
|
|
211
212
|
# deadlock: a child that fills its stderr pipe buffer (~64KB) mid-run blocks on that
|
|
@@ -348,7 +349,7 @@ class CopilotCLIAdapter:
|
|
|
348
349
|
|
|
349
350
|
async def cancel(self) -> None:
|
|
350
351
|
for process in self._active_processes:
|
|
351
|
-
kill_process_group(process
|
|
352
|
+
kill_process_group(process)
|
|
352
353
|
self._active_processes.clear()
|
|
353
354
|
|
|
354
355
|
async def health_check(
|
|
@@ -365,7 +366,7 @@ class CopilotCLIAdapter:
|
|
|
365
366
|
timeout: float = 30.0,
|
|
366
367
|
) -> list[str]:
|
|
367
368
|
cmd = ["copilot", "--headless", "--no-auto-update", "--stdio"]
|
|
368
|
-
process
|
|
369
|
+
process = await start_model_process(
|
|
369
370
|
cmd,
|
|
370
371
|
cwd,
|
|
371
372
|
stdin=asyncio.subprocess.PIPE,
|
|
@@ -401,7 +402,7 @@ class CopilotCLIAdapter:
|
|
|
401
402
|
) from error
|
|
402
403
|
finally:
|
|
403
404
|
try:
|
|
404
|
-
await stop_model_processes(process
|
|
405
|
+
await stop_model_processes(process)
|
|
405
406
|
finally:
|
|
406
407
|
if not stderr_task.done():
|
|
407
408
|
stderr_task.cancel()
|
|
@@ -480,8 +481,10 @@ class CopilotCLIAdapter:
|
|
|
480
481
|
|
|
481
482
|
for name, entry in servers.items():
|
|
482
483
|
if not isinstance(entry, dict):
|
|
484
|
+
actual_type = type(entry).__name__
|
|
483
485
|
warnings.warn(
|
|
484
|
-
f"Skipping malformed MCP entry '{name}':
|
|
486
|
+
f"Skipping malformed MCP entry '{name}': "
|
|
487
|
+
f"expected object, got {actual_type}",
|
|
485
488
|
UserWarning,
|
|
486
489
|
stacklevel=2,
|
|
487
490
|
)
|
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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}':
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
312
|
+
kill_process_group(process)
|
|
311
313
|
self._active_processes.clear()
|
|
312
314
|
|
|
313
315
|
async def health_check(
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Process-group ownership and cleanup through dedicated guardian processes.
|
|
2
|
+
|
|
3
|
+
Every CLI runs in a process group containing a tiny guardian. AgentShell owns the guardian through
|
|
4
|
+
an anonymous pipe, which is an exact kernel object rather than a reusable numeric PID. Cleanup sends
|
|
5
|
+
one byte through that pipe; the guardian then signals its own group.
|
|
6
|
+
|
|
7
|
+
A normal stream sends RELEASE so a CLI's intentional leftover processes may continue. Cancellation,
|
|
8
|
+
abandonment, model-discovery cleanup, and atexit send KILL. If AgentShell disappears before cleanup,
|
|
9
|
+
the pipe closes and the guardian treats EOF as KILL.
|
|
10
|
+
|
|
11
|
+
The parent never calls ``killpg``. If the guardian has already died, writing its pipe fails safely
|
|
12
|
+
and cleanup accepts a possible leak rather than risking a signal to a recycled process-group ID.
|
|
13
|
+
"""
|
|
14
|
+
import asyncio
|
|
15
|
+
import atexit
|
|
16
|
+
import contextlib
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("agent_shell.process_cleanup")
|
|
25
|
+
|
|
26
|
+
_KILL_GROUP = b"K"
|
|
27
|
+
_RELEASE_GROUP = b"R"
|
|
28
|
+
|
|
29
|
+
_GROUP_GUARDIAN = """
|
|
30
|
+
import os
|
|
31
|
+
import signal
|
|
32
|
+
|
|
33
|
+
command = os.read(0, 1)
|
|
34
|
+
if command == b"R":
|
|
35
|
+
raise SystemExit(0)
|
|
36
|
+
os.kill(0, signal.SIGKILL)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class _GroupGuardian:
|
|
42
|
+
process: subprocess.Popen
|
|
43
|
+
control_fd: int
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def pid(self) -> int:
|
|
47
|
+
return self.process.pid
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Process objects have stable identity even after their numeric PIDs are reaped and reused.
|
|
51
|
+
_guardians: dict[asyncio.subprocess.Process, _GroupGuardian] = {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _send_guardian_command(guardian: _GroupGuardian, command: bytes) -> None:
|
|
55
|
+
try:
|
|
56
|
+
os.write(guardian.control_fd, command)
|
|
57
|
+
except OSError as error:
|
|
58
|
+
logger.warning("Could not contact process-group guardian: %s", error)
|
|
59
|
+
finally:
|
|
60
|
+
with contextlib.suppress(OSError):
|
|
61
|
+
os.close(guardian.control_fd)
|
|
62
|
+
|
|
63
|
+
# subprocess.Popen, rather than asyncio, owns this direct child. Waiting here reaps that
|
|
64
|
+
# exact child; the PID is never used to choose a process or group to signal.
|
|
65
|
+
with contextlib.suppress(OSError, ChildProcessError):
|
|
66
|
+
guardian.process.wait()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _start_guardian() -> _GroupGuardian:
|
|
70
|
+
read_fd, write_fd = os.pipe()
|
|
71
|
+
argv = [sys.executable, "-I", "-S", "-c", _GROUP_GUARDIAN]
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
process = subprocess.Popen(
|
|
75
|
+
argv,
|
|
76
|
+
stdin=read_fd,
|
|
77
|
+
stdout=subprocess.DEVNULL,
|
|
78
|
+
stderr=subprocess.DEVNULL,
|
|
79
|
+
close_fds=True,
|
|
80
|
+
process_group=0,
|
|
81
|
+
)
|
|
82
|
+
except BaseException:
|
|
83
|
+
os.close(write_fd)
|
|
84
|
+
raise
|
|
85
|
+
finally:
|
|
86
|
+
os.close(read_fd)
|
|
87
|
+
|
|
88
|
+
return _GroupGuardian(process=process, control_fd=write_fd)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def create_grouped_process(
|
|
92
|
+
command: list[str],
|
|
93
|
+
cwd: str,
|
|
94
|
+
*,
|
|
95
|
+
env: dict[str, str] | None = None,
|
|
96
|
+
stdin: int = asyncio.subprocess.DEVNULL,
|
|
97
|
+
) -> asyncio.subprocess.Process:
|
|
98
|
+
"""Start a command in a process group owned by an exact guardian pipe."""
|
|
99
|
+
guardian = _start_guardian()
|
|
100
|
+
try:
|
|
101
|
+
process = await asyncio.create_subprocess_exec(
|
|
102
|
+
*command,
|
|
103
|
+
stdin=stdin,
|
|
104
|
+
stdout=asyncio.subprocess.PIPE,
|
|
105
|
+
stderr=asyncio.subprocess.PIPE,
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
env=env,
|
|
108
|
+
process_group=guardian.pid,
|
|
109
|
+
)
|
|
110
|
+
except BaseException:
|
|
111
|
+
_send_guardian_command(guardian, _KILL_GROUP)
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
_guardians[process] = guardian
|
|
115
|
+
return process
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def release_process_group(process: asyncio.subprocess.Process) -> None:
|
|
119
|
+
"""Stop the guardian without signalling leftovers from a completed CLI."""
|
|
120
|
+
guardian = _guardians.pop(process, None)
|
|
121
|
+
if guardian is not None:
|
|
122
|
+
_send_guardian_command(guardian, _RELEASE_GROUP)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def kill_process_group(process: asyncio.subprocess.Process) -> None:
|
|
126
|
+
"""Ask the process's exact guardian to SIGKILL its own group."""
|
|
127
|
+
guardian = _guardians.pop(process, None)
|
|
128
|
+
if guardian is not None:
|
|
129
|
+
_send_guardian_command(guardian, _KILL_GROUP)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def release_process(
|
|
133
|
+
process: asyncio.subprocess.Process,
|
|
134
|
+
active_processes: list,
|
|
135
|
+
stderr_task,
|
|
136
|
+
*,
|
|
137
|
+
child_exited: bool,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Release the resources owned by one adapter stream on every exit path."""
|
|
140
|
+
if not stderr_task.done():
|
|
141
|
+
stderr_task.cancel()
|
|
142
|
+
|
|
143
|
+
if process in active_processes:
|
|
144
|
+
active_processes.remove(process)
|
|
145
|
+
|
|
146
|
+
if child_exited or process.returncode is not None:
|
|
147
|
+
release_process_group(process)
|
|
148
|
+
else:
|
|
149
|
+
kill_process_group(process)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cleanup_process_groups() -> None:
|
|
153
|
+
"""Ask every still-registered guardian to kill its group during interpreter exit."""
|
|
154
|
+
for process in list(_guardians):
|
|
155
|
+
kill_process_group(process)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
atexit.register(cleanup_process_groups)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Fixtures shared by the whole suite."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from agent_shell import process_cleanup
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture(autouse=True)
|
|
8
|
+
def isolate_process_group_registry():
|
|
9
|
+
"""Give every test an isolated guardian registry and close anything it leaks."""
|
|
10
|
+
saved = dict(process_cleanup._guardians)
|
|
11
|
+
process_cleanup._guardians.clear()
|
|
12
|
+
try:
|
|
13
|
+
yield
|
|
14
|
+
finally:
|
|
15
|
+
# This runs on assertion failures too. Leaving a guardian's pipe open would leave its
|
|
16
|
+
# process blocked and could hide the test failure behind process cleanup at interpreter
|
|
17
|
+
# exit.
|
|
18
|
+
for process in list(process_cleanup._guardians):
|
|
19
|
+
process_cleanup.kill_process_group(process)
|
|
20
|
+
process_cleanup._guardians.update(saved)
|