Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion config-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -1621,7 +1621,7 @@
"sensitive": false,
"tags": [],
"label": "Max Triggered",
"help": "Maximum number of skills to load per message (≥0). Defaults to 0 (disabled); set to a positive integer to re-enable per-turn trigger matching.",
"help": "Maximum number of skills a single message may flag as relevant (≥0). Each match injects that skill's full content, unless the skill sets inject_on_trigger: false (pointer-only; requires max_triggered > 0 to have any effect). Defaults to 0 (disabled): the agent discovers skills from the Available Skills index and reads them on demand via cat, $skillname, or skill_search. Set to a positive integer to re-enable per-turn word-overlap trigger matching.",
"hasChildren": false,
"enumValues": null,
"defaultValue": 0
Expand Down Expand Up @@ -3965,6 +3965,7 @@
"widget_density": "more",
"verbosity": "default",
"link_previews": false,
"usage_text_scrape_enabled": false,
"tail_fork_enabled": false,
"auto_open_browser": true,
"prevent_sleep": false,
Expand Down Expand Up @@ -4201,6 +4202,20 @@
"enumValues": null,
"defaultValue": false
},
{
"path": "dashboard.usage_text_scrape_enabled",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"label": "Spend Credits To Read The Credit Meter",
"help": "Let the credit pill fall back to a `kiro-cli /usage` chat turn when the free usage API returns no plan. That fallback is a REAL billed LLM turn on whichever model the lite agent resolves, and it repeats on every refresh interval for as long as any dashboard tab is open, so it is off by default: a meter that reports spending must not itself spend. While it is off the pill shows whatever the free API returned and hides when the API has nothing to show.",
"hasChildren": false,
"enumValues": null,
"defaultValue": false
},
{
"path": "dashboard.tail_fork_enabled",
"kind": "core",
Expand Down
16 changes: 16 additions & 0 deletions src/kiro_crew/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1939,6 +1939,19 @@ class DashboardConfig:
"returns 403.",
),
)
usage_text_scrape_enabled: bool = field(
default=False,
metadata=_meta(
"Spend Credits To Read The Credit Meter",
"Let the credit pill fall back to a `kiro-cli /usage` chat turn when "
"the free usage API returns no plan. That fallback is a REAL billed "
"LLM turn on whichever model the lite agent resolves, and it repeats "
"on every refresh interval for as long as any dashboard tab is open, "
"so it is off by default: a meter that reports spending must not "
"itself spend. While it is off the pill shows whatever the free API "
"returned and hides when the API has nothing to show.",
),
)
tail_fork_enabled: bool = field(
default=False,
metadata=_meta(
Expand Down Expand Up @@ -4908,6 +4921,9 @@ def load(cls) -> KiroCrewConfig:
widget_density=dashboard_data.get("widget_density", "more"),
verbosity=dashboard_data.get("verbosity", "default"),
link_previews=_safe_bool(dashboard_data.get("link_previews"), False),
usage_text_scrape_enabled=_safe_bool(
dashboard_data.get("usage_text_scrape_enabled"), False
),
tail_fork_enabled=dashboard_data.get("tail_fork_enabled", False),
terminal=dashboard_data.get("terminal", {"enabled": True}),
default_project=dashboard_data.get("default_project", ""),
Expand Down
144 changes: 144 additions & 0 deletions src/kiro_crew/dashboard/handlers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,123 @@ async def api_sessions_health(request: web.Request) -> web.Response:
_USAGE_REFRESH_SECS = 600 # background refresh every 10 min
_usage_fetching = False

# --- Text-scrape gate ------------------------------------------------------
# The `/usage` text scrape is a REAL billed kiro-cli chat turn, unlike the
# GetUsageLimits API read the primary path uses. It runs on a timer for as long
# as a dashboard tab is open, so an ungated fallback bills the user forever just
# to render a credit meter. Hence: opt-in via config, logged once when it is
# skipped, and backed off when it repeatedly fails.

#: True once the "scrape is disabled" notice has been logged. The refresh runs
#: every _USAGE_REFRESH_SECS forever, so logging per cycle would fill the log
#: with a message that never changes.
_usage_scrape_disabled_logged = False
#: Consecutive scrape attempts that produced no usable credit plan.
_usage_scrape_failures = 0
#: monotonic deadline before which no further scrape is attempted.
_usage_scrape_backoff_until = 0.0
#: Consecutive failures tolerated before the scrape is parked. Two refresh
#: intervals of bad luck stay within normal retry; a third means the scrape is
#: broken (kiro-cli format change, wedged CLI, revoked auth), and every further
#: attempt spends credits for output that cannot be parsed.
_USAGE_SCRAPE_FAILURE_THRESHOLD = 3
#: How long a broken scrape is parked. Long relative to the 10-minute refresh so
#: a persistent breakage costs a handful of turns per day, not one per interval.
_USAGE_SCRAPE_BACKOFF_SECS = 6 * 3600


def _text_scrape_enabled() -> bool:
"""True when the user has opted in to the credit-spending `/usage` scrape.

Fails CLOSED: any error reading config means the scrape does not run, so a
malformed config can never silently start billing chat turns. Blocking I/O
(stat + parse), so callers offload it.
"""
try:
from kiro_crew.config.loader import KiroCrewConfig

return bool(KiroCrewConfig.load().dashboard.usage_text_scrape_enabled)
except Exception:
logger.debug("usage text-scrape gate unreadable; treating as disabled", exc_info=True)
return False


def _log_scrape_disabled_once() -> None:
"""Announce the skipped scrape exactly once per process."""
global _usage_scrape_disabled_logged
if _usage_scrape_disabled_logged:
return
_usage_scrape_disabled_logged = True
logger.info(
"Kiro usage: the API returned no credit plan and the /usage text scrape "
"is disabled, so the credit pill stays unavailable. The scrape is a "
"billed kiro-cli chat turn every %ds; enable it with "
"dashboard.usage_text_scrape_enabled = true in config.json if you want "
"to pay for the readout.",
_USAGE_REFRESH_SECS,
)


def _scrape_in_backoff() -> bool:
"""True while a repeatedly-failing scrape is parked."""
return time.monotonic() < _usage_scrape_backoff_until


def _record_scrape_outcome(success: bool) -> None:
"""Track consecutive scrape failures and park the scrape once they pile up.

Every attempt costs credits, so a scrape that cannot produce a usable plan
must stop retrying on each TTL expiry. Any success clears the counter, so a
transient hiccup does not accumulate toward the ceiling.
"""
global _usage_scrape_failures, _usage_scrape_backoff_until
if success:
_usage_scrape_failures = 0
_usage_scrape_backoff_until = 0.0
return
_usage_scrape_failures += 1
if _usage_scrape_failures >= _USAGE_SCRAPE_FAILURE_THRESHOLD:
_usage_scrape_backoff_until = time.monotonic() + _USAGE_SCRAPE_BACKOFF_SECS
logger.warning(
"Kiro usage: %d consecutive /usage text scrapes yielded no credit "
"plan; pausing the scrape for %ds so it stops spending credits on "
"unusable output.",
_usage_scrape_failures,
_USAGE_SCRAPE_BACKOFF_SECS,
)


def _cache_without_scrape(api_usage: object, identity: dict[str, object]) -> None:
"""Cache the best available value when the scrape is not going to run.

Degrades rather than erroring: keep a previously-good value (dimmed
``stale``) so the pill does not blink out, otherwise surface whatever
partial fields the API did return alongside ``available: False`` — the
frontend's existing signal to hide the pill instead of rendering blanks.

Preserving is gated on ``_same_identity``: with the scrape disabled, a
plan-less API answer recurs every refresh forever, so an unguarded preserve
would serve the PREVIOUS account's balance and email indefinitely after a
switch A->B. An unproven identity (missing or mismatched email / start_url,
including an account that never carried one) therefore reports unavailable
instead — hiding the pill is a cosmetic loss, attributing one account's
spend to another is not.

``identity`` is this refresh's whoami, resolved before the API attempt, so a
switch landing inside that attempt is caught on the following refresh rather
than this one.
"""
global _usage_cache, _usage_cache_ts
if _usage_cache.get("credits_plan") is not None and _same_identity(_usage_cache, identity):
_usage_cache = {**_usage_cache, "stale": True}
else:
partial = {k: _redact_strings(v) for k, v in api_usage.items()} if (
isinstance(api_usage, dict)
) else {}
partial.pop("_profile_arn", None)
_usage_cache = {**partial, "available": False}
_usage_cache_ts = time.time()


def _safe_float(text: str) -> float | None:
"""Parse a float, returning None on malformed input instead of raising."""
Expand Down Expand Up @@ -476,6 +593,10 @@ async def _fetch_usage_bg() -> None:
proc = None
sandbox_cleanup = None
kiro_bin: str | None = None
# Only a refresh that actually SPAWNED the billed scrape feeds the failure
# backoff — an API-path error or a missing kiro-cli says nothing about
# whether the scrape works.
scrape_attempted = False
try:
kiro_bin = await _resolve_kiro_bin_for_spawn()
if not kiro_bin:
Expand Down Expand Up @@ -542,6 +663,20 @@ async def _fetch_usage_bg() -> None:
# Fallback: scrape kiro-cli /usage stdout. Lossy for org-managed accounts
# on recent kiro-cli (no overage line), but the only source when the API
# path is unavailable (no token / non-Kiro build).
#
# This is a BILLED chat turn, not a free read, and this refresh runs on a
# timer whenever a dashboard tab is open — so it only happens when the
# user has explicitly opted in, and stops entirely once it has failed
# enough times to look broken. Both checks are before the spawn, so a
# disabled or parked scrape costs nothing at all.
if not await asyncio.to_thread(_text_scrape_enabled):
_log_scrape_disabled_once()
_cache_without_scrape(api_usage, identity)
return
if _scrape_in_backoff():
_cache_without_scrape(api_usage, identity)
return
scrape_attempted = True
# Route through the OS-level sandbox, consistent with how the main agent
# kiro-cli process is spawned (AcpClient._spawn -> wrap_argv).
argv, sandbox_cleanup = wrap_argv(
Expand All @@ -558,6 +693,10 @@ async def _fetch_usage_bg() -> None:
raw = (out or err or b"").decode(errors="replace")
parsed = _parse_usage(raw)
if parsed.get("credits_plan") is not None:
# A parseable plan means the scrape itself works, so clear any
# accumulated failures even on the preservation path below (which
# discards the value for being overage-blind, not for being broken).
_record_scrape_outcome(True)
# Converge on the canonical shape (credits_used = total, explicit
# credits_overage) so the dashboard never branches on source, then
# redact credentials / exfil URLs from every string leaf before the
Expand Down Expand Up @@ -613,13 +752,18 @@ async def _fetch_usage_bg() -> None:
# No parseable credit plan this cycle (unrecognized /usage output,
# or transient garbage). Keep the last good value (stale) rather than
# blanking the pill; only hide when we have nothing to show.
_record_scrape_outcome(False)
_cache_transient_failure()
except asyncio.TimeoutError:
# Transient hang — keep the last good value (stale) instead of blanking.
logger.debug("Background usage fetch timed out")
if scrape_attempted:
_record_scrape_outcome(False)
_cache_transient_failure()
except Exception:
logger.debug("Background usage fetch failed", exc_info=True)
if scrape_attempted:
_record_scrape_outcome(False)
_cache_transient_failure()
finally:
# Always reap the subprocess on any exit path (timeout, error, or task
Expand Down
Loading
Loading