agentpool-cli 0.1.6__tar.gz → 0.1.8__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.8}/CHANGELOG.md +17 -0
  2. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/PKG-INFO +7 -3
  3. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/README.md +6 -2
  4. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/agentpool-skill.md +3 -0
  5. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/usage-detection.md +9 -2
  6. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/pyproject.toml +1 -1
  7. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/server.json +2 -2
  8. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/__init__.py +1 -1
  9. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/config.py +8 -0
  10. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/git_worktree.py +7 -18
  11. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/mcp/tools.py +1 -1
  12. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/onboarding.py +2 -1
  13. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/runtimes/tmux.py +27 -31
  14. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/session_manager.py +34 -1
  15. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/stats/compute.py +2 -3
  16. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/_common.py +3 -29
  17. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/codex.py +3 -5
  18. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/summary.py +1 -4
  19. agentpool_cli-0.1.8/src/agentpool/utils.py +139 -0
  20. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_models_config_store.py +79 -0
  21. agentpool_cli-0.1.8/tests/unit/test_subprocess_safety.py +143 -0
  22. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_usage_probes.py +5 -13
  23. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_usage_summary_enrichment.py +5 -4
  24. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/uv.lock +1 -1
  25. agentpool_cli-0.1.6/src/agentpool/utils.py +0 -59
  26. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.cursor/mcp.json.example +0 -0
  27. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.github/CODEOWNERS +0 -0
  28. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  29. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.github/ISSUE_TEMPLATE/provider_probe.md +0 -0
  30. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.github/dependabot.yml +0 -0
  31. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.github/workflows/ci.yml +0 -0
  32. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.github/workflows/release.yml +0 -0
  33. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.gitignore +0 -0
  34. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/.mcp.json.example +0 -0
  35. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/AGENTS.md +0 -0
  36. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/CONTRIBUTING.md +0 -0
  37. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/LICENSE +0 -0
  38. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/SECURITY.md +0 -0
  39. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/agent-cli-and-mcp.md +0 -0
  40. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/architecture.md +0 -0
  41. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/examples/README.md +0 -0
  42. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/examples.md +0 -0
  43. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/install.md +0 -0
  44. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/mcp-clients.md +0 -0
  45. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/mcp-tools.md +0 -0
  46. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/model-catalog.md +0 -0
  47. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/onboarding.md +0 -0
  48. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/provider-adapters.md +0 -0
  49. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/provider-lifecycle-matrix.md +0 -0
  50. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/quickstart.md +0 -0
  51. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/release.md +0 -0
  52. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/security.md +0 -0
  53. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-claude-code.md +0 -0
  54. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-codex.md +0 -0
  55. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-copilot.md +0 -0
  56. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-cursor-cli.md +0 -0
  57. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-cursor.md +0 -0
  58. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-devin.md +0 -0
  59. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/setup-droid.md +0 -0
  60. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/stats.md +0 -0
  61. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/docs/usage-probe-matrix.md +0 -0
  62. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/scripts/install.sh +0 -0
  63. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/agent_io.py +0 -0
  64. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/artifacts.py +0 -0
  65. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/cli.py +0 -0
  66. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/event_detection.py +0 -0
  67. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/__init__.py +0 -0
  68. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/__init__.py +0 -0
  69. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_approval_agent.py +0 -0
  70. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_common.py +0 -0
  71. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_completed_agent.py +0 -0
  72. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_idle_agent.py +0 -0
  73. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_limit_agent.py +0 -0
  74. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_patch_agent.py +0 -0
  75. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/fixtures/fake_agents/fake_question_agent.py +0 -0
  76. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/mcp/__init__.py +0 -0
  77. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/mcp/resources.py +0 -0
  78. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/mcp_server.py +0 -0
  79. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/models.py +0 -0
  80. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/policy.py +0 -0
  81. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/preferences.py +0 -0
  82. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/provider_model_catalog.json +0 -0
  83. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/providers/__init__.py +0 -0
  84. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/providers/base.py +0 -0
  85. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/providers/registry.py +0 -0
  86. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/redaction.py +0 -0
  87. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/runtimes/__init__.py +0 -0
  88. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/runtimes/base.py +0 -0
  89. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/stats/__init__.py +0 -0
  90. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/stats/card.py +0 -0
  91. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/stats/queries.py +0 -0
  92. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/stats/render.py +0 -0
  93. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/stats/window.py +0 -0
  94. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/store.py +0 -0
  95. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/__init__.py +0 -0
  96. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/ccusage.py +0 -0
  97. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/claude.py +0 -0
  98. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/codexbar.py +0 -0
  99. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/combine.py +0 -0
  100. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/copilot.py +0 -0
  101. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/devin.py +0 -0
  102. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/parsers.py +0 -0
  103. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/probes.py +0 -0
  104. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/src/agentpool/usage/provider_parsers.py +0 -0
  105. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/fixtures/provider_model_catalog_golden.json +0 -0
  106. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/fixtures/stats_seed.py +0 -0
  107. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/fixtures/usage/claude_usage.txt +0 -0
  108. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/fixtures/usage/codex_rate_limits.json +0 -0
  109. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/fixtures/usage/copilot_user.json +0 -0
  110. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/fixtures/usage/devin_plan_status.json +0 -0
  111. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/integration/test_fake_tmux_flow.py +0 -0
  112. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_agent_io.py +0 -0
  113. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_cli.py +0 -0
  114. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_event_policy.py +0 -0
  115. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_mcp_surface.py +0 -0
  116. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_mcp_tools.py +0 -0
  117. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_onboarding.py +0 -0
  118. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_redaction.py +0 -0
  119. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_stats_cli.py +0 -0
  120. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_stats_mcp.py +0 -0
  121. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_stats_window.py +0 -0
  122. {agentpool_cli-0.1.6 → agentpool_cli-0.1.8}/tests/unit/test_usage_provider_parsers.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.8 - 2026-06-01
