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.
- claudeloop/__init__.py +4 -0
- claudeloop/application/__init__.py +0 -0
- claudeloop/application/dto.py +40 -0
- claudeloop/application/ports.py +69 -0
- claudeloop/application/runner.py +140 -0
- claudeloop/application/usecases/__init__.py +0 -0
- claudeloop/application/usecases/doctor.py +98 -0
- claudeloop/application/usecases/list_sessions.py +11 -0
- claudeloop/application/usecases/resume_session.py +34 -0
- claudeloop/application/usecases/run_plan.py +41 -0
- claudeloop/bootstrap.py +93 -0
- claudeloop/cli/__init__.py +0 -0
- claudeloop/cli/app.py +60 -0
- claudeloop/cli/asyncio.py +50 -0
- claudeloop/cli/commands/__init__.py +0 -0
- claudeloop/cli/commands/doctor.py +25 -0
- claudeloop/cli/commands/resume.py +86 -0
- claudeloop/cli/commands/run.py +76 -0
- claudeloop/cli/commands/sessions.py +25 -0
- claudeloop/cli/render.py +47 -0
- claudeloop/domain/__init__.py +0 -0
- claudeloop/domain/budget.py +60 -0
- claudeloop/domain/capacity.py +53 -0
- claudeloop/domain/classify.py +73 -0
- claudeloop/domain/completion.py +65 -0
- claudeloop/domain/errors.py +26 -0
- claudeloop/domain/loop.py +196 -0
- claudeloop/domain/plan.py +73 -0
- claudeloop/domain/session.py +61 -0
- claudeloop/domain/waiting.py +86 -0
- claudeloop/infrastructure/__init__.py +0 -0
- claudeloop/infrastructure/agent/__init__.py +0 -0
- claudeloop/infrastructure/agent/autonomy.py +83 -0
- claudeloop/infrastructure/agent/catalog.py +53 -0
- claudeloop/infrastructure/agent/gateway.py +102 -0
- claudeloop/infrastructure/agent/options.py +69 -0
- claudeloop/infrastructure/agent/translate.py +141 -0
- claudeloop/infrastructure/api/__init__.py +0 -0
- claudeloop/infrastructure/audit.py +26 -0
- claudeloop/infrastructure/clock.py +27 -0
- claudeloop/infrastructure/config.py +93 -0
- claudeloop/infrastructure/doctor_env.py +86 -0
- claudeloop/infrastructure/lock.py +41 -0
- claudeloop/infrastructure/logging.py +79 -0
- claudeloop/infrastructure/notify.py +16 -0
- claudeloop/infrastructure/progress.py +18 -0
- claudeloop/infrastructure/state.py +27 -0
- claudeloop/py.typed +0 -0
- claudeloop-0.2.0.dist-info/METADATA +165 -0
- claudeloop-0.2.0.dist-info/RECORD +53 -0
- claudeloop-0.2.0.dist-info/WHEEL +4 -0
- claudeloop-0.2.0.dist-info/entry_points.txt +2 -0
- claudeloop-0.2.0.dist-info/licenses/LICENSE +21 -0
claudeloop/__init__.py
ADDED
|
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)
|
claudeloop/bootstrap.py
ADDED
|
@@ -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)
|