agentpool-cli 0.1.6__tar.gz → 0.1.7__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 (122) hide show
  1. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/CHANGELOG.md +9 -0
  2. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/PKG-INFO +1 -1
  3. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/pyproject.toml +1 -1
  4. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/server.json +2 -2
  5. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/__init__.py +1 -1
  6. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/git_worktree.py +7 -18
  7. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/runtimes/tmux.py +27 -31
  8. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/_common.py +3 -29
  9. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/codex.py +3 -5
  10. agentpool_cli-0.1.7/src/agentpool/utils.py +139 -0
  11. agentpool_cli-0.1.7/tests/unit/test_subprocess_safety.py +143 -0
  12. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_usage_probes.py +5 -13
  13. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/uv.lock +1 -1
  14. agentpool_cli-0.1.6/src/agentpool/utils.py +0 -59
  15. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.cursor/mcp.json.example +0 -0
  16. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.github/CODEOWNERS +0 -0
  17. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.github/ISSUE_TEMPLATE/provider_probe.md +0 -0
  19. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.github/dependabot.yml +0 -0
  20. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.github/workflows/ci.yml +0 -0
  21. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.github/workflows/release.yml +0 -0
  22. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.gitignore +0 -0
  23. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/.mcp.json.example +0 -0
  24. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/AGENTS.md +0 -0
  25. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/CONTRIBUTING.md +0 -0
  26. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/LICENSE +0 -0
  27. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/README.md +0 -0
  28. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/SECURITY.md +0 -0
  29. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/agent-cli-and-mcp.md +0 -0
  30. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/agentpool-skill.md +0 -0
  31. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/architecture.md +0 -0
  32. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/examples/README.md +0 -0
  33. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/examples.md +0 -0
  34. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/install.md +0 -0
  35. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/mcp-clients.md +0 -0
  36. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/mcp-tools.md +0 -0
  37. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/model-catalog.md +0 -0
  38. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/onboarding.md +0 -0
  39. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/provider-adapters.md +0 -0
  40. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/provider-lifecycle-matrix.md +0 -0
  41. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/quickstart.md +0 -0
  42. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/release.md +0 -0
  43. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/security.md +0 -0
  44. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-claude-code.md +0 -0
  45. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-codex.md +0 -0
  46. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-copilot.md +0 -0
  47. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-cursor-cli.md +0 -0
  48. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-cursor.md +0 -0
  49. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-devin.md +0 -0
  50. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/setup-droid.md +0 -0
  51. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/stats.md +0 -0
  52. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/usage-detection.md +0 -0
  53. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/docs/usage-probe-matrix.md +0 -0
  54. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/scripts/install.sh +0 -0
  55. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/agent_io.py +0 -0
  56. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/artifacts.py +0 -0
  57. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/cli.py +0 -0
  58. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/config.py +0 -0
  59. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/event_detection.py +0 -0
  60. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/__init__.py +0 -0
  61. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/__init__.py +0 -0
  62. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_approval_agent.py +0 -0
  63. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_common.py +0 -0
  64. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_completed_agent.py +0 -0
  65. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_idle_agent.py +0 -0
  66. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_limit_agent.py +0 -0
  67. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_patch_agent.py +0 -0
  68. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/fixtures/fake_agents/fake_question_agent.py +0 -0
  69. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/mcp/__init__.py +0 -0
  70. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/mcp/resources.py +0 -0
  71. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/mcp/tools.py +0 -0
  72. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/mcp_server.py +0 -0
  73. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/models.py +0 -0
  74. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/onboarding.py +0 -0
  75. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/policy.py +0 -0
  76. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/preferences.py +0 -0
  77. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/provider_model_catalog.json +0 -0
  78. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/providers/__init__.py +0 -0
  79. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/providers/base.py +0 -0
  80. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/providers/registry.py +0 -0
  81. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/redaction.py +0 -0
  82. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/runtimes/__init__.py +0 -0
  83. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/runtimes/base.py +0 -0
  84. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/session_manager.py +0 -0
  85. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/stats/__init__.py +0 -0
  86. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/stats/card.py +0 -0
  87. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/stats/compute.py +0 -0
  88. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/stats/queries.py +0 -0
  89. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/stats/render.py +0 -0
  90. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/stats/window.py +0 -0
  91. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/store.py +0 -0
  92. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/__init__.py +0 -0
  93. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/ccusage.py +0 -0
  94. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/claude.py +0 -0
  95. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/codexbar.py +0 -0
  96. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/combine.py +0 -0
  97. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/copilot.py +0 -0
  98. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/devin.py +0 -0
  99. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/parsers.py +0 -0
  100. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/probes.py +0 -0
  101. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/provider_parsers.py +0 -0
  102. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/src/agentpool/usage/summary.py +0 -0
  103. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/fixtures/provider_model_catalog_golden.json +0 -0
  104. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/fixtures/stats_seed.py +0 -0
  105. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/fixtures/usage/claude_usage.txt +0 -0
  106. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/fixtures/usage/codex_rate_limits.json +0 -0
  107. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/fixtures/usage/copilot_user.json +0 -0
  108. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/fixtures/usage/devin_plan_status.json +0 -0
  109. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/integration/test_fake_tmux_flow.py +0 -0
  110. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_agent_io.py +0 -0
  111. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_cli.py +0 -0
  112. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_event_policy.py +0 -0
  113. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_mcp_surface.py +0 -0
  114. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_mcp_tools.py +0 -0
  115. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_models_config_store.py +0 -0
  116. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_onboarding.py +0 -0
  117. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_redaction.py +0 -0
  118. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_stats_cli.py +0 -0
  119. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_stats_mcp.py +0 -0
  120. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_stats_window.py +0 -0
  121. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_usage_provider_parsers.py +0 -0
  122. {agentpool_cli-0.1.6 → agentpool_cli-0.1.7}/tests/unit/test_usage_summary_enrichment.py +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.7 - 2026-06-01