6
+
7
+ - Treat stale usage snapshots as age metadata, not as an unusable-provider
8
+ reason. `usage-summary` still reports `stale` and `age_seconds`, but a stale
9
+ cache entry no longer overrides usable quota/status data.
10
+ - Add optional `policy.usage_auto_refresh_after_seconds` for users who want
11
+ cached usage summaries to refresh automatically after a configured age.
12
+
13
+ ## 0.1.7 - 2026-06-01
14
+
15
+ - Centralize non-interactive subprocess execution behind terminal-safe helpers.
16
+ Git, tmux client operations, provider detection, Cursor status checks, Codex
17
+ app-server probes, and external usage helpers now detach from the host TTY by
18
+ default.
19
+ - Add a subprocess-safety regression test so new product code cannot introduce
20
+ raw `subprocess.run`/`Popen` calls outside the shared utility.
21
+
5
22
  ## 0.1.6 - 2026-06-01
6
23
 
7
24
  - 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.8
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
@@ -171,14 +171,18 @@ agentpool usage-summary --refresh --backend ccusage --provider claude-code --jso
171
171
 
172
172
  `usage-summary` returns a `providers` object keyed by provider id. It is not
173
173
  ordered and it is not a recommendation list. Each row includes `usable`,
174
- `unusable_reason`, quota windows, confidence, staleness, and reset timing when
174
+ `unusable_reason`, quota windows, confidence, age/staleness, and reset timing when
175
175
  the provider exposes it. The older CLI `capacity-summary` command is retained
176
176
  as a human convenience alias; the MCP surface only exposes `get_usage_snapshot`
177
177
  and the opt-in `get_usage_summary` tool.
178
178
 
179
179
  The default buffer is `policy.min_remaining_percent = 10`. If any reported
180
180
  quota window is below that buffer, the provider row is marked unusable for the
181
- summary. AgentPool still does not pick an alternative provider for you.
181
+ summary. Staleness is reported as age information only; it does not by itself
182
+ make a provider unusable. If you want cached summary reads to refresh
183
+ automatically after a threshold, set `policy.usage_auto_refresh_after_seconds`
184
+ in `~/.agentpool/config.yaml`.
185
+ AgentPool still does not pick an alternative provider for you.
182
186
  MCP usage refreshes are intentionally bounded and may return `partial=true`;
183
187
  use the CLI commands above when a shell-capable agent needs a complete live
184
188
  refresh.
@@ -150,14 +150,18 @@ agentpool usage-summary --refresh --backend ccusage --provider claude-code --jso
150
150
 
151
151
  `usage-summary` returns a `providers` object keyed by provider id. It is not
152
152
  ordered and it is not a recommendation list. Each row includes `usable`,
153
- `unusable_reason`, quota windows, confidence, staleness, and reset timing when
153
+ `unusable_reason`, quota windows, confidence, age/staleness, and reset timing when
154
154
  the provider exposes it. The older CLI `capacity-summary` command is retained
155
155
  as a human convenience alias; the MCP surface only exposes `get_usage_snapshot`
156
156
  and the opt-in `get_usage_summary` tool.
157
157
 
158
158
  The default buffer is `policy.min_remaining_percent = 10`. If any reported
159
159
  quota window is below that buffer, the provider row is marked unusable for the
160
- summary. AgentPool still does not pick an alternative provider for you.
160
+ summary. Staleness is reported as age information only; it does not by itself
161
+ make a provider unusable. If you want cached summary reads to refresh
162
+ automatically after a threshold, set `policy.usage_auto_refresh_after_seconds`
163
+ in `~/.agentpool/config.yaml`.
164
+ AgentPool still does not pick an alternative provider for you.
161
165
  MCP usage refreshes are intentionally bounded and may return `partial=true`;
