agentpool-cli 0.1.0__py3-none-any.whl
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.
- agentpool/__init__.py +3 -0
- agentpool/agent_io.py +134 -0
- agentpool/artifacts.py +151 -0
- agentpool/cli.py +1199 -0
- agentpool/config.py +373 -0
- agentpool/docs/agentpool-skill.md +85 -0
- agentpool/docs/onboarding.md +169 -0
- agentpool/event_detection.py +150 -0
- agentpool/fixtures/__init__.py +1 -0
- agentpool/fixtures/fake_agents/__init__.py +1 -0
- agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_common.py +44 -0
- agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
- agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
- agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
- agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
- agentpool/git_worktree.py +144 -0
- agentpool/mcp/__init__.py +1 -0
- agentpool/mcp/resources.py +64 -0
- agentpool/mcp/tools.py +259 -0
- agentpool/mcp_server.py +487 -0
- agentpool/models.py +310 -0
- agentpool/onboarding.py +1279 -0
- agentpool/policy.py +63 -0
- agentpool/provider_model_catalog.json +997 -0
- agentpool/providers/__init__.py +3 -0
- agentpool/providers/base.py +411 -0
- agentpool/providers/registry.py +139 -0
- agentpool/redaction.py +30 -0
- agentpool/runtimes/__init__.py +3 -0
- agentpool/runtimes/base.py +36 -0
- agentpool/runtimes/tmux.py +133 -0
- agentpool/session_manager.py +1061 -0
- agentpool/stats/__init__.py +6 -0
- agentpool/stats/card.py +74 -0
- agentpool/stats/compute.py +496 -0
- agentpool/stats/queries.py +138 -0
- agentpool/stats/render.py +103 -0
- agentpool/stats/window.py +85 -0
- agentpool/store.py +478 -0
- agentpool/usage/__init__.py +1 -0
- agentpool/usage/_common.py +223 -0
- agentpool/usage/ccusage.py +130 -0
- agentpool/usage/claude.py +23 -0
- agentpool/usage/codex.py +210 -0
- agentpool/usage/codexbar.py +186 -0
- agentpool/usage/combine.py +71 -0
- agentpool/usage/copilot.py +146 -0
- agentpool/usage/devin.py +265 -0
- agentpool/usage/parsers.py +41 -0
- agentpool/usage/probes.py +52 -0
- agentpool/usage/provider_parsers.py +276 -0
- agentpool/usage/summary.py +166 -0
- agentpool/utils.py +59 -0
- agentpool_cli-0.1.0.dist-info/METADATA +292 -0
- agentpool_cli-0.1.0.dist-info/RECORD +60 -0
- agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpool/onboarding.py
ADDED
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from agentpool.config import load_config
|
|
16
|
+
from agentpool.models import ObserveEvent, SessionState, SpawnWorkerRequest, ToolError
|
|
17
|
+
from agentpool.runtimes.tmux import TmuxRuntime
|
|
18
|
+
from agentpool.session_manager import SessionManager
|
|
19
|
+
from agentpool.store import Store
|
|
20
|
+
from agentpool.usage.probes import detect_ccusage, detect_codexbar
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init_config(path: Path, force: bool = False) -> dict[str, Any]:
|
|
24
|
+
path = path.expanduser()
|
|
25
|
+
existed = path.exists()
|
|
26
|
+
backup_path: Path | None = None
|
|
27
|
+
if existed and not force:
|
|
28
|
+
config = load_config(path)
|
|
29
|
+
return {
|
|
30
|
+
"changed": False,
|
|
31
|
+
"config_path": str(path),
|
|
32
|
+
"reason": "config already exists",
|
|
33
|
+
"providers": sorted(config.providers),
|
|
34
|
+
"next_commands": default_onboarding_nudges(),
|
|
35
|
+
}
|
|
36
|
+
if existed:
|
|
37
|
+
backup_path = path.with_suffix(path.suffix + ".bak")
|
|
38
|
+
backup_path.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
39
|
+
config = load_config(Path("__missing_agentpool_config__.yaml"))
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text(yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False), encoding="utf-8")
|
|
42
|
+
return {
|
|
43
|
+
"changed": True,
|
|
44
|
+
"config_path": str(path),
|
|
45
|
+
"backup_path": str(backup_path) if backup_path else None,
|
|
46
|
+
"providers": sorted(config.providers),
|
|
47
|
+
"next_commands": default_onboarding_nudges(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
CURSOR_SETUP_NUDGE = "agentpool setup cursor"
|
|
52
|
+
CURSOR_INSTALL_NUDGE = "agentpool mcp-config --client cursor --absolute-command --install"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_onboarding_nudges() -> list[str]:
|
|
56
|
+
return [CURSOR_SETUP_NUDGE]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
MCP_CLIENTS = {
|
|
60
|
+
"generic",
|
|
61
|
+
"claude-code",
|
|
62
|
+
"claude-desktop",
|
|
63
|
+
"codex",
|
|
64
|
+
"cursor",
|
|
65
|
+
"copilot-cli",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
SETUP_TARGETS = {
|
|
70
|
+
"codex": {
|
|
71
|
+
"provider_id": "codex-cli",
|
|
72
|
+
"mcp_client": "codex",
|
|
73
|
+
"display_name": "Codex CLI",
|
|
74
|
+
"usage_backend": "native",
|
|
75
|
+
"install_hint": "Install Codex CLI, for example `npm install -g @openai/codex`, then confirm `codex --version` works.",
|
|
76
|
+
"login_hint": "Run `codex` and complete the provider login/trust flow outside AgentPool.",
|
|
77
|
+
"setup_doc": "docs/setup-codex.md",
|
|
78
|
+
"manual_steps": [
|
|
79
|
+
"Install Codex CLI if provider_installed is false.",
|
|
80
|
+
"Log in with Codex CLI yourself if usage_probe reports unauthenticated or unavailable.",
|
|
81
|
+
"Paste the MCP config into ~/.codex/config.toml or project .codex/config.toml.",
|
|
82
|
+
"Open Codex and run /mcp to confirm AgentPool is connected.",
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
"codex-cli": {
|
|
86
|
+
"provider_id": "codex-cli",
|
|
87
|
+
"mcp_client": "codex",
|
|
88
|
+
"display_name": "Codex CLI",
|
|
89
|
+
"usage_backend": "native",
|
|
90
|
+
"install_hint": "Install Codex CLI, for example `npm install -g @openai/codex`, then confirm `codex --version` works.",
|
|
91
|
+
"login_hint": "Run `codex` and complete the provider login/trust flow outside AgentPool.",
|
|
92
|
+
"setup_doc": "docs/setup-codex.md",
|
|
93
|
+
"manual_steps": [
|
|
94
|
+
"Install Codex CLI if provider_installed is false.",
|
|
95
|
+
"Log in with Codex CLI yourself if usage_probe reports unauthenticated or unavailable.",
|
|
96
|
+
"Paste the MCP config into ~/.codex/config.toml or project .codex/config.toml.",
|
|
97
|
+
"Open Codex and run /mcp to confirm AgentPool is connected.",
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
"claude": {
|
|
101
|
+
"provider_id": "claude-code",
|
|
102
|
+
"mcp_client": "claude-code",
|
|
103
|
+
"display_name": "Claude Code",
|
|
104
|
+
"usage_backend": "native",
|
|
105
|
+
"install_hint": "Install Claude Code, then confirm `claude --version` works.",
|
|
106
|
+
"login_hint": "Run `claude` and complete the provider login flow outside AgentPool.",
|
|
107
|
+
"setup_doc": "docs/setup-claude-code.md",
|
|
108
|
+
"manual_steps": [
|
|
109
|
+
"Install Claude Code if provider_installed is false.",
|
|
110
|
+
"Log in with Claude Code yourself if usage_probe reports unauthenticated or unavailable.",
|
|
111
|
+
"Use the generated Claude Code MCP command or .mcp.json config.",
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
"claude-code": {
|
|
115
|
+
"provider_id": "claude-code",
|
|
116
|
+
"mcp_client": "claude-code",
|
|
117
|
+
"display_name": "Claude Code",
|
|
118
|
+
"usage_backend": "native",
|
|
119
|
+
"install_hint": "Install Claude Code, then confirm `claude --version` works.",
|
|
120
|
+
"login_hint": "Run `claude` and complete the provider login flow outside AgentPool.",
|
|
121
|
+
"setup_doc": "docs/setup-claude-code.md",
|
|
122
|
+
"manual_steps": [
|
|
123
|
+
"Install Claude Code if provider_installed is false.",
|
|
124
|
+
"Log in with Claude Code yourself if usage_probe reports unauthenticated or unavailable.",
|
|
125
|
+
"Use the generated Claude Code MCP command or .mcp.json config.",
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
"copilot": {
|
|
129
|
+
"provider_id": "copilot-cli",
|
|
130
|
+
"mcp_client": None,
|
|
131
|
+
"display_name": "GitHub Copilot CLI",
|
|
132
|
+
"usage_backend": "native",
|
|
133
|
+
"install_hint": "Install GitHub CLI and the Copilot extension, then confirm `gh copilot --help` works.",
|
|
134
|
+
"login_hint": "Run `gh auth login`; AgentPool only uses ambient GitHub auth for explicit usage refresh.",
|
|
135
|
+
"setup_doc": "docs/setup-copilot.md",
|
|
136
|
+
"manual_steps": [
|
|
137
|
+
"Install GitHub CLI and the Copilot extension if provider_installed is false.",
|
|
138
|
+
"Run gh auth login yourself if usage_probe reports unauthenticated or unavailable.",
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
"copilot-cli": {
|
|
142
|
+
"provider_id": "copilot-cli",
|
|
143
|
+
"mcp_client": None,
|
|
144
|
+
"display_name": "GitHub Copilot CLI",
|
|
145
|
+
"usage_backend": "native",
|
|
146
|
+
"install_hint": "Install GitHub CLI and the Copilot extension, then confirm `gh copilot --help` works.",
|
|
147
|
+
"login_hint": "Run `gh auth login`; AgentPool only uses ambient GitHub auth for explicit usage refresh.",
|
|
148
|
+
"setup_doc": "docs/setup-copilot.md",
|
|
149
|
+
"manual_steps": [
|
|
150
|
+
"Install GitHub CLI and the Copilot extension if provider_installed is false.",
|
|
151
|
+
"Run gh auth login yourself if usage_probe reports unauthenticated or unavailable.",
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
"devin": {
|
|
155
|
+
"provider_id": "devin-cli",
|
|
156
|
+
"mcp_client": None,
|
|
157
|
+
"display_name": "Devin CLI",
|
|
158
|
+
"usage_backend": "native",
|
|
159
|
+
"install_hint": "Install Devin CLI, then confirm `devin --version` works.",
|
|
160
|
+
"login_hint": "Run `devin` and complete the provider login flow outside AgentPool.",
|
|
161
|
+
"setup_doc": "docs/setup-devin.md",
|
|
162
|
+
"manual_steps": [
|
|
163
|
+
"Install Devin CLI if provider_installed is false.",
|
|
164
|
+
"Log in with Devin CLI yourself if usage_probe reports unauthenticated or unavailable.",
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
"devin-cli": {
|
|
168
|
+
"provider_id": "devin-cli",
|
|
169
|
+
"mcp_client": None,
|
|
170
|
+
"display_name": "Devin CLI",
|
|
171
|
+
"usage_backend": "native",
|
|
172
|
+
"install_hint": "Install Devin CLI, then confirm `devin --version` works.",
|
|
173
|
+
"login_hint": "Run `devin` and complete the provider login flow outside AgentPool.",
|
|
174
|
+
"setup_doc": "docs/setup-devin.md",
|
|
175
|
+
"manual_steps": [
|
|
176
|
+
"Install Devin CLI if provider_installed is false.",
|
|
177
|
+
"Log in with Devin CLI yourself if usage_probe reports unauthenticated or unavailable.",
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
"cursor-cli": {
|
|
181
|
+
"provider_id": "cursor-cli",
|
|
182
|
+
"mcp_client": None,
|
|
183
|
+
"display_name": "Cursor Agent CLI",
|
|
184
|
+
"usage_backend": "codexbar",
|
|
185
|
+
"install_hint": "Install Cursor Agent CLI, for example `curl https://cursor.com/install -fsS | bash`, then confirm `agent --version` or `cursor-agent --version` works.",
|
|
186
|
+
"login_hint": "Run `agent login` and confirm `agent status --format json` reports authenticated.",
|
|
187
|
+
"setup_doc": "docs/setup-cursor-cli.md",
|
|
188
|
+
"manual_steps": [
|
|
189
|
+
"Install Cursor Agent CLI if provider_installed is false.",
|
|
190
|
+
"Run agent login yourself if auth_probe reports unauthenticated or unavailable.",
|
|
191
|
+
"Run agent models to see whether Cursor exposes account-specific model slugs.",
|
|
192
|
+
"Use `agentpool usage --provider cursor-cli --backend codexbar --json` for optional Cursor usage if CodexBar is installed.",
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
"cursor": {
|
|
196
|
+
"kind": "host",
|
|
197
|
+
"mcp_client": "cursor",
|
|
198
|
+
"display_name": "Cursor",
|
|
199
|
+
"setup_doc": "docs/setup-cursor.md",
|
|
200
|
+
"manual_steps": [
|
|
201
|
+
"Run `agentpool mcp-config --client cursor --absolute-command --install`.",
|
|
202
|
+
"Click the Cursor deeplink or paste `.cursor/mcp.json` from the generated config.",
|
|
203
|
+
"Open Cursor Settings > MCP and confirm agentpool tools are visible.",
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
"droid": {
|
|
207
|
+
"provider_id": "droid-cli",
|
|
208
|
+
"mcp_client": None,
|
|
209
|
+
"display_name": "Droid CLI",
|
|
210
|
+
"usage_backend": "native",
|
|
211
|
+
"install_hint": "Install Factory Droid CLI, then confirm `droid --version` works.",
|
|
212
|
+
"login_hint": "Run `droid` and complete the provider login flow outside AgentPool.",
|
|
213
|
+
"setup_doc": "docs/setup-droid.md",
|
|
214
|
+
"manual_steps": [
|
|
215
|
+
"Install Droid CLI if provider_installed is false.",
|
|
216
|
+
"Log in with Droid yourself if usage_probe reports unauthenticated or unavailable.",
|
|
217
|
+
"Wire AgentPool into Cursor or another MCP host before spawning Droid workers.",
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
"droid-cli": {
|
|
221
|
+
"provider_id": "droid-cli",
|
|
222
|
+
"mcp_client": None,
|
|
223
|
+
"display_name": "Droid CLI",
|
|
224
|
+
"usage_backend": "native",
|
|
225
|
+
"install_hint": "Install Factory Droid CLI, then confirm `droid --version` works.",
|
|
226
|
+
"login_hint": "Run `droid` and complete the provider login flow outside AgentPool.",
|
|
227
|
+
"setup_doc": "docs/setup-droid.md",
|
|
228
|
+
"manual_steps": [
|
|
229
|
+
"Install Droid CLI if provider_installed is false.",
|
|
230
|
+
"Log in with Droid yourself if usage_probe reports unauthenticated or unavailable.",
|
|
231
|
+
"Wire AgentPool into Cursor or another MCP host before spawning Droid workers.",
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
SETUP_ALL_TARGETS = ["codex", "claude-code", "copilot-cli", "devin-cli", "droid-cli", "cursor-cli"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def mcp_host_config(command: str | None = None) -> dict[str, Any]:
|
|
240
|
+
return {"mcpServers": {"agentpool": _stdio_server_config(command)}}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def mcp_client_config(client: str = "generic", command: str | None = None) -> dict[str, Any]:
|
|
244
|
+
"""Return copy/pasteable MCP setup for common local MCP hosts."""
|
|
245
|
+
normalized = client.strip().lower()
|
|
246
|
+
if normalized not in MCP_CLIENTS:
|
|
247
|
+
return {
|
|
248
|
+
"client": client,
|
|
249
|
+
"ok": False,
|
|
250
|
+
"error": f"unknown MCP client {client!r}",
|
|
251
|
+
"supported_clients": sorted(MCP_CLIENTS),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
server = _stdio_server_config(command)
|
|
255
|
+
if normalized == "codex":
|
|
256
|
+
result: dict[str, Any] = {
|
|
257
|
+
"client": normalized,
|
|
258
|
+
"ok": True,
|
|
259
|
+
"path": "~/.codex/config.toml or .codex/config.toml",
|
|
260
|
+
"format": "toml",
|
|
261
|
+
"config": _codex_mcp_toml(server),
|
|
262
|
+
"commands": [_codex_mcp_add_command(server)],
|
|
263
|
+
"verify": ["codex mcp list", "open Codex TUI and run /mcp"],
|
|
264
|
+
}
|
|
265
|
+
elif normalized == "cursor":
|
|
266
|
+
result = {
|
|
267
|
+
"client": normalized,
|
|
268
|
+
"ok": True,
|
|
269
|
+
"path": ".cursor/mcp.json or ~/.cursor/mcp.json",
|
|
270
|
+
"format": "json",
|
|
271
|
+
"config": {"mcpServers": {"agentpool": {"type": "stdio", **server}}},
|
|
272
|
+
"verify": ["Cursor Settings > MCP", "restart Cursor if tools do not appear"],
|
|
273
|
+
}
|
|
274
|
+
elif normalized == "claude-code":
|
|
275
|
+
result = {
|
|
276
|
+
"client": normalized,
|
|
277
|
+
"ok": True,
|
|
278
|
+
"path": ".mcp.json for project scope, or ~/.claude.json for user scope",
|
|
279
|
+
"format": "json",
|
|
280
|
+
"config": {"mcpServers": {"agentpool": {"type": "stdio", **server, "env": {}}}},
|
|
281
|
+
"commands": _claude_mcp_add_commands(server),
|
|
282
|
+
"verify": ["claude mcp list", "open Claude Code and run /mcp"],
|
|
283
|
+
}
|
|
284
|
+
elif normalized == "copilot-cli":
|
|
285
|
+
result = {
|
|
286
|
+
"client": normalized,
|
|
287
|
+
"ok": True,
|
|
288
|
+
"path": "~/.copilot/mcp-config.json",
|
|
289
|
+
"format": "json",
|
|
290
|
+
"config": {
|
|
291
|
+
"mcpServers": {
|
|
292
|
+
"agentpool": {
|
|
293
|
+
"type": "local",
|
|
294
|
+
**server,
|
|
295
|
+
"tools": ["*"],
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
"commands": [_copilot_mcp_add_command(server)],
|
|
300
|
+
"verify": ["copilot mcp list", "copilot mcp get agentpool", "/mcp show agentpool inside Copilot CLI"],
|
|
301
|
+
}
|
|
302
|
+
elif normalized == "claude-desktop":
|
|
303
|
+
result = {
|
|
304
|
+
"client": normalized,
|
|
305
|
+
"ok": True,
|
|
306
|
+
"path": "~/Library/Application Support/Claude/claude_desktop_config.json",
|
|
307
|
+
"format": "json",
|
|
308
|
+
"config": {"mcpServers": {"agentpool": server}},
|
|
309
|
+
"verify": ["restart Claude Desktop", "check MCP/server settings"],
|
|
310
|
+
}
|
|
311
|
+
else:
|
|
312
|
+
result = {
|
|
313
|
+
"client": normalized,
|
|
314
|
+
"ok": True,
|
|
315
|
+
"path": "host-specific MCP config file",
|
|
316
|
+
"format": "json",
|
|
317
|
+
"config": {"mcpServers": {"agentpool": server}},
|
|
318
|
+
"verify": ["restart or reload the MCP host", "confirm agentpool tools/resources are visible"],
|
|
319
|
+
}
|
|
320
|
+
_attach_mcp_install(normalized, server, result)
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _stdio_server_config(command: str | None = None) -> dict[str, Any]:
|
|
325
|
+
return {"command": command or "agentpool", "args": ["mcp"]}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _codex_mcp_toml(server: dict[str, Any]) -> str:
|
|
329
|
+
args = ", ".join(json.dumps(arg) for arg in server.get("args", []))
|
|
330
|
+
command = json.dumps(server["command"])
|
|
331
|
+
return (
|
|
332
|
+
"[mcp_servers.agentpool]\n"
|
|
333
|
+
f"command = {command}\n"
|
|
334
|
+
f"args = [{args}]\n"
|
|
335
|
+
"startup_timeout_sec = 10\n"
|
|
336
|
+
"tool_timeout_sec = 300\n"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _stdio_server_invocation(server: dict[str, Any]) -> str:
|
|
341
|
+
args = server.get("args") or []
|
|
342
|
+
if not args:
|
|
343
|
+
return str(server["command"])
|
|
344
|
+
return f"{server['command']} {' '.join(args)}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _codex_mcp_add_command(server: dict[str, Any]) -> str:
|
|
348
|
+
return f"codex mcp add agentpool -- {_stdio_server_invocation(server)}"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _claude_mcp_add_commands(server: dict[str, Any]) -> list[str]:
|
|
352
|
+
invocation = _stdio_server_invocation(server)
|
|
353
|
+
return [
|
|
354
|
+
f"claude mcp add --transport stdio --scope local agentpool -- {invocation}",
|
|
355
|
+
f"claude mcp add --transport stdio --scope project agentpool -- {invocation}",
|
|
356
|
+
f"claude mcp add --transport stdio --scope user agentpool -- {invocation}",
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _copilot_mcp_add_command(server: dict[str, Any]) -> str:
|
|
361
|
+
return f"copilot mcp add agentpool -- {_stdio_server_invocation(server)}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _cursor_mcp_install_deeplink(server: dict[str, Any], name: str = "agentpool") -> str:
|
|
365
|
+
transport = json.dumps(
|
|
366
|
+
{"command": server["command"], "args": server.get("args", [])},
|
|
367
|
+
separators=(",", ":"),
|
|
368
|
+
)
|
|
369
|
+
encoded = base64.b64encode(transport.encode("utf-8")).decode("ascii")
|
|
370
|
+
return (
|
|
371
|
+
"cursor://anysphere.cursor-deeplink/mcp/install?"
|
|
372
|
+
f"name={quote(name)}&config={quote(encoded, safe='')}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _attach_mcp_install(client: str, server: dict[str, Any], payload: dict[str, Any]) -> None:
|
|
377
|
+
install: dict[str, Any] = {"client": client}
|
|
378
|
+
if client == "cursor":
|
|
379
|
+
install["kind"] = "deeplink"
|
|
380
|
+
install["deeplink"] = _cursor_mcp_install_deeplink(server)
|
|
381
|
+
install["instructions"] = [
|
|
382
|
+
"Click the deeplink or paste it into your browser.",
|
|
383
|
+
"Cursor prompts to install the MCP server.",
|
|
384
|
+
]
|
|
385
|
+
elif client == "claude-code":
|
|
386
|
+
install["kind"] = "shell"
|
|
387
|
+
install["commands"] = payload.get("commands") or _claude_mcp_add_commands(server)
|
|
388
|
+
install["instructions"] = [
|
|
389
|
+
"Run one shell command below.",
|
|
390
|
+
"`--scope local` is personal to the current project (Claude default).",
|
|
391
|
+
"`--scope project` writes team-shared `.mcp.json`. `--scope user` writes `~/.claude.json`.",
|
|
392
|
+
"Verify with `claude mcp list` and `/mcp` inside Claude Code.",
|
|
393
|
+
]
|
|
394
|
+
elif client == "codex":
|
|
395
|
+
install["kind"] = "shell"
|
|
396
|
+
install["commands"] = payload.get("commands") or [_codex_mcp_add_command(server)]
|
|
397
|
+
install["instructions"] = [
|
|
398
|
+
"Run the shell command below, or paste the TOML block into ~/.codex/config.toml.",
|
|
399
|
+
"Verify with `codex mcp list` and `/mcp` inside Codex.",
|
|
400
|
+
]
|
|
401
|
+
elif client == "copilot-cli":
|
|
402
|
+
install["kind"] = "shell"
|
|
403
|
+
install["commands"] = payload.get("commands") or [_copilot_mcp_add_command(server)]
|
|
404
|
+
install["instructions"] = [
|
|
405
|
+
"Run the shell command below (writes ~/.copilot/mcp-config.json).",
|
|
406
|
+
"Or use interactive `/mcp add` inside Copilot CLI with the same STDIO command.",
|
|
407
|
+
"Verify with `copilot mcp list` or `/mcp show agentpool`.",
|
|
408
|
+
]
|
|
409
|
+
install["tui"] = {
|
|
410
|
+
"command": "/mcp add",
|
|
411
|
+
"fields": {
|
|
412
|
+
"Server Name": "agentpool",
|
|
413
|
+
"Server Type": "STDIO",
|
|
414
|
+
"Command": _stdio_server_invocation(server),
|
|
415
|
+
"Tools": "*",
|
|
416
|
+
},
|
|
417
|
+
}
|
|
418
|
+
else:
|
|
419
|
+
install["kind"] = "manual"
|
|
420
|
+
install["instructions"] = [
|
|
421
|
+
f"Paste the generated config into {payload.get('path', 'the host MCP config file')}.",
|
|
422
|
+
"Restart or reload the MCP host if tools do not appear.",
|
|
423
|
+
]
|
|
424
|
+
payload["install"] = install
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def format_mcp_install(data: dict[str, Any]) -> str:
|
|
428
|
+
install = data.get("install")
|
|
429
|
+
if not isinstance(install, dict):
|
|
430
|
+
return ""
|
|
431
|
+
|
|
432
|
+
lines = [f"MCP install helper for {install.get('client', data.get('client', 'unknown'))}:"]
|
|
433
|
+
kind = install.get("kind")
|
|
434
|
+
if kind == "deeplink" and install.get("deeplink"):
|
|
435
|
+
lines.extend(["", "One-click install (Cursor deeplink):", str(install["deeplink"])])
|
|
436
|
+
if install.get("commands"):
|
|
437
|
+
lines.extend(["", "Run one of these commands:"])
|
|
438
|
+
lines.extend(str(command) for command in install["commands"])
|
|
439
|
+
tui = install.get("tui")
|
|
440
|
+
if isinstance(tui, dict):
|
|
441
|
+
lines.extend(["", f"Copilot CLI interactive setup ({tui.get('command', '/mcp add')}):"])
|
|
442
|
+
fields = tui.get("fields") or {}
|
|
443
|
+
for label, value in fields.items():
|
|
444
|
+
lines.append(f" {label}: {value}")
|
|
445
|
+
if install.get("instructions"):
|
|
446
|
+
lines.extend(["", "Steps:"])
|
|
447
|
+
lines.extend(f" - {step}" for step in install["instructions"])
|
|
448
|
+
return "\n".join(lines)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def deep_doctor(manager: SessionManager) -> dict[str, Any]:
|
|
452
|
+
checks = [
|
|
453
|
+
_check_tmux_roundtrip(manager.config.runtime.tmux.session_prefix),
|
|
454
|
+
_check_sqlite(manager.store),
|
|
455
|
+
_check_artifact_root(manager.config.storage.artifacts),
|
|
456
|
+
_check_usage_cache(manager),
|
|
457
|
+
_check_codexbar(),
|
|
458
|
+
]
|
|
459
|
+
return {
|
|
460
|
+
"ok": all(check["ok"] for check in checks),
|
|
461
|
+
"checks": checks,
|
|
462
|
+
"inventory": manager.inventory(include_usage=False),
|
|
463
|
+
"next_commands": default_onboarding_nudges(),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def privacy_doctor(manager: SessionManager) -> dict[str, Any]:
|
|
468
|
+
return {
|
|
469
|
+
"credential_storage": {
|
|
470
|
+
"agentpool_stores_provider_credentials": False,
|
|
471
|
+
"browser_scraping_enabled_by_default": False,
|
|
472
|
+
"browser_cookie_sources_enabled_by_default": False,
|
|
473
|
+
"macos_keychain_access_by_agentpool": False,
|
|
474
|
+
},
|
|
475
|
+
"local_storage": {
|
|
476
|
+
"config_path": str(Path("~/.agentpool/config.yaml").expanduser()),
|
|
477
|
+
"sqlite_db": str(manager.config.storage.db),
|
|
478
|
+
"artifact_root": str(manager.config.storage.artifacts),
|
|
479
|
+
"stores": [
|
|
480
|
+
"sessions",
|
|
481
|
+
"events",
|
|
482
|
+
"usage snapshots",
|
|
483
|
+
"artifact manifests",
|
|
484
|
+
"advisory file leases",
|
|
485
|
+
],
|
|
486
|
+
},
|
|
487
|
+
"usage_refresh": {
|
|
488
|
+
"inventory_runs_live_usage_probes": False,
|
|
489
|
+
"live_usage_requires_explicit_refresh": True,
|
|
490
|
+
"default_backend": "combined",
|
|
491
|
+
"optional_backends": {
|
|
492
|
+
"codexbar": detect_codexbar(),
|
|
493
|
+
"ccusage": detect_ccusage(),
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
"usage_sources": [
|
|
497
|
+
{
|
|
498
|
+
"provider_id": "codex-cli",
|
|
499
|
+
"source": "Codex local app-server RPC",
|
|
500
|
+
"reads_existing_auth_state": True,
|
|
501
|
+
"network": "local app-server",
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
"provider_id": "claude-code",
|
|
505
|
+
"source": "temporary Claude Code tmux session with /usage",
|
|
506
|
+
"reads_existing_auth_state": True,
|
|
507
|
+
"network": "provider CLI decides",
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
"provider_id": "devin-cli",
|
|
511
|
+
"source": "existing Devin CLI credentials in memory and plan-status API",
|
|
512
|
+
"reads_existing_auth_state": True,
|
|
513
|
+
"network": "Devin/Windsurf API on explicit refresh",
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
"provider_id": "copilot-cli",
|
|
517
|
+
"source": "ambient GitHub token from env or gh auth token",
|
|
518
|
+
"reads_existing_auth_state": True,
|
|
519
|
+
"network": "GitHub Copilot API on explicit refresh",
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
"provider_id": "cursor-cli",
|
|
523
|
+
"source": "Cursor Agent CLI status/about and optional CodexBar Cursor usage",
|
|
524
|
+
"reads_existing_auth_state": True,
|
|
525
|
+
"network": "Cursor CLI or CodexBar source dependent on explicit refresh",
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
"provider_id": "codexbar",
|
|
529
|
+
"source": "optional external CodexBar CLI safe sources",
|
|
530
|
+
"reads_existing_auth_state": True,
|
|
531
|
+
"network": "CodexBar source dependent",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
"provider_id": "ccusage",
|
|
535
|
+
"source": "optional local Claude Code usage logs",
|
|
536
|
+
"reads_existing_auth_state": False,
|
|
537
|
+
"network": "offline command only",
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
"redaction": {
|
|
541
|
+
"applied_before_persistence": True,
|
|
542
|
+
"patterns": [
|
|
543
|
+
"authorization headers",
|
|
544
|
+
"token/key/secret/password assignments",
|
|
545
|
+
"provider API keys",
|
|
546
|
+
"GitHub tokens",
|
|
547
|
+
"Slack tokens",
|
|
548
|
+
"AWS access key ids",
|
|
549
|
+
"JWT-shaped strings",
|
|
550
|
+
"URI passwords",
|
|
551
|
+
"private key blocks",
|
|
552
|
+
],
|
|
553
|
+
},
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def setup_provider(
|
|
558
|
+
manager: SessionManager,
|
|
559
|
+
target: str,
|
|
560
|
+
*,
|
|
561
|
+
backend: str | None = None,
|
|
562
|
+
run_usage: bool = True,
|
|
563
|
+
absolute_command: bool = True,
|
|
564
|
+
) -> dict[str, Any]:
|
|
565
|
+
normalized = target.strip().lower()
|
|
566
|
+
setup = SETUP_TARGETS.get(normalized)
|
|
567
|
+
if setup is None:
|
|
568
|
+
return {
|
|
569
|
+
"ok": False,
|
|
570
|
+
"target": target,
|
|
571
|
+
"error": f"unknown setup target {target!r}",
|
|
572
|
+
"supported_targets": sorted(SETUP_TARGETS),
|
|
573
|
+
"action": "Run `agentpool setup all` to inspect supported providers, or choose one of the supported targets.",
|
|
574
|
+
"checks": [],
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if setup.get("kind") == "host":
|
|
578
|
+
return _setup_host(normalized, setup, absolute_command=absolute_command)
|
|
579
|
+
|
|
580
|
+
provider_id = str(setup["provider_id"])
|
|
581
|
+
usage_backend = backend or str(setup["usage_backend"])
|
|
582
|
+
checks: list[dict[str, Any]] = []
|
|
583
|
+
result: dict[str, Any] = {
|
|
584
|
+
"target": normalized,
|
|
585
|
+
"provider_id": provider_id,
|
|
586
|
+
"display_name": setup["display_name"],
|
|
587
|
+
"usage_backend": usage_backend,
|
|
588
|
+
"does_not": [
|
|
589
|
+
"store provider credentials",
|
|
590
|
+
"log in to provider CLIs",
|
|
591
|
+
"scrape browser dashboards",
|
|
592
|
+
"edit MCP host config files",
|
|
593
|
+
],
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
provider = _provider_descriptor(manager, provider_id)
|
|
597
|
+
configured = provider is not None
|
|
598
|
+
checks.append(
|
|
599
|
+
{
|
|
600
|
+
"name": "provider_configured",
|
|
601
|
+
"ok": configured,
|
|
602
|
+
"message": "Provider is configured." if configured else f"Provider {provider_id} is not configured.",
|
|
603
|
+
"action": None
|
|
604
|
+
if configured
|
|
605
|
+
else f"Run `agentpool init` or add `{provider_id}` to the `providers` section in your AgentPool config.",
|
|
606
|
+
}
|
|
607
|
+
)
|
|
608
|
+
if provider is None:
|
|
609
|
+
result["checks"] = checks
|
|
610
|
+
result["manual_steps"] = setup["manual_steps"]
|
|
611
|
+
result["actions"] = _setup_actions(checks)
|
|
612
|
+
result["ok"] = False
|
|
613
|
+
return result
|
|
614
|
+
|
|
615
|
+
result["provider"] = provider
|
|
616
|
+
installed = bool(provider.get("installed"))
|
|
617
|
+
checks.append(
|
|
618
|
+
{
|
|
619
|
+
"name": "provider_installed",
|
|
620
|
+
"ok": installed,
|
|
621
|
+
"message": provider.get("binary_path") or "Provider binary was not found on PATH.",
|
|
622
|
+
"version": provider.get("version"),
|
|
623
|
+
"action": None if installed else str(setup["install_hint"]),
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
auth = provider.get("auth") or {}
|
|
627
|
+
auth_ok = auth.get("status") in {"authenticated", "unknown"}
|
|
628
|
+
checks.append(
|
|
629
|
+
{
|
|
630
|
+
"name": "auth_probe",
|
|
631
|
+
"ok": auth_ok,
|
|
632
|
+
"message": auth.get("reason") or f"Auth status: {auth.get('status', 'unknown')}.",
|
|
633
|
+
"status": auth.get("status"),
|
|
634
|
+
"confidence": auth.get("confidence"),
|
|
635
|
+
"action": None if auth_ok else str(setup["login_hint"]),
|
|
636
|
+
}
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
models = manager.provider_models(provider_id)["providers"][0]
|
|
641
|
+
result["models"] = models
|
|
642
|
+
checks.append(
|
|
643
|
+
{
|
|
644
|
+
"name": "model_catalog",
|
|
645
|
+
"ok": bool(models.get("models")),
|
|
646
|
+
"message": f"default={models.get('default_model') or 'unset'}, models={len(models.get('models') or [])}",
|
|
647
|
+
"action": None
|
|
648
|
+
if models.get("models")
|
|
649
|
+
else (
|
|
650
|
+
f"Review `agentpool models --provider {provider_id}` and "
|
|
651
|
+
"`agentpool models validate --path src/agentpool/provider_model_catalog.json`."
|
|
652
|
+
),
|
|
653
|
+
}
|
|
654
|
+
)
|
|
655
|
+
except Exception as exc:
|
|
656
|
+
checks.append(
|
|
657
|
+
{
|
|
658
|
+
"name": "model_catalog",
|
|
659
|
+
"ok": False,
|
|
660
|
+
"message": str(exc),
|
|
661
|
+
"action": "Validate the model catalog, then rerun setup.",
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
if run_usage:
|
|
666
|
+
try:
|
|
667
|
+
usage = manager.usage_snapshot(provider_id, backend=usage_backend)
|
|
668
|
+
snapshots = usage.get("snapshots") or []
|
|
669
|
+
snapshot = snapshots[0] if snapshots else {}
|
|
670
|
+
result["usage"] = usage
|
|
671
|
+
checks.append(_usage_check(snapshot))
|
|
672
|
+
try:
|
|
673
|
+
result["capacity_summary"] = manager.usage_summary(provider_id=provider_id, refresh=False)
|
|
674
|
+
except Exception as exc:
|
|
675
|
+
checks.append(
|
|
676
|
+
{
|
|
677
|
+
"name": "capacity_summary",
|
|
678
|
+
"ok": False,
|
|
679
|
+
"message": str(exc),
|
|
680
|
+
"action": f"Run `agentpool usage-summary --provider {provider_id} --refresh --json` for the raw usage response.",
|
|
681
|
+
}
|
|
682
|
+
)
|
|
683
|
+
except Exception as exc:
|
|
684
|
+
checks.append(
|
|
685
|
+
{
|
|
686
|
+
"name": "usage_probe",
|
|
687
|
+
"ok": False,
|
|
688
|
+
"message": str(exc),
|
|
689
|
+
"backend": usage_backend,
|
|
690
|
+
"action": (
|
|
691
|
+
f"Run `agentpool usage-summary --provider {provider_id} --refresh --json`; "
|
|
692
|
+
f"if this is only setup validation, rerun `agentpool setup {normalized} --skip-usage`."
|
|
693
|
+
),
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
else:
|
|
697
|
+
checks.append(
|
|
698
|
+
{
|
|
699
|
+
"name": "usage_probe",
|
|
700
|
+
"ok": None,
|
|
701
|
+
"message": "Skipped by --skip-usage.",
|
|
702
|
+
"backend": usage_backend,
|
|
703
|
+
"action": f"Run `agentpool usage-summary --provider {provider_id} --refresh --json` when you want live usage.",
|
|
704
|
+
}
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
mcp_client = setup.get("mcp_client")
|
|
708
|
+
if mcp_client:
|
|
709
|
+
mcp_config = mcp_client_config(str(mcp_client), command_path(absolute=absolute_command))
|
|
710
|
+
result["mcp_config"] = mcp_config
|
|
711
|
+
checks.append(
|
|
712
|
+
{
|
|
713
|
+
"name": "mcp_config",
|
|
714
|
+
"ok": bool(mcp_config.get("ok")),
|
|
715
|
+
"message": f"Generated {mcp_config.get('format')} config for {mcp_config.get('path')}.",
|
|
716
|
+
"action": "Run the generated install command or deeplink, or paste the MCP config manually.",
|
|
717
|
+
}
|
|
718
|
+
)
|
|
719
|
+
else:
|
|
720
|
+
checks.append(
|
|
721
|
+
{
|
|
722
|
+
"name": "mcp_config",
|
|
723
|
+
"ok": None,
|
|
724
|
+
"message": "No provider-specific MCP host config for this target.",
|
|
725
|
+
"action": "Add AgentPool to your MCP host with `agentpool mcp-config --client <client> --absolute-command` if needed.",
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
result["checks"] = checks
|
|
730
|
+
result["manual_steps"] = setup["manual_steps"]
|
|
731
|
+
result["setup_doc"] = setup.get("setup_doc")
|
|
732
|
+
result["actions"] = _setup_actions(checks)
|
|
733
|
+
result["next_commands"] = _setup_next_commands(provider_id, normalized, setup)
|
|
734
|
+
result["ok"] = all(check["ok"] is not False for check in checks)
|
|
735
|
+
return result
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _setup_host(
|
|
739
|
+
target: str,
|
|
740
|
+
setup: dict[str, Any],
|
|
741
|
+
*,
|
|
742
|
+
absolute_command: bool = True,
|
|
743
|
+
) -> dict[str, Any]:
|
|
744
|
+
checks: list[dict[str, Any]] = []
|
|
745
|
+
command = command_path(absolute=absolute_command)
|
|
746
|
+
installed = shutil.which("agentpool") is not None or Path(command).exists()
|
|
747
|
+
checks.append(
|
|
748
|
+
{
|
|
749
|
+
"name": "agentpool_installed",
|
|
750
|
+
"ok": installed,
|
|
751
|
+
"message": command,
|
|
752
|
+
"action": None
|
|
753
|
+
if installed
|
|
754
|
+
else "Install AgentPool with pipx or `uv pip install -e '.[dev]'`, then rerun setup.",
|
|
755
|
+
}
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
mcp_client = str(setup["mcp_client"])
|
|
759
|
+
mcp_config = mcp_client_config(mcp_client, command if absolute_command else "agentpool")
|
|
760
|
+
checks.append(
|
|
761
|
+
{
|
|
762
|
+
"name": "mcp_config",
|
|
763
|
+
"ok": bool(mcp_config.get("ok")),
|
|
764
|
+
"message": f"Generated {mcp_config.get('format')} install helper for {mcp_config.get('path')}.",
|
|
765
|
+
"action": "Run the generated install command or deeplink, or paste the MCP config manually.",
|
|
766
|
+
}
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
result: dict[str, Any] = {
|
|
770
|
+
"target": target,
|
|
771
|
+
"kind": "host",
|
|
772
|
+
"provider_id": None,
|
|
773
|
+
"display_name": setup["display_name"],
|
|
774
|
+
"does_not": [
|
|
775
|
+
"store provider credentials",
|
|
776
|
+
"log in to provider CLIs",
|
|
777
|
+
"scrape browser dashboards",
|
|
778
|
+
"edit MCP host config files",
|
|
779
|
+
],
|
|
780
|
+
"mcp_config": mcp_config,
|
|
781
|
+
"checks": checks,
|
|
782
|
+
"manual_steps": setup["manual_steps"],
|
|
783
|
+
"setup_doc": setup.get("setup_doc"),
|
|
784
|
+
"actions": _setup_actions(checks),
|
|
785
|
+
"next_commands": _setup_host_next_commands(target),
|
|
786
|
+
}
|
|
787
|
+
result["ok"] = all(check["ok"] is not False for check in checks)
|
|
788
|
+
return result
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def setup_all_providers(
|
|
792
|
+
manager: SessionManager,
|
|
793
|
+
*,
|
|
794
|
+
backend: str | None = None,
|
|
795
|
+
run_usage: bool = True,
|
|
796
|
+
absolute_command: bool = True,
|
|
797
|
+
) -> dict[str, Any]:
|
|
798
|
+
results = [
|
|
799
|
+
setup_provider(
|
|
800
|
+
manager,
|
|
801
|
+
target,
|
|
802
|
+
backend=backend,
|
|
803
|
+
run_usage=run_usage,
|
|
804
|
+
absolute_command=absolute_command,
|
|
805
|
+
)
|
|
806
|
+
for target in SETUP_ALL_TARGETS
|
|
807
|
+
]
|
|
808
|
+
rows = []
|
|
809
|
+
for result in results:
|
|
810
|
+
checks = {check["name"]: check for check in result.get("checks", [])}
|
|
811
|
+
usage = checks.get("usage_probe") or {}
|
|
812
|
+
installed = checks.get("provider_installed") or {}
|
|
813
|
+
rows.append(
|
|
814
|
+
{
|
|
815
|
+
"target": result["target"],
|
|
816
|
+
"provider_id": result["provider_id"],
|
|
817
|
+
"display_name": result["display_name"],
|
|
818
|
+
"ok": result["ok"],
|
|
819
|
+
"installed": installed.get("ok"),
|
|
820
|
+
"binary": installed.get("message"),
|
|
821
|
+
"usage_status": usage.get("status"),
|
|
822
|
+
"usage_ok": usage.get("ok"),
|
|
823
|
+
"usage_message": usage.get("message"),
|
|
824
|
+
"mcp_config": "yes" if result.get("mcp_config") else "n/a",
|
|
825
|
+
"action": _first_setup_action(result),
|
|
826
|
+
}
|
|
827
|
+
)
|
|
828
|
+
return {
|
|
829
|
+
"ok": all(row["ok"] for row in rows),
|
|
830
|
+
"targets": SETUP_ALL_TARGETS,
|
|
831
|
+
"rows": rows,
|
|
832
|
+
"results": results,
|
|
833
|
+
"does_not": [
|
|
834
|
+
"store provider credentials",
|
|
835
|
+
"log in to provider CLIs",
|
|
836
|
+
"scrape browser dashboards",
|
|
837
|
+
"edit MCP host config files",
|
|
838
|
+
],
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def run_fake_smoke(manager: SessionManager, repo: Path, provider_id: str = "fake-question") -> dict[str, Any]:
|
|
843
|
+
result = manager.spawn_worker(
|
|
844
|
+
SpawnWorkerRequest(
|
|
845
|
+
provider_id=provider_id,
|
|
846
|
+
task="AgentPool smoke test. Ask one question, then finish after steering.",
|
|
847
|
+
repo_path=str(repo),
|
|
848
|
+
isolation="read_only",
|
|
849
|
+
)
|
|
850
|
+
)
|
|
851
|
+
session_id = result["session"]["id"]
|
|
852
|
+
output: dict[str, Any] = {"session_id": session_id, "provider_id": provider_id}
|
|
853
|
+
try:
|
|
854
|
+
observed = manager.observe_worker(session_id, wait_for=["question"], timeout_seconds=8)
|
|
855
|
+
output["question_event"] = observed.event.value
|
|
856
|
+
output["question_state"] = observed.state.value
|
|
857
|
+
output["send"] = manager.send_worker_message(session_id, "Continue read-only.")["ok"]
|
|
858
|
+
done = manager.observe_worker(session_id, wait_for=["completed"], timeout_seconds=8)
|
|
859
|
+
output["completed_event"] = done.event.value
|
|
860
|
+
output["completed_state"] = done.state.value
|
|
861
|
+
collected = manager.collect_worker_artifacts(session_id, mark_completed=True)
|
|
862
|
+
output["artifact_dir"] = collected["artifact_dir"]
|
|
863
|
+
output["artifact_kinds"] = [artifact["kind"] for artifact in collected["artifacts"]]
|
|
864
|
+
output["git_dirty"] = collected["git"]["dirty"]
|
|
865
|
+
output["ok"] = observed.event == ObserveEvent.QUESTION and done.event == ObserveEvent.COMPLETED
|
|
866
|
+
return output
|
|
867
|
+
finally:
|
|
868
|
+
session = manager.store.get_session(session_id)
|
|
869
|
+
if session and session.tmux and manager.runtime.exists(session.tmux):
|
|
870
|
+
manager.terminate_worker(session_id, reason="smoke cleanup")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def run_real_read_only_smoke(
|
|
874
|
+
manager: SessionManager,
|
|
875
|
+
repo: Path,
|
|
876
|
+
provider_id: str,
|
|
877
|
+
model: str | None = None,
|
|
878
|
+
timeout_seconds: int = 60,
|
|
879
|
+
accept_startup_trust: bool = True,
|
|
880
|
+
) -> dict[str, Any]:
|
|
881
|
+
if provider_id.startswith("fake-"):
|
|
882
|
+
return run_fake_smoke(manager, repo=repo, provider_id=provider_id)
|
|
883
|
+
selected_model = model or _smoke_model(manager, provider_id)
|
|
884
|
+
result = manager.spawn_worker(
|
|
885
|
+
SpawnWorkerRequest(
|
|
886
|
+
provider_id=provider_id,
|
|
887
|
+
task=_real_read_only_smoke_task(),
|
|
888
|
+
repo_path=str(repo),
|
|
889
|
+
isolation="read_only",
|
|
890
|
+
role="reviewer",
|
|
891
|
+
model=selected_model,
|
|
892
|
+
initial_prompt_mode="arg",
|
|
893
|
+
)
|
|
894
|
+
)
|
|
895
|
+
session_id = result["session"]["id"]
|
|
896
|
+
output: dict[str, Any] = {
|
|
897
|
+
"session_id": session_id,
|
|
898
|
+
"provider_id": provider_id,
|
|
899
|
+
"model": selected_model,
|
|
900
|
+
"isolation": "read_only",
|
|
901
|
+
"attach": manager.attach_info(session_id),
|
|
902
|
+
"lifecycle": {"spawn": True},
|
|
903
|
+
}
|
|
904
|
+
final_observe = None
|
|
905
|
+
try:
|
|
906
|
+
first = _safe_observe(
|
|
907
|
+
manager,
|
|
908
|
+
session_id,
|
|
909
|
+
wait_for=["question", "approval", "completed", "error"],
|
|
910
|
+
timeout_seconds=min(timeout_seconds, 30),
|
|
911
|
+
output=output,
|
|
912
|
+
)
|
|
913
|
+
if first is None:
|
|
914
|
+
_collect_smoke_artifacts(manager, session_id, output, final_observe)
|
|
915
|
+
output["ok"] = False
|
|
916
|
+
return output
|
|
917
|
+
output["initial_event"] = first.event.value
|
|
918
|
+
output["initial_state"] = first.state.value
|
|
919
|
+
final_observe = first
|
|
920
|
+
startup_approval_steps = 0
|
|
921
|
+
while first.event == ObserveEvent.APPROVAL_PROMPT and startup_approval_steps < 4:
|
|
922
|
+
startup_approval_steps += 1
|
|
923
|
+
if accept_startup_trust and _is_startup_trust_prompt(first.screen_excerpt or ""):
|
|
924
|
+
output["lifecycle"]["startup_trust_prompt"] = "accepted"
|
|
925
|
+
manager.send_worker_message(session_id, _startup_trust_message(provider_id))
|
|
926
|
+
time.sleep(0.5)
|
|
927
|
+
first = _safe_observe(
|
|
928
|
+
manager,
|
|
929
|
+
session_id,
|
|
930
|
+
wait_for=["question", "approval", "completed", "error"],
|
|
931
|
+
timeout_seconds=min(timeout_seconds, 30),
|
|
932
|
+
output=output,
|
|
933
|
+
)
|
|
934
|
+
if first is None:
|
|
935
|
+
_collect_smoke_artifacts(manager, session_id, output, final_observe)
|
|
936
|
+
output["ok"] = False
|
|
937
|
+
return output
|
|
938
|
+
output["post_trust_event"] = first.event.value
|
|
939
|
+
output["post_trust_state"] = first.state.value
|
|
940
|
+
final_observe = first
|
|
941
|
+
continue
|
|
942
|
+
elif _is_startup_update_prompt(first.screen_excerpt or ""):
|
|
943
|
+
output["lifecycle"]["startup_update_prompt"] = "skipped"
|
|
944
|
+
manager.send_worker_message(session_id, "2")
|
|
945
|
+
time.sleep(0.5)
|
|
946
|
+
first = _safe_observe(
|
|
947
|
+
manager,
|
|
948
|
+
session_id,
|
|
949
|
+
wait_for=["question", "approval", "completed", "error"],
|
|
950
|
+
timeout_seconds=min(timeout_seconds, 30),
|
|
951
|
+
output=output,
|
|
952
|
+
)
|
|
953
|
+
if first is None:
|
|
954
|
+
_collect_smoke_artifacts(manager, session_id, output, final_observe)
|
|
955
|
+
output["ok"] = False
|
|
956
|
+
return output
|
|
957
|
+
output["post_update_event"] = first.event.value
|
|
958
|
+
output["post_update_state"] = first.state.value
|
|
959
|
+
final_observe = first
|
|
960
|
+
continue
|
|
961
|
+
elif _is_startup_hook_prompt(first.screen_excerpt or ""):
|
|
962
|
+
output["lifecycle"]["startup_hook_prompt"] = "continued_without_trusting"
|
|
963
|
+
manager.send_worker_message(session_id, "3")
|
|
964
|
+
time.sleep(0.5)
|
|
965
|
+
first = _safe_observe(
|
|
966
|
+
manager,
|
|
967
|
+
session_id,
|
|
968
|
+
wait_for=["question", "approval", "completed", "error"],
|
|
969
|
+
timeout_seconds=min(timeout_seconds, 30),
|
|
970
|
+
output=output,
|
|
971
|
+
)
|
|
972
|
+
if first is None:
|
|
973
|
+
_collect_smoke_artifacts(manager, session_id, output, final_observe)
|
|
974
|
+
output["ok"] = False
|
|
975
|
+
return output
|
|
976
|
+
output["post_hook_event"] = first.event.value
|
|
977
|
+
output["post_hook_state"] = first.state.value
|
|
978
|
+
final_observe = first
|
|
979
|
+
continue
|
|
980
|
+
else:
|
|
981
|
+
output["blocked_reason"] = "approval prompt requires human review"
|
|
982
|
+
break
|
|
983
|
+
if first.event == ObserveEvent.APPROVAL_PROMPT and "blocked_reason" not in output:
|
|
984
|
+
output["blocked_reason"] = "startup approval prompt did not clear"
|
|
985
|
+
if "blocked_reason" not in output and final_observe and final_observe.event != ObserveEvent.ERROR:
|
|
986
|
+
output["send"] = _send_smoke_message(manager, session_id, provider_id)
|
|
987
|
+
output["lifecycle"]["send"] = True
|
|
988
|
+
final_observe = _safe_observe(
|
|
989
|
+
manager,
|
|
990
|
+
session_id,
|
|
991
|
+
wait_for=["completed", "question", "approval", "error"],
|
|
992
|
+
timeout_seconds=timeout_seconds,
|
|
993
|
+
output=output,
|
|
994
|
+
)
|
|
995
|
+
if final_observe is None:
|
|
996
|
+
_collect_smoke_artifacts(manager, session_id, output, final_observe)
|
|
997
|
+
output["ok"] = False
|
|
998
|
+
return output
|
|
999
|
+
output["final_event"] = final_observe.event.value
|
|
1000
|
+
output["final_state"] = final_observe.state.value
|
|
1001
|
+
output["completion_verified"] = final_observe.event == ObserveEvent.COMPLETED
|
|
1002
|
+
if final_observe and final_observe.state not in {SessionState.COMPLETED, SessionState.FAILED}:
|
|
1003
|
+
output["interrupt"] = manager.interrupt_worker(session_id)["ok"]
|
|
1004
|
+
output["lifecycle"]["interrupt"] = True
|
|
1005
|
+
else:
|
|
1006
|
+
output["interrupt"] = "skipped_final_state"
|
|
1007
|
+
_collect_smoke_artifacts(manager, session_id, output, final_observe)
|
|
1008
|
+
output["ok"] = _real_smoke_ok(output, final_observe)
|
|
1009
|
+
return output
|
|
1010
|
+
finally:
|
|
1011
|
+
session = manager.store.get_session(session_id)
|
|
1012
|
+
if session and session.tmux and manager.runtime.exists(session.tmux):
|
|
1013
|
+
terminated = manager.terminate_worker(session_id, reason="real read-only smoke cleanup")
|
|
1014
|
+
output["lifecycle"]["terminate"] = terminated["ok"]
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _check_tmux_roundtrip(prefix: str) -> dict[str, Any]:
|
|
1018
|
+
runtime = TmuxRuntime()
|
|
1019
|
+
if not runtime.tmux_binary:
|
|
1020
|
+
return {"name": "tmux_roundtrip", "ok": False, "reason": "tmux not found"}
|
|
1021
|
+
session_name = f"{prefix}-doctor-{int(time.time() * 1000) % 100000}"
|
|
1022
|
+
with tempfile.TemporaryDirectory(prefix="agentpool-doctor-") as tmp:
|
|
1023
|
+
try:
|
|
1024
|
+
ref = runtime.spawn(
|
|
1025
|
+
[
|
|
1026
|
+
sys.executable,
|
|
1027
|
+
"-c",
|
|
1028
|
+
"import time; print('agentpool doctor ok', flush=True); time.sleep(2)",
|
|
1029
|
+
],
|
|
1030
|
+
Path(tmp),
|
|
1031
|
+
{},
|
|
1032
|
+
session_name,
|
|
1033
|
+
)
|
|
1034
|
+
except Exception as exc:
|
|
1035
|
+
return {"name": "tmux_roundtrip", "ok": False, "reason": str(exc)}
|
|
1036
|
+
try:
|
|
1037
|
+
deadline = time.monotonic() + 3
|
|
1038
|
+
captured = ""
|
|
1039
|
+
while time.monotonic() < deadline:
|
|
1040
|
+
try:
|
|
1041
|
+
captured = runtime.capture(ref, 20)
|
|
1042
|
+
except Exception as exc:
|
|
1043
|
+
return {"name": "tmux_roundtrip", "ok": False, "reason": str(exc)}
|
|
1044
|
+
if "agentpool doctor ok" in captured:
|
|
1045
|
+
break
|
|
1046
|
+
time.sleep(0.2)
|
|
1047
|
+
return {"name": "tmux_roundtrip", "ok": "agentpool doctor ok" in captured}
|
|
1048
|
+
finally:
|
|
1049
|
+
if runtime.exists(ref):
|
|
1050
|
+
runtime.terminate(ref)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _check_sqlite(store: Store) -> dict[str, Any]:
|
|
1054
|
+
try:
|
|
1055
|
+
with store.connect() as conn:
|
|
1056
|
+
conn.execute("SELECT 1").fetchone()
|
|
1057
|
+
return {"name": "sqlite", "ok": True, "path": str(store.db_path)}
|
|
1058
|
+
except Exception as exc:
|
|
1059
|
+
return {"name": "sqlite", "ok": False, "path": str(store.db_path), "reason": str(exc)}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _check_artifact_root(path: Path) -> dict[str, Any]:
|
|
1063
|
+
try:
|
|
1064
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1065
|
+
probe = path / ".agentpool-write-test"
|
|
1066
|
+
probe.write_text("ok", encoding="utf-8")
|
|
1067
|
+
probe.unlink(missing_ok=True)
|
|
1068
|
+
return {"name": "artifact_root", "ok": True, "path": str(path)}
|
|
1069
|
+
except Exception as exc:
|
|
1070
|
+
return {"name": "artifact_root", "ok": False, "path": str(path), "reason": str(exc)}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _check_usage_cache(manager: SessionManager) -> dict[str, Any]:
|
|
1074
|
+
try:
|
|
1075
|
+
manager.cached_usage_snapshot()
|
|
1076
|
+
return {"name": "usage_cache", "ok": True}
|
|
1077
|
+
except Exception as exc:
|
|
1078
|
+
return {"name": "usage_cache", "ok": False, "reason": str(exc)}
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _check_codexbar() -> dict[str, Any]:
|
|
1082
|
+
info = detect_codexbar()
|
|
1083
|
+
return {"name": "codexbar", "ok": True, **info}
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def command_path(absolute: bool = False) -> str:
|
|
1087
|
+
if absolute:
|
|
1088
|
+
return str(Path(sys.argv[0]).resolve())
|
|
1089
|
+
found = shutil.which("agentpool")
|
|
1090
|
+
return found or "agentpool"
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _provider_descriptor(manager: SessionManager, provider_id: str) -> dict[str, Any] | None:
|
|
1094
|
+
for provider in manager.inventory(include_usage=False)["providers"]:
|
|
1095
|
+
if provider["id"] == provider_id:
|
|
1096
|
+
return provider
|
|
1097
|
+
return None
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
def _usage_check(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
1101
|
+
status = snapshot.get("status", "unknown")
|
|
1102
|
+
warnings = snapshot.get("warnings") or []
|
|
1103
|
+
windows = snapshot.get("windows") or []
|
|
1104
|
+
ok = status not in {"unavailable", "unauthenticated", "unknown"}
|
|
1105
|
+
if status in {"near_limit", "limit_reached", "overage_possible"}:
|
|
1106
|
+
ok = False
|
|
1107
|
+
details = []
|
|
1108
|
+
for window in windows:
|
|
1109
|
+
name = window.get("name") or window.get("kind") or "window"
|
|
1110
|
+
remaining = window.get("remaining_percent")
|
|
1111
|
+
reset_at = window.get("reset_at")
|
|
1112
|
+
if remaining is not None:
|
|
1113
|
+
details.append(f"{name}: {remaining}% remaining")
|
|
1114
|
+
elif reset_at:
|
|
1115
|
+
details.append(f"{name}: resets {reset_at}")
|
|
1116
|
+
message = ", ".join(details) if details else f"status={status}"
|
|
1117
|
+
if warnings:
|
|
1118
|
+
message = f"{message}; warnings: {'; '.join(str(warning) for warning in warnings)}"
|
|
1119
|
+
return {
|
|
1120
|
+
"name": "usage_probe",
|
|
1121
|
+
"ok": ok,
|
|
1122
|
+
"message": message,
|
|
1123
|
+
"status": status,
|
|
1124
|
+
"confidence": snapshot.get("confidence"),
|
|
1125
|
+
"checked_at": snapshot.get("checked_at"),
|
|
1126
|
+
"action": None
|
|
1127
|
+
if ok
|
|
1128
|
+
else "Refresh usage after confirming the provider CLI is logged in, or rerun setup with `--skip-usage`.",
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def _setup_actions(checks: list[dict[str, Any]]) -> list[str]:
|
|
1133
|
+
actions: list[str] = []
|
|
1134
|
+
for check in checks:
|
|
1135
|
+
if check.get("ok") is False and check.get("action"):
|
|
1136
|
+
actions.append(str(check["action"]))
|
|
1137
|
+
return actions
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _first_setup_action(result: dict[str, Any]) -> str | None:
|
|
1141
|
+
actions = result.get("actions") or _setup_actions(result.get("checks", []))
|
|
1142
|
+
if actions:
|
|
1143
|
+
return str(actions[0])
|
|
1144
|
+
setup_doc = result.get("setup_doc")
|
|
1145
|
+
if setup_doc:
|
|
1146
|
+
return f"See {setup_doc} for host-specific setup notes."
|
|
1147
|
+
return None
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _setup_host_next_commands(target: str) -> list[str]:
|
|
1151
|
+
return [
|
|
1152
|
+
"agentpool mcp-config --client cursor --absolute-command --install",
|
|
1153
|
+
"agentpool doctor --deep",
|
|
1154
|
+
"agentpool setup codex",
|
|
1155
|
+
]
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _setup_next_commands(provider_id: str, target: str, setup: dict[str, Any] | None = None) -> list[str]:
|
|
1159
|
+
if setup and setup.get("kind") == "host":
|
|
1160
|
+
return _setup_host_next_commands(target)
|
|
1161
|
+
commands = [
|
|
1162
|
+
f"agentpool usage-summary --provider {provider_id} --refresh --json",
|
|
1163
|
+
f"agentpool models --provider {provider_id}",
|
|
1164
|
+
]
|
|
1165
|
+
if target in {"codex", "codex-cli"}:
|
|
1166
|
+
commands.append("agentpool mcp-config --client codex --absolute-command --install")
|
|
1167
|
+
elif target in {"claude", "claude-code"}:
|
|
1168
|
+
commands.append("agentpool mcp-config --client claude-code --absolute-command --install")
|
|
1169
|
+
commands.append(
|
|
1170
|
+
f"agentpool spawn --provider {provider_id} --repo . --task \"Inspect this repo read-only.\" --isolation read_only"
|
|
1171
|
+
)
|
|
1172
|
+
return commands
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def dumps_json(data: Any) -> str:
|
|
1176
|
+
return json.dumps(data, indent=2, sort_keys=True, default=str)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _real_read_only_smoke_task() -> str:
|
|
1180
|
+
return """AgentPool real-provider read-only smoke test.
|
|
1181
|
+
|
|
1182
|
+
Rules:
|
|
1183
|
+
- Do not create, modify, delete, move, or format files.
|
|
1184
|
+
- Do not install packages.
|
|
1185
|
+
- Do not access network services.
|
|
1186
|
+
- Do not run expensive commands.
|
|
1187
|
+
- Inspect the current directory only if needed.
|
|
1188
|
+
|
|
1189
|
+
Wait for a single steering message from AgentPool before doing anything.
|
|
1190
|
+
"""
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _real_smoke_continue_message() -> str:
|
|
1194
|
+
return "Read-only smoke: do not edit files or run installs. Print the words AGENTPOOL, SMOKE, and DONE joined by underscores, then stop."
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _startup_trust_message(provider_id: str) -> str:
|
|
1198
|
+
if provider_id == "cursor-cli":
|
|
1199
|
+
return "a"
|
|
1200
|
+
return ""
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _smoke_model(manager: SessionManager, provider_id: str) -> str | None:
|
|
1204
|
+
config = manager.config.providers.get(provider_id)
|
|
1205
|
+
if not config:
|
|
1206
|
+
return None
|
|
1207
|
+
value = config.metadata.get("smoke_model") or config.metadata.get("default_model")
|
|
1208
|
+
return str(value) if value else None
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _is_startup_trust_prompt(text: str) -> bool:
|
|
1212
|
+
lowered = text.lower()
|
|
1213
|
+
return "do you trust" in lowered or "trust the files" in lowered or "trust this directory" in lowered
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _is_startup_update_prompt(text: str) -> bool:
|
|
1217
|
+
lowered = text.lower()
|
|
1218
|
+
return "update available" in lowered and ("skip" in lowered or "press enter to continue" in lowered)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _is_startup_hook_prompt(text: str) -> bool:
|
|
1222
|
+
lowered = text.lower()
|
|
1223
|
+
return (
|
|
1224
|
+
"hooks need review" in lowered
|
|
1225
|
+
and "continue without trusting" in lowered
|
|
1226
|
+
and "trust all and continue" in lowered
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _real_smoke_ok(output: dict[str, Any], final_observe: Any) -> bool:
|
|
1231
|
+
if output.get("blocked_reason") or output.get("git_dirty"):
|
|
1232
|
+
return False
|
|
1233
|
+
if final_observe and final_observe.event == ObserveEvent.ERROR:
|
|
1234
|
+
return False
|
|
1235
|
+
return bool(
|
|
1236
|
+
final_observe
|
|
1237
|
+
and final_observe.event == ObserveEvent.COMPLETED
|
|
1238
|
+
and output["lifecycle"].get("spawn")
|
|
1239
|
+
and output["lifecycle"].get("send")
|
|
1240
|
+
and output["lifecycle"].get("collect")
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _safe_observe(
|
|
1245
|
+
manager: SessionManager,
|
|
1246
|
+
session_id: str,
|
|
1247
|
+
wait_for: list[str],
|
|
1248
|
+
timeout_seconds: int,
|
|
1249
|
+
output: dict[str, Any],
|
|
1250
|
+
) -> Any | None:
|
|
1251
|
+
try:
|
|
1252
|
+
return manager.observe_worker(session_id, wait_for=wait_for, timeout_seconds=timeout_seconds)
|
|
1253
|
+
except ToolError as exc:
|
|
1254
|
+
output["observe_error"] = exc.error.model_dump(mode="json")
|
|
1255
|
+
return None
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _send_smoke_message(manager: SessionManager, session_id: str, provider_id: str) -> bool:
|
|
1259
|
+
return manager.send_worker_message(session_id, _real_smoke_continue_message())["ok"]
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _collect_smoke_artifacts(
|
|
1263
|
+
manager: SessionManager,
|
|
1264
|
+
session_id: str,
|
|
1265
|
+
output: dict[str, Any],
|
|
1266
|
+
final_observe: Any,
|
|
1267
|
+
) -> None:
|
|
1268
|
+
try:
|
|
1269
|
+
collected = manager.collect_worker_artifacts(
|
|
1270
|
+
session_id,
|
|
1271
|
+
mark_completed=bool(final_observe and final_observe.state == SessionState.COMPLETED),
|
|
1272
|
+
)
|
|
1273
|
+
except ToolError as exc:
|
|
1274
|
+
output["collect_error"] = exc.error.model_dump(mode="json")
|
|
1275
|
+
return
|
|
1276
|
+
output["lifecycle"]["collect"] = True
|
|
1277
|
+
output["artifact_dir"] = collected["artifact_dir"]
|
|
1278
|
+
output["artifact_kinds"] = [artifact["kind"] for artifact in collected["artifacts"]]
|
|
1279
|
+
output["git_dirty"] = collected["git"]["dirty"]
|