subagent-cli 0.1.1__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.
subagent/daemon.py ADDED
@@ -0,0 +1,133 @@
1
+ """Minimal local daemon process for subagent runtime bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import typer
13
+
14
+ from .constants import DAEMON_STATUS_PATH
15
+ from .output import emit_json
16
+ from .paths import resolve_state_dir
17
+ from .state import StateStore
18
+
19
+ app = typer.Typer(help="subagentd: local control-plane daemon")
20
+
21
+
22
+ def _utc_now() -> str:
23
+ return datetime.now(tz=UTC).replace(microsecond=0).isoformat()
24
+
25
+
26
+ def _status_path_for_state_dir(state_dir: Path) -> Path:
27
+ default_parent = DAEMON_STATUS_PATH.parent
28
+ if state_dir == default_parent:
29
+ return DAEMON_STATUS_PATH
30
+ return state_dir / "subagentd-status.json"
31
+
32
+
33
+ def _write_status(path: Path, payload: dict[str, Any]) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
36
+
37
+
38
+ @app.command("run")
39
+ def run_daemon(
40
+ once: bool = typer.Option(
41
+ False,
42
+ "--once",
43
+ help="Initialize state and exit immediately (bootstrap mode).",
44
+ ),
45
+ heartbeat_seconds: int = typer.Option(
46
+ 5,
47
+ "--heartbeat-seconds",
48
+ min=1,
49
+ help="Heartbeat update interval when running foreground.",
50
+ ),
51
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON response."),
52
+ ) -> None:
53
+ state_dir = resolve_state_dir()
54
+ store = StateStore(state_dir / "state.db")
55
+ store.bootstrap()
56
+ status_path = _status_path_for_state_dir(state_dir)
57
+ base_payload = {
58
+ "schemaVersion": "v1",
59
+ "pid": os.getpid(),
60
+ "stateDir": str(state_dir),
61
+ "dbPath": str(store.db_path),
62
+ "startedAt": _utc_now(),
63
+ "lastHeartbeatAt": _utc_now(),
64
+ "mode": "once" if once else "foreground",
65
+ }
66
+ _write_status(status_path, base_payload)
67
+
68
+ if once:
69
+ if json_output:
70
+ emit_json(base_payload)
71
+ else:
72
+ typer.echo(f"subagentd initialized: {store.db_path}")
73
+ return
74
+
75
+ if json_output:
76
+ emit_json(base_payload)
77
+ else:
78
+ typer.echo(f"subagentd running (pid={base_payload['pid']})")
79
+ try:
80
+ while True:
81
+ time.sleep(heartbeat_seconds)
82
+ base_payload["lastHeartbeatAt"] = _utc_now()
83
+ _write_status(status_path, base_payload)
84
+ except KeyboardInterrupt:
85
+ base_payload["stoppedAt"] = _utc_now()
86
+ _write_status(status_path, base_payload)
87
+ raise typer.Exit(code=0)
88
+
89
+
90
+ @app.command("status")
91
+ def daemon_status(
92
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON response."),
93
+ ) -> None:
94
+ state_dir = resolve_state_dir()
95
+ status_path = _status_path_for_state_dir(state_dir)
96
+ if not status_path.exists():
97
+ payload = {
98
+ "schemaVersion": "v1",
99
+ "running": False,
100
+ "stateDir": str(state_dir),
101
+ "statusFile": str(status_path),
102
+ }
103
+ if json_output:
104
+ emit_json(payload)
105
+ else:
106
+ typer.echo("subagentd not initialized")
107
+ raise typer.Exit(code=1)
108
+ payload = json.loads(status_path.read_text(encoding="utf-8"))
109
+ pid = payload.get("pid")
110
+ running = False
111
+ if isinstance(pid, int):
112
+ try:
113
+ os.kill(pid, 0)
114
+ except OSError:
115
+ running = False
116
+ else:
117
+ running = True
118
+ payload["running"] = running
119
+ if json_output:
120
+ emit_json(payload)
121
+ else:
122
+ typer.echo(
123
+ f"subagentd running={running} pid={payload.get('pid')} "
124
+ f"startedAt={payload.get('startedAt')}"
125
+ )
126
+
127
+
128
+ def main() -> None:
129
+ app()
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()
subagent/errors.py ADDED
@@ -0,0 +1,24 @@
1
+ """Typed errors with stable error codes for CLI contracts."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class SubagentError(Exception):
9
+ """Error mapped to the versioned CLI error envelope."""
10
+
11
+ code: str
12
+ message: str
13
+ retryable: bool = False
14
+ details: dict[str, Any] = field(default_factory=dict)
15
+
16
+ def to_dict(self) -> dict[str, Any]:
17
+ payload = {
18
+ "code": self.code,
19
+ "message": self.message,
20
+ "retryable": self.retryable,
21
+ }
22
+ if self.details:
23
+ payload["details"] = self.details
24
+ return payload
@@ -0,0 +1,354 @@
1
+ """Handoff artifact generation and continue flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .config import SubagentConfig
11
+ from .errors import SubagentError
12
+ from .paths import resolve_handoffs_dir, resolve_workspace_path
13
+ from .state import StateStore
14
+ from .turn_service import send_message
15
+ from .worker_service import start_worker
16
+
17
+
18
+ def _safe_text(value: Any, default: str) -> str:
19
+ if isinstance(value, str) and value.strip():
20
+ return value.strip()
21
+ return default
22
+
23
+
24
+ def _pick_task_from_events(events: list[dict[str, Any]]) -> str:
25
+ for event in events:
26
+ if str(event.get("event_type")) != "message.sent":
27
+ continue
28
+ data = event.get("data")
29
+ if not isinstance(data, dict):
30
+ continue
31
+ text = data.get("text")
32
+ if isinstance(text, str) and text.strip():
33
+ return text.strip()
34
+ return "Continue the previous worker task safely."
35
+
36
+
37
+ def _pick_turn_id(events: list[dict[str, Any]]) -> str | None:
38
+ for event in reversed(events):
39
+ turn_id = event.get("turn_id")
40
+ if isinstance(turn_id, str) and turn_id:
41
+ return turn_id
42
+ return None
43
+
44
+
45
+ def _build_handoff_markdown(
46
+ *,
47
+ worker: dict[str, Any],
48
+ task: str,
49
+ completed_lines: list[str],
50
+ pending_lines: list[str],
51
+ risk_lines: list[str],
52
+ handoff_path: Path,
53
+ checkpoint_path: Path,
54
+ ) -> str:
55
+ files_of_interest = "- (not captured in v1 fallback handoff)"
56
+ commands_run = "- (not captured in v1 fallback handoff)"
57
+ completed = "\n".join(completed_lines or ["- No completed steps captured yet."])
58
+ pending = "\n".join(pending_lines or ["- No explicit pending items captured."])
59
+ risks = "\n".join(risk_lines or ["- No explicit risks captured."])
60
+ recommended = (
61
+ f"- Run `subagent worker continue --from-worker {worker['worker_id']}` to continue with a new worker."
62
+ )
63
+ artifacts = "\n".join(
64
+ [
65
+ f"- {handoff_path}",
66
+ f"- {checkpoint_path}",
67
+ ]
68
+ )
69
+ lines = [
70
+ "# Handoff",
71
+ "",
72
+ "## Task",
73
+ task,
74
+ "",
75
+ "## Goal",
76
+ "Continue from the latest known context and complete the pending work safely.",
77
+ "",
78
+ "## Current Status",
79
+ f"Worker `{worker['worker_id']}` is currently `{worker['state']}`.",
80
+ "",
81
+ "## Completed",
82
+ completed,
83
+ "",
84
+ "## Pending",
85
+ pending,
86
+ "",
87
+ "## Files of Interest",
88
+ files_of_interest,
89
+ "",
90
+ "## Commands Run",
91
+ commands_run,
92
+ "",
93
+ "## Risks / Notes",
94
+ risks,
95
+ "",
96
+ "## Recommended Next Step",
97
+ recommended,
98
+ "",
99
+ "## Artifacts",
100
+ artifacts,
101
+ "",
102
+ ]
103
+ return "\n".join(lines)
104
+
105
+
106
+ def create_handoff(
107
+ store: StateStore,
108
+ *,
109
+ worker_id: str,
110
+ handoffs_dir: Path | None = None,
111
+ ) -> dict[str, Any]:
112
+ worker = store.get_worker(worker_id)
113
+ if worker is None:
114
+ raise SubagentError(
115
+ code="WORKER_NOT_FOUND",
116
+ message=f"Worker not found: {worker_id}",
117
+ details={"workerId": worker_id},
118
+ )
119
+ events = store.list_worker_events(worker_id)
120
+ task = _pick_task_from_events(events)
121
+ source_turn_id = _pick_turn_id(events)
122
+
123
+ completed_lines: list[str] = []
124
+ pending_lines: list[str] = []
125
+ for event in events:
126
+ event_type = str(event.get("event_type"))
127
+ turn_id = event.get("turn_id")
128
+ if event_type == "turn.completed":
129
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
130
+ outcome = _safe_text(data.get("outcome"), "completed")
131
+ completed_lines.append(f"- Turn `{turn_id}` completed with outcome `{outcome}`.")
132
+ if event_type == "approval.requested":
133
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
134
+ request_id = _safe_text(data.get("requestId"), "unknown")
135
+ pending_lines.append(f"- Approval decision pending for request `{request_id}`.")
136
+
137
+ pending_requests = store.list_pending_approval_requests(worker_id)
138
+ risk_lines = [
139
+ f"- Pending approval request `{req['request_id']}` may block continuation."
140
+ for req in pending_requests
141
+ ]
142
+
143
+ root = resolve_handoffs_dir(handoffs_dir)
144
+ snapshot_id = f"hs_{uuid.uuid4().hex[:10]}"
145
+ snapshot_dir = root / worker_id / snapshot_id
146
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
147
+ handoff_path = snapshot_dir / "handoff.md"
148
+ checkpoint_path = snapshot_dir / "checkpoint.json"
149
+
150
+ markdown = _build_handoff_markdown(
151
+ worker=worker,
152
+ task=task,
153
+ completed_lines=completed_lines,
154
+ pending_lines=pending_lines,
155
+ risk_lines=risk_lines,
156
+ handoff_path=handoff_path,
157
+ checkpoint_path=checkpoint_path,
158
+ )
159
+ handoff_path.write_text(markdown, encoding="utf-8")
160
+
161
+ checkpoint = {
162
+ "schemaVersion": "v1",
163
+ "workerId": worker["worker_id"],
164
+ "controllerId": worker["controller_id"],
165
+ "launcher": worker["launcher"],
166
+ "profile": worker["profile"],
167
+ "packs": worker["packs"],
168
+ "cwd": worker["cwd"],
169
+ "state": "handoff_ready",
170
+ "sourceTurnId": source_turn_id,
171
+ "handoffPath": str(handoff_path),
172
+ "artifacts": [str(handoff_path)],
173
+ "filesChanged": [],
174
+ }
175
+ checkpoint_path.write_text(
176
+ json.dumps(checkpoint, ensure_ascii=False, indent=2),
177
+ encoding="utf-8",
178
+ )
179
+
180
+ snapshot = store.register_handoff_snapshot(
181
+ worker_id=worker_id,
182
+ source_turn_id=source_turn_id,
183
+ handoff_path=str(handoff_path),
184
+ checkpoint_path=str(checkpoint_path),
185
+ )
186
+ return {
187
+ "snapshotId": snapshot["snapshot_id"],
188
+ "workerId": worker_id,
189
+ "handoffPath": str(handoff_path),
190
+ "checkpointPath": str(checkpoint_path),
191
+ "sourceTurnId": source_turn_id,
192
+ }
193
+
194
+
195
+ def _load_checkpoint_if_exists(path: Path) -> dict[str, Any]:
196
+ if not path.exists():
197
+ return {}
198
+ try:
199
+ payload = json.loads(path.read_text(encoding="utf-8"))
200
+ except json.JSONDecodeError:
201
+ return {}
202
+ return payload if isinstance(payload, dict) else {}
203
+
204
+
205
+ def resolve_handoff_input(
206
+ store: StateStore,
207
+ *,
208
+ from_worker: str | None,
209
+ from_handoff: Path | None,
210
+ handoffs_dir: Path | None = None,
211
+ ) -> dict[str, Any]:
212
+ if bool(from_worker) == bool(from_handoff):
213
+ raise SubagentError(
214
+ code="INVALID_ARGUMENT",
215
+ message="Specify exactly one of --from-worker or --from-handoff.",
216
+ )
217
+
218
+ if from_worker:
219
+ latest = store.get_latest_handoff_snapshot(from_worker)
220
+ if latest is None:
221
+ created = create_handoff(store, worker_id=from_worker, handoffs_dir=handoffs_dir)
222
+ handoff_path = Path(created["handoffPath"]).expanduser().resolve()
223
+ checkpoint_path = Path(created["checkpointPath"]).expanduser().resolve()
224
+ checkpoint = _load_checkpoint_if_exists(checkpoint_path)
225
+ return {
226
+ "sourceWorkerId": from_worker,
227
+ "handoffPath": handoff_path,
228
+ "checkpointPath": checkpoint_path,
229
+ "checkpoint": checkpoint,
230
+ }
231
+ handoff_path = Path(str(latest["handoff_path"])).expanduser().resolve()
232
+ checkpoint_path = Path(str(latest["checkpoint_path"])).expanduser().resolve()
233
+ checkpoint = _load_checkpoint_if_exists(checkpoint_path)
234
+ return {
235
+ "sourceWorkerId": from_worker,
236
+ "handoffPath": handoff_path,
237
+ "checkpointPath": checkpoint_path,
238
+ "checkpoint": checkpoint,
239
+ }
240
+
241
+ assert from_handoff is not None
242
+ handoff_path = from_handoff.expanduser().resolve()
243
+ if not handoff_path.exists():
244
+ raise SubagentError(
245
+ code="HANDOFF_NOT_FOUND",
246
+ message=f"Handoff file not found: {handoff_path}",
247
+ details={"handoffPath": str(handoff_path)},
248
+ )
249
+ checkpoint_path = handoff_path.parent / "checkpoint.json"
250
+ checkpoint = _load_checkpoint_if_exists(checkpoint_path)
251
+ source_worker_id = checkpoint.get("workerId")
252
+ source_worker = str(source_worker_id) if isinstance(source_worker_id, str) else None
253
+ return {
254
+ "sourceWorkerId": source_worker,
255
+ "handoffPath": handoff_path,
256
+ "checkpointPath": checkpoint_path if checkpoint_path.exists() else None,
257
+ "checkpoint": checkpoint,
258
+ }
259
+
260
+
261
+ def continue_worker(
262
+ store: StateStore,
263
+ config: SubagentConfig,
264
+ *,
265
+ from_worker: str | None,
266
+ from_handoff: Path | None,
267
+ launcher: str | None,
268
+ profile: str | None,
269
+ packs: list[str],
270
+ cwd: Path | None,
271
+ label: str | None,
272
+ controller_id: str | None,
273
+ handoffs_dir: Path | None = None,
274
+ debug_mode: bool = False,
275
+ execution_mode: str = "strict",
276
+ ) -> dict[str, Any]:
277
+ source = resolve_handoff_input(
278
+ store,
279
+ from_worker=from_worker,
280
+ from_handoff=from_handoff,
281
+ handoffs_dir=handoffs_dir,
282
+ )
283
+ checkpoint = source.get("checkpoint", {})
284
+ if not isinstance(checkpoint, dict):
285
+ checkpoint = {}
286
+
287
+ source_handoff_path = Path(source["handoffPath"])
288
+ source_worker_id = source.get("sourceWorkerId")
289
+ checkpoint_launcher = checkpoint.get("launcher")
290
+ checkpoint_profile = checkpoint.get("profile")
291
+ checkpoint_packs = checkpoint.get("packs")
292
+ checkpoint_cwd = checkpoint.get("cwd")
293
+ checkpoint_controller = checkpoint.get("controllerId")
294
+
295
+ target_launcher = launcher or (str(checkpoint_launcher) if isinstance(checkpoint_launcher, str) else None)
296
+ target_profile = profile or (str(checkpoint_profile) if isinstance(checkpoint_profile, str) else None)
297
+ target_packs: list[str]
298
+ if packs:
299
+ target_packs = packs
300
+ elif isinstance(checkpoint_packs, list):
301
+ target_packs = [str(item) for item in checkpoint_packs]
302
+ else:
303
+ target_packs = []
304
+
305
+ target_cwd = resolve_workspace_path(cwd if cwd is not None else Path(str(checkpoint_cwd)) if isinstance(checkpoint_cwd, str) else Path.cwd())
306
+ target_controller = controller_id or (
307
+ str(checkpoint_controller) if isinstance(checkpoint_controller, str) else None
308
+ )
309
+ target_label = label or (
310
+ f"continued-from-{source_worker_id}" if isinstance(source_worker_id, str) and source_worker_id else "continued-worker"
311
+ )
312
+
313
+ started = start_worker(
314
+ store,
315
+ config,
316
+ workspace=target_cwd,
317
+ worker_cwd=target_cwd,
318
+ controller_id=target_controller,
319
+ launcher=target_launcher,
320
+ profile=target_profile,
321
+ packs=target_packs,
322
+ label=target_label,
323
+ debug_mode=debug_mode,
324
+ )
325
+
326
+ prompt_text = (
327
+ "Read the handoff document and continue from the previous worker context. "
328
+ "Validate with the repository state and proceed with the next safe step."
329
+ )
330
+ blocks = [
331
+ {
332
+ "type": "resource_link",
333
+ "resource": {
334
+ "uri": source_handoff_path.as_uri(),
335
+ "mimeType": "text/markdown",
336
+ },
337
+ }
338
+ ]
339
+ turn = send_message(
340
+ store,
341
+ worker_id=str(started["workerId"]),
342
+ text=prompt_text,
343
+ blocks=blocks,
344
+ request_approval=False,
345
+ config=config,
346
+ execution_mode=execution_mode,
347
+ )
348
+ return {
349
+ "sourceWorkerId": source_worker_id,
350
+ "sourceHandoffPath": str(source_handoff_path),
351
+ "checkpointPath": str(source["checkpointPath"]) if source.get("checkpointPath") else None,
352
+ "worker": started,
353
+ "bootstrapTurn": turn,
354
+ }
subagent/hints.py ADDED
@@ -0,0 +1,36 @@
1
+ """Project-local controller hint read/write helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .constants import SCHEMA_VERSION
10
+ from .paths import project_hint_path
11
+
12
+
13
+ def read_project_hint(workspace: Path) -> dict[str, Any] | None:
14
+ hint_path = project_hint_path(workspace)
15
+ if not hint_path.exists():
16
+ return None
17
+ try:
18
+ payload = json.loads(hint_path.read_text(encoding="utf-8"))
19
+ except json.JSONDecodeError:
20
+ return None
21
+ if not isinstance(payload, dict):
22
+ return None
23
+ return payload
24
+
25
+
26
+ def write_project_hint(workspace: Path, *, controller_id: str, label: str) -> Path:
27
+ hint_path = project_hint_path(workspace)
28
+ hint_path.parent.mkdir(parents=True, exist_ok=True)
29
+ payload = {
30
+ "schemaVersion": SCHEMA_VERSION,
31
+ "controllerId": controller_id,
32
+ "label": label,
33
+ "workspaceKey": str(workspace),
34
+ }
35
+ hint_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
36
+ return hint_path
@@ -0,0 +1,121 @@
1
+ """Helpers for `--input` JSON contract and duplicate field protection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .errors import SubagentError
10
+
11
+
12
+ def load_input_payload(input_path: str | None) -> dict[str, Any] | None:
13
+ if input_path is None:
14
+ return None
15
+ if input_path == "-":
16
+ import sys
17
+
18
+ raw = sys.stdin.read()
19
+ else:
20
+ raw = Path(input_path).read_text(encoding="utf-8")
21
+ try:
22
+ payload = json.loads(raw)
23
+ except json.JSONDecodeError as error:
24
+ raise SubagentError(
25
+ code="INVALID_INPUT",
26
+ message="--input must contain valid JSON",
27
+ details={"error": str(error)},
28
+ ) from error
29
+ if not isinstance(payload, dict):
30
+ raise SubagentError(
31
+ code="INVALID_INPUT",
32
+ message="--input JSON must be an object",
33
+ )
34
+ return payload
35
+
36
+
37
+ def reject_duplicates(
38
+ payload: dict[str, Any] | None,
39
+ *,
40
+ flag_values: dict[str, Any],
41
+ value_is_default: dict[str, bool],
42
+ mapping: dict[str, str],
43
+ ) -> None:
44
+ if payload is None:
45
+ return
46
+ for json_field, flag_name in mapping.items():
47
+ if json_field not in payload:
48
+ continue
49
+ if flag_name not in flag_values:
50
+ continue
51
+ if value_is_default.get(flag_name, True):
52
+ continue
53
+ raise SubagentError(
54
+ code="INVALID_INPUT",
55
+ message=f"Field `{json_field}` is provided by both --input and flags.",
56
+ details={"field": json_field, "flag": flag_name},
57
+ )
58
+
59
+
60
+ def read_string(payload: dict[str, Any], key: str) -> str | None:
61
+ value = payload.get(key)
62
+ if value is None:
63
+ return None
64
+ if not isinstance(value, str):
65
+ raise SubagentError(
66
+ code="INVALID_INPUT",
67
+ message=f"`{key}` must be a string",
68
+ )
69
+ return value
70
+
71
+
72
+ def read_bool(payload: dict[str, Any], key: str) -> bool | None:
73
+ if key not in payload:
74
+ return None
75
+ value = payload[key]
76
+ if not isinstance(value, bool):
77
+ raise SubagentError(
78
+ code="INVALID_INPUT",
79
+ message=f"`{key}` must be a boolean",
80
+ )
81
+ return value
82
+
83
+
84
+ def read_string_list(payload: dict[str, Any], key: str) -> list[str] | None:
85
+ if key not in payload:
86
+ return None
87
+ value = payload[key]
88
+ if not isinstance(value, list):
89
+ raise SubagentError(
90
+ code="INVALID_INPUT",
91
+ message=f"`{key}` must be a list of strings",
92
+ )
93
+ out: list[str] = []
94
+ for idx, item in enumerate(value):
95
+ if not isinstance(item, str):
96
+ raise SubagentError(
97
+ code="INVALID_INPUT",
98
+ message=f"`{key}[{idx}]` must be a string",
99
+ )
100
+ out.append(item)
101
+ return out
102
+
103
+
104
+ def read_blocks(payload: dict[str, Any], key: str = "blocks") -> list[dict[str, Any]] | None:
105
+ if key not in payload:
106
+ return None
107
+ value = payload[key]
108
+ if not isinstance(value, list):
109
+ raise SubagentError(
110
+ code="INVALID_INPUT",
111
+ message=f"`{key}` must be a list",
112
+ )
113
+ blocks: list[dict[str, Any]] = []
114
+ for idx, item in enumerate(value):
115
+ if not isinstance(item, dict):
116
+ raise SubagentError(
117
+ code="INVALID_INPUT",
118
+ message=f"`{key}[{idx}]` must be an object",
119
+ )
120
+ blocks.append(item)
121
+ return blocks
@@ -0,0 +1,30 @@
1
+ """Launcher helper operations (e.g., probe)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from typing import Any
7
+
8
+ from .config import SubagentConfig
9
+ from .errors import SubagentError
10
+
11
+
12
+ def probe_launcher(config: SubagentConfig, launcher_name: str) -> dict[str, Any]:
13
+ launcher = config.launchers.get(launcher_name)
14
+ if launcher is None:
15
+ raise SubagentError(
16
+ code="LAUNCHER_NOT_FOUND",
17
+ message=f"Launcher not found: {launcher_name}",
18
+ details={"launcher": launcher_name},
19
+ )
20
+ command_path = shutil.which(launcher.command)
21
+ available = command_path is not None
22
+ return {
23
+ "name": launcher.name,
24
+ "backendKind": launcher.backend_kind,
25
+ "command": launcher.command,
26
+ "resolvedCommandPath": command_path,
27
+ "available": available,
28
+ "args": launcher.args,
29
+ "envKeys": sorted(launcher.env.keys()),
30
+ }