162
166
  use the CLI commands above when a shell-capable agent needs a complete live
163
167
  refresh.
@@ -27,6 +27,9 @@ need to delegate coding-agent work.
27
27
  live refresh. MCP `refresh=true` is bounded and can return `partial=true`
28
28
  with unknown rows for slow providers.
29
29
  - Treat usage rows as a provider-id map. They are not ordered and not ranked.
30
+ - Treat `stale` and `age_seconds` as age metadata, not as an instruction to
31
+ avoid a provider. If the user configured usage auto-refresh, cached summary
32
+ reads may refresh themselves before returning.
30
33
  - Inspect provider models before spawning when the model is not already chosen:
31
34
  - CLI: `agentpool models --provider <provider-id>`
32
35
  - MCP: `get_provider_models(provider_id=...)`
@@ -9,11 +9,18 @@ Live probes are only run by explicit usage requests. Inventory remains non-invas
9
9
  `agentpool usage-summary` returns a `providers` map keyed by provider id. The
10
10
  CLI `capacity-summary` command is a human convenience alias; MCP does not expose
11
11
  a capacity alias. Each row includes `usable`, `unusable_reason`, `stale`, and
12
- `age_seconds`. `usable` is conservative: unknown, unauthenticated, unavailable,
13
- stale, untrusted-confidence, or below-buffer windows are unusable. The default
12
+ `age_seconds`. `stale` is informational age metadata only; it does not by
13
+ itself make a provider unusable. `usable` is derived from install/auth status,
14
+ provider usage status, confidence, and reported quota windows. The default
14
15
  buffer is `policy.min_remaining_percent = 10`, and it applies to every reported
15
16
  quota window.
16
17
 
18
+ Cached summary reads can optionally refresh themselves when old enough. Set
19
+ `policy.usage_auto_refresh_after_seconds` to a non-negative number in
20
+ `~/.agentpool/config.yaml` to refresh `usage-summary` / `get_usage_summary`
21
+ when the cached summary data is missing or older than that threshold. Leave it
22
+ as `null` to keep refreshes explicit.
23
+
17
24
  Usage windows carry a stable `kind` in addition to provider-specific names:
18
25
 
19
26
  - `daily`
@@ -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.8"
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.8",
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.8",
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.8"
@@ -68,6 +68,7 @@ class PolicyConfig(BaseModel):
68
68
  default_isolation: str = "read_only"
69
69
  min_remaining_percent: int = 10
70
70
  usage_stale_after_seconds: int = 1800
71
+ usage_auto_refresh_after_seconds: int | None = None
71
72
  allowed_providers: list[str] = Field(default_factory=list)
72
73
  denied_providers: list[str] = Field(default_factory=list)
73
74
  block_on_usage_statuses: list[str] = Field(
@@ -326,6 +327,13 @@ def validate_config(config: AgentPoolConfig) -> dict[str, Any]:
326
327
  errors.append("policy.allow_auto_routing must stay false in AgentPool v0.1")
327
328
  if "auto" in config.providers:
328
329
  errors.append("providers.auto is not allowed")
330
+ if config.policy.usage_stale_after_seconds < 0:
331
+ errors.append("policy.usage_stale_after_seconds must be non-negative")
332
+ if (
333
+ config.policy.usage_auto_refresh_after_seconds is not None
334
+ and config.policy.usage_auto_refresh_after_seconds < 0
335
+ ):
336
+ errors.append("policy.usage_auto_refresh_after_seconds must be non-negative or null")
329
337
  if "factory-droid" in config.providers:
330
338
  warnings.append("factory-droid is a PRD compatibility name; use droid-cli for the droid binary")
331
339
  for provider_id, reason in DEPRECATED_PROVIDER_IDS.items():
@@ -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",
@@ -68,7 +68,7 @@ def get_usage_summary(
68
68
  refresh=refresh,
69
69
  backend=backend,
70
70
  allow_interactive=False,
71
- timeout_seconds=timeout_seconds if refresh else None,
71
+ timeout_seconds=timeout_seconds,
72
72
  )
73
73
 
74
74
 
@@ -492,7 +492,8 @@ def privacy_doctor(manager: SessionManager) -> dict[str, Any]:
492
492
  },
