claude-cli: pause until reset on session-limit; harden capability-set loader

- On a Claude Code session/usage limit ('resets 7:10pm'), _call_claude_cli now
  pauses IN PLACE until the stated reset time + CLAUDE_CLI_LIMIT_BUFFER_SECONDS
  (default 60s past), then retries — up to CLAUDE_CLI_LIMIT_MAX_WAITS windows.
  Parses 12-hour reset times to their next occurrence; falls back to a fixed
  wait when no time is present. Makes long unattended runs survive reset windows.
- load_library() reports invalid capability-set JSON as a clean SystemExit
  (line number + reason) instead of a traceback; fixed a trailing-comma typo in
  data/capability_sets/dx02.json.
- Docs: USAGE.md + .env.example document the claude-cli provider and its knobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 08:42:19 -04:00
parent fefda7481f
commit 07eb9a5411
6 changed files with 170 additions and 27 deletions

View File

@@ -1,12 +1,19 @@
# Copy to .env and fill in. The loop runs WITHOUT a key using a mock reasoner.
# Copy to .env and fill in.
# --- LLM provider ---
# LLM_PROVIDER: anthropic | openai | gemini (auto-detected if blank).
LLM_PROVIDER=anthropic
# LLM_PROVIDER: claude-cli | anthropic | openai | gemini.
# claude-cli = run inference through Claude Code headless mode (`claude -p`),
# the sanctioned way to use a Claude subscription programmatically.
# (Direct-API calls with a Claude Code OAuth token are policy-blocked.)
LLM_PROVIDER=claude-cli
CLAUDE_CLI_MODEL=sonnet
CLAUDE_CLI_TIMEOUT=300
# On a session-limit hit ("resets 7:10pm"), pause until reset + buffer, then retry:
CLAUDE_CLI_LIMIT_BUFFER_SECONDS=60
CLAUDE_CLI_LIMIT_MAX_WAITS=6
CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS=3600
# Anthropic via OAuth (Claude subscription) — token from ANTHROPIC_OAUTH_TOKEN,
# else the Claude Code credential in the macOS keychain (auto-refreshed by
# Claude Code; run any `claude -p ...` command if it has expired).
# Direct Anthropic API (real API key) — use LLM_PROVIDER=anthropic:
LLM_MODEL=claude-sonnet-5
ANTHROPIC_EFFORT=low # low | medium | high | max
ANTHROPIC_USE_KEYCHAIN=1

View File

@@ -150,8 +150,13 @@ entitlements.json are gitignored and never leave the machine.
| Var | Default | Meaning |
|---|---|---|
| `LLM_PROVIDER` | auto | `anthropic` \| `openai` \| `gemini` (auto-detects from keys if blank) |
| `LLM_MODEL` | `claude-sonnet-5` | Anthropic model id |
| `LLM_PROVIDER` | `claude-cli` | `claude-cli` (subscription via Claude Code headless) \| `anthropic` (direct API key) \| `openai` \| `gemini` |
| `CLAUDE_CLI_MODEL` | `sonnet` | Model alias for the `claude -p` headless provider |
| `CLAUDE_CLI_TIMEOUT` | `300` | Per-call timeout (s) for the headless CLI |
| `CLAUDE_CLI_LIMIT_BUFFER_SECONDS` | `60` | On a session-limit hit, pause until the stated reset time **plus this** (1 min past), then retry |
| `CLAUDE_CLI_LIMIT_MAX_WAITS` | `6` | Max reset windows to wait through in one call before giving up |
| `CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS` | `3600` | Wait used only when no reset time can be parsed from the CLI message |
| `LLM_MODEL` | `claude-sonnet-5` | Anthropic model id (direct-API `anthropic` provider) |
| `ANTHROPIC_EFFORT` | `low` | Reasoning effort: `low` \| `medium` \| `high` \| `max` |
| `ANTHROPIC_USE_KEYCHAIN` | `1` | Read the Claude Code OAuth token from the macOS keychain (kept fresh by Claude Code; refresh with any `claude -p ...` call if expired) |
| `ANTHROPIC_OAUTH_TOKEN` | — | Explicit OAuth token (overrides keychain) |

View File

