claudeloop 0.2.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 (53) hide show
  1. claudeloop/__init__.py +4 -0
  2. claudeloop/application/__init__.py +0 -0
  3. claudeloop/application/dto.py +40 -0
  4. claudeloop/application/ports.py +69 -0
  5. claudeloop/application/runner.py +140 -0
  6. claudeloop/application/usecases/__init__.py +0 -0
  7. claudeloop/application/usecases/doctor.py +98 -0
  8. claudeloop/application/usecases/list_sessions.py +11 -0
  9. claudeloop/application/usecases/resume_session.py +34 -0
  10. claudeloop/application/usecases/run_plan.py +41 -0
  11. claudeloop/bootstrap.py +93 -0
  12. claudeloop/cli/__init__.py +0 -0
  13. claudeloop/cli/app.py +60 -0
  14. claudeloop/cli/asyncio.py +50 -0
  15. claudeloop/cli/commands/__init__.py +0 -0
  16. claudeloop/cli/commands/doctor.py +25 -0
  17. claudeloop/cli/commands/resume.py +86 -0
  18. claudeloop/cli/commands/run.py +76 -0
  19. claudeloop/cli/commands/sessions.py +25 -0
  20. claudeloop/cli/render.py +47 -0
  21. claudeloop/domain/__init__.py +0 -0
  22. claudeloop/domain/budget.py +60 -0
  23. claudeloop/domain/capacity.py +53 -0
  24. claudeloop/domain/classify.py +73 -0
  25. claudeloop/domain/completion.py +65 -0
  26. claudeloop/domain/errors.py +26 -0
  27. claudeloop/domain/loop.py +196 -0
  28. claudeloop/domain/plan.py +73 -0
  29. claudeloop/domain/session.py +61 -0
  30. claudeloop/domain/waiting.py +86 -0
  31. claudeloop/infrastructure/__init__.py +0 -0
  32. claudeloop/infrastructure/agent/__init__.py +0 -0
  33. claudeloop/infrastructure/agent/autonomy.py +83 -0
  34. claudeloop/infrastructure/agent/catalog.py +53 -0
  35. claudeloop/infrastructure/agent/gateway.py +102 -0
  36. claudeloop/infrastructure/agent/options.py +69 -0
  37. claudeloop/infrastructure/agent/translate.py +141 -0
  38. claudeloop/infrastructure/api/__init__.py +0 -0
  39. claudeloop/infrastructure/audit.py +26 -0
  40. claudeloop/infrastructure/clock.py +27 -0
  41. claudeloop/infrastructure/config.py +93 -0
  42. claudeloop/infrastructure/doctor_env.py +86 -0
  43. claudeloop/infrastructure/lock.py +41 -0
  44. claudeloop/infrastructure/logging.py +79 -0
  45. claudeloop/infrastructure/notify.py +16 -0
  46. claudeloop/infrastructure/progress.py +18 -0
  47. claudeloop/infrastructure/state.py +27 -0
  48. claudeloop/py.typed +0 -0
  49. claudeloop-0.2.0.dist-info/METADATA +165 -0
  50. claudeloop-0.2.0.dist-info/RECORD +53 -0
  51. claudeloop-0.2.0.dist-info/WHEEL +4 -0
  52. claudeloop-0.2.0.dist-info/entry_points.txt +2 -0
  53. claudeloop-0.2.0.dist-info/licenses/LICENSE +21 -0
