diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4507f822..93373894 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@
### Fixed
+## [v0.50.243] — 2026-04-30
+
+### Fixed
+- **Chip composer model badge — removed the `PRIMARY` projection** — The chip-projected configured-model badge added in #1287 was eating ≈30% of chip width (235px → 164px) without adding signal, since the model name is already right next to it. The dropdown rows still show `Primary` / `Fallback N` badges where they actually help distinguish picker entries. Backend `_build_configured_model_badges()` and the `configured_model_badges` payload on `/api/models` are preserved for the dropdown to consume. (`static/index.html`, `static/ui.js`, `static/style.css`, `tests/test_model_picker_badges.py`) — PR #1301
+- **Claude Opus 4.7 label rendering** — Adds explicit label entries for `anthropic/claude-opus-4.7`, `claude-opus-4.7`, and `claude-opus-4-7` so the picker no longer renders "Claude Opus 4 **7**" (missing dot) when the dashed-form model ID falls through to the generic dash-replace formatter. (`api/config.py`) — PR #1301
+- **Cron output snippet preserves the `## Response` section** — `/api/crons/output` returned `txt[:8000]` which could drop the useful response section when a large skill dump appeared in the prompt context. Now: if `## Response` exists, preserves a short header plus the response section; if no marker exists, returns the file tail rather than the head. (`api/routes.py`, `tests/test_sprint10.py`) @franksong2702 — PR #1297, fixes #1295
+
## [v0.50.242] — 2026-04-30
### Reverted
diff --git a/api/config.py b/api/config.py
index 21dde7d6..406261e2 100644
--- a/api/config.py
+++ b/api/config.py
@@ -503,6 +503,7 @@ _FALLBACK_MODELS = [
{"provider": "OpenAI", "id": "openai/gpt-5.4-mini", "label": "GPT-5.4 Mini"},
{"provider": "OpenAI", "id": "openai/gpt-5.4", "label": "GPT-5.4"},
# Anthropic — 4.6 flagship + 4.5 generation
+ {"provider": "Anthropic", "id": "anthropic/claude-opus-4.7", "label": "Claude Opus 4.7"},
{"provider": "Anthropic", "id": "anthropic/claude-opus-4.6", "label": "Claude Opus 4.6"},
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
{"provider": "Anthropic", "id": "anthropic/claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
@@ -641,6 +642,7 @@ def _resolve_provider_alias(name: str) -> str:
# Well-known models per provider (used to populate dropdown for direct API providers)
_PROVIDER_MODELS = {
"anthropic": [
+ {"id": "claude-opus-4.7", "label": "Claude Opus 4.7"},
{"id": "claude-opus-4.6", "label": "Claude Opus 4.6"},
{"id": "claude-sonnet-4.6", "label": "Claude Sonnet 4.6"},
{"id": "claude-sonnet-4-5", "label": "Claude Sonnet 4.5"},
@@ -738,6 +740,7 @@ _PROVIDER_MODELS = {
{"id": "gpt-5", "label": "GPT-5"},
{"id": "gpt-5-codex", "label": "GPT-5 Codex"},
{"id": "gpt-5-nano", "label": "GPT-5 Nano"},
+ {"id": "claude-opus-4-7", "label": "Claude Opus 4.7"},
{"id": "claude-opus-4-6", "label": "Claude Opus 4.6"},
{"id": "claude-opus-4-5", "label": "Claude Opus 4.5"},
{"id": "claude-opus-4-1", "label": "Claude Opus 4.1"},
diff --git a/api/routes.py b/api/routes.py
index 7f4ad069..8524c3e2 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -35,6 +35,8 @@ _CLIENT_DISCONNECT_ERRORS = (
# Track job IDs currently being executed so the frontend can poll status.
_RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp
_RUNNING_CRON_LOCK = threading.Lock()
+_CRON_OUTPUT_CONTENT_LIMIT = 8000
+_CRON_OUTPUT_HEADER_CONTEXT = 200
def _mark_cron_running(job_id: str):
@@ -56,6 +58,40 @@ def _is_cron_running(job_id: str) -> tuple[bool, float]:
return True, time.time() - t
+def _cron_response_marker_index(text: str) -> int:
+ """Return the start index of a markdown Response heading, if present."""
+ candidates = []
+ for heading in ("## Response", "# Response"):
+ if text.startswith(heading):
+ candidates.append(0)
+ idx = text.find(f"\n{heading}")
+ if idx >= 0:
+ candidates.append(idx + 1)
+ return min(candidates) if candidates else -1
+
+
+def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIMIT) -> str:
+ """Return a bounded cron output window that preserves useful response text.
+
+ Cron output files can contain large skill dumps in the Prompt section. The
+ UI already extracts ``## Response`` when present, so keep that section in
+ the API payload instead of blindly returning the first ``limit`` chars.
+ """
+ if limit <= 0:
+ return ""
+ if len(text) <= limit:
+ return text
+
+ response_idx = _cron_response_marker_index(text)
+ if response_idx >= 0:
+ header = text[:min(_CRON_OUTPUT_HEADER_CONTEXT, response_idx)].rstrip()
+ response = text[response_idx:].lstrip("\n")
+ content = f"{header}\n...\n{response}" if header else response
+ return content[:limit]
+
+ return text[-limit:]
+
+
def _run_cron_tracked(job):
"""Wrapper that tracks running state around cron.scheduler.run_job."""
try:
@@ -2932,7 +2968,7 @@ def _handle_cron_output(handler, parsed):
for f in files:
try:
txt = f.read_text(encoding="utf-8", errors="replace")
- outputs.append({"filename": f.name, "content": txt[:8000]})
+ outputs.append({"filename": f.name, "content": _cron_output_content_window(txt)})
except Exception:
logger.debug("Failed to read cron output file %s", f)
return j(handler, {"job_id": job_id, "outputs": outputs})
diff --git a/static/index.html b/static/index.html
index 34dd1515..50d5a59e 100644
--- a/static/index.html
+++ b/static/index.html
@@ -401,7 +401,6 @@