claude-supervisor 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.
Files changed (42) hide show
  1. claude_supervisor/__init__.py +15 -0
  2. claude_supervisor/cli/__init__.py +7 -0
  3. claude_supervisor/cli/app.py +386 -0
  4. claude_supervisor/config/__init__.py +39 -0
  5. claude_supervisor/config/loader.py +160 -0
  6. claude_supervisor/config/models.py +227 -0
  7. claude_supervisor/core/__init__.py +9 -0
  8. claude_supervisor/core/stats.py +47 -0
  9. claude_supervisor/core/supervisor.py +288 -0
  10. claude_supervisor/core/transcript.py +62 -0
  11. claude_supervisor/logging/__init__.py +7 -0
  12. claude_supervisor/logging/setup.py +97 -0
  13. claude_supervisor/parser/__init__.py +31 -0
  14. claude_supervisor/parser/events.py +45 -0
  15. claude_supervisor/parser/parser.py +99 -0
  16. claude_supervisor/parser/patterns.py +194 -0
  17. claude_supervisor/parser/reset_time.py +115 -0
  18. claude_supervisor/parser/rules/claude.yaml +65 -0
  19. claude_supervisor/permissions/__init__.py +15 -0
  20. claude_supervisor/permissions/engine.py +90 -0
  21. claude_supervisor/py.typed +0 -0
  22. claude_supervisor/resume/__init__.py +14 -0
  23. claude_supervisor/resume/clock.py +95 -0
  24. claude_supervisor/resume/planner.py +71 -0
  25. claude_supervisor/session/__init__.py +12 -0
  26. claude_supervisor/session/manager.py +95 -0
  27. claude_supervisor/state_machine/__init__.py +19 -0
  28. claude_supervisor/state_machine/machine.py +114 -0
  29. claude_supervisor/state_machine/states.py +62 -0
  30. claude_supervisor/storage/__init__.py +26 -0
  31. claude_supervisor/storage/base.py +136 -0
  32. claude_supervisor/storage/sqlite.py +224 -0
  33. claude_supervisor/terminal/__init__.py +26 -0
  34. claude_supervisor/terminal/backends.py +153 -0
  35. claude_supervisor/terminal/base.py +153 -0
  36. claude_supervisor/terminal/factory.py +46 -0
  37. claude_supervisor/terminal/threaded.py +134 -0
  38. claude_supervisor-0.1.0.dist-info/METADATA +302 -0
  39. claude_supervisor-0.1.0.dist-info/RECORD +42 -0
  40. claude_supervisor-0.1.0.dist-info/WHEEL +4 -0
  41. claude_supervisor-0.1.0.dist-info/entry_points.txt +2 -0
  42. claude_supervisor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,15 @@