claudeloop/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """claudeloop — onion-architected, autonomous Claude Code session runner and
2
+ full Anthropic SDK CLI."""
3
+
4
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,40 @@
1
+ """Data transfer objects passed between application/ and infrastructure/ adapters.
2
+ Not domain value objects — these carry the raw shape of one SDK interaction
3
+ before domain.classify.classify() and domain.completion.evaluate() reduce them
4
+ to CapacityState / CompletionVerdict."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+
11
+ from claudeloop.domain.classify import TurnSignals
12
+ from claudeloop.domain.completion import StructuredVerdict
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class TurnOutcome:
17
+ """What one real or probe turn produced, translated from raw SDK messages
18
+ by infrastructure/agent/translate.py."""
19
+
20
+ signals: TurnSignals
21
+ verdict: StructuredVerdict | None
22
+ output_text: str
23
+ session_id: str | None
24
+ cost_usd: float = 0.0
25
+ raw_events: tuple[dict[str, object], ...] = ()
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ProbeResult:
30
+ signals: TurnSignals
31
+ at: datetime
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class RunResult:
36
+ success: bool
37
+ reason: str
38
+ session_id: str | None
39
+ turns_spent: int
40
+ dollars_spent: float
@@ -0,0 +1,69 @@
1
+ """Application ports — Protocols implemented by infrastructure/, never imported
2
+ from it. See docs/architecture/overview.md for the onion rule this enforces:
3
+ application/ knows the SHAPE of a collaborator, never its concrete type."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+ from typing import Any, Protocol
9
+
10
+ from claudeloop.application.dto import TurnOutcome
11
+ from claudeloop.domain.session import SessionRef
12
+
13
+
14
+ class Clock(Protocol):
15
+ def now(self) -> datetime: ...
16
+
17
+
18
+ class Sleeper(Protocol):
19
+ async def sleep_until(self, instant: datetime) -> None: ...
20
+
21
+
22
+ class AgentGateway(Protocol):
23
+ """Wraps a live claude_agent_sdk.ClaudeSDKClient session. Deliberately NOT
24
+ query() — see docs/architecture/decisions/0002-agent-sdk-over-subprocess.md
25
+ for why query() cannot be used here (it raises after an error result and
26
+ exits the process, where ClaudeSDKClient survives to be resumed)."""
27
+
28
+ async def send_turn(self, prompt_text: str) -> TurnOutcome: ...
29
+ async def close(self) -> None: ...
30
+
31
+
32
+ class CapacityProbe(Protocol):
33
+ async def probe(self) -> TurnOutcome: ...
34
+
35
+
36
+ class SessionCatalog(Protocol):
37
+ def most_recent(self, cwd: str) -> SessionRef | None: ...
38
+ def list_all(self, cwd: str | None = None) -> list[SessionRef]: ...
39
+
40
+
41
+ class ProgressReporter(Protocol):
42
+ def turn_sent(self, *, attempt: int) -> None: ...
43
+ def waiting(self, *, reason: str, until: datetime) -> None: ...
44
+ def finished(self, *, success: bool, reason: str) -> None: ...
45
+
46
+
47
+ class AuditLog(Protocol):
48
+ def record(self, event_type: str, payload: dict[str, Any]) -> None: ...
49
+
50
+
51
+ class Notifier(Protocol):
52
+ def notify(self, message: str) -> None: ...
53
+
54
+
55
+ class RunStateStore(Protocol):
56
+ def save(self, run_id: str, state: dict[str, Any]) -> None: ...
57
+ def load(self, run_id: str) -> dict[str, Any] | None: ...
58
+
59
+
60
+ class SessionLock(Protocol):
61
+ def acquire(self, session_id: str) -> bool: ...
62
+ def release(self, session_id: str) -> None: ...
63
+
64
+
65
+ class ApiGateway(Protocol):
66
+ """Declared now; implemented in M4 alongside the generated REST surface.
67
+ See docs/architecture/decisions/0006-generated-rest-surface-not-hand-written.md."""
68
+
69
+ def invoke(self, method_path: str, **kwargs: Any) -> Any: ...
@@ -0,0 +1,140 @@
1
+ """AutonomousRunner — executes domain.loop's pure Decisions against real ports.
2
+
3
+ Contains NO capacity or completion logic of its own. Every "is this waitable",
4
+ "how long do we wait", "is the task done" question is answered by domain/ before
5
+ this class ever sees it; this class only performs the I/O domain/loop.py decided
6
+ was needed and feeds the result back in."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from claudeloop.application.dto import RunResult, TurnOutcome
11
+ from claudeloop.application.ports import (
12
+ AgentGateway,
13
+ AuditLog,
14
+ CapacityProbe,
15
+ Clock,
16
+ ProgressReporter,
17
+ Sleeper,
18
+ )
19
+ from claudeloop.domain.budget import Budget, BudgetLedger
20
+ from claudeloop.domain.capacity import CapacityState
21
+ from claudeloop.domain.classify import classify
22
+ from claudeloop.domain.completion import CompletionVerdict, evaluate
23
+ from claudeloop.domain.loop import (
24
+ Finish,
25
+ Phase,
26
+ RunState,
27
+ ScheduleProbe,
28
+ SendTurn,
29
+ decide_after_probe,
30
+ decide_after_turn,
31
+ decide_preflight,
32
+ start,
33
+ )
34
+ from claudeloop.domain.waiting import DEFAULT_WAIT_POLICY_CONFIG, WaitPolicyConfig
35
+
36
+ _DEFAULT_BUDGET = Budget()
37
+
38
+
39
+ class AutonomousRunner:
40
+ def __init__(
41
+ self,
42
+ *,
43
+ agent_gateway: AgentGateway,
44
+ capacity_probe: CapacityProbe,
45
+ clock: Clock,
46
+ sleeper: Sleeper,
47
+ audit_log: AuditLog,
48
+ progress: ProgressReporter,
49
+ budget: Budget = _DEFAULT_BUDGET,
50
+ wait_policy: WaitPolicyConfig = DEFAULT_WAIT_POLICY_CONFIG,
51
+ done_marker: str | None = None,
52
+ ) -> None:
53
+ self._gateway = agent_gateway
54
+ self._probe = capacity_probe
55
+ self._clock = clock
56
+ self._sleeper = sleeper
57
+ self._audit = audit_log
58
+ self._progress = progress
59
+ self._budget = budget
60
+ self._wait_policy = wait_policy
61
+ self._done_marker = done_marker
62
+
63
+ async def run(self, *, initial_prompt: str, continue_prompt: str) -> RunResult:
64
+ """Drive one run to completion: send `initial_prompt` first, then
65
+ `continue_prompt` on every subsequent SendTurn."""
66
+ state = start(BudgetLedger(budget=self._budget))
67
+ session_id: str | None = None
68
+ first_turn = True
69
+ attempt = 0
70
+
71
+ preflight_outcome = await self._probe.probe()
72
+ state, decision = decide_preflight(
73
+ state,
74
+ self._verdict_capacity(preflight_outcome),
75
+ now=self._clock.now(),
76
+ config=self._wait_policy,
77
+ )
78
+ self._audit.record("preflight", {"phase": state.phase.name})
79
+
80
+ while True:
81
+ if isinstance(decision, SendTurn):
82
+ attempt += 1
83
+ prompt = initial_prompt if first_turn else continue_prompt
84
+ first_turn = False
85
+ self._progress.turn_sent(attempt=attempt)
86
+ outcome = await self._gateway.send_turn(prompt)
87
+ session_id = outcome.session_id or session_id
88
+ capacity = classify(outcome.signals)
89
+ verdict = self._completion_verdict(outcome)
90
+ self._audit.record(
91
+ "turn", {"attempt": attempt, "capacity": type(capacity).__name__}
92
+ )
93
+ state, decision = decide_after_turn(
94
+ state,
95
+ capacity=capacity,
96
+ verdict=verdict,
97
+ now=self._clock.now(),
98
+ config=self._wait_policy,
99
+ )
100
+ elif isinstance(decision, ScheduleProbe):
101
+ self._progress.waiting(reason=state.phase.name, until=decision.at)
102
+ self._audit.record("waiting", {"until": decision.at.isoformat()})
103
+ await self._sleeper.sleep_until(decision.at)
104
+ probe_outcome = await self._probe.probe()
105
+ state, decision = decide_after_probe(
106
+ state,
107
+ self._verdict_capacity(probe_outcome),
108
+ now=self._clock.now(),
109
+ config=self._wait_policy,
110
+ )
111
+ else:
112
+ # RunProbe is a declared member of domain.loop.Decision but no
113
+ # decide_* function currently produces it standalone — probing
114
+ # is folded into the ScheduleProbe branch above (schedule, wait,
115
+ # then probe). Same unreachable-by-construction pattern as
116
+ # domain.loop.Phase.PROBING; see that module's own exhaustiveness
117
+ # asserts for the precedent this follows.
118
+ assert isinstance(decision, Finish) # nosec B101
119
+ await self._gateway.close()
120
+ self._progress.finished(success=decision.success, reason=decision.reason)
121
+ self._audit.record(
122
+ "finished", {"success": decision.success, "reason": decision.reason}
123
+ )
124
+ return RunResult(
125
+ success=decision.success,
126
+ reason=decision.reason,
127
+ session_id=session_id,
128
+ turns_spent=state.ledger.turns_spent,
129
+ dollars_spent=state.ledger.dollars_spent,
130
+ )
131
+
132
+ def _verdict_capacity(self, outcome: TurnOutcome) -> CapacityState:
133
+ return classify(outcome.signals)
134
+
135
+ def _completion_verdict(self, outcome: TurnOutcome) -> CompletionVerdict:
136
+ kwargs = {"done_marker": self._done_marker} if self._done_marker else {}
137
+ return evaluate(structured=outcome.verdict, output_text=outcome.output_text, **kwargs)
138
+
139
+
140
+ __all__ = ["AutonomousRunner", "RunState", "Phase"]
File without changes
@@ -0,0 +1,98 @@
1
+ """Use case: pre-flight checks before starting a long unattended run.
2
+
3
+ Deliberately checks BEFORE a run starts, not during — an MCP OAuth prompt or a
4
+ missing `claude` binary discovered three hours into an unattended run is much
5
+ worse than the same failure at `claudeloop doctor` time. See
6
+ docs/architecture/decisions/0007-ask-user-question-denied-with-guidance.md for
7
+ why MCP OAuth specifically can never be mitigated mid-run."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Protocol
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class DoctorCheck:
18
+ name: str
19
+ passed: bool
20
+ detail: str
21
+
22
+
23
+ class DoctorEnvironment(Protocol):
24
+ """What doctor needs from the outside world — kept separate from the
25
+ larger AgentGateway/SessionCatalog ports so `doctor` stays cheap to run
26
+ and doesn't require a live SDK connection."""
27
+
28
+ def find_claude_cli(self) -> str | None: ...
29
+ def claude_cli_version(self, path: str) -> str | None: ...
30
+ def is_authenticated(self) -> bool: ...
31
+ def configured_mcp_servers(self) -> list[str]: ...
32
+
33
+
34
+ def run_doctor(env: DoctorEnvironment, *, cwd: Path) -> list[DoctorCheck]:
35
+ checks: list[DoctorCheck] = []
36
+
37
+ cli_path = env.find_claude_cli()
38
+ if cli_path is None:
39
+ checks.append(
40
+ DoctorCheck(
41
+ name="claude-cli",
42
+ passed=False,
43
+ detail="`claude` not found on PATH. Install Claude Code first.",
44
+ )
45
+ )
46
+ else:
47
+ version = env.claude_cli_version(cli_path)
48
+ checks.append(
49
+ DoctorCheck(
50
+ name="claude-cli",
51
+ passed=version is not None,
52
+ detail=f"found at {cli_path} ({version or 'version unknown'})",
53
+ )
54
+ )
55
+
56
+ authed = env.is_authenticated()
57
+ checks.append(
58
+ DoctorCheck(
59
+ name="authentication",
60
+ passed=authed,
61
+ detail="credentials present" if authed else "no credentials found",
62
+ )
63
+ )
64
+
65
+ mcp_servers = env.configured_mcp_servers()
66
+ if mcp_servers:
67
+ checks.append(
68
+ DoctorCheck(
69
+ name="mcp-servers",
70
+ passed=False,
71
+ detail=(
72
+ f"{len(mcp_servers)} MCP server(s) configured ({', '.join(mcp_servers)}) — "
73
+ "MCP OAuth cannot complete unattended; verify these are already "
74
+ "authorized before starting a long run."
75
+ ),
76
+ )
77
+ )
78
+ else:
79
+ checks.append(DoctorCheck(name="mcp-servers", passed=True, detail="none configured"))
80
+
81
+ is_git_repo = (cwd / ".git").is_dir()
82
+ checks.append(
83
+ DoctorCheck(
84
+ name="working-directory",
85
+ passed=is_git_repo,
86
+ detail=(
87
+ f"{cwd} is a git repository"
88
+ if is_git_repo
89
+ else f"{cwd} is NOT a git repository — bypassing permissions here is riskier"
90
+ ),
91
+ )
92
+ )
93
+
94
+ return checks
95
+
96
+
97
+ def all_passed(checks: list[DoctorCheck]) -> bool:
98
+ return all(c.passed for c in checks)
@@ -0,0 +1,11 @@
1
+ """Use case: list known Claude Code sessions for the current (or a given)
2
+ working directory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from claudeloop.application.ports import SessionCatalog
7
+ from claudeloop.domain.session import SessionRef
8
+
9
+
10
+ def list_sessions(catalog: SessionCatalog, cwd: str | None = None) -> list[SessionRef]:
11
+ return catalog.list_all(cwd)
@@ -0,0 +1,34 @@
1
+ """Use cases: resume a specific session, or auto-select the most recently
2
+ modified one for a working directory."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from claudeloop.application.dto import RunResult
7
+ from claudeloop.application.ports import SessionCatalog
8
+ from claudeloop.application.runner import AutonomousRunner
9
+ from claudeloop.application.usecases.run_plan import with_done_marker_instruction
10
+ from claudeloop.domain.errors import InvalidSessionSelectorError
11
+ from claudeloop.domain.session import SessionRef
12
+
13
+
14
+ async def resume_explicit(
15
+ runner: AutonomousRunner,
16
+ *,
17
+ continue_prompt: str = "Continue exactly where you left off.",
18
+ ) -> RunResult:
19
+ """Resume via a session_id the AgentGateway was already constructed with
20
+ (see infrastructure/agent/gateway.py — resume/continuation is an option
21
+ passed at ClaudeAgentOptions construction time, not per-call)."""
22
+ prompt = with_done_marker_instruction(continue_prompt)
23
+ return await runner.run(initial_prompt=prompt, continue_prompt=prompt)
24
+
25
+
26
+ def resolve_most_recent(catalog: SessionCatalog, cwd: str) -> SessionRef:
27
+ ref = catalog.most_recent(cwd)
28
+ if ref is None:
29
+ raise InvalidSessionSelectorError(
30
+ f"No prior Claude Code sessions found for this directory ({cwd}). "
31
+ "Pass a plan file to start fresh, or --session-id to target a specific "
32
+ "session."
33
+ )
34
+ return ref
@@ -0,0 +1,41 @@
1
+ """Use case: seed a fresh session from a plan file and run it to completion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from claudeloop.application.dto import RunResult
8
+ from claudeloop.application.runner import AutonomousRunner
9
+ from claudeloop.domain.completion import DEFAULT_DONE_MARKER
10
+ from claudeloop.domain.plan import WorkPlan
11
+
12
+ _DONE_INSTRUCTION_TEMPLATE = (
13
+ "{prompt}\n\n"
14
+ "---\n"
15
+ "Process note (from the automation running you, not the user): this session "
16
+ "may be resumed automatically across multiple turns if you get cut off. "
17
+ "If, and only if, the ENTIRE task above is now fully complete with nothing "
18
+ "left to do, end your final message with this exact line on its own: "
19
+ "{marker}\n"
20
+ "If any work remains -- including work you were mid-way through -- do NOT "
21
+ "include that line, so the automation knows to resume you."
22
+ )
23
+
24
+
25
+ def with_done_marker_instruction(prompt_text: str, done_marker: str = DEFAULT_DONE_MARKER) -> str:
26
+ """Ported from legacy/claude_autoresume.py's with_done_marker_instruction()
27
+ (lines 232-248) as the fallback path for models without structured output."""
28
+ return _DONE_INSTRUCTION_TEMPLATE.format(prompt=prompt_text, marker=done_marker)
29
+
30
+
31
+ async def run_from_plan_file(
32
+ runner: AutonomousRunner,
33
+ plan_path: Path,
34
+ *,
35
+ continue_prompt: str = "Continue exactly where you left off.",
36
+ ) -> RunResult:
37
+ raw_text = plan_path.read_text(encoding="utf-8")
38
+ plan = WorkPlan.parse(raw_text)
39
+ initial_prompt = with_done_marker_instruction(plan.raw_text)
40
+ continue_with_marker = with_done_marker_instruction(continue_prompt)
41
+ return await runner.run(initial_prompt=initial_prompt, continue_prompt=continue_with_marker)
@@ -0,0 +1,93 @@
1
+ """Composition root — the only module permitted to know about every layer at
2
+ once. Wires concrete infrastructure adapters into application ports and hands
3
+ the assembled AutonomousRunner (or a lighter-weight use-case dependency) to
4
+ cli/. Nothing outside this file should import both a port name from
5
+ application/ports.py and its concrete infrastructure implementation."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import timedelta
11
+ from pathlib import Path
12
+
13
+ from claudeloop.application.runner import AutonomousRunner
14
+ from claudeloop.application.usecases.doctor import DoctorEnvironment
15
+ from claudeloop.domain.budget import Budget
16
+ from claudeloop.domain.waiting import WaitPolicyConfig
17
+ from claudeloop.infrastructure.agent.catalog import SdkSessionCatalog
18
+ from claudeloop.infrastructure.agent.gateway import ClaudeAgentGateway, ClaudeCapacityProbe
19
+ from claudeloop.infrastructure.audit import JsonlAuditLog
20
+ from claudeloop.infrastructure.clock import AnyioSleeper, SystemClock
21
+ from claudeloop.infrastructure.config import RunnerConfig
22
+ from claudeloop.infrastructure.doctor_env import RealDoctorEnvironment
23
+ from claudeloop.infrastructure.progress import ConsoleProgressReporter
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class RunnerContext:
28
+ runner: AutonomousRunner
29
+ gateway: ClaudeAgentGateway
30
+
31
+
32
+ def build_runner(
33
+ *,
34
+ cwd: Path,
35
+ config: RunnerConfig,
36
+ session_id: str | None = None,
37
+ resume: str | None = None,
38
+ continue_conversation: bool = False,
39
+ log_file: Path | None = None,
40
+ ) -> RunnerContext:
41
+ gateway = ClaudeAgentGateway(
42
+ cwd=str(cwd),
43
+ session_id=session_id,
44
+ resume=resume,
45
+ continue_conversation=continue_conversation,
46
+ max_turns=config.max_turns,
47
+ max_budget_usd=config.max_dollars,
48
+ retry_watchdog=config.retry_watchdog,
49
+ model=config.model,
50
+ )
51
+ probe = ClaudeCapacityProbe(cwd=str(cwd))
52
+ clock = SystemClock()
53
+ sleeper = AnyioSleeper(clock)
54
+ audit_path = Path(log_file) if log_file else cwd / "claudeloop.log.jsonl"
55
+ audit_log = JsonlAuditLog(audit_path)
56
+ progress = ConsoleProgressReporter()
57
+ budget = Budget(
58
+ max_turns=config.max_turns,
59
+ max_dollars=config.max_dollars,
60
+ max_attempts=config.max_attempts,
61
+ )
62
+ wait_policy = WaitPolicyConfig(
63
+ credits_probe_interval=timedelta(seconds=config.credits_probe_interval_seconds),
64
+ credits_probe_ceiling=timedelta(seconds=config.credits_probe_ceiling_seconds),
65
+ window_probe_interval=timedelta(seconds=config.window_probe_interval_seconds),
66
+ reset_grace=timedelta(seconds=config.reset_grace_seconds),
67
+ max_wait=(
68
+ timedelta(seconds=config.max_wait_seconds)
69
+ if config.max_wait_seconds is not None
70
+ else None
71
+ ),
72
+ )
73
+
74
+ runner = AutonomousRunner(
75
+ agent_gateway=gateway,
76
+ capacity_probe=probe,
77
+ clock=clock,
78
+ sleeper=sleeper,
79
+ audit_log=audit_log,
80
+ progress=progress,
81
+ budget=budget,
82
+ wait_policy=wait_policy,
83
+ done_marker=config.done_marker,
84
+ )
85
+ return RunnerContext(runner=runner, gateway=gateway)
86
+
87
+
88
+ def build_session_catalog() -> SdkSessionCatalog:
89
+ return SdkSessionCatalog()
90
+
91
+
92
+ def build_doctor_environment() -> DoctorEnvironment:
93
+ return RealDoctorEnvironment()
File without changes
claudeloop/cli/app.py ADDED
@@ -0,0 +1,60 @@
1
+ """The Typer root app and console-script entry point.
2
+
3
+ Registered in pyproject.toml as:
4
+ [project.scripts]
5
+ claudeloop = "claudeloop.cli.app:main"
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+
12
+ from claudeloop import __version__
13
+ from claudeloop.cli.commands.doctor import app as doctor_app
14
+ from claudeloop.cli.commands.resume import resume
15
+ from claudeloop.cli.commands.run import run
16
+ from claudeloop.cli.commands.sessions import app as sessions_app
17
+
18
+ app = typer.Typer(
19
+ name="claudeloop",
20
+ help=(
21
+ "Onion-architected, autonomous Claude Code session runner — never "
22
+ "blocks on a human, distinguishes rate limits from exhausted credits, "
23
+ "and resumes safely across usage windows."
24
+ ),
25
+ add_completion=False,
26
+ no_args_is_help=True,
27
+ )
28
+
29
+ app.command(name="run")(run)
30
+ app.command(name="resume")(resume)
31
+ app.add_typer(sessions_app, name="sessions")
32
+ app.add_typer(doctor_app, name="doctor")
33
+
34
+
35
+ def _version_callback(value: bool) -> None:
36
+ if value:
37
+ typer.echo(f"claudeloop {__version__}")
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def main_callback(
43
+ version: bool = typer.Option(
44
+ False,
45
+ "--version",
46
+ callback=_version_callback,
47
+ is_eager=True,
48
+ help="Show the installed claudeloop version and exit.",
49
+ ),
50
+ ) -> None:
51
+ del version # handled entirely by the eager callback above
52
+
53
+
54
+ def main() -> int:
55
+ app()
56
+ return 0
57
+
58
+
59
+ if __name__ == "__main__":
60
+ raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ """The single anyio bridge point between Typer's sync command functions and the
2
+ async claude_agent_sdk / AutonomousRunner call chain. One bridge, not one per
3
+ command — see docs/architecture/overview.md's async-bridge note.
4
+
5
+ SIGTERM is converted to SIGINT at the OS level before anyio.run() starts, so
6
+ both signals get the same well-understood handling: Python's default SIGINT
7
+ handler raises KeyboardInterrupt in the main thread, which anyio propagates
8
+ into the running task tree as a cancellation — letting in-flight `finally`
9
+ blocks (closing the AgentGateway, flushing the audit log) run before the
10
+ process exits, instead of dying mid-write."""
11
+
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import os
16
+ import signal
17
+ import sys
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import ParamSpec, TypeVar
20
+
21
+ import anyio
22
+
23
+ P = ParamSpec("P")
24
+ R = TypeVar("R")
25
+
26
+
27
+ def _sigterm_as_sigint(signum: int, frame: object) -> None:
28
+ del signum, frame
29
+ os.kill(os.getpid(), signal.SIGINT)
30
+
31
+
32
+ def async_command(func: Callable[P, Awaitable[R]]) -> Callable[P, R]:
33
+ """Wrap an async Typer command body so Typer (sync) can call it directly."""
34
+
35
+ @functools.wraps(func)
36
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
37
+ previous_handler = None
38
+ if hasattr(signal, "SIGTERM"): # pragma: no cover - platform-dependent
39
+ previous_handler = signal.signal(signal.SIGTERM, _sigterm_as_sigint)
40
+ bound = functools.partial(func, *args, **kwargs)
41
+ try:
42
+ return anyio.run(bound)
43
+ except KeyboardInterrupt: # pragma: no cover - real Ctrl-C/SIGTERM not exercised in tests
44
+ print("\nInterrupted — shutting down gracefully.", file=sys.stderr)
45
+ raise SystemExit(130) from None
46
+ finally:
47
+ if previous_handler is not None: # pragma: no cover - platform-dependent
48
+ signal.signal(signal.SIGTERM, previous_handler)
49
+
50
+ return wrapper
File without changes
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+
7
+ from claudeloop import bootstrap
8
+ from claudeloop.application.usecases.doctor import all_passed, run_doctor
9
+ from claudeloop.cli.render import render_doctor_checks
10
+
11
+ app = typer.Typer(add_completion=False)
12
+
13
+
14
+ @app.callback(invoke_without_command=True)
15
+ def doctor(ctx: typer.Context) -> None:
16
+ """Pre-flight checks before starting a long unattended run: Claude Code
17
+ installed and authenticated, configured MCP servers, working-directory
18
+ safety. Run this BEFORE `run`/`resume`, not instead of them."""
19
+ if ctx.invoked_subcommand is not None:
20
+ return
21
+ env = bootstrap.build_doctor_environment()
22
+ checks = run_doctor(env, cwd=Path.cwd())
23
+ typer.echo(render_doctor_checks(checks))
24
+ if not all_passed(checks):
25
+ raise typer.Exit(code=1)