@@ -0,0 +1,55 @@
{
"version": "1.0",
"lastUpdated": "2026-07-16",
"_source": "https://www.broadcom.com/products/software/aiops-observability/operational-observability",
"_note": "One-off targeted capability set. Select at runtime: `loop --capability dx02`. Add more sets as data/capability_sets/<name>.json with this same schema.",
"capabilities": [
{
"id": "cap_es_dx_apm",
"grouping": "Enterprise Software",
"category": "DX — Observability & AIOps",
"name": "DX Application Performance Management (DX APM)",
"description": "Full-stack application performance monitoring with AI-driven analytics to detect, triage, and resolve performance issues fast.",
"businessProblems": [
"Application performance incidents hurting customer experience",
"Slow root-cause analysis across complex stacks",
"Siloed monitoring tools with no end-to-end view"
],
"buyingSignals": [
"Executive posts about reliability, uptime, or customer experience",
"Hiring SRE, observability, or performance engineers",
"Mentions of major outage or digital experience initiatives",
"Cloud migration increasing monitoring complexity",
"public complaints by users about application performance or availability",
"Major new mobile application announced",
"Mention of costly monitoring tools, specifically DataDog, Dynatrace, AppDynamics"
],
"antiSignals": ["Already owns DX APM per CRM", "Standardized on a competing APM/observability vendor"],
"targetBuyer": { "titles": ["VP IT Operations", "Head of SRE", "CIO"], "departments": ["IT", "Operations"] },
"keywords": ["apm", "dx apm", "observability", "application performance", "monitoring", "sre", "reliability", "root cause", "uptime"]
},
{
"id": "cap_es_dx_aiops",
"grouping": "Enterprise Software",
"category": "DX — Observability & AIOps",
"name": "DX Operational Observability & AIOps",
"description": "Unifies metrics, events, logs, and traces with AIOps to reduce noise, predict issues, and automate IT operations.",
"businessProblems": [
"Alert storms and tool sprawl overwhelming ops teams",
"Reactive operations with slow MTTR",
"No correlated view across hybrid infrastructure"
],
"buyingSignals": [
"Executive posts about AIOps, noise reduction, or MTTR",
"Hiring observability/AIOps roles",
"Mentions of consolidating monitoring tools",
"Major incident driving operations modernization"
],
"antiSignals": ["Already owns DX OI/AIOps per CRM"],
"targetBuyer": { "titles": ["VP IT Operations", "Head of Observability", "CIO"], "departments": ["IT", "Operations"] },
"keywords": ["aiops", "observability", "operational intelligence", "mttr", "noise reduction", "events", "logs", "traces", "automation"]
}
],
"bundleSignals": [],
"globalAntiSignals": []
}

View File

@@ -50,11 +50,16 @@ def active_set() -> str:
@lru_cache(maxsize=1)
def load_library() -> dict:
if _active_set == "ALL":
return json.loads((config.DATA_DIR / "capabilities.json").read_text())
return json.loads(
(config.DATA_DIR / "capability_sets" / f"{_active_set}.json").read_text()
)
path = (config.DATA_DIR / "capabilities.json" if _active_set == "ALL"
else config.DATA_DIR / "capability_sets" / f"{_active_set}.json")
try:
lib = json.loads(path.read_text())
except json.JSONDecodeError as e:
raise SystemExit(f"Capability set '{_active_set}' has invalid JSON "
f"({path.name}, line {e.lineno}): {e.msg}. Fix the file and re-run.")
if not lib.get("capabilities"):
raise SystemExit(f"Capability set '{_active_set}' ({path.name}) has no 'capabilities'.")
return lib
def capability_by_id(cap_id: str) -> dict | None:

View File

@@ -82,6 +82,13 @@ ANTHROPIC_AUTOREFRESH = os.environ.get("ANTHROPIC_AUTOREFRESH", "1").strip() ==
# policy-blocked (they return a fake 429 "Error"). Requires `claude` on PATH.
CLAUDE_CLI_MODEL = os.environ.get("CLAUDE_CLI_MODEL", "sonnet").strip()
CLAUDE_CLI_TIMEOUT = int(os.environ.get("CLAUDE_CLI_TIMEOUT", "300"))
# When `claude -p` reports a session/usage limit ("resets 7:10pm"), pause IN
# PLACE until that reset time plus this buffer, then retry — so unattended runs
# survive the subscription's rolling reset windows.
CLAUDE_CLI_LIMIT_BUFFER_SECONDS = int(os.environ.get("CLAUDE_CLI_LIMIT_BUFFER_SECONDS", "60"))
CLAUDE_CLI_LIMIT_MAX_WAITS = int(os.environ.get("CLAUDE_CLI_LIMIT_MAX_WAITS", "6"))
# Used only when a reset time can't be parsed from the CLI message.
CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS = int(os.environ.get("CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS", "3600"))
# Effort level for reasoning models (Sonnet 5 etc.): low | medium | high | max.
ANTHROPIC_EFFORT = os.environ.get("ANTHROPIC_EFFORT", "low").strip()

View File