6
+
7
+ - Centralize non-interactive subprocess execution behind terminal-safe helpers.
8
+ Git, tmux client operations, provider detection, Cursor status checks, Codex
9
+ app-server probes, and external usage helpers now detach from the host TTY by
10
+ default.
11
+ - Add a subprocess-safety regression test so new product code cannot introduce
12
+ raw `subprocess.run`/`Popen` calls outside the shared utility.
13
+
5
14
  ## 0.1.6 - 2026-06-01
6
15
 
7
16
  - Run external usage helper commands in isolated, non-interactive subprocess
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentpool-cli
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Make full use of every coding-agent subscription you pay for: a local CLI + MCP server that surfaces live usage limits and offloads work to providers with headroom.
5
5
  Author: AgentPool contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentpool-cli"
7
- version = "0.1.6"
7
+ version = "0.1.7"
8
8
  description = "Make full use of every coding-agent subscription you pay for: a local CLI + MCP server that surfaces live usage limits and offloads work to providers with headroom."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.sidduHERE/agentpool",
4
4
  "title": "AgentPool",
5
5
  "description": "See each coding-agent subscription's live limits and offload work to one with headroom.",
6
- "version": "0.1.6",
6
+ "version": "0.1.7",
7
7
  "repository": {
8
8
  "url": "https://github.com/sidduHERE/agentpool",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "pypi",
15
15
  "identifier": "agentpool-cli",
16
- "version": "0.1.6",
16
+ "version": "0.1.7",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },
@@ -1,3 +1,3 @@
1
1
  """AgentPool local agent control plane."""
2
2
 
3
- __version__ = "0.1.6"
3
+ __version__ = "0.1.7"
@@ -1,6 +1,4 @@
1
1
  from __future__ import annotations
2
-
3
- import subprocess
4
2
  from pathlib import Path
5
3
 
6
4
  from agentpool.models import ToolError
@@ -41,12 +39,9 @@ def create_worktree(repo_path: Path, provider_id: str, session_id: str) -> Path:
41
39
  parent.mkdir(parents=True, exist_ok=True)
42
40
  worktree_path = parent / session_id
43
41
  branch = agentpool_branch(provider_id, session_id)
