claude-team-mcp 0.6.1__py3-none-any.whl → 0.8.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.
- claude_team/__init__.py +11 -0
- claude_team/events.py +501 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/cli_backends/__init__.py +4 -2
- claude_team_mcp/cli_backends/claude.py +45 -5
- claude_team_mcp/cli_backends/codex.py +44 -3
- claude_team_mcp/config.py +350 -0
- claude_team_mcp/config_cli.py +263 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/issue_tracker/__init__.py +68 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +164 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +49 -0
- claude_team_mcp/terminal_backends/base.py +106 -0
- claude_team_mcp/terminal_backends/iterm.py +251 -0
- claude_team_mcp/terminal_backends/tmux.py +683 -0
- claude_team_mcp/tools/__init__.py +4 -2
- claude_team_mcp/tools/adopt_worker.py +89 -32
- claude_team_mcp/tools/close_workers.py +39 -10
- claude_team_mcp/tools/discover_workers.py +176 -32
- claude_team_mcp/tools/list_workers.py +29 -0
- claude_team_mcp/tools/message_workers.py +35 -5
- claude_team_mcp/tools/poll_worker_changes.py +227 -0
- claude_team_mcp/tools/spawn_workers.py +254 -153
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +73 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loading for Claude Team MCP.
|
|
3
|
+
|
|
4
|
+
Defines dataclasses for the config schema and utilities for loading
|
|
5
|
+
and validating JSON config files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
CONFIG_VERSION = 1
|
|
16
|
+
CONFIG_DIR = Path.home() / ".claude-team"
|
|
17
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
18
|
+
|
|
19
|
+
AgentType = Literal["claude", "codex"]
|
|
20
|
+
LayoutMode = Literal["auto", "new"]
|
|
21
|
+
TerminalBackend = Literal["iterm", "tmux"]
|
|
22
|
+
IssueTrackerName = Literal["beads", "pebbles"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigError(ValueError):
|
|
26
|
+
"""Raised when the configuration file is invalid."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CommandsConfig:
|
|
31
|
+
"""CLI command overrides for supported agent backends."""
|
|
32
|
+
|
|
33
|
+
claude: str | None = None
|
|
34
|
+
codex: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DefaultsConfig:
|
|
39
|
+
"""Default values applied when spawn_workers fields are omitted."""
|
|
40
|
+
|
|
41
|
+
agent_type: AgentType = "claude"
|
|
42
|
+
skip_permissions: bool = False
|
|
43
|
+
use_worktree: bool = True
|
|
44
|
+
layout: LayoutMode = "auto"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TerminalConfig:
|
|
49
|
+
"""Terminal backend configuration."""
|
|
50
|
+
|
|
51
|
+
backend: TerminalBackend | None = None # None = auto-detect
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class EventsConfig:
|
|
56
|
+
"""Event log rotation configuration."""
|
|
57
|
+
|
|
58
|
+
max_size_mb: int = 1
|
|
59
|
+
recent_hours: int = 24
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class IssueTrackerConfig:
|
|
64
|
+
"""Issue tracker configuration overrides."""
|
|
65
|
+
|
|
66
|
+
override: IssueTrackerName | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ClaudeTeamConfig:
|
|
71
|
+
"""Top-level configuration container for claude-team."""
|
|
72
|
+
|
|
73
|
+
version: int = CONFIG_VERSION
|
|
74
|
+
commands: CommandsConfig = field(default_factory=CommandsConfig)
|
|
75
|
+
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
|
76
|
+
terminal: TerminalConfig = field(default_factory=TerminalConfig)
|
|
77
|
+
events: EventsConfig = field(default_factory=EventsConfig)
|
|
78
|
+
issue_tracker: IssueTrackerConfig = field(default_factory=IssueTrackerConfig)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def default_config() -> ClaudeTeamConfig:
|
|
82
|
+
"""Return a new config instance with default values."""
|
|
83
|
+
|
|
84
|
+
return ClaudeTeamConfig()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_config(config_path: Path | None = None) -> ClaudeTeamConfig:
|
|
88
|
+
"""Load config from disk, creating defaults if missing."""
|
|
89
|
+
|
|
90
|
+
path = _resolve_config_path(config_path)
|
|
91
|
+
if not path.exists():
|
|
92
|
+
return default_config()
|
|
93
|
+
|
|
94
|
+
data = _read_json(path)
|
|
95
|
+
return _parse_config(data)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_config(data: dict) -> ClaudeTeamConfig:
|
|
99
|
+
"""Parse and validate a config dictionary."""
|
|
100
|
+
|
|
101
|
+
return _parse_config(data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def save_config(config: ClaudeTeamConfig, config_path: Path | None = None) -> Path:
|
|
105
|
+
"""Persist config to disk and return the path written."""
|
|
106
|
+
|
|
107
|
+
path = _resolve_config_path(config_path)
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
payload = json.dumps(asdict(config), indent=2, sort_keys=True)
|
|
110
|
+
path.write_text(payload + "\n")
|
|
111
|
+
return path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_config_path(config_path: Path | None) -> Path:
|
|
115
|
+
# Resolve the config path, using the default location if needed.
|
|
116
|
+
return (config_path or CONFIG_PATH).expanduser()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_json(path: Path) -> dict:
|
|
120
|
+
# Read the file contents first so we can surface IO errors cleanly.
|
|
121
|
+
try:
|
|
122
|
+
raw = path.read_text()
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
raise ConfigError(f"Unable to read config file: {path}") from exc
|
|
125
|
+
|
|
126
|
+
# Decode JSON and enforce an object payload.
|
|
127
|
+
try:
|
|
128
|
+
data = json.loads(raw)
|
|
129
|
+
except json.JSONDecodeError as exc:
|
|
130
|
+
raise ConfigError(f"Invalid JSON in config file: {path}") from exc
|
|
131
|
+
|
|
132
|
+
if not isinstance(data, dict):
|
|
133
|
+
raise ConfigError("Config file must contain a JSON object")
|
|
134
|
+
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_config(data: dict) -> ClaudeTeamConfig:
|
|
139
|
+
# Validate expected top-level keys before parsing sections.
|
|
140
|
+
_validate_keys(
|
|
141
|
+
data,
|
|
142
|
+
{"version", "commands", "defaults", "terminal", "events", "issue_tracker"},
|
|
143
|
+
"config",
|
|
144
|
+
)
|
|
145
|
+
version = _read_version(data.get("version"))
|
|
146
|
+
commands = _parse_commands(data.get("commands"))
|
|
147
|
+
defaults = _parse_defaults(data.get("defaults"))
|
|
148
|
+
terminal = _parse_terminal(data.get("terminal"))
|
|
149
|
+
events = _parse_events(data.get("events"))
|
|
150
|
+
issue_tracker = _parse_issue_tracker(data.get("issue_tracker"))
|
|
151
|
+
return ClaudeTeamConfig(
|
|
152
|
+
version=version,
|
|
153
|
+
commands=commands,
|
|
154
|
+
defaults=defaults,
|
|
155
|
+
terminal=terminal,
|
|
156
|
+
events=events,
|
|
157
|
+
issue_tracker=issue_tracker,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _read_version(value: object) -> int:
|
|
162
|
+
# Allow missing versions for backward compatibility with early configs.
|
|
163
|
+
if value is None:
|
|
164
|
+
return CONFIG_VERSION
|
|
165
|
+
if not isinstance(value, int):
|
|
166
|
+
raise ConfigError("config.version must be an integer")
|
|
167
|
+
if value != CONFIG_VERSION:
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
f"Unsupported config version {value}; expected {CONFIG_VERSION}"
|
|
170
|
+
)
|
|
171
|
+
return value
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_commands(value: object) -> CommandsConfig:
|
|
175
|
+
# Parse CLI command overrides for each backend.
|
|
176
|
+
data = _ensure_dict(value, "commands")
|
|
177
|
+
_validate_keys(data, {"claude", "codex"}, "commands")
|
|
178
|
+
return CommandsConfig(
|
|
179
|
+
claude=_optional_str(data.get("claude"), "commands.claude"),
|
|
180
|
+
codex=_optional_str(data.get("codex"), "commands.codex"),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_defaults(value: object) -> DefaultsConfig:
|
|
185
|
+
# Parse default spawn_workers fields with explicit validation.
|
|
186
|
+
data = _ensure_dict(value, "defaults")
|
|
187
|
+
_validate_keys(
|
|
188
|
+
data,
|
|
189
|
+
{"agent_type", "skip_permissions", "use_worktree", "layout"},
|
|
190
|
+
"defaults",
|
|
191
|
+
)
|
|
192
|
+
return DefaultsConfig(
|
|
193
|
+
agent_type=_optional_literal(
|
|
194
|
+
data.get("agent_type"),
|
|
195
|
+
{"claude", "codex"},
|
|
196
|
+
"defaults.agent_type",
|
|
197
|
+
DefaultsConfig.agent_type,
|
|
198
|
+
),
|
|
199
|
+
skip_permissions=_optional_bool(
|
|
200
|
+
data.get("skip_permissions"),
|
|
201
|
+
"defaults.skip_permissions",
|
|
202
|
+
DefaultsConfig.skip_permissions,
|
|
203
|
+
),
|
|
204
|
+
use_worktree=_optional_bool(
|
|
205
|
+
data.get("use_worktree"),
|
|
206
|
+
"defaults.use_worktree",
|
|
207
|
+
DefaultsConfig.use_worktree,
|
|
208
|
+
),
|
|
209
|
+
layout=_optional_literal(
|
|
210
|
+
data.get("layout"),
|
|
211
|
+
{"auto", "new"},
|
|
212
|
+
"defaults.layout",
|
|
213
|
+
DefaultsConfig.layout,
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_terminal(value: object) -> TerminalConfig:
|
|
219
|
+
# Parse terminal backend configuration.
|
|
220
|
+
data = _ensure_dict(value, "terminal")
|
|
221
|
+
_validate_keys(data, {"backend"}, "terminal")
|
|
222
|
+
return TerminalConfig(
|
|
223
|
+
backend=_optional_literal(
|
|
224
|
+
data.get("backend"),
|
|
225
|
+
{"iterm", "tmux"},
|
|
226
|
+
"terminal.backend",
|
|
227
|
+
None,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_events(value: object) -> EventsConfig:
|
|
233
|
+
# Parse event log rotation configuration.
|
|
234
|
+
data = _ensure_dict(value, "events")
|
|
235
|
+
_validate_keys(data, {"max_size_mb", "recent_hours"}, "events")
|
|
236
|
+
return EventsConfig(
|
|
237
|
+
max_size_mb=_optional_int(
|
|
238
|
+
data.get("max_size_mb"),
|
|
239
|
+
"events.max_size_mb",
|
|
240
|
+
EventsConfig.max_size_mb,
|
|
241
|
+
min_value=1,
|
|
242
|
+
),
|
|
243
|
+
recent_hours=_optional_int(
|
|
244
|
+
data.get("recent_hours"),
|
|
245
|
+
"events.recent_hours",
|
|
246
|
+
EventsConfig.recent_hours,
|
|
247
|
+
min_value=0,
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_issue_tracker(value: object) -> IssueTrackerConfig:
|
|
253
|
+
# Parse issue tracker overrides.
|
|
254
|
+
data = _ensure_dict(value, "issue_tracker")
|
|
255
|
+
_validate_keys(data, {"override"}, "issue_tracker")
|
|
256
|
+
return IssueTrackerConfig(
|
|
257
|
+
override=_optional_literal(
|
|
258
|
+
data.get("override"),
|
|
259
|
+
{"beads", "pebbles"},
|
|
260
|
+
"issue_tracker.override",
|
|
261
|
+
None,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _ensure_dict(value: object, path: str) -> dict:
|
|
267
|
+
# Ensure sections are JSON objects, defaulting to empty dicts.
|
|
268
|
+
if value is None:
|
|
269
|
+
return {}
|
|
270
|
+
if not isinstance(value, dict):
|
|
271
|
+
raise ConfigError(f"{path} must be a JSON object")
|
|
272
|
+
return value
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _validate_keys(data: dict, allowed: set[str], path: str) -> None:
|
|
276
|
+
# Reject unexpected keys for a config section.
|
|
277
|
+
unknown = set(data.keys()) - allowed
|
|
278
|
+
if unknown:
|
|
279
|
+
joined = ", ".join(sorted(unknown))
|
|
280
|
+
raise ConfigError(f"Unknown keys in {path}: {joined}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _optional_str(value: object, path: str) -> str | None:
|
|
284
|
+
# Validate optional string fields.
|
|
285
|
+
if value is None:
|
|
286
|
+
return None
|
|
287
|
+
if not isinstance(value, str):
|
|
288
|
+
raise ConfigError(f"{path} must be a string")
|
|
289
|
+
if not value.strip():
|
|
290
|
+
raise ConfigError(f"{path} cannot be empty")
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _optional_int(value: object, path: str, default: int, min_value: int = 1) -> int:
|
|
295
|
+
# Validate optional integer fields.
|
|
296
|
+
if value is None:
|
|
297
|
+
return default
|
|
298
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
299
|
+
raise ConfigError(f"{path} must be an integer")
|
|
300
|
+
if value < min_value:
|
|
301
|
+
raise ConfigError(f"{path} must be at least {min_value}")
|
|
302
|
+
return value
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _optional_bool(value: object, path: str, default: bool) -> bool:
|
|
306
|
+
# Validate optional boolean fields.
|
|
307
|
+
if value is None:
|
|
308
|
+
return default
|
|
309
|
+
if not isinstance(value, bool):
|
|
310
|
+
raise ConfigError(f"{path} must be a boolean")
|
|
311
|
+
return value
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _optional_literal(
|
|
315
|
+
value: object,
|
|
316
|
+
allowed: set[str],
|
|
317
|
+
path: str,
|
|
318
|
+
default: str | None,
|
|
319
|
+
) -> str | None:
|
|
320
|
+
# Validate optional string fields constrained to allowed values.
|
|
321
|
+
if value is None:
|
|
322
|
+
return default
|
|
323
|
+
if not isinstance(value, str):
|
|
324
|
+
raise ConfigError(f"{path} must be a string")
|
|
325
|
+
if value not in allowed:
|
|
326
|
+
joined = ", ".join(sorted(allowed))
|
|
327
|
+
raise ConfigError(f"{path} must be one of: {joined}")
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
__all__ = [
|
|
332
|
+
"AgentType",
|
|
333
|
+
"ClaudeTeamConfig",
|
|
334
|
+
"CommandsConfig",
|
|
335
|
+
"ConfigError",
|
|
336
|
+
"DefaultsConfig",
|
|
337
|
+
"EventsConfig",
|
|
338
|
+
"IssueTrackerConfig",
|
|
339
|
+
"LayoutMode",
|
|
340
|
+
"TerminalBackend",
|
|
341
|
+
"TerminalConfig",
|
|
342
|
+
"IssueTrackerName",
|
|
343
|
+
"CONFIG_DIR",
|
|
344
|
+
"CONFIG_PATH",
|
|
345
|
+
"CONFIG_VERSION",
|
|
346
|
+
"default_config",
|
|
347
|
+
"load_config",
|
|
348
|
+
"parse_config",
|
|
349
|
+
"save_config",
|
|
350
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI helpers for claude-team configuration commands.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
|
|
13
|
+
from . import config as config_module
|
|
14
|
+
from .config import ClaudeTeamConfig, ConfigError, parse_config
|
|
15
|
+
|
|
16
|
+
_ALLOWED_AGENT_TYPES = {"claude", "codex"}
|
|
17
|
+
_ALLOWED_LAYOUTS = {"auto", "new"}
|
|
18
|
+
_ALLOWED_TERMINAL_BACKENDS = {"iterm", "tmux"}
|
|
19
|
+
_ALLOWED_ISSUE_TRACKERS = {"beads", "pebbles"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init_config(
|
|
23
|
+
*,
|
|
24
|
+
force: bool = False,
|
|
25
|
+
config_path: Path | None = None,
|
|
26
|
+
) -> Path:
|
|
27
|
+
"""Write the default config file to disk and return the path."""
|
|
28
|
+
|
|
29
|
+
path = _resolve_config_path(config_path)
|
|
30
|
+
if path.exists() and not force:
|
|
31
|
+
raise ConfigError(f"Config file already exists: {path}")
|
|
32
|
+
config = config_module.default_config()
|
|
33
|
+
return config_module.save_config(config, path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_effective_config_data(
|
|
37
|
+
*,
|
|
38
|
+
env: Mapping[str, str] | None = None,
|
|
39
|
+
config_path: Path | None = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Load config data with environment overrides applied."""
|
|
42
|
+
|
|
43
|
+
config = config_module.load_config(config_path)
|
|
44
|
+
data = asdict(config)
|
|
45
|
+
_apply_env_overrides(data, env or os.environ)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_config_json(
|
|
50
|
+
*,
|
|
51
|
+
env: Mapping[str, str] | None = None,
|
|
52
|
+
config_path: Path | None = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Render the effective config as formatted JSON."""
|
|
55
|
+
|
|
56
|
+
data = load_effective_config_data(env=env, config_path=config_path)
|
|
57
|
+
return json.dumps(data, indent=2, sort_keys=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_config_value(
|
|
61
|
+
key: str,
|
|
62
|
+
*,
|
|
63
|
+
env: Mapping[str, str] | None = None,
|
|
64
|
+
config_path: Path | None = None,
|
|
65
|
+
) -> object:
|
|
66
|
+
"""Return a single config value by dotted path."""
|
|
67
|
+
|
|
68
|
+
data = load_effective_config_data(env=env, config_path=config_path)
|
|
69
|
+
return _get_nested_value(data, key)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_config_value(
|
|
73
|
+
key: str,
|
|
74
|
+
raw_value: str,
|
|
75
|
+
*,
|
|
76
|
+
config_path: Path | None = None,
|
|
77
|
+
) -> ClaudeTeamConfig:
|
|
78
|
+
"""Set a config value by dotted path, validate, and persist."""
|
|
79
|
+
|
|
80
|
+
config = config_module.load_config(config_path)
|
|
81
|
+
data = asdict(config)
|
|
82
|
+
parsed_value = _parse_cli_value(key, raw_value)
|
|
83
|
+
_set_nested_value(data, key, parsed_value)
|
|
84
|
+
updated = parse_config(data)
|
|
85
|
+
config_module.save_config(updated, _resolve_config_path(config_path))
|
|
86
|
+
return updated
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_value_json(value: object) -> str:
|
|
90
|
+
"""Format a single config value as JSON."""
|
|
91
|
+
|
|
92
|
+
return json.dumps(value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_config_path(config_path: Path | None) -> Path:
|
|
96
|
+
# Resolve the config path, defaulting to ~/.claude-team/config.json.
|
|
97
|
+
return (config_path or config_module.CONFIG_PATH).expanduser()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_env_overrides(data: dict, env: Mapping[str, str]) -> None:
|
|
101
|
+
# Apply env overrides using the same precedence logic as runtime helpers.
|
|
102
|
+
command_override = env.get("CLAUDE_TEAM_COMMAND")
|
|
103
|
+
if command_override:
|
|
104
|
+
data["commands"]["claude"] = command_override
|
|
105
|
+
|
|
106
|
+
codex_override = env.get("CLAUDE_TEAM_CODEX_COMMAND")
|
|
107
|
+
if codex_override:
|
|
108
|
+
data["commands"]["codex"] = codex_override
|
|
109
|
+
|
|
110
|
+
# Terminal backend is a direct override (mirrors select_backend_id).
|
|
111
|
+
backend_override = env.get("CLAUDE_TEAM_TERMINAL_BACKEND")
|
|
112
|
+
if backend_override:
|
|
113
|
+
data["terminal"]["backend"] = backend_override.strip().lower()
|
|
114
|
+
|
|
115
|
+
# Issue tracker override mirrors detect_issue_tracker validation.
|
|
116
|
+
tracker_override = env.get("CLAUDE_TEAM_ISSUE_TRACKER")
|
|
117
|
+
if tracker_override:
|
|
118
|
+
normalized = tracker_override.strip().lower()
|
|
119
|
+
if normalized in _ALLOWED_ISSUE_TRACKERS:
|
|
120
|
+
data["issue_tracker"]["override"] = normalized
|
|
121
|
+
|
|
122
|
+
# Events overrides use integer parsing with graceful fallback.
|
|
123
|
+
max_size_override = env.get("CLAUDE_TEAM_EVENTS_MAX_SIZE_MB")
|
|
124
|
+
if max_size_override:
|
|
125
|
+
parsed = _parse_int_override(max_size_override)
|
|
126
|
+
if parsed is not None:
|
|
127
|
+
data["events"]["max_size_mb"] = parsed
|
|
128
|
+
|
|
129
|
+
recent_hours_override = env.get("CLAUDE_TEAM_EVENTS_RECENT_HOURS")
|
|
130
|
+
if recent_hours_override:
|
|
131
|
+
parsed = _parse_int_override(recent_hours_override)
|
|
132
|
+
if parsed is not None:
|
|
133
|
+
data["events"]["recent_hours"] = parsed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_int_override(raw_value: str) -> int | None:
|
|
137
|
+
# Parse env overrides as integers; invalid values are ignored.
|
|
138
|
+
try:
|
|
139
|
+
return int(raw_value)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_cli_value(key: str, raw_value: str) -> object:
|
|
145
|
+
# Parse CLI values according to the config schema.
|
|
146
|
+
parser = _FIELD_PARSERS.get(key)
|
|
147
|
+
if parser is None:
|
|
148
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
149
|
+
return parser(raw_value, key)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_optional_string(raw_value: str, field: str) -> str | None:
|
|
153
|
+
# Parse optional string fields (allows null).
|
|
154
|
+
if _is_null(raw_value):
|
|
155
|
+
return None
|
|
156
|
+
if not raw_value.strip():
|
|
157
|
+
raise ConfigError(f"{field} cannot be empty")
|
|
158
|
+
return raw_value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_bool(raw_value: str, field: str) -> bool:
|
|
162
|
+
# Parse boolean values in JSON-compatible form.
|
|
163
|
+
normalized = raw_value.strip().lower()
|
|
164
|
+
if normalized == "true":
|
|
165
|
+
return True
|
|
166
|
+
if normalized == "false":
|
|
167
|
+
return False
|
|
168
|
+
raise ConfigError(f"{field} must be a boolean")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_int(raw_value: str, field: str) -> int:
|
|
172
|
+
# Parse integer values with minimum validation.
|
|
173
|
+
try:
|
|
174
|
+
value = int(raw_value.strip())
|
|
175
|
+
except ValueError as exc:
|
|
176
|
+
raise ConfigError(f"{field} must be an integer") from exc
|
|
177
|
+
if value < 1:
|
|
178
|
+
raise ConfigError(f"{field} must be at least 1")
|
|
179
|
+
return value
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_literal(raw_value: str, field: str, allowed: set[str]) -> str:
|
|
183
|
+
# Parse literal string values constrained to allowed sets.
|
|
184
|
+
value = raw_value.strip()
|
|
185
|
+
if value not in allowed:
|
|
186
|
+
joined = ", ".join(sorted(allowed))
|
|
187
|
+
raise ConfigError(f"{field} must be one of: {joined}")
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _parse_optional_literal(
|
|
192
|
+
raw_value: str,
|
|
193
|
+
field: str,
|
|
194
|
+
allowed: set[str],
|
|
195
|
+
) -> str | None:
|
|
196
|
+
# Parse nullable literal values constrained to allowed sets.
|
|
197
|
+
if _is_null(raw_value):
|
|
198
|
+
return None
|
|
199
|
+
return _parse_literal(raw_value, field, allowed)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_null(raw_value: str) -> bool:
|
|
203
|
+
# Treat "null" as a request to clear optional fields.
|
|
204
|
+
return raw_value.strip().lower() == "null"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_nested_value(data: dict, key: str) -> object:
|
|
208
|
+
# Retrieve values by dotted path, validating against known keys.
|
|
209
|
+
if key not in _GET_KEYS:
|
|
210
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
211
|
+
parts = key.split(".")
|
|
212
|
+
current: object = data
|
|
213
|
+
for part in parts:
|
|
214
|
+
if not isinstance(current, dict) or part not in current:
|
|
215
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
216
|
+
current = current[part]
|
|
217
|
+
return current
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _set_nested_value(data: dict, key: str, value: object) -> None:
|
|
221
|
+
# Assign values by dotted path, ensuring the key exists.
|
|
222
|
+
parts = key.split(".")
|
|
223
|
+
current: dict = data
|
|
224
|
+
for part in parts[:-1]:
|
|
225
|
+
if part not in current or not isinstance(current[part], dict):
|
|
226
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
227
|
+
current = current[part]
|
|
228
|
+
leaf = parts[-1]
|
|
229
|
+
if leaf not in current:
|
|
230
|
+
raise ConfigError(f"Unknown config key: {key}")
|
|
231
|
+
current[leaf] = value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_FIELD_PARSERS: dict[str, Callable[[str, str], object]] = {
|
|
235
|
+
"commands.claude": _parse_optional_string,
|
|
236
|
+
"commands.codex": _parse_optional_string,
|
|
237
|
+
"defaults.agent_type": lambda value, field: _parse_literal(
|
|
238
|
+
value,
|
|
239
|
+
field,
|
|
240
|
+
_ALLOWED_AGENT_TYPES,
|
|
241
|
+
),
|
|
242
|
+
"defaults.skip_permissions": _parse_bool,
|
|
243
|
+
"defaults.use_worktree": _parse_bool,
|
|
244
|
+
"defaults.layout": lambda value, field: _parse_literal(
|
|
245
|
+
value,
|
|
246
|
+
field,
|
|
247
|
+
_ALLOWED_LAYOUTS,
|
|
248
|
+
),
|
|
249
|
+
"terminal.backend": lambda value, field: _parse_optional_literal(
|
|
250
|
+
value,
|
|
251
|
+
field,
|
|
252
|
+
_ALLOWED_TERMINAL_BACKENDS,
|
|
253
|
+
),
|
|
254
|
+
"events.max_size_mb": _parse_int,
|
|
255
|
+
"events.recent_hours": _parse_int,
|
|
256
|
+
"issue_tracker.override": lambda value, field: _parse_optional_literal(
|
|
257
|
+
value,
|
|
258
|
+
field,
|
|
259
|
+
_ALLOWED_ISSUE_TRACKERS,
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_GET_KEYS = set(_FIELD_PARSERS.keys()) | {"version"}
|
|
@@ -372,6 +372,7 @@ class SessionInfo:
|
|
|
372
372
|
|
|
373
373
|
jsonl_path: Path
|
|
374
374
|
session_id: str
|
|
375
|
+
agent_type: str = "claude"
|
|
375
376
|
|
|
376
377
|
|
|
377
378
|
async def wait_for_any_idle(
|
|
@@ -403,7 +404,11 @@ async def wait_for_any_idle(
|
|
|
403
404
|
|
|
404
405
|
while time.time() - start < timeout:
|
|
405
406
|
for session in sessions:
|
|
406
|
-
if
|
|
407
|
+
if session.agent_type == "codex":
|
|
408
|
+
idle = is_codex_idle(session.jsonl_path)
|
|
409
|
+
else:
|
|
410
|
+
idle = is_idle(session.jsonl_path, session.session_id)
|
|
411
|
+
if idle:
|
|
407
412
|
return {
|
|
408
413
|
"idle_session_id": session.session_id,
|
|
409
414
|
"idle": True,
|
|
@@ -453,7 +458,11 @@ async def wait_for_all_idle(
|
|
|
453
458
|
working_sessions = []
|
|
454
459
|
|
|
455
460
|
for session in sessions:
|
|
456
|
-
if
|
|
461
|
+
if session.agent_type == "codex":
|
|
462
|
+
idle = is_codex_idle(session.jsonl_path)
|
|
463
|
+
else:
|
|
464
|
+
idle = is_idle(session.jsonl_path, session.session_id)
|
|
465
|
+
if idle:
|
|
457
466
|
idle_sessions.append(session.session_id)
|
|
458
467
|
else:
|
|
459
468
|
working_sessions.append(session.session_id)
|
|
@@ -474,7 +483,11 @@ async def wait_for_all_idle(
|
|
|
474
483
|
idle_sessions = []
|
|
475
484
|
working_sessions = []
|
|
476
485
|
for session in sessions:
|
|
477
|
-
if
|
|
486
|
+
if session.agent_type == "codex":
|
|
487
|
+
idle = is_codex_idle(session.jsonl_path)
|
|
488
|
+
else:
|
|
489
|
+
idle = is_idle(session.jsonl_path, session.session_id)
|
|
490
|
+
if idle:
|
|
478
491
|
idle_sessions.append(session.session_id)
|
|
479
492
|
else:
|
|
480
493
|
working_sessions.append(session.session_id)
|