@@ -256,12 +256,68 @@ def _call_anthropic(system: str, user: str, max_tokens: int) -> dict:
return _extract_json(text)
# Matches the CLI's session/usage-limit banners, e.g.
# "You've hit your session limit · resets 7:10pm" / "5-hour limit reached ∙ resets 3pm".
_USAGE_LIMIT_RE = re.compile(r"(session|usage|\d+-hour)\s+limit|hit your .*limit", re.I)
_RESET_TIME_RE = re.compile(r"resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*m\.?", re.I)
def _is_usage_limit(text: str) -> bool:
return bool(_USAGE_LIMIT_RE.search(text or ""))
def _reset_epoch(text: str) -> float | None:
"""Parse the reset wall-clock time (local tz) into an epoch for its NEXT
occurrence. Returns None if no time is present."""
from datetime import datetime, timedelta
m = _RESET_TIME_RE.search(text or "")
if not m:
return None
hour = int(m.group(1)) % 12
if m.group(3).lower() == "p":
hour += 12
minute = int(m.group(2) or 0)
now = datetime.now()
cand = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if cand <= now: # already passed today -> next occurrence
cand += timedelta(days=1)
return cand.timestamp()
def _wait_out_session_limit(text: str) -> None:
"""Pause in place until the stated reset time + buffer (or a fallback wait)."""
epoch = _reset_epoch(text)
now = time.time()
if epoch is None:
resume = now + config.CLAUDE_CLI_LIMIT_FALLBACK_WAIT_SECONDS
note = "no reset time parsed; using fallback wait"
else:
resume = epoch + config.CLAUDE_CLI_LIMIT_BUFFER_SECONDS
note = f"1 min past reset"
resume = max(resume, now + 1)
mins = int((resume - now) // 60)
hhmm = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(resume))
banner = (text or "").strip().replace("\n", " ")[:90]
print(f" [llm] Claude CLI session limit — pausing until {hhmm} "
f"(~{mins} min, {note}) then resuming. «{banner}»")
# Chunked sleep so a long pause heartbeats and stays Ctrl-C responsive.
while True:
remaining = resume - time.time()
if remaining <= 0:
break
time.sleep(min(remaining, 300))
print(" [llm] reset window elapsed — resuming.")
def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict:
"""Inference through Claude Code's official headless mode (`claude -p`).
This is the sanctioned way to run subscription inference programmatically:
direct /v1/messages calls with a Claude Code OAuth token are policy-blocked
(the API returns a disguised 429 'Error' for non-Claude-Code traffic).
On a session/usage limit the call pauses IN PLACE until the stated reset
time (+buffer) and retries, so long unattended runs survive reset windows.
"""
import subprocess
cmd = [
@@ -269,20 +325,28 @@ def _call_claude_cli(system: str, user: str, max_tokens: int) -> dict:
"--append-system-prompt", system + "\nReturn ONLY a JSON object. No prose, no markdown fences.",
"--model", config.CLAUDE_CLI_MODEL,
]
try:
proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=config.CLAUDE_CLI_TIMEOUT)
except FileNotFoundError:
raise LLMNotConfigured("`claude` CLI not found on PATH (needed for LLM_PROVIDER=claude-cli).")
except subprocess.TimeoutExpired:
raise LLMRetryable(f"claude CLI timed out after {config.CLAUDE_CLI_TIMEOUT}s")
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip()[:300]
# Usage-limit messages from the CLI are worth waiting out like a 429.
if "limit" in err.lower():
raise LLMRetryable(f"claude CLI usage limit: {err}")
raise LLMError(f"claude CLI failed (rc={proc.returncode}): {err}")
return _extract_json(proc.stdout.strip())
for wait_i in range(config.CLAUDE_CLI_LIMIT_MAX_WAITS + 1):
try:
proc = subprocess.run(cmd, capture_output=True, text=True,
timeout=config.CLAUDE_CLI_TIMEOUT)
except FileNotFoundError:
raise LLMNotConfigured("`claude` CLI not found on PATH (needed for LLM_PROVIDER=claude-cli).")
except subprocess.TimeoutExpired:
raise LLMRetryable(f"claude CLI timed out after {config.CLAUDE_CLI_TIMEOUT}s")
out, err = (proc.stdout or "").strip(), (proc.stderr or "").strip()
# A session/usage limit surfaces as a CLI-level message (stderr, or
# stdout on a nonzero exit) — never inside a successful JSON answer.
limit_text = err if _is_usage_limit(err) else (out if (proc.returncode != 0 and _is_usage_limit(out)) else "")
if limit_text:
if wait_i >= config.CLAUDE_CLI_LIMIT_MAX_WAITS:
raise LLMError(f"claude CLI session limit persisted after "
f"{config.CLAUDE_CLI_LIMIT_MAX_WAITS} wait(s): {limit_text[:200]}")
_wait_out_session_limit(limit_text)
continue
if proc.returncode != 0:
raise LLMError(f"claude CLI failed (rc={proc.returncode}): {(err or out)[:300]}")
return _extract_json(out)
def _dispatch(system: str, user: str, max_tokens: int) -> dict: