Plan: claudeloop — autonomous Claude session runner + full Anthropic SDK CLI¶
Status. This is the original approved architecture plan (milestone M1–M5). M1 is complete — the pure domain core described below exists at
src/claudeloop/domain/, fully tested. M2–M5 are roadmap, not yet built. This document is preserved verbatim as the design record; see../architecture/overview.mdfor the living description of what's actually implemented, anddecisions/for individual ADRs distilled from the reasoning below.
Context¶
claude_autoresume.py (now legacy/claude_autoresume.py) is a 663-line single-file script that drives a Claude Code session to completion unattended. It shells out to claude -p --dangerously-skip-permissions --output-format stream-json --verbose, scrapes the stream for usage-limit signals with regexes, sleeps until a parsed reset time, and re-invokes with --continue/--resume until a done-marker string appears. It works, but it is a script: no package, no tests, no types, hand-rolled string matching against an undocumented stream format, and session discovery by globbing ~/.claude/projects/ — a path the docs explicitly warn against parsing because the format changes between releases.
The goal is to turn it into a proper pip-installable library and CLI: onion architecture, OOP, Typer, near-total coverage, extensive debug logging, security controls, and CI quality gates — covering both the Claude Agent SDK (the autonomous session runner) and the Anthropic REST SDK (full 1:1 command parity).
Four research findings reshape the design, and each removes or replaces something the current script hand-rolls:
-
Structured rate-limit data replaces regex scraping. The Agent SDK yields a typed
RateLimitEventcarryingstatus(allowed/allowed_warning/rejected),resets_at,rate_limit_type(five_hour/seven_day/seven_day_opus/seven_day_sonnet/overage),utilization, and separateoverage_status/overage_resets_at/overage_disabled_reason. Theallowed_warningfalse positive documented atlegacy/claude_autoresume.py:89-104becomes a field comparison instead of a heuristic. -
Not every 429 is waitable, and the current script cannot tell. A real transcript captured during development contains a real rejection with
error_code: "credits_required",disabled_reason: "out_of_credits",can_user_purchase_credits: true— and no reset time. Only a human adding credits clears it. Today the script sleeps an hour and retries forever. Because a credit top-up can also arrive during an ordinary window wait, blind sleeping to a far-futureresets_atis wrong in both cases: waiting must become a probe loop that notices returning capacity early. -
Session discovery has a supported API.
list_sessions()/get_session_info()returnSDKSessionInfo(session_id, summary, last_modified, custom_title, first_prompt, git_branch, cwd, tag, created_at) using cheap stat + head/tail reads. This replaces the fragile glob and the hand-rolled JSONL parse atlegacy/claude_autoresume.py:133-192. -
ClaudeSDKClientsurvives errors;query()does not. Single-shotquery()raises a bareExceptionafter yielding the error result and the process exits non-zero. A streaming-inputClaudeSDKClientstays alive across error results, so the outer respawn-and-resume loop collapses into repeated sends on one live process.
One deliberate non-finding: the official ant CLI already covers all 131 REST endpoints from the same Stainless spec hash as the Python SDK. The project chose to include the REST surface anyway, so it is scoped below as a generated namespace with a drift gate — never hand-written, because a hand-written snapshot is silently incomplete after the next SDK release.
Architecture¶
Onion, four layers, dependencies strictly inward. The point is not ceremony: it is that every hard decision (is this limit waitable? how long do we wait? is the work done?) becomes a pure function over value objects, which is what makes near-100% coverage honest rather than a mocking exercise.
src/claudeloop/
├── domain/ # pure. no I/O, no third-party imports, no async
│ ├── errors.py # AutoclaudeError hierarchy
│ ├── plan.py # WorkPlan, PlanItem (parsed from the md handoff)
│ ├── session.py # SessionRef, SessionSelector = PlanFile | MostRecent | Explicit
│ ├── capacity.py # CapacityState ADT, RateLimitWindow, CreditState
│ ├── classify.py # TurnSignals -> CapacityState
│ ├── completion.py # CompletionVerdict ADT, CompletionEvaluator
│ ├── waiting.py # AdaptiveWaitPolicy -> next probe instant
│ ├── budget.py # Budget, BudgetLedger (turns, dollars, wall clock)
│ └── loop.py # RunLoopStateMachine: (RunState, TurnOutcome, now) -> Decision
├── application/ # ports + use cases; depends only on domain
│ ├── ports.py # Protocols: AgentGateway, CapacityProbe, SessionCatalog,
│ │ # ApiGateway, Clock, Sleeper, ProgressReporter,
│ │ # AuditLog, Notifier, RunStateStore, SessionLock
│ ├── dto.py # TurnOutcome, ProbeResult, ApiInvocation
│ ├── runner.py # AutonomousRunner — drives the state machine over the ports
│ └── usecases/ # RunFromPlanFile, ResumeMostRecent, ResumeSession,
│ # Preflight, ListSessions, InvokeApiMethod
├── infrastructure/ # adapters; the only layer importing anthropic / claude_agent_sdk
│ ├── agent/ # gateway, options builder, message translation, probe,
│ │ # autonomy (can_use_tool + hooks), session catalog
│ ├── api/ # introspect, binder, gateway, providers
│ ├── clock.py logging.py audit.py state.py lock.py notify.py config.py
├── cli/ # Typer; hand-written core commands + generated `api` sub-app
└── bootstrap.py # composition root — the one module that knows every layer
The dependency rule is enforced in CI by import-linter (layered contract: cli → bootstrap → application → domain, with infrastructure importable only by bootstrap), not by convention.
Async bridge. The Agent SDK is anyio-based and Typer is sync. Wrap async commands in a single @async_command decorator in cli/asyncio.py that calls anyio.run(), installs SIGINT/SIGTERM handlers that request graceful drain (finish the in-flight turn, persist state, disconnect the client), and translates ClaudeSDKError subclasses into Typer exit codes. One bridge point, not one per command.
The autonomous run loop¶
domain/loop.py is a pure state machine; application/runner.py executes its decisions against the ports. States and the decisions that move between them:
| State | Entered when | Decision produced |
|---|---|---|
Preflight |
run starts | probe capacity before spending a real turn |
Running |
capacity available | send plan text (first turn) or continuation prompt |
Evaluating |
a turn ended | classify signals, evaluate completion |
Waiting |
capacity exhausted | compute next probe instant |
Probing |
wake from wait | cheap throwaway turn; re-classify |
Complete / Failed |
terminal | exit 0 / non-zero |
Classification (domain/classify.py) maps turn signals to a CapacityState, and the ordering matters:
RateLimitEvent.status == "allowed_warning"is not a limit — recordutilizationand keep going. This is the exact false positive that caused multi-day cooldowns before.status == "rejected", orResultMessage.api_error_status == 429, orAssistantMessage.error == "rate_limit"→ exhausted. Then discriminate:error_code == "credits_required"/disabled_reason == "out_of_credits"/overage_disabled_reasonset →CreditsExhausted(can_purchase)— no reset exists.resets_atpresent →WindowExhausted(resets_at, rate_limit_type).- neither →
WindowExhausted(None, unknown), fall back to the configured wait. AssistantMessage.error == "authentication_failed"→ terminal abort, never retry.subtype in {"error_max_turns", "error_max_budget_usd"}→ policy-controlled: raise the cap and continue, or abort.
Because the SDK's typed RateLimitEvent is reportedly dropped on some adapter paths (the binary contains [sdkMessageAdapter] Ignoring rate_limit_event message), classification reads all three signals and never depends on RateLimitEvent alone.
Waiting that notices a credit top-up¶
This is the part that replaces time.sleep(wait_seconds) at legacy/claude_autoresume.py:655. AdaptiveWaitPolicy returns the next instant to probe, never a single long sleep:
CreditsExhausted— no reset time exists, so the only thing that can change is a human buying credits. Probe on a bounded cadence (default 120s, backing off to a 600s ceiling) for as long as--max-waitallows. Fire theNotifieron entry so the human actually learns they need to act.WindowExhausted(resets_at)— probe atmin(resets_at + grace, now + window_probe_interval). Theresets_atbound is the expected path; the interval bound is what catches a top-up that lifts an overage-driven rejection before the window rolls over.- Each probe result is compared against the previous
CapacityState. A transition to available is logged explicitly — "capacity restored at probe #7, 26m into a 5h window; cause: overage_status allowed → resuming" — so the recovery is visible in the audit log rather than inferred.
The probe itself (infrastructure/agent/probe.py) runs a minimal throwaway turn: one-token prompt, max_turns=1, no tools, setting_sources=None so no CLAUDE.md is loaded, and extra_args={"no-session-persistence": None} so it leaves no transcript. Two improvements over today's preflight_wait(): it costs less, and it stops polluting the working session with "OK" turns. A rejected probe is not billed, so the cadence is safe.
CLAUDE_CODE_RETRY_WATCHDOG is deliberately left off for the outer loop. It would retry 429s in-process indefinitely, which sounds attractive but blocks silently for hours: no progress reporting, no credit-vs-window discrimination, no --max-wait, nothing in the audit log. Instead set a modest CLAUDE_CODE_MAX_RETRIES so transient 5xx/529 blips are absorbed in-process while hard limits surface to the runner. Expose --retry-watchdog for anyone who prefers the built-in behavior.
Never blocking on a human¶
The hard requirement is that the run never stalls waiting for an answer. Notifying the human is fine; waiting on them is not. Every stall path gets a mitigation:
- Permission prompts —
permission_mode="bypassPermissions". Note the Python SDK has nodangerously_skip_permissionsfield; this is the equivalent. - Defense in depth — a
can_use_toolcallback that returnsPermissionResultAllowwithout ever awaiting input, so an unexpected permission path still cannot block. AskUserQuestion— intercepted incan_use_tooland denied with guidance rather than auto-answered: returning a fabricated choice silently invents a decision the user never made, whereas a deny message ("running autonomously, no user available — choose the option you would recommend, note the assumption, and proceed") hands the decision back to the model with the constraint stated. The chosen assumption then lands in the transcript where it can be reviewed.ExitPlanMode— auto-approved, so a plan-mode turn cannot park.- Hooks —
PermissionRequestauto-allows;Notificationlogs only. - The model simply asking "Shall I proceed?" — no tool call, so no interception point. Handled by an appended system-prompt fragment establishing autonomous operation, and by the completion evaluator treating a turn with
complete: falseand no progress as a continuation rather than a stop. - stdin — never inherit a TTY; the runner is safe under
nohupand systemd. - MCP OAuth — cannot complete unattended.
doctorchecks configured MCP servers up front and fails fast with the servers named, rather than discovering it mid-run.
Completion detection¶
ClaudeAgentOptions.output_format carries a JSON schema so each turn returns a typed verdict in ResultMessage.structured_output:
{"complete": bool, "remaining_work": [str], "blocked_on": str | null, "summary": str}
domain/completion.py maps that to Done / Continue(remaining) / Blocked(reason). This kills both current failure modes: a marker colliding with the user's own prompt text, and a truncated limit message coincidentally containing marker-like text — the guard in legacy/claude_autoresume.py (the if not limited and done: check near the end of the retry loop) becomes unnecessary because completion is a typed field, not a substring.
The existing CLAUDELOOP_TASK_FULLY_COMPLETE marker is retained as a fallback for when structured_output is absent, with the instruction-appending logic ported from with_done_marker_instruction() in the legacy script. A limit always outranks a completion claim, as today.
When the input is an md plan, WorkPlan parses it into items and remaining_work is tracked per item, so the log shows what is actually left rather than one boolean.
The generated REST surface¶
claudeloop api … covers all 131 endpoints without hand-writing any of them.
- Discovery walks the class tree under
anthropic.resourcesvia thecached_propertydescriptors, not a live client — so no credentials are needed at import time. Each leaf yields resource path, method name, andinspect.signature. - Binding maps path and scalar params to real typed Typer options; the request body goes to
--json/--json-filewith@pathinlining. Mapping every nested TypedDict to flags is not worth it —antreaches the same conclusion with relaxed-YAML structured flags. - Modifiers:
--raw/--streamselect thewith_raw_response/with_streaming_responsevariants; list methods auto-paginate with--max-items;--providerselects the alternate clients (onlyAnthropicAWS,AnthropicGoogleCloud,AnthropicFoundrycarry the full tree — Bedrock/Mantle/Vertex expose Messages and Beta only, and the binder must reflect that rather than offering commands that will fail). - The drift gate is the deliverable that makes "no gaps" real: a test enumerates the SDK surface and asserts every endpoint method has a registered command, failing CI when an SDK upgrade adds one. It also asserts the discovered count against a committed baseline so removals are caught too. The six local helpers (
messages.stream/parse,beta.messages.stream/parse/tool_runner,beta.webhooks.unwrap) are explicitly enumerated as either bound or exempted — no silent omissions.
Logging, security, quality gates¶
Logging. structlog with a JSON renderer to file and a human renderer to console. Every record carries run_id, attempt_no, session_id, event_type. The full raw event stream is preserved to a per-run JSONL audit file, keeping today's "nothing is lost" property from run_once() in the legacy script. -v/-vv, --log-level, --log-file.
Security.
- A redaction processor in the structlog pipeline scrubs api_key, authorization_token, access_token, refresh_token, client_secret, secret_value, and Authorization headers. This matters more than usual because the REST surface includes vaults and credentials, and because debug logging is a stated requirement.
- Bypassing permissions is required for autonomy but must be chosen: explicit opt-in, refuse to run as root, and refuse outside a git repository or an allowlisted directory unless overridden. Support add_dirs allowlisting and the SDK's SandboxSettings.
- Budget guardrails are a safety control, not a nicety, for an unattended multi-hour loop: max_budget_usd, task_budget, max_turns, --max-wait, --max-attempts.
- No shell=True anywhere; moving off subprocess removes the surface entirely. Plan-file and log paths are resolved and confined.
- A per-session advisory file lock prevents two runners from driving one session concurrently.
Quality gates (pre-commit + GitHub Actions): ruff lint and format, mypy --strict, pytest with --cov-fail-under set per-package (100% domain and application, high floor for infrastructure), import-linter for the onion contract, the API drift test, bandit, and pip-audit.
Testing to ~100%¶
- Domain — pure unit tests, plus Hypothesis property tests for
AdaptiveWaitPolicy(never returns a past instant, never exceeds--max-wait, always converges) andLimitClassifier(a credits rejection never classifies as waitable). - Application — fakes for every port.
FakeAgentGatewayreplays scripted event sequences;FakeClock/FakeSleepermake a simulated 7-day wait run in microseconds with zero real sleeping. The credit-top-up path is tested by scripting a probe sequence that returnsCreditsExhaustedfive times thenAvailable, and asserting the runner resumes on probe six. - Contract tests run the same suite against both fake and real adapters where feasible.
- Fixtures — a real transcript captured during development already contains a genuine
credits_required429; capture it as a golden fixture rather than inventing one. - CLI — Typer's
CliRunner. # pragma: no coveris reserved for genuinely unreachable branches (signal handlers,TYPE_CHECKINGblocks) and each use carries a reason.
Build order¶
Each milestone leaves the tree working, and M2 already replaces the current script.
- M1 — pure core. Package skeleton,
pyproject.toml, domain layer, ports, full unit suite, CI with all gates. No SDK dependency yet. ✅ Complete. - M2 — runner parity. Agent gateway, options builder, message translation, session catalog,
run/resume/sessionscommands. Reaches feature parity withclaude_autoresume.py. - M3 — resilient waiting. Capacity probe, adaptive wait policy, credit-top-up detection, notifier, resumable run state.
- M4 — REST surface. Introspection, binder,
apisub-app, drift gate. - M5 — polish. Docs,
doctor, packaging verification, security review.
Verification¶
- Unit and property suites —
pytest --cov, gates green, including the simulated multi-day wait with no wall-clock sleep. - Drift gate — deliberately hide one SDK method from discovery and confirm CI fails; that proves the "no gaps" claim is enforced rather than asserted.
- Onion contract — add an import from
domaintoinfrastructureand confirmimport-linterrejects it. - End-to-end, plan-file mode — run against a small md plan in a scratch git repo and confirm it completes, exits 0, and the audit log shows the structured completion verdict.
- Never-block — run a plan that explicitly instructs the model to ask a clarifying question, and confirm the runner denies
AskUserQuestionwith guidance and continues instead of hanging. - Limit handling without waiting for a real limit — a fake gateway scripted to emit a
rejectedRateLimitEventwith aresets_at, then acredits_requiredrejection, asserting the first schedules a bounded probe and the second never schedules a blind sleep. - Credit top-up, live — the honest test is opportunistic: when a real
credits_requiredrejection occurs, add credits mid-wait and confirm the runner resumes on the next probe rather than at the window boundary. Until then the scripted probe sequence in M3 covers the logic. - Install check —
pipx install .on macOS and Linux, confirm theclaudeloopentry point resolves and--helprenders.