1
+ """Claude Supervisor.
2
+
3
+ A safe, human-in-control companion for Claude Code. It waits for legitimate
4
+ usage resets, resumes the user's existing session, and can optionally automate
5
+ repetitive permission prompts for the *currently active* task only.
6
+
7
+ It never bypasses usage limits, authentication, or subscription requirements,
8
+ and it never starts new work on its own.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = ["__version__"]
@@ -0,0 +1,7 @@
1
+ """Command-line interface for Claude Supervisor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from claude_supervisor.cli.app import app, main
6
+
7
+ __all__ = ["app", "main"]
@@ -0,0 +1,386 @@
1
+ """Typer application exposing the supervisor's commands.
2
+
3
+ Commands: ``version``, ``config``, ``doctor`` (diagnostics), ``start`` /
4
+ ``resume`` (supervise a session), and ``status`` / ``logs`` (inspect recorded
5
+ sessions, statistics, and the log tail).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import signal
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import typer
21
+ from rich.console import Console
22
+ from rich.table import Table
23
+
24
+ from claude_supervisor import __version__
25
+ from claude_supervisor.config import (
26
+ ConfigError,
27
+ PermissionMode,
28
+ SupervisorConfig,
29
+ default_config_path,
30
+ effective_database,
31
+ effective_log_file,
32
+ load_config,
33
+ starter_config,
34
+ )
35
+ from claude_supervisor.core import RunStats, Supervisor, TranscriptWriter
36
+ from claude_supervisor.logging import configure_logging
37
+ from claude_supervisor.parser.patterns import PatternSetError, load_pattern_set
38
+ from claude_supervisor.session import SessionManager
39
+ from claude_supervisor.storage import SqliteStorage
40
+ from claude_supervisor.terminal import TerminalError, terminal_factory
41
+
42
+ app = typer.Typer(
43
+ name="claude-supervisor",
44
+ help="Safe, human-in-control companion for Claude Code.",
45
+ no_args_is_help=True,
46
+ add_completion=True,
47
+ )
48
+ _console = Console()
49
+
50
+ _MIN_PYTHON = (3, 12)
51
+
52
+
53
+ def _config_option() -> Any:
54
+ """Return a reusable ``--config`` Typer option (an ``OptionInfo``)."""
55
+ return typer.Option(
56
+ None,
57
+ "--config",
58
+ "-c",
59
+ help="Path to config.yaml (defaults to the per-user location).",
60
+ show_default=False,
61
+ )
62
+
63
+
64
+ def _require_config_exists(config_path: Path | None) -> None:
65
+ """Error out if an *explicitly provided* config file does not exist.
66
+
67
+ A missing default location is fine (defaults are used); a missing path the
68
+ user typed is almost always a mistake, so we surface it instead of silently
69
+ ignoring their config.
70
+ """
71
+ if config_path is not None and not config_path.exists():
72
+ _console.print(f"[red]Config error:[/red] file not found: {config_path}")
73
+ raise typer.Exit(code=1)
74
+
75
+
76
+ def _load_config(config_path: Path | None) -> SupervisorConfig:
77
+ """Load config, exiting with a clear message on a missing or invalid file."""
78
+ _require_config_exists(config_path)
79
+ try:
80
+ return load_config(config_path)
81
+ except ConfigError as exc:
82
+ _console.print(f"[red]Config error:[/red] {exc}")
83
+ raise typer.Exit(code=1) from exc
84
+
85
+
86
+ @app.command()
87
+ def version() -> None:
88
+ """Print the installed version."""
89
+ _console.print(f"claude-supervisor {__version__}")
90
+
91
+
92
+ @app.command()
93
+ def init(
94
+ config_path: Path | None = _config_option(),
95
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing config file."),
96
+ ) -> None:
97
+ """Write a starter config file with sensible, validated defaults."""
98
+ target = Path(config_path) if config_path is not None else default_config_path()
99
+ if target.exists() and not force:
100
+ _console.print(
101
+ f"[yellow]Config already exists at {target}[/yellow] — use --force to overwrite."
102
+ )
103
+ raise typer.Exit(code=1)
104
+ target.parent.mkdir(parents=True, exist_ok=True)
105
+ target.write_text(starter_config(), encoding="utf-8")
106
+ _console.print(f"[green]Wrote starter config to {target}[/green]")
107
+ _console.print("Edit it if your Claude setup differs, then run: claude-supervisor doctor")
108
+
109
+
110
+ @app.command()
111
+ def config(config_path: Path | None = _config_option()) -> None:
112
+ """Show the effective configuration (defaults merged with your file)."""
113
+ cfg = _load_config(config_path)
114
+ source = config_path or default_config_path()
115
+ exists = Path(source).exists()
116
+ _console.print(
117
+ f"[bold]Source:[/bold] {source} {'' if exists else '(not found; using defaults)'}"
118
+ )
119
+ _console.print_json(cfg.model_dump_json(indent=2))
120
+
121
+
122
+ @app.command()
123
+ def doctor(config_path: Path | None = _config_option()) -> None:
124
+ """Run environment and configuration health checks."""
125
+ table = Table(title="claude-supervisor doctor", show_lines=False)
126
+ table.add_column("Check", style="bold")
127
+ table.add_column("Result")
128
+ table.add_column("Detail", overflow="fold")
129
+
130
+ ok = True
131
+ _require_config_exists(config_path)
132
+
133
+ py_ok = sys.version_info[:2] >= _MIN_PYTHON
134
+ ok &= py_ok
135
+ table.add_row(
136
+ "Python >= 3.12",
137
+ _status(py_ok),
138
+ platform.python_version(),
139
+ )
140
+
141
+ # Config loads?
142
+ try:
143
+ cfg = load_config(config_path)
144
+ table.add_row("Config loads", _status(True), str(config_path or default_config_path()))
145
+ except ConfigError as exc:
146
+ ok = False
147
+ table.add_row("Config loads", _status(False), str(exc).splitlines()[0])
148
+ cfg = None
149
+
150
+ # Pattern rules compile?
151
+ rules_path = None
152
+ if cfg is not None and cfg.paths.pattern_rules is not None:
153
+ rules_path = cfg.paths.pattern_rules
154
+ try:
155
+ pattern_set = load_pattern_set(rules_path)
156
+ table.add_row(
157
+ "Parser rules compile",
158
+ _status(True),
159
+ f"{len(pattern_set)} patterns (v{pattern_set.version})",
160
+ )
161
+ except PatternSetError as exc:
162
+ ok = False
163
+ table.add_row("Parser rules compile", _status(False), str(exc).splitlines()[0])
164
+
165
+ # Claude CLI present? Informational — the tool itself is healthy without it,
166
+ # but you need it to actually supervise anything.
167
+ claude_exe = cfg.claude_command[0] if cfg is not None else "claude"
168
+ resolved = shutil.which(claude_exe)
169
+ if resolved:
170
+ table.add_row("Claude CLI on PATH", _status(True), resolved)
171
+ else:
172
+ table.add_row(
173
+ "Claude CLI on PATH",
174
+ "[yellow]MISSING[/yellow]",
175
+ f"'{claude_exe}' not found — install: npm install -g @anthropic-ai/claude-code",
176
+ )
177
+
178
+ _console.print(table)
179
+ if not ok:
180
+ raise typer.Exit(code=1)
181
+
182
+
183
+ def _render_stats(stats: RunStats) -> None:
184
+ table = Table(title="run summary", show_header=False)
185
+ table.add_column("Field", style="bold")
186
+ table.add_column("Value")
187
+ for key, value in stats.as_dict().items():
188
+ table.add_row(key, str(value))
189
+ _console.print(table)
190
+
191
+
192
+ def _run_supervisor(
193
+ config: SupervisorConfig,
194
+ argv: Sequence[str],
195
+ *,
196
+ task: str | None = None,
197
+ capture: Path | None = None,
198
+ ) -> RunStats:
199
+ """Set up logging + persistence + signals, run the supervisor, return stats."""
200
+ configure_logging(config.logging, log_file=effective_log_file(config), force=True)
201
+ factory = terminal_factory(cwd=os.getcwd())
202
+
203
+ transcript = TranscriptWriter(capture) if capture is not None else None
204
+ supervisor = Supervisor(config, factory, on_line=transcript)
205
+
206
+ storage = SqliteStorage(effective_database(config))
207
+ manager = SessionManager(storage)
208
+ session_id = manager.begin(
209
+ argv, started_at=supervisor.stats.started_at, machine=supervisor.machine
210
+ )
211
+
212
+ previous = signal.getsignal(signal.SIGINT)
213
+
214
+ def _on_sigint(_signum: int, _frame: object) -> None:
215
+ supervisor.request_stop("keyboard interrupt (SIGINT)")
216
+
217
+ signal.signal(signal.SIGINT, _on_sigint)
218
+ try:
219
+ return supervisor.run(argv, task=task)
220
+ finally:
221
+ signal.signal(signal.SIGINT, previous)
222
+ manager.end(session_id, supervisor.stats, supervisor.machine.state)
223
+ storage.close()
224
+ if transcript is not None:
225
+ transcript.close()
226
+ _console.print(f"[dim]Transcript written to {transcript.path}[/dim]")
227
+
228
+
229
+ @app.command()
230
+ def start(
231
+ task: str | None = typer.Option(
232
+ None, "--task", "-t", help="Task/prompt to run unattended.", show_default=False
233
+ ),
234
+ auto_approve: bool = typer.Option(
235
+ False,
236
+ "--auto-approve",
237
+ help="Auto-answer permission prompts for this run (active-task scope).",
238
+ ),
239
+ capture: Path | None = typer.Option(
240
+ None,
241
+ "--capture",
242
+ help="Write a transcript of Claude's output + detected events to this file.",
243
+ show_default=False,
244
+ ),
245
+ extra_args: list[str] | None = typer.Argument(
246
+ None, help="Extra arguments appended to the configured claude command."
247
+ ),
248
+ config_path: Path | None = _config_option(),
249
+ ) -> None:
250
+ """Launch and supervise a Claude Code session (optionally an unattended task)."""
251
+ config = _load_config(config_path)
252
+
253
+ if auto_approve:
254
+ config = config.model_copy(
255
+ update={
256
+ "auto_permissions": True,
257
+ "permission_mode": PermissionMode.ACTIVE_TASK_ONLY,
258
+ }
259
+ )
260
+
261
+ argv = [*config.claude_command, *(extra_args or [])]
262
+ try:
263
+ stats = _run_supervisor(config, argv, task=task, capture=capture)
264
+ except TerminalError as exc:
265
+ _console.print(f"[red]Terminal error:[/red] {exc}")
266
+ raise typer.Exit(code=1) from exc
267
+ _render_stats(stats)
268
+
269
+
270
+ @app.command()
271
+ def resume(config_path: Path | None = _config_option()) -> None:
272
+ """Resume an existing Claude Code session (waiting for a reset if needed)."""
273
+ config = _load_config(config_path)
274
+ try:
275
+ stats = _run_supervisor(config, config.resume_command)
276
+ except TerminalError as exc:
277
+ _console.print(f"[red]Terminal error:[/red] {exc}")
278
+ raise typer.Exit(code=1) from exc
279
+ _render_stats(stats)
280
+
281
+
282
+ @app.command()
283
+ def status(config_path: Path | None = _config_option()) -> None:
284
+ """Show the latest session and aggregate statistics."""
285
+ config = _load_config(config_path)
286
+
287
+ database = effective_database(config)
288
+ if not database.exists():
289
+ _console.print("No sessions recorded yet.")
290
+ return
291
+
292
+ with SqliteStorage(database) as storage:
293
+ manager = SessionManager(storage)
294
+ latest = manager.latest()
295
+ stats = manager.statistics()
296
+
297
+ if latest is None:
298
+ _console.print("No sessions recorded yet.")
299
+ return
300
+
301
+ session_table = Table(title="latest session", show_header=False)
302
+ session_table.add_column("Field", style="bold")
303
+ session_table.add_column("Value", overflow="fold")
304
+ session_table.add_row("id", str(latest.id))
305
+ session_table.add_row("command", " ".join(latest.command))
306
+ session_table.add_row("started_at", latest.started_at)
307
+ session_table.add_row("final_state", latest.final_state or "(running)")
308
+ session_table.add_row("completed", str(latest.completed))
309
+ session_table.add_row("resumes", str(latest.resumes))
310
+ session_table.add_row("approvals", str(latest.approvals))
311
+ session_table.add_row("stop_reason", latest.stop_reason or "-")
312
+ if latest.error:
313
+ session_table.add_row("error", latest.error)
314
+ _console.print(session_table)
315
+
316
+ stats_table = Table(title="statistics (all sessions)", show_header=False)
317
+ stats_table.add_column("Metric", style="bold")
318
+ stats_table.add_column("Value")
319
+ for key, value in stats.as_dict().items():
320
+ stats_table.add_row(key, str(value))
321
+ _console.print(stats_table)
322
+
323
+
324
+ @app.command()
325
+ def logs(
326
+ lines: int = typer.Option(40, "--lines", "-n", help="Number of trailing log lines to show."),
327
+ config_path: Path | None = _config_option(),
328
+ ) -> None:
329
+ """Show the tail of the supervisor log file."""
330
+ config = _load_config(config_path)
331
+ log_file = effective_log_file(config)
332
+ if not log_file.exists():
333
+ _console.print(f"No log file yet at {log_file}")
334
+ return
335
+
336
+ content = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
337
+ tail = content[-lines:] if lines > 0 else content
338
+ _console.print("\n".join(tail))
339
+
340
+
341
+ @app.command()
342
+ def statusline() -> None:
343
+ """Emit a one-line status summary for Claude Code's status line.
344
+
345
+ Designed to be wired into Claude Code's ``statusLine`` setting. It reads the
346
+ session database and prints a single plain-text line, and is deliberately
347
+ defensive: any error yields a minimal line rather than a traceback, so it can
348
+ never disrupt the Claude Code UI.
349
+ """
350
+ # The line renders inside another program's UI (often UTF-8), but may also be
351
+ # printed to a legacy console (cp1252 on Windows). Force UTF-8 with replacement
352
+ # so the emoji never raises UnicodeEncodeError.
353
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
354
+ if reconfigure is not None:
355
+ with contextlib.suppress(ValueError, OSError):
356
+ reconfigure(encoding="utf-8", errors="replace")
357
+
358
+ try:
359
+ config = load_config(None)
360
+ database = effective_database(config)
361
+ if not database.exists():
362
+ typer.echo("🛡 claude-supervisor · no runs yet")
363
+ return
364
+ with SqliteStorage(database) as storage:
365
+ stats = storage.statistics()
366
+ parts = [f"{stats.total_sessions} run" + ("s" if stats.total_sessions != 1 else "")]
367
+ if stats.resumes:
368
+ parts.append(f"{stats.resumes} resume" + ("s" if stats.resumes != 1 else ""))
369
+ if stats.hours_saved >= 0.05:
370
+ parts.append(f"{stats.hours_saved:.1f}h saved")
371
+ typer.echo("🛡 " + " · ".join(parts))
372
+ except Exception: # pragma: no cover - never break the host UI
373
+ typer.echo("🛡 claude-supervisor")
374
+
375
+
376
+ def _status(ok: bool) -> str:
377
+ return "[green]OK[/green]" if ok else "[red]FAIL[/red]"
378
+
379
+
380
+ def main() -> None:
381
+ """Console-script entry point."""
382
+ app()
383
+
384
+
385
+ if __name__ == "__main__": # pragma: no cover
386
+ main()
@@ -0,0 +1,39 @@
1
+ """Configuration subsystem: typed, validated settings loaded from YAML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from claude_supervisor.config.loader import (
6
+ ConfigError,
7
+ default_config_path,
8
+ dump_config,
9
+ effective_database,
10
+ effective_log_file,
11
+ effective_state_dir,
12
+ load_config,
13
+ starter_config,
14
+ )
15
+ from claude_supervisor.config.models import (
16
+ CompletionMode,
17
+ LoggingConfig,
18
+ PathsConfig,
19
+ PermissionMode,
20
+ SupervisorConfig,
21
+ TaskDelivery,
22
+ )
23
+
24
+ __all__ = [
25
+ "CompletionMode",
26
+ "ConfigError",
27
+ "LoggingConfig",
28
+ "PathsConfig",
29
+ "PermissionMode",
30
+ "SupervisorConfig",
31
+ "TaskDelivery",
32
+ "default_config_path",
33
+ "dump_config",
34
+ "effective_database",
35
+ "effective_log_file",
36
+ "effective_state_dir",
37
+ "load_config",
38
+ "starter_config",
39
+ ]
@@ -0,0 +1,160 @@
1
+ """Load and persist :class:`SupervisorConfig` from YAML.
2
+
3
+ The loader is tolerant of the flat, spec-style config shown in the project
4
+ specification (``log_level: INFO``) and transparently maps such keys onto the
5
+ richer nested model, so users never have to migrate their file by hand.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+ from pydantic import ValidationError
16
+
17
+ from claude_supervisor.config.models import SupervisorConfig
18
+
19
+ _APP_NAME = "claude-supervisor"
20
+
21
+ # Flat spec-style keys that live under a nested section in the model.
22
+ _FLAT_TO_NESTED: dict[str, tuple[str, str]] = {
23
+ "log_level": ("logging", "level"),
24
+ }
25
+
26
+
27
+ class ConfigError(RuntimeError):
28
+ """Raised when a configuration file cannot be read, parsed, or validated."""
29
+
30
+
31
+ def default_config_dir() -> Path:
32
+ """Return the platform-appropriate config directory.
33
+
34
+ Honors ``CLAUDE_SUPERVISOR_HOME`` when set, else ``XDG_CONFIG_HOME`` on
35
+ POSIX and ``%APPDATA%`` on Windows, falling back to ``~/.config``.
36
+ """
37
+ override = os.environ.get("CLAUDE_SUPERVISOR_HOME")
38
+ if override:
39
+ return Path(override).expanduser()
40
+
41
+ if os.name == "nt":
42
+ base = os.environ.get("APPDATA")
43
+ if base:
44
+ return Path(base) / _APP_NAME
45
+ return Path.home() / "AppData" / "Roaming" / _APP_NAME
46
+
47
+ base = os.environ.get("XDG_CONFIG_HOME")
48
+ if base:
49
+ return Path(base) / _APP_NAME
50
+ return Path.home() / ".config" / _APP_NAME
51
+
52
+
53
+ def default_config_path() -> Path:
54
+ """Return the default ``config.yaml`` path."""
55
+ return default_config_dir() / "config.yaml"
56
+
57
+
58
+ def _migrate_flat_keys(raw: dict[str, Any]) -> dict[str, Any]:
59
+ """Fold flat spec-style keys into their nested homes without clobbering."""
60
+ data = dict(raw)
61
+ for flat_key, (section, field) in _FLAT_TO_NESTED.items():
62
+ if flat_key not in data:
63
+ continue
64
+ value = data.pop(flat_key)
65
+ section_data = dict(data.get(section) or {})
66
+ section_data.setdefault(field, value)
67
+ data[section] = section_data
68
+ return data
69
+
70
+
71
+ def load_config(path: str | os.PathLike[str] | None = None) -> SupervisorConfig:
72
+ """Load configuration from ``path`` (or the default location).
73
+
74
+ A missing file yields a fully-defaulted config; that is a valid, supported
75
+ state rather than an error. Malformed YAML or values that fail validation
76
+ raise :class:`ConfigError` with a human-readable message.
77
+ """
78
+ resolved = Path(path) if path is not None else default_config_path()
79
+
80
+ if not resolved.exists():
81
+ return SupervisorConfig()
82
+
83
+ try:
84
+ text = resolved.read_text(encoding="utf-8")
85
+ except OSError as exc: # pragma: no cover - platform/permission dependent
86
+ raise ConfigError(f"Could not read config file {resolved}: {exc}") from exc
87
+
88
+ try:
89
+ raw = yaml.safe_load(text) or {}
90
+ except yaml.YAMLError as exc:
91
+ raise ConfigError(f"Config file {resolved} is not valid YAML: {exc}") from exc
92
+
93
+ if not isinstance(raw, dict):
94
+ raise ConfigError(
95
+ f"Config file {resolved} must contain a top-level mapping, got {type(raw).__name__}."
96
+ )
97
+
98
+ try:
99
+ return SupervisorConfig.model_validate(_migrate_flat_keys(raw))
100
+ except ValidationError as exc:
101
+ raise ConfigError(f"Config file {resolved} failed validation:\n{exc}") from exc
102
+
103
+
104
+ _STARTER_CONFIG = """\
105
+ # Claude Supervisor configuration.
106
+ # Full reference: examples/config.yaml in the project repository.
107
+
108
+ # How Claude is launched for an unattended task. `-p` (print/headless) runs
109
+ # one-shot; the permission flag lets Claude use tools without prompting.
110
+ # For broader access use "--dangerously-skip-permissions" instead (with care).
111
+ claude_command: ["claude", "-p", "--permission-mode", "acceptEdits"]
112
+
113
+ # How to resume after a usage-limit reset (continues the most recent session).
114
+ resume_command: ["claude", "-p", "--continue", "--permission-mode", "acceptEdits"]
115
+
116
+ # Wait for a legitimate reset and resume automatically.
117
+ auto_resume: true
118
+
119
+ # Fallback wait (hours) if Claude reports no reset time.
120
+ default_reset_hours: 5
121
+
122
+ # A completion marker OR a clean process exit counts as done; 'heuristic' also
123
+ # stops when Claude goes idle. Headless `claude -p` exits cleanly when finished.
124
+ completion_mode: heuristic
125
+ """
126
+
127
+
128
+ def starter_config() -> str:
129
+ """Return the text of a sensible, validated starter config file."""
130
+ return _STARTER_CONFIG
131
+
132
+
133
+ def effective_state_dir(config: SupervisorConfig) -> Path:
134
+ """Return the runtime state directory (``paths.state_dir`` or the default)."""
135
+ return config.paths.state_dir or default_config_dir()
136
+
137
+
138
+ def effective_log_file(config: SupervisorConfig) -> Path:
139
+ """Return the resolved log-file path (``paths.log_file`` or a derived default)."""
140
+ return config.paths.log_file or (effective_state_dir(config) / "supervisor.log")
141
+
142
+
143
+ def effective_database(config: SupervisorConfig) -> Path:
144
+ """Return the resolved SQLite path (``paths.database`` or a derived default)."""
145
+ return config.paths.database or (effective_state_dir(config) / "supervisor.db")
146
+
147
+
148
+ def dump_config(config: SupervisorConfig, path: str | os.PathLike[str]) -> Path:
149
+ """Serialize ``config`` to ``path`` as YAML, creating parent dirs as needed.
150
+
151
+ Returns the path written to.
152
+ """
153
+ resolved = Path(path)
154
+ resolved.parent.mkdir(parents=True, exist_ok=True)
155
+ payload = config.model_dump(mode="json", exclude_none=True)
156
+ resolved.write_text(
157
+ yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
158
+ encoding="utf-8",
159
+ )
160
+ return resolved