44
- proc = subprocess.run(
42
+ proc = run_capture(
45
43
  ["git", "worktree", "add", "-b", branch, str(worktree_path)],
46
- cwd=str(repo_path),
47
- text=True,
48
- capture_output=True,
49
- check=False,
44
+ cwd=repo_path,
50
45
  )
51
46
  if proc.returncode != 0:
52
47
  raise ToolError(
@@ -66,12 +61,9 @@ def delete_agentpool_branch(repo_path: Path, provider_id: str, session_id: str)
66
61
  if not is_git_repo(repo_path):
67
62
  return {"deleted": False, "reason": "not_git_repo"}
68
63
  branch = agentpool_branch(provider_id, session_id)
69
- proc = subprocess.run(
64
+ proc = run_capture(
70
65
  ["git", "branch", "-D", branch],
71
- cwd=str(repo_path),
72
- text=True,
73
- capture_output=True,
74
- check=False,
66
+ cwd=repo_path,
75
67
  )
76
68
  if proc.returncode != 0:
77
69
  return {"deleted": False, "branch": branch, "stderr": proc.stderr.strip()}
@@ -81,12 +73,9 @@ def delete_agentpool_branch(repo_path: Path, provider_id: str, session_id: str)
81
73
  def list_agentpool_worktrees(repo_path: Path) -> list[dict[str, str | bool]]:
82
74
  if not is_git_repo(repo_path):
83
75
  return []
84
- proc = subprocess.run(
76
+ proc = run_capture(
85
77
  ["git", "worktree", "list", "--porcelain"],
86
- cwd=str(repo_path),
87
- text=True,
88
- capture_output=True,
89
- check=False,
78
+ cwd=repo_path,
90
79
  )
91
80
  if proc.returncode != 0:
92
81
  return []
@@ -128,7 +117,7 @@ def cleanup_worktree(repo_path: Path, worktree_path: Path, force: bool = False)
128
117
  if force:
129
118
  args.append("--force")
130
119
  args.append(str(worktree_path))
131
- proc = subprocess.run(args, cwd=str(repo_path), text=True, capture_output=True, check=False)
120
+ proc = run_capture(args, cwd=repo_path)
132
121
  if proc.returncode != 0:
133
122
  raise ToolError(
134
123
  "WORKTREE_CLEANUP_FAILED",
@@ -3,10 +3,10 @@ from __future__ import annotations
3
3
  import os
4
4
  import signal
5
5
  import shutil
6
- import subprocess
7
6
  from pathlib import Path
8
7
 
9
8
  from agentpool.models import RuntimeKind, TmuxSessionRef, ToolError
9
+ from agentpool.utils import run_capture
10
10
 
11
11
 
12
12
  class TmuxRuntime:
@@ -28,29 +28,22 @@ class TmuxRuntime:
28
28
  merged_env = os.environ.copy()
29
29
  if env:
30
30
  merged_env.update(env)
31
- try:
32
- subprocess.run(
33
- [tmux, "new-session", "-d", "-s", session_name, "-c", str(cwd), *command],
34
- env=merged_env,
35
- text=True,
36
- capture_output=True,
37
- check=True,
38
- )
39
- except subprocess.CalledProcessError as exc:
31
+ proc = run_capture(
32
+ [tmux, "new-session", "-d", "-s", session_name, "-c", str(cwd), *command],
33
+ env=merged_env,
34
+ )
35
+ if proc.returncode != 0:
40
36
  raise ToolError(
41
37
  "SPAWN_FAILED",
42
38
  f"Failed to create tmux session {session_name}.",
43
- {"stderr": exc.stderr, "stdout": exc.stdout, "command": command},
44
- ) from exc
39
+ {"stderr": proc.stderr, "stdout": proc.stdout, "command": command},
40
+ )
45
41
  return TmuxSessionRef(session_name=session_name)
46
42
 
47
43
  def capture(self, ref: TmuxSessionRef, lines: int = 300) -> str:
48
44
  tmux = self.require_tmux()
49
- proc = subprocess.run(
45
+ proc = run_capture(
50
46
  [tmux, "capture-pane", "-p", "-J", "-t", ref.target, "-S", f"-{lines}"],
51
- text=True,
52
- capture_output=True,
53
- check=False,
54
47
  )
55
48
  if proc.returncode != 0:
56
49
  raise ToolError(
@@ -66,18 +59,27 @@ class TmuxRuntime:
66
59
  return
67
60
  tmux = self.require_tmux()
68
61
  buffer_name = f"agentpool-{ref.session_name}"
69
- subprocess.run([tmux, "load-buffer", "-b", buffer_name, "-"], input=text, text=True, check=True)
70
- subprocess.run([tmux, "paste-buffer", "-b", buffer_name, "-t", ref.target], check=True)
62
+ load = run_capture([tmux, "load-buffer", "-b", buffer_name, "-"], input_text=text)
63
+ if load.returncode != 0:
64
+ raise ToolError(
65
+ "TMUX_SEND_FAILED",
66
+ f"Could not load tmux paste buffer for {ref.target}.",
67
+ {"stderr": load.stderr},
68
+ )
69
+ paste = run_capture([tmux, "paste-buffer", "-b", buffer_name, "-t", ref.target])
70
+ if paste.returncode != 0:
71
+ raise ToolError(
72
+ "TMUX_SEND_FAILED",
73
+ f"Could not paste tmux buffer to {ref.target}.",
74
+ {"stderr": paste.stderr},
75
+ )
71
76
  if submit and not text.endswith("\n"):
72
77
  self.send_keys(ref, ["Enter"])
73
78
 
74
79
  def send_keys(self, ref: TmuxSessionRef, keys: list[str]) -> None:
75
80
  tmux = self.require_tmux()
76
- proc = subprocess.run(
81
+ proc = run_capture(
77
82
  [tmux, "send-keys", "-t", ref.target, *keys],
78
- text=True,
79
- capture_output=True,
80
- check=False,
81
83
  )
82
84
  if proc.returncode != 0:
83
85
  raise ToolError(
@@ -95,7 +97,7 @@ class TmuxRuntime:
95
97
  def terminate(self, ref: TmuxSessionRef) -> None:
96
98
  tmux = self.require_tmux()
97
99
  pgid = self._pane_process_group(ref)
98
- subprocess.run([tmux, "kill-session", "-t", ref.session_name], check=False)
100
+ run_capture([tmux, "kill-session", "-t", ref.session_name])
99
101
  if pgid is not None:
100
102
  try:
101
103
  os.killpg(pgid, signal.SIGTERM)
@@ -107,22 +109,16 @@ class TmuxRuntime:
107
109
  def exists(self, ref: TmuxSessionRef) -> bool:
108
110
  if not self.tmux_binary:
109
111
  return False
110
- proc = subprocess.run(
112
+ proc = run_capture(
111
113
  [self.tmux_binary, "has-session", "-t", ref.session_name],
112
- text=True,
113
- capture_output=True,
114
- check=False,
115
114
  )
116
115
  return proc.returncode == 0
117
116
 
118
117
  def _pane_process_group(self, ref: TmuxSessionRef) -> int | None:
119
118
  if not self.tmux_binary:
120
119
  return None
121
- proc = subprocess.run(
120
+ proc = run_capture(
122
121
  [self.tmux_binary, "display-message", "-p", "-t", ref.target, "#{pane_pid}"],
123
- text=True,
124
- capture_output=True,
125
- check=False,
126
122
  )
127
123
  if proc.returncode != 0:
128
124
  return None
@@ -17,6 +17,7 @@ import certifi
17
17
 
18
18
  from agentpool.models import CapacitySnapshot, Confidence, TmuxSessionRef, UsageStatus, UsageWindow, UsageWindowKind
19
19
  from agentpool.runtimes.tmux import TmuxRuntime
20
+ from agentpool.utils import run_capture, terminate_process_group
20
21
 
21
22
 
22
23
  class ProbeError(Exception):
@@ -65,34 +66,12 @@ def _urlopen(
65
66
  return urllib.request.urlopen(request, timeout=timeout, context=context)
66
67
 
67
68
 
68
- def _probe_env() -> dict[str, str]:
69
- env = os.environ.copy()
70
- env.update(
71
- {
72
- "TERM": "dumb",
73
- "NO_COLOR": "1",
74
- "CLICOLOR": "0",
75
- "FORCE_COLOR": "0",
76
- }
77
- )
78
- return env
79
-
80
-
81
69
  def _run_probe_command(
82
70
  command: list[str],
83
71
  *,
84
72
  timeout: float,
85
73
  ) -> subprocess.CompletedProcess[str]:
86
- return subprocess.run(
87
- command,
88
- stdin=subprocess.DEVNULL,
89
- capture_output=True,
90
- text=True,
91
- timeout=timeout,
92
- check=False,
93
- start_new_session=True,
94
- env=_probe_env(),
95
- )
74
+ return run_capture(command, timeout=timeout, terminal_dumb=True)
96
75
 
97
76
 
98
77
  def _request_json(request: urllib.request.Request) -> dict[str, Any]:
@@ -248,12 +227,7 @@ def _clean_optional_string(value: object) -> str | None:
248
227
 
249
228
 
250
229
  def _terminate_process(proc: subprocess.Popen[str]) -> None:
251
- if proc.poll() is None:
252
- proc.terminate()
253
- try:
254
- proc.wait(timeout=1)
255
- except subprocess.TimeoutExpired:
256
- proc.kill()
230
+ terminate_process_group(proc)
257
231
 
258
232
 
259
233
  def _safe_read_pipe(pipe: Any) -> str:
@@ -20,6 +20,7 @@ from agentpool.usage._common import (
20
20
  unavailable,
21
21
  unknown,
22
22
  )
23
+ from agentpool.utils import popen_text
23
24
 
24
25
 
25
26
  def codex_cli_usage_snapshot(provider_id: str, binary: str | None = None) -> CapacitySnapshot:
@@ -79,12 +80,9 @@ def parse_codex_rate_limits(provider_id: str, payload: dict[str, Any]) -> Capaci
79
80
 
80
81
 
81
82
  def _codex_rpc_rate_limits(executable: str) -> dict[str, Any]:
82
- proc = subprocess.Popen(
83
+ proc = popen_text(
83
84
  [executable, "-s", "read-only", "-a", "untrusted", "app-server"],
84
- stdin=subprocess.PIPE,
85
- stdout=subprocess.PIPE,
86
- stderr=subprocess.PIPE,
87
- text=True,
85
+ terminal_dumb=True,
88
86
  )
89
87
  try:
90
88
  _json_rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "agentpool", "version": "0.1.0"}}, 8.0)
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ def utc_now_iso() -> str:
15
+ return datetime.now(timezone.utc).isoformat()
16
+
17
+
18
+ def new_session_id() -> str:
19
+ return f"ap_{uuid.uuid4().hex[:12]}"
20
+
21
+
22
+ def repo_hash(path: Path) -> str:
23
+ return hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
24
+
25
+
26
+ def sha256_file(path: Path) -> str:
27
+ digest = hashlib.sha256()
28
+ with path.open("rb") as fh:
29
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
30
+ digest.update(chunk)
31
+ return digest.hexdigest()
32
+
33
+
34
+ def write_json(path: Path, data: Any) -> None:
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ path.write_text(json.dumps(data, indent=2, sort_keys=True, default=str) + "\n")
37
+
38
+
39
+ def append_jsonl(path: Path, data: Any) -> None:
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with path.open("a", encoding="utf-8") as fh:
42
+ fh.write(json.dumps(data, sort_keys=True, default=str) + "\n")
43
+
44
+
45
+ def subprocess_env(
46
+ env: dict[str, str] | None = None,
47
+ *,
48
+ terminal_dumb: bool = False,
49
+ ) -> dict[str, str]:
50
+ merged = os.environ.copy()
51
+ if env:
52
+ merged.update(env)
53
+ if terminal_dumb:
54
+ merged.update(
55
+ {
56
+ "TERM": "dumb",
57
+ "NO_COLOR": "1",
58
+ "CLICOLOR": "0",
59
+ "FORCE_COLOR": "0",
60
+ }
61
+ )
62
+ return merged
63
+
64
+
65
+ def run_capture(
66
+ args: list[str],
67
+ cwd: Path | None = None,
68
+ timeout: float = 10,
69
+ *,
70
+ env: dict[str, str] | None = None,
71
+ input_text: str | None = None,
72
+ terminal_dumb: bool = False,
73
+ ) -> subprocess.CompletedProcess[str]:
74
+ stdin = subprocess.PIPE if input_text is not None else subprocess.DEVNULL
75
+ proc = subprocess.Popen(
76
+ args,
77
+ cwd=str(cwd) if cwd else None,
78
+ stdin=stdin,
79
+ stdout=subprocess.PIPE,
80
+ stderr=subprocess.PIPE,
81
+ text=True,
82
+ start_new_session=True,
83
+ env=subprocess_env(env, terminal_dumb=terminal_dumb),
84
+ )
85
+ try:
86
+ stdout, stderr = proc.communicate(input_text, timeout=timeout)
87
+ except subprocess.TimeoutExpired as exc:
88
+ terminate_process_group(proc)
89
+ stdout = exc.stdout or ""
90
+ stderr = exc.stderr or "timed out"
91
+ if isinstance(stdout, bytes):
92
+ stdout = stdout.decode("utf-8", errors="replace")
93
+ if isinstance(stderr, bytes):
94
+ stderr = stderr.decode("utf-8", errors="replace")
95
+ return subprocess.CompletedProcess(args, 124, stdout, stderr)
96
+ return subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
97
+
98
+
99
+ def popen_text(
100
+ args: list[str],
101
+ *,
102
+ env: dict[str, str] | None = None,
103
+ terminal_dumb: bool = False,
104
+ ) -> subprocess.Popen[str]:
105
+ return subprocess.Popen(
106
+ args,
107
+ stdin=subprocess.PIPE,
108
+ stdout=subprocess.PIPE,
109
+ stderr=subprocess.PIPE,
110
+ text=True,
111
+ start_new_session=True,
112
+ env=subprocess_env(env, terminal_dumb=terminal_dumb),
113
+ )
114
+
115
+
116
+ def terminate_process_group(proc: subprocess.Popen[str], timeout: float = 1) -> None:
117
+ if proc.poll() is not None:
118
+ return
119
+ try:
120
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
121
+ except OSError:
122
+ try:
123
+ proc.terminate()
124
+ except OSError:
125
+ return
126
+ try:
127
+ proc.wait(timeout=timeout)
128
+ except subprocess.TimeoutExpired:
129
+ try:
130
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
131
+ except OSError:
132
+ try:
133
+ proc.kill()
134
+ except OSError:
135
+ return
136
+
137
+
138
+ def expand_user_path(value: str) -> Path:
139
+ return Path(os.path.expandvars(value)).expanduser()
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from agentpool import utils
7
+
8
+
9
+ def test_run_capture_detaches_from_host_terminal(monkeypatch) -> None:
10
+ seen: dict[str, object] = {}
11
+
12
+ class FakeProcess:
13
+ pid = 12345
14
+ returncode = 0
15
+
16
+ def communicate(self, input: str | None = None, timeout: float | None = None) -> tuple[str, str]:
17
+ seen["communicate_input"] = input
18
+ seen["communicate_timeout"] = timeout
19
+ return "out", ""
20
+
21
+ def fake_popen(command: list[str], **kwargs: object) -> FakeProcess:
22
+ seen["command"] = command
23
+ seen.update(kwargs)
24
+ return FakeProcess()
25
+
26
+ monkeypatch.setattr(utils.subprocess, "Popen", fake_popen)
27
+
28
+ result = utils.run_capture(["helper", "usage"], cwd=Path("/tmp"), timeout=3, terminal_dumb=True)
29
+
30
+ assert result.stdout == "out"
31
+ assert seen["command"] == ["helper", "usage"]
32
+ assert seen["cwd"] == "/tmp"
33
+ assert seen["stdin"] is subprocess.DEVNULL
34
+ assert seen["stdout"] is subprocess.PIPE
35
+ assert seen["stderr"] is subprocess.PIPE
36
+ assert seen["text"] is True
37
+ assert seen["start_new_session"] is True
38
+ assert seen["communicate_timeout"] == 3
39
+ env = seen["env"]
40
+ assert isinstance(env, dict)
41
+ assert env["TERM"] == "dumb"
42
+ assert env["NO_COLOR"] == "1"
43
+ assert env["CLICOLOR"] == "0"
44
+ assert env["FORCE_COLOR"] == "0"
45
+
46
+
47
+ def test_run_capture_uses_pipe_only_for_explicit_input(monkeypatch) -> None:
48
+ seen: dict[str, object] = {}
49
+
50
+ class FakeProcess:
51
+ pid = 12345
52
+ returncode = 0
53
+
54
+ def communicate(self, input: str | None = None, timeout: float | None = None) -> tuple[str, str]:
55
+ seen["communicate_input"] = input
56
+ return "", ""
57
+
58
+ def fake_popen(command: list[str], **kwargs: object) -> FakeProcess:
59
+ seen.update(kwargs)
60
+ return FakeProcess()
61
+
62
+ monkeypatch.setattr(utils.subprocess, "Popen", fake_popen)
63
+
64
+ utils.run_capture(["tmux", "load-buffer"], input_text="hello")
65
+
66
+ assert seen["stdin"] is subprocess.PIPE
67
+ assert seen["communicate_input"] == "hello"
68
+ assert seen["start_new_session"] is True
69
+
70
+
71
+ def test_run_capture_timeout_terminates_process_group(monkeypatch) -> None:
72
+ seen: dict[str, object] = {}
73
+
74
+ class FakeProcess:
75
+ pid = 12345
76
+ returncode = None
77
+
78
+ def communicate(self, input: str | None = None, timeout: float | None = None) -> tuple[str, str]:
79
+ raise subprocess.TimeoutExpired(["slow"], timeout or 0, output="partial")
80
+
81
+ def poll(self) -> None:
82
+ return None
83
+
84
+ def terminate(self) -> None:
85
+ seen["terminated"] = True
86
+
87
+ def wait(self, timeout: float | None = None) -> None:
88
+ seen["wait_timeout"] = timeout
89
+ self.returncode = -15
90
+
91
+ def fake_popen(command: list[str], **kwargs: object) -> FakeProcess:
92
+ return FakeProcess()
93
+
94
+ monkeypatch.setattr(utils.subprocess, "Popen", fake_popen)
95
+ monkeypatch.setattr(utils.os, "getpgid", lambda pid: pid)
96
+ monkeypatch.setattr(utils.os, "killpg", lambda pgid, sig: seen.update({"pgid": pgid, "signal": sig}))
97
+
98
+ result = utils.run_capture(["slow"], timeout=0.01)
99
+
100
+ assert result.returncode == 124
101
+ assert result.stdout == "partial"
102
+ assert seen["pgid"] == 12345
103
+ assert seen["wait_timeout"] == 1
104
+
105
+
106
+ def test_popen_text_detaches_from_host_terminal(monkeypatch) -> None:
107
+ seen: dict[str, object] = {}
108
+
109
+ class FakeProcess:
110
+ pass
111
+
112
+ def fake_popen(command: list[str], **kwargs: object) -> FakeProcess:
113
+ seen["command"] = command
114
+ seen.update(kwargs)
115
+ return FakeProcess()
116
+
117
+ monkeypatch.setattr(utils.subprocess, "Popen", fake_popen)
118
+
119
+ proc = utils.popen_text(["codex", "app-server"], terminal_dumb=True)
120
+
121
+ assert isinstance(proc, FakeProcess)
122
+ assert seen["stdin"] is subprocess.PIPE
123
+ assert seen["stdout"] is subprocess.PIPE
124
+ assert seen["stderr"] is subprocess.PIPE
125
+ assert seen["text"] is True
126
+ assert seen["start_new_session"] is True
127
+ env = seen["env"]
128
+ assert isinstance(env, dict)
129
+ assert env["TERM"] == "dumb"
130
+
131
+
132
+ def test_product_code_uses_subprocess_only_through_utils() -> None:
133
+ root = Path(__file__).resolve().parents[2] / "src" / "agentpool"
134
+ offenders: list[str] = []
135
+ for path in root.rglob("*.py"):
136
+ if path.name == "utils.py":
137
+ continue
138
+ text = path.read_text(encoding="utf-8")
139
+ for needle in ("subprocess.run(", "subprocess.Popen(", "subprocess.check_output(", "subprocess.check_call("):
140
+ if needle in text:
141
+ offenders.append(f"{path.relative_to(root)} uses {needle}")
142
+
143
+ assert offenders == []
@@ -311,27 +311,19 @@ def test_usage_http_requests_use_certifi_context(monkeypatch: pytest.MonkeyPatch
311
311
  def test_usage_probe_subprocesses_are_tty_isolated(monkeypatch: pytest.MonkeyPatch) -> None:
312
312
  seen: dict[str, object] = {}
313
313
 
314
- def fake_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
314
+ def fake_run_capture(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
315
+ seen["command"] = command
315
316
  seen.update(kwargs)
316
317
  return subprocess.CompletedProcess(command, 0, "{}", "")
317
318
 
318
- monkeypatch.setattr(usage_common.subprocess, "run", fake_run)
319
+ monkeypatch.setattr(usage_common, "run_capture", fake_run_capture)
319
320
 
320
321
  result = usage_common._run_probe_command(["codexbar", "usage"], timeout=7)
321
322
 
322
323
  assert result.returncode == 0
323
- assert seen["stdin"] is subprocess.DEVNULL
324
- assert seen["capture_output"] is True
325
- assert seen["text"] is True
324
+ assert seen["command"] == ["codexbar", "usage"]
326
325
  assert seen["timeout"] == 7
327
- assert seen["check"] is False
328
- assert seen["start_new_session"] is True
329
- env = seen["env"]
330
- assert isinstance(env, dict)
331
- assert env["TERM"] == "dumb"
332
- assert env["NO_COLOR"] == "1"
333
- assert env["CLICOLOR"] == "0"
334
- assert env["FORCE_COLOR"] == "0"
326
+ assert seen["terminal_dumb"] is True
335
327
 
336
328
 
337
329
  def test_devin_plan_status_request_contains_auth_and_top_up_flag() -> None:
@@ -3,7 +3,7 @@ requires-python = ">=3.11"
3
3
 
4
4
  [[package]]
5
5
  name = "agentpool-cli"
6
- version = "0.1.6"
6
+ version = "0.1.7"
7
7
  source = { editable = "." }
8
8
  dependencies = [
9
9
  { name = "certifi" },
@@ -1,59 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import json
5
- import os
6
- import subprocess
7
- import uuid
8
- from datetime import datetime, timezone
9
- from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- def utc_now_iso() -> str:
14
- return datetime.now(timezone.utc).isoformat()
15
-
16
-
17
- def new_session_id() -> str:
18
- return f"ap_{uuid.uuid4().hex[:12]}"
19
-
20
-
21
- def repo_hash(path: Path) -> str:
22
- return hashlib.sha256(str(path.resolve()).encode("utf-8")).hexdigest()[:16]
23
-
24
-
25
- def sha256_file(path: Path) -> str:
26
- digest = hashlib.sha256()
27
- with path.open("rb") as fh:
28
- for chunk in iter(lambda: fh.read(1024 * 1024), b""):
29
- digest.update(chunk)
30
- return digest.hexdigest()
31
-
32
-
33
- def write_json(path: Path, data: Any) -> None:
34
- path.parent.mkdir(parents=True, exist_ok=True)
35
- path.write_text(json.dumps(data, indent=2, sort_keys=True, default=str) + "\n")
36
-
37
-
38
- def append_jsonl(path: Path, data: Any) -> None:
39
- path.parent.mkdir(parents=True, exist_ok=True)
40
- with path.open("a", encoding="utf-8") as fh:
41
- fh.write(json.dumps(data, sort_keys=True, default=str) + "\n")
42
-
43
-
44
- def run_capture(args: list[str], cwd: Path | None = None, timeout: float = 10) -> subprocess.CompletedProcess[str]:
45
- try:
46
- return subprocess.run(
47
- args,
48
- cwd=str(cwd) if cwd else None,
49
- text=True,
50
- capture_output=True,
51
- timeout=timeout,
52
- check=False,
53
- )
54
- except subprocess.TimeoutExpired as exc:
55
- return subprocess.CompletedProcess(args, 124, exc.stdout or "", exc.stderr or "timed out")
56
-
57
-
58
- def expand_user_path(value: str) -> Path:
59
- return Path(os.path.expandvars(value)).expanduser()
File without changes
File without changes
File without changes
File without changes
File without changes