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/config.py ADDED
@@ -0,0 +1,305 @@
1
+ """Config model and loader for launchers / profiles / packs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .errors import SubagentError
11
+ from .paths import resolve_config_path
12
+ from .simple_yaml import ParseError as SimpleYamlParseError
13
+ from .simple_yaml import parse_yaml_subset
14
+
15
+
16
+ def _ensure_mapping(value: Any, *, field_name: str) -> Mapping[str, Any]:
17
+ if isinstance(value, Mapping):
18
+ return value
19
+ raise SubagentError(
20
+ code="CONFIG_PARSE_ERROR",
21
+ message=f"`{field_name}` must be a mapping",
22
+ details={"field": field_name},
23
+ )
24
+
25
+
26
+ def _ensure_string_list(value: Any, *, field_name: str) -> list[str]:
27
+ if value is None:
28
+ return []
29
+ if not isinstance(value, list):
30
+ raise SubagentError(
31
+ code="CONFIG_PARSE_ERROR",
32
+ message=f"`{field_name}` must be a list",
33
+ details={"field": field_name},
34
+ )
35
+ string_values: list[str] = []
36
+ for idx, item in enumerate(value):
37
+ if not isinstance(item, str):
38
+ raise SubagentError(
39
+ code="CONFIG_PARSE_ERROR",
40
+ message=f"`{field_name}[{idx}]` must be a string",
41
+ details={"field": field_name, "index": idx},
42
+ )
43
+ string_values.append(item)
44
+ return string_values
45
+
46
+
47
+ def _ensure_string_map(value: Any, *, field_name: str) -> dict[str, str]:
48
+ if value is None:
49
+ return {}
50
+ mapping = _ensure_mapping(value, field_name=field_name)
51
+ string_map: dict[str, str] = {}
52
+ for key, item in mapping.items():
53
+ if not isinstance(key, str):
54
+ raise SubagentError(
55
+ code="CONFIG_PARSE_ERROR",
56
+ message=f"`{field_name}` key must be string",
57
+ details={"field": field_name},
58
+ )
59
+ if not isinstance(item, str):
60
+ raise SubagentError(
61
+ code="CONFIG_PARSE_ERROR",
62
+ message=f"`{field_name}.{key}` must be string",
63
+ details={"field": field_name, "key": key},
64
+ )
65
+ string_map[key] = item
66
+ return string_map
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class Launcher:
71
+ name: str
72
+ backend_kind: str
73
+ command: str
74
+ args: list[str] = field(default_factory=list)
75
+ env: dict[str, str] = field(default_factory=dict)
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {
79
+ "name": self.name,
80
+ "backend": {"kind": self.backend_kind},
81
+ "command": self.command,
82
+ "args": self.args,
83
+ "env": self.env,
84
+ }
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class Profile:
89
+ name: str
90
+ prompt_language: str = "en"
91
+ response_language: str = "same_as_manager"
92
+ auto_handoff: str | None = None
93
+ policy_preset: str | None = None
94
+ default_packs: list[str] = field(default_factory=list)
95
+ bootstrap: str = ""
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ return {
99
+ "name": self.name,
100
+ "promptLanguage": self.prompt_language,
101
+ "responseLanguage": self.response_language,
102
+ "autoHandoff": self.auto_handoff,
103
+ "policyPreset": self.policy_preset,
104
+ "defaultPacks": self.default_packs,
105
+ "bootstrap": self.bootstrap,
106
+ }
107
+
108
+
109
+ @dataclass(slots=True)
110
+ class Pack:
111
+ name: str
112
+ description: str = ""
113
+ prompt: str = ""
114
+
115
+ def to_dict(self) -> dict[str, Any]:
116
+ return {
117
+ "name": self.name,
118
+ "description": self.description,
119
+ "prompt": self.prompt,
120
+ }
121
+
122
+
123
+ @dataclass(slots=True)
124
+ class SubagentConfig:
125
+ path: Path
126
+ loaded: bool
127
+ launchers: dict[str, Launcher] = field(default_factory=dict)
128
+ profiles: dict[str, Profile] = field(default_factory=dict)
129
+ packs: dict[str, Pack] = field(default_factory=dict)
130
+ policy_presets: dict[str, Any] = field(default_factory=dict)
131
+ defaults: dict[str, Any] = field(default_factory=dict)
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {
135
+ "path": str(self.path),
136
+ "loaded": self.loaded,
137
+ "launchers": {name: launcher.to_dict() for name, launcher in self.launchers.items()},
138
+ "profiles": {name: profile.to_dict() for name, profile in self.profiles.items()},
139
+ "packs": {name: pack.to_dict() for name, pack in self.packs.items()},
140
+ "policyPresets": self.policy_presets,
141
+ "defaults": self.defaults,
142
+ }
143
+
144
+
145
+ def _load_raw_config(config_path: Path) -> Mapping[str, Any]:
146
+ contents = config_path.read_text(encoding="utf-8")
147
+
148
+ # Use YAML if available; fallback to JSON for sandbox/offline bootstrap.
149
+ try:
150
+ import yaml # type: ignore[import-not-found]
151
+ except ModuleNotFoundError:
152
+ try:
153
+ parsed = json.loads(contents)
154
+ except json.JSONDecodeError as exc:
155
+ try:
156
+ parsed = parse_yaml_subset(contents)
157
+ except SimpleYamlParseError as yaml_exc:
158
+ raise SubagentError(
159
+ code="CONFIG_PARSE_ERROR",
160
+ message=(
161
+ "Failed to parse config as JSON or YAML subset. "
162
+ "Install PyYAML for full YAML support."
163
+ ),
164
+ details={
165
+ "path": str(config_path),
166
+ "jsonError": str(exc),
167
+ "yamlError": str(yaml_exc),
168
+ },
169
+ ) from yaml_exc
170
+ else:
171
+ try:
172
+ parsed = yaml.safe_load(contents)
173
+ except Exception as exc: # pragma: no cover - parser raises varied exceptions
174
+ raise SubagentError(
175
+ code="CONFIG_PARSE_ERROR",
176
+ message=f"Failed to parse config file: {config_path}",
177
+ details={"path": str(config_path), "error": str(exc)},
178
+ ) from exc
179
+
180
+ if parsed is None:
181
+ parsed = {}
182
+ return _ensure_mapping(parsed, field_name="root")
183
+
184
+
185
+ def _parse_launchers(raw: Any) -> dict[str, Launcher]:
186
+ mapping = _ensure_mapping(raw or {}, field_name="launchers")
187
+ launchers: dict[str, Launcher] = {}
188
+ for name, payload in mapping.items():
189
+ entry = _ensure_mapping(payload, field_name=f"launchers.{name}")
190
+ backend = _ensure_mapping(entry.get("backend", {}), field_name=f"launchers.{name}.backend")
191
+ command = entry.get("command")
192
+ if not isinstance(command, str) or not command.strip():
193
+ raise SubagentError(
194
+ code="CONFIG_PARSE_ERROR",
195
+ message=f"`launchers.{name}.command` must be a non-empty string",
196
+ details={"launcher": name},
197
+ )
198
+ launchers[name] = Launcher(
199
+ name=name,
200
+ backend_kind=str(backend.get("kind", "acp-stdio")),
201
+ command=command,
202
+ args=_ensure_string_list(entry.get("args"), field_name=f"launchers.{name}.args"),
203
+ env=_ensure_string_map(entry.get("env"), field_name=f"launchers.{name}.env"),
204
+ )
205
+ return launchers
206
+
207
+
208
+ def _parse_profiles(raw: Any) -> dict[str, Profile]:
209
+ mapping = _ensure_mapping(raw or {}, field_name="profiles")
210
+ profiles: dict[str, Profile] = {}
211
+ for name, payload in mapping.items():
212
+ entry = _ensure_mapping(payload, field_name=f"profiles.{name}")
213
+ prompt_language = entry.get("promptLanguage", "en")
214
+ response_language = entry.get("responseLanguage", "same_as_manager")
215
+ auto_handoff = entry.get("autoHandoff")
216
+ policy_preset = entry.get("policyPreset")
217
+ bootstrap = entry.get("bootstrap", "")
218
+ if not isinstance(prompt_language, str) or not isinstance(response_language, str):
219
+ raise SubagentError(
220
+ code="CONFIG_PARSE_ERROR",
221
+ message=f"`profiles.{name}` language fields must be strings",
222
+ details={"profile": name},
223
+ )
224
+ if auto_handoff is not None and not isinstance(auto_handoff, str):
225
+ raise SubagentError(
226
+ code="CONFIG_PARSE_ERROR",
227
+ message=f"`profiles.{name}.autoHandoff` must be string or null",
228
+ details={"profile": name},
229
+ )
230
+ if policy_preset is not None and not isinstance(policy_preset, str):
231
+ raise SubagentError(
232
+ code="CONFIG_PARSE_ERROR",
233
+ message=f"`profiles.{name}.policyPreset` must be string or null",
234
+ details={"profile": name},
235
+ )
236
+ if not isinstance(bootstrap, str):
237
+ raise SubagentError(
238
+ code="CONFIG_PARSE_ERROR",
239
+ message=f"`profiles.{name}.bootstrap` must be a string",
240
+ details={"profile": name},
241
+ )
242
+ profiles[name] = Profile(
243
+ name=name,
244
+ prompt_language=prompt_language,
245
+ response_language=response_language,
246
+ auto_handoff=auto_handoff,
247
+ policy_preset=policy_preset,
248
+ default_packs=_ensure_string_list(
249
+ entry.get("defaultPacks"),
250
+ field_name=f"profiles.{name}.defaultPacks",
251
+ ),
252
+ bootstrap=bootstrap,
253
+ )
254
+ return profiles
255
+
256
+
257
+ def _parse_packs(raw: Any) -> dict[str, Pack]:
258
+ mapping = _ensure_mapping(raw or {}, field_name="packs")
259
+ packs: dict[str, Pack] = {}
260
+ for name, payload in mapping.items():
261
+ entry = _ensure_mapping(payload, field_name=f"packs.{name}")
262
+ description = entry.get("description", "")
263
+ prompt = entry.get("prompt", "")
264
+ if not isinstance(description, str) or not isinstance(prompt, str):
265
+ raise SubagentError(
266
+ code="CONFIG_PARSE_ERROR",
267
+ message=f"`packs.{name}` fields must be strings",
268
+ details={"pack": name},
269
+ )
270
+ packs[name] = Pack(name=name, description=description, prompt=prompt)
271
+ return packs
272
+
273
+
274
+ def load_config(config_path: Path | None = None) -> SubagentConfig:
275
+ resolved_path = resolve_config_path(config_path)
276
+ if not resolved_path.exists():
277
+ return SubagentConfig(path=resolved_path, loaded=False)
278
+ raw = _load_raw_config(resolved_path)
279
+ policy_presets = raw.get("policyPresets", {})
280
+ defaults = raw.get("defaults", {})
281
+ if policy_presets is None:
282
+ policy_presets = {}
283
+ if defaults is None:
284
+ defaults = {}
285
+ if not isinstance(policy_presets, dict):
286
+ raise SubagentError(
287
+ code="CONFIG_PARSE_ERROR",
288
+ message="`policyPresets` must be a mapping",
289
+ details={"field": "policyPresets"},
290
+ )
291
+ if not isinstance(defaults, dict):
292
+ raise SubagentError(
293
+ code="CONFIG_PARSE_ERROR",
294
+ message="`defaults` must be a mapping",
295
+ details={"field": "defaults"},
296
+ )
297
+ return SubagentConfig(
298
+ path=resolved_path,
299
+ loaded=True,
300
+ launchers=_parse_launchers(raw.get("launchers")),
301
+ profiles=_parse_profiles(raw.get("profiles")),
302
+ packs=_parse_packs(raw.get("packs")),
303
+ policy_presets=policy_presets,
304
+ defaults=defaults,
305
+ )
subagent/constants.py ADDED
@@ -0,0 +1,21 @@
1
+ """Shared constants used across the subagent CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ APP_NAME = "subagent"
6
+ SCHEMA_VERSION = "v1"
7
+
8
+ ENV_CONFIG_PATH = "SUBAGENT_CONFIG"
9
+ ENV_STATE_DIR = "SUBAGENT_STATE_DIR"
10
+ ENV_CTL_ID = "SUBAGENT_CTL_ID"
11
+ ENV_CTL_EPOCH = "SUBAGENT_CTL_EPOCH"
12
+ ENV_CTL_TOKEN = "SUBAGENT_CTL_TOKEN"
13
+
14
+ DEFAULT_CONFIG_PATH = Path.home() / ".config" / APP_NAME / "config.yaml"
15
+ DEFAULT_STATE_DIR = Path.home() / ".local" / "share" / APP_NAME
16
+ DEFAULT_STATE_DB_PATH = DEFAULT_STATE_DIR / "state.db"
17
+ DAEMON_STATUS_PATH = DEFAULT_STATE_DIR / "subagentd-status.json"
18
+ DEFAULT_HANDOFFS_DIR = DEFAULT_STATE_DIR / "handoffs"
19
+
20
+ PROJECT_HINT_DIRNAME = ".subagent"
21
+ PROJECT_HINT_FILENAME = "controller.json"
@@ -0,0 +1,267 @@
1
+ """Controller ownership orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .constants import ENV_CTL_EPOCH, ENV_CTL_ID, ENV_CTL_TOKEN
12
+ from .errors import SubagentError
13
+ from .hints import read_project_hint, write_project_hint
14
+ from .paths import resolve_workspace_path
15
+ from .state import ControllerHandle, StateStore
16
+
17
+
18
+ def _generate_controller_id() -> str:
19
+ return f"ctl_{uuid.uuid4().hex[:10]}"
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class InitializedController:
24
+ controller_id: str
25
+ label: str
26
+ workspace_key: str
27
+ hint_path: str
28
+ owner: ControllerHandle
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ return {
32
+ "controllerId": self.controller_id,
33
+ "label": self.label,
34
+ "workspaceKey": self.workspace_key,
35
+ "hintPath": self.hint_path,
36
+ "owner": self.owner.to_dict(),
37
+ }
38
+
39
+
40
+ def resolve_controller_id(
41
+ store: StateStore,
42
+ workspace: Path,
43
+ *,
44
+ explicit_controller_id: str | None = None,
45
+ ) -> str | None:
46
+ if explicit_controller_id:
47
+ return explicit_controller_id
48
+ hint = read_project_hint(workspace)
49
+ if hint and isinstance(hint.get("controllerId"), str):
50
+ return str(hint["controllerId"])
51
+ row = store.get_controller_by_workspace(str(workspace))
52
+ if row:
53
+ return str(row["controller_id"])
54
+ return None
55
+
56
+
57
+ def init_controller(
58
+ store: StateStore,
59
+ *,
60
+ workspace: Path,
61
+ controller_id: str | None,
62
+ label: str,
63
+ pid: int | None = None,
64
+ ) -> InitializedController:
65
+ resolved_workspace = resolve_workspace_path(workspace)
66
+ existing_id = resolve_controller_id(
67
+ store,
68
+ resolved_workspace,
69
+ explicit_controller_id=controller_id,
70
+ )
71
+ target_controller_id = existing_id or _generate_controller_id()
72
+ controller_row = store.register_controller(
73
+ controller_id=target_controller_id,
74
+ label=label,
75
+ workspace_key=str(resolved_workspace),
76
+ )
77
+ owner = store.acquire_owner_handle(
78
+ target_controller_id,
79
+ takeover=False,
80
+ pid=pid if pid is not None else os.getpid(),
81
+ )
82
+ hint_path = write_project_hint(
83
+ resolved_workspace,
84
+ controller_id=target_controller_id,
85
+ label=str(controller_row["label"]),
86
+ )
87
+ return InitializedController(
88
+ controller_id=target_controller_id,
89
+ label=str(controller_row["label"]),
90
+ workspace_key=str(resolved_workspace),
91
+ hint_path=str(hint_path),
92
+ owner=owner,
93
+ )
94
+
95
+
96
+ def attach_controller(
97
+ store: StateStore,
98
+ *,
99
+ workspace: Path,
100
+ controller_id: str | None,
101
+ takeover: bool,
102
+ pid: int | None = None,
103
+ ) -> InitializedController:
104
+ resolved_workspace = resolve_workspace_path(workspace)
105
+ target_controller_id = resolve_controller_id(
106
+ store,
107
+ resolved_workspace,
108
+ explicit_controller_id=controller_id,
109
+ )
110
+ if target_controller_id is None:
111
+ raise SubagentError(
112
+ code="CONTROLLER_NOT_FOUND",
113
+ message=(
114
+ "Controller could not be resolved. Specify --controller-id "
115
+ "or run `subagent controller init` first."
116
+ ),
117
+ details={"workspaceKey": str(resolved_workspace)},
118
+ )
119
+ controller_row = store.get_controller(target_controller_id)
120
+ if controller_row is None:
121
+ raise SubagentError(
122
+ code="CONTROLLER_NOT_FOUND",
123
+ message=f"Controller not found: {target_controller_id}",
124
+ details={"controllerId": target_controller_id},
125
+ )
126
+ owner = store.acquire_owner_handle(
127
+ target_controller_id,
128
+ takeover=takeover,
129
+ pid=pid if pid is not None else os.getpid(),
130
+ )
131
+ hint_path = write_project_hint(
132
+ resolved_workspace,
133
+ controller_id=target_controller_id,
134
+ label=str(controller_row["label"]),
135
+ )
136
+ return InitializedController(
137
+ controller_id=target_controller_id,
138
+ label=str(controller_row["label"]),
139
+ workspace_key=str(resolved_workspace),
140
+ hint_path=str(hint_path),
141
+ owner=owner,
142
+ )
143
+
144
+
145
+ def read_env_handle() -> dict[str, Any] | None:
146
+ controller_id = os.environ.get(ENV_CTL_ID)
147
+ epoch_text = os.environ.get(ENV_CTL_EPOCH)
148
+ token = os.environ.get(ENV_CTL_TOKEN)
149
+ if not controller_id or not epoch_text or not token:
150
+ return None
151
+ try:
152
+ epoch = int(epoch_text)
153
+ except ValueError:
154
+ return {
155
+ "controllerId": controller_id,
156
+ "epochRaw": epoch_text,
157
+ "token": token,
158
+ "valid": False,
159
+ "reason": "ENV_EPOCH_NOT_INTEGER",
160
+ }
161
+ return {
162
+ "controllerId": controller_id,
163
+ "epoch": epoch,
164
+ "token": token,
165
+ }
166
+
167
+
168
+ def shell_env_exports(handle: ControllerHandle) -> list[str]:
169
+ return [
170
+ f"export {ENV_CTL_ID}={handle.controller_id}",
171
+ f"export {ENV_CTL_EPOCH}={handle.epoch}",
172
+ f"export {ENV_CTL_TOKEN}={handle.token}",
173
+ ]
174
+
175
+
176
+ def release_controller(
177
+ store: StateStore,
178
+ *,
179
+ workspace: Path,
180
+ controller_id: str | None,
181
+ force: bool,
182
+ ) -> dict[str, Any]:
183
+ resolved_workspace = resolve_workspace_path(workspace)
184
+ target_controller_id = resolve_controller_id(
185
+ store,
186
+ resolved_workspace,
187
+ explicit_controller_id=controller_id,
188
+ )
189
+ if target_controller_id is None:
190
+ raise SubagentError(
191
+ code="CONTROLLER_NOT_FOUND",
192
+ message="Controller could not be resolved for release.",
193
+ details={"workspaceKey": str(resolved_workspace)},
194
+ )
195
+ env_handle = read_env_handle()
196
+ epoch: int | None = None
197
+ token: str | None = None
198
+ if env_handle is not None and "epoch" in env_handle and "token" in env_handle:
199
+ if str(env_handle.get("controllerId")) == target_controller_id:
200
+ epoch = int(env_handle["epoch"])
201
+ token = str(env_handle["token"])
202
+ return store.release_owner_handle(
203
+ controller_id=target_controller_id,
204
+ epoch=epoch,
205
+ token=token,
206
+ force=force,
207
+ )
208
+
209
+
210
+ def recover_controllers(store: StateStore, *, workspace: Path | None = None) -> dict[str, Any]:
211
+ workspace_key = str(resolve_workspace_path(workspace)) if workspace is not None else None
212
+ controllers = store.list_controllers()
213
+ active_instances = store.list_active_instances()
214
+ active_by_controller = {str(item["controller_id"]): item for item in active_instances}
215
+
216
+ recovered: list[dict[str, Any]] = []
217
+ for controller in controllers:
218
+ controller_id = str(controller["controller_id"])
219
+ if workspace_key and str(controller["workspace_key"]) != workspace_key:
220
+ continue
221
+ active = active_by_controller.get(controller_id)
222
+ owner_alive: bool | None = None
223
+ if active is not None and active.get("pid") is not None:
224
+ pid = int(active["pid"])
225
+ try:
226
+ os.kill(pid, 0)
227
+ except OSError:
228
+ owner_alive = False
229
+ else:
230
+ owner_alive = True
231
+ if active is None:
232
+ state = "dormant"
233
+ elif owner_alive is True:
234
+ state = "active"
235
+ elif owner_alive is False:
236
+ state = "orphaned"
237
+ else:
238
+ state = "conflicted"
239
+ recovered.append(
240
+ {
241
+ "controllerId": controller_id,
242
+ "label": controller["label"],
243
+ "workspaceKey": controller["workspace_key"],
244
+ "state": state,
245
+ "activeOwner": (
246
+ {
247
+ "instanceId": active["instance_id"],
248
+ "epoch": active["epoch"],
249
+ "pid": active["pid"],
250
+ "createdAt": active["created_at"],
251
+ "alive": owner_alive,
252
+ }
253
+ if active is not None
254
+ else None
255
+ ),
256
+ "suggestedAction": (
257
+ f"subagent controller attach --cwd {controller['workspace_key']} --takeover"
258
+ if state in {"orphaned", "conflicted"}
259
+ else None
260
+ ),
261
+ }
262
+ )
263
+ return {
264
+ "count": len(recovered),
265
+ "items": recovered,
266
+ "workspaceFilter": workspace_key,
267
+ }