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.
Files changed (35) hide show
  1. claude_team/__init__.py +11 -0
  2. claude_team/events.py +501 -0
  3. claude_team/idle_detection.py +173 -0
  4. claude_team/poller.py +245 -0
  5. claude_team_mcp/cli_backends/__init__.py +4 -2
  6. claude_team_mcp/cli_backends/claude.py +45 -5
  7. claude_team_mcp/cli_backends/codex.py +44 -3
  8. claude_team_mcp/config.py +350 -0
  9. claude_team_mcp/config_cli.py +263 -0
  10. claude_team_mcp/idle_detection.py +16 -3
  11. claude_team_mcp/issue_tracker/__init__.py +68 -3
  12. claude_team_mcp/iterm_utils.py +5 -73
  13. claude_team_mcp/registry.py +43 -26
  14. claude_team_mcp/server.py +164 -61
  15. claude_team_mcp/session_state.py +364 -2
  16. claude_team_mcp/terminal_backends/__init__.py +49 -0
  17. claude_team_mcp/terminal_backends/base.py +106 -0
  18. claude_team_mcp/terminal_backends/iterm.py +251 -0
  19. claude_team_mcp/terminal_backends/tmux.py +683 -0
  20. claude_team_mcp/tools/__init__.py +4 -2
  21. claude_team_mcp/tools/adopt_worker.py +89 -32
  22. claude_team_mcp/tools/close_workers.py +39 -10
  23. claude_team_mcp/tools/discover_workers.py +176 -32
  24. claude_team_mcp/tools/list_workers.py +29 -0
  25. claude_team_mcp/tools/message_workers.py +35 -5
  26. claude_team_mcp/tools/poll_worker_changes.py +227 -0
  27. claude_team_mcp/tools/spawn_workers.py +254 -153
  28. claude_team_mcp/tools/wait_idle_workers.py +1 -0
  29. claude_team_mcp/utils/errors.py +7 -3
  30. claude_team_mcp/worktree.py +73 -12
  31. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
  32. claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
  33. claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
  34. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
  35. {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 is_idle(session.jsonl_path, session.session_id):
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 is_idle(session.jsonl_path, session.session_id):
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 is_idle(session.jsonl_path, session.session_id):
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)