493
493
  "usage_refresh": {
494
494
  "inventory_runs_live_usage_probes": False,
495
- "live_usage_requires_explicit_refresh": True,
495
+ "live_usage_requires_explicit_refresh": manager.config.policy.usage_auto_refresh_after_seconds is None,
496
+ "summary_auto_refresh_after_seconds": manager.config.policy.usage_auto_refresh_after_seconds,
496
497
  "default_backend": "combined",
497
498
  "optional_backends": {
498
499
  "codexbar": detect_codexbar(),
@@ -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
@@ -155,9 +155,22 @@ class SessionManager:
155
155
  for snapshot in snapshots:
156
156
  self.store.save_usage_snapshot(snapshot)
157
157
  source = "live_probe"
158
+ summary_backend = backend
158
159
  else:
159
160
  snapshots = self._configured_usage_snapshots(self.store.latest_usage_snapshots(provider_id), provider_id)
160
161
  source = "sqlite_cache"
162
+ summary_backend = "cache"
163
+ if self._usage_summary_should_auto_refresh(snapshots, provider_id):
164
+ snapshots = self.registry.usage(
165
+ provider_id,
166
+ backend=backend,
167
+ allow_interactive=allow_interactive,
168
+ timeout_seconds=_remaining_seconds(deadline),
169
+ )
170
+ for snapshot in snapshots:
171
+ self.store.save_usage_snapshot(snapshot)
172
+ source = "live_probe"
173
+ summary_backend = backend
161
174
  return {
162
175
  **build_usage_summary(
163
176
  snapshots,
@@ -167,7 +180,7 @@ class SessionManager:
167
180
  ),
168
181
  "preferences": preferences_reference(),
169
182
  "source": source,
170
- "backend": backend if refresh else "cache",
183
+ "backend": summary_backend,
171
184
  "partial": _has_partial_usage(snapshots) or _has_partial_descriptors(descriptors),
172
185
  }
173
186
 
@@ -865,6 +878,26 @@ class SessionManager:
865
878
  configured = set(self.config.providers)
866
879
  return [snapshot for snapshot in snapshots if snapshot.provider_id in configured]
867
880
 
881
+ def _usage_summary_should_auto_refresh(self, snapshots: list[Any], provider_id: str | None = None) -> bool:
882
+ threshold = self.config.policy.usage_auto_refresh_after_seconds
883
+ if threshold is None:
884
+ return False
885
+ if provider_id and not snapshots:
886
+ return True
887
+ if not provider_id:
888
+ configured = set(self.config.providers)
889
+ seen = {snapshot.provider_id for snapshot in snapshots}
890
+ if not configured.issubset(seen):
891
+ return True
892
+ now = datetime.now(timezone.utc)
893
+ for snapshot in snapshots:
894
+ checked_at = snapshot.checked_at
895
+ if checked_at.tzinfo is None:
896
+ checked_at = checked_at.replace(tzinfo=timezone.utc)
897
+ if max(0.0, (now - checked_at).total_seconds()) > threshold:
898
+ return True
899
+ return False
900
+
868
901
  def _enforce_deadline(self, session: AgentSession) -> ObserveWorkerResponse | None:
869
902
  deadline_at = session.metadata.get("deadline_at")
870
903
  if not deadline_at or not active_state(session.state):
@@ -457,9 +457,8 @@ def _snapshot_usability(
457
457
  }
458
458
  for window in snapshot.windows
459
459
  ]
460
- age_seconds = max(0.0, (at - _ensure_utc(snapshot.checked_at)).total_seconds())
461
- stale = age_seconds > stale_after_seconds
462
- return _usable_reason(snapshot, windows, min_remaining_percent, descriptor, stale)
460
+ _ = stale_after_seconds
461
+ return _usable_reason(snapshot, windows, min_remaining_percent, descriptor)
463
462
 
464
463
 
465
464
  def _is_quota_unusable_reason(reason: str | None) -> bool:
@@ -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)
@@ -65,7 +65,7 @@ def _provider_summary(
65
65
  ]
66
66
  age_seconds = max(0.0, (now - snapshot.checked_at).total_seconds())
67
67
  stale = age_seconds > stale_after_seconds
68
- usable, unusable_reason = _usable_reason(snapshot, windows, min_remaining_percent, descriptor, stale)
68
+ usable, unusable_reason = _usable_reason(snapshot, windows, min_remaining_percent, descriptor)
69
69
  return {
70
70
  "provider_id": snapshot.provider_id,
71
71
  "status": snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status),
@@ -90,7 +90,6 @@ def _usable_reason(
90
90
  windows: list[dict[str, Any]],
91
91
  min_remaining_percent: int,
92
92
  descriptor: ProviderDescriptor | None,
93
- stale: bool,
94
93
  ) -> tuple[bool, str | None]:
95
94
  if descriptor and not descriptor.installed:
96
95
  return False, "not_installed"
@@ -110,8 +109,6 @@ def _usable_reason(
110
109
  }
111
110
  if confidence not in allowed_confidence:
112
111
  return False, f"confidence_{confidence}"
113
- if stale:
114
- return False, "usage_stale"
115
112
  for window in windows:
116
113
  remaining = window["remaining_percent"]
117
114
  if remaining is not None and remaining < min_remaining_percent: