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.
Files changed (118) hide show
  1. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/PKG-INFO +1 -1
  2. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/_version.py +2 -2
  3. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/claude_code_adapter.py +16 -13
  4. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/codex_adapter.py +16 -12
  5. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/copilot_cli_adapter.py +17 -14
  6. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/cursor_adapter.py +15 -13
  7. agent_shell_py-0.2.2/src/agent_shell/adapters/model_discovery.py +81 -0
  8. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/opencode_adapter.py +20 -15
  9. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/pi_adapter.py +15 -13
  10. agent_shell_py-0.2.2/src/agent_shell/process_cleanup.py +158 -0
  11. agent_shell_py-0.2.2/tests/conftest.py +20 -0
  12. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_model_discovery_integration.py +6 -16
  13. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_process_lifecycle.py +282 -54
  14. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_adapter_transport.py +8 -19
  15. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cancel.py +1 -1
  16. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_cancel.py +1 -1
  17. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_cancel.py +1 -1
  18. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_cancel.py +1 -1
  19. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_cancel.py +1 -1
  20. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_cancel.py +1 -1
  21. agent_shell_py-0.2.2/tests/unit/test_process_cleanup.py +201 -0
  22. agent_shell_py-0.2.2/tests/unit/test_process_group_registration.py +57 -0
  23. agent_shell_py-0.2.1/src/agent_shell/adapters/model_discovery.py +0 -117
  24. agent_shell_py-0.2.1/src/agent_shell/process_cleanup.py +0 -215
  25. agent_shell_py-0.2.1/tests/conftest.py +0 -50
  26. agent_shell_py-0.2.1/tests/unit/test_process_cleanup.py +0 -596
  27. agent_shell_py-0.2.1/tests/unit/test_process_group_registration.py +0 -208
  28. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.github/workflows/build.yml +0 -0
  29. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.github/workflows/ci.yml +0 -0
  30. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.github/workflows/publish.yml +0 -0
  31. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.gitignore +0 -0
  32. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/.python-version +0 -0
  33. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/AGENTS.md +0 -0
  34. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/LICENSE +0 -0
  35. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/README.md +0 -0
  36. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/assets/skill_banner.png +0 -0
  37. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/agent_parameter_comparison.md +0 -0
  38. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/disabled_tools.md +0 -0
  39. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/info.md +0 -0
  40. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/docs/development/total_token_count.md +0 -0
  41. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/pyproject.toml +0 -0
  42. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/skills/delegating-code-review/SKILL.md +0 -0
  43. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/skills/invoking-cli-agents/SKILL.md +0 -0
  44. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/skills/invoking-cli-agents/api-reference.md +0 -0
  45. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/__init__.py +0 -0
  46. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/__init__.py +0 -0
  47. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/agent_adapter_protocol.py +0 -0
  48. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/health.py +0 -0
  49. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/outcome.py +0 -0
  50. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/response.py +0 -0
  51. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/stderr_format.py +0 -0
  52. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/adapters/tool_denial.py +0 -0
  53. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/models/__init__.py +0 -0
  54. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/models/agent.py +0 -0
  55. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/src/agent_shell/shell.py +0 -0
  56. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/__init__.py +0 -0
  57. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/__init__.py +0 -0
  58. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_claude_code_e2e.py +0 -0
  59. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_codex_e2e.py +0 -0
  60. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_copilot_cli_e2e.py +0 -0
  61. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_cursor_e2e.py +0 -0
  62. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_health_check_e2e.py +0 -0
  63. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_model_discovery_e2e.py +0 -0
  64. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_opencode_e2e.py +0 -0
  65. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/e2e/test_pi_e2e.py +0 -0
  66. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/__init__.py +0 -0
  67. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_claude_code_integration.py +0 -0
  68. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_claude_code_mcp_integration.py +0 -0
  69. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_codex_integration.py +0 -0
  70. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_codex_mcp_integration.py +0 -0
  71. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_copilot_cli_integration.py +0 -0
  72. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_copilot_cli_mcp_integration.py +0 -0
  73. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_cursor_integration.py +0 -0
  74. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_cursor_mcp_integration.py +0 -0
  75. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_health_check_integration.py +0 -0
  76. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_opencode_integration.py +0 -0
  77. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_opencode_mcp_integration.py +0 -0
  78. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_pi_integration.py +0 -0
  79. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/integration/test_pi_mcp_integration.py +0 -0
  80. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/__init__.py +0 -0
  81. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/adapter_matrix.py +0 -0
  82. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/codex_fixtures.py +0 -0
  83. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/copilot_fixtures.py +0 -0
  84. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/cursor_fixtures.py +0 -0
  85. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/fixtures.py +0 -0
  86. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/opencode_fixtures.py +0 -0
  87. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/pi_fixtures.py +0 -0
  88. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_execute.py +0 -0
  89. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_parse_event.py +0 -0
  90. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_codex_warnings.py +0 -0
  91. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_execute.py +0 -0
  92. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_parse_event.py +0 -0
  93. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_copilot_cli_stream.py +0 -0
  94. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_execute.py +0 -0
  95. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_parse_event.py +0 -0
  96. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_cursor_warnings.py +0 -0
  97. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_execute.py +0 -0
  98. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_execute_outcome.py +0 -0
  99. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_health_probe.py +0 -0
  100. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_mcp_server_spec.py +0 -0
  101. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_model_discovery.py +0 -0
  102. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_models.py +0 -0
  103. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_execute.py +0 -0
  104. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_parse_event.py +0 -0
  105. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_spawn.py +0 -0
  106. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_opencode_stream.py +0 -0
  107. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_parse_event.py +0 -0
  108. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_execute.py +0 -0
  109. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_parse_event.py +0 -0
  110. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_pi_warnings.py +0 -0
  111. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_response_aggregation.py +0 -0
  112. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_shell.py +0 -0
  113. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_shell_cancellation.py +0 -0
  114. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_shell_mcp.py +0 -0
  115. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_stderr_format.py +0 -0
  116. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_stream.py +0 -0
  117. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/tests/unit/test_tool_denial.py +0 -0
  118. {agent_shell_py-0.2.1 → agent_shell_py-0.2.2}/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.2
4
4
  Summary: A lightweight abstraction for executing CLI coding agents headlessly
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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.2'
22
+ __version_tuple__ = version_tuple = (0, 2, 2)
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
@@ -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 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,
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.pid)
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, group_leader = await start_model_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, group_leader)
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}': expected object, got {type(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 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(
@@ -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)