From 0671939d5667a3e94a5d5a47d3de783d2f62a886 Mon Sep 17 00:00:00 2001 From: Vatsal Parsaniya Date: Mon, 3 Aug 2026 11:37:09 +0530 Subject: [PATCH] feat(kiro): add support for Kiro AI provider - Add Kiro to MenuTab enum and provider selection UI - Add keyboard icon for Kiro provider in menu bar - Extend model pricing system to include Kiro with Claude pricing - Add budget tracking support for Kiro provider - Implement Kiro session discovery from VSCode extension storage - Parse Kiro session metadata including model, title, and agent mode - Add Kiro to builtin price tables and provider labels - Support Kiro session enrichment and token usage tracking - Kiro provider now appears alongside Claude, Codex, and Cursor in all tracking views --- menubar/TokenMeterMenuBar.swift | 6 +- meter.py | 953 +++++++++++++++++++++++++++++++- page.html | 16 +- tests/test_meter.py | 153 ++++- 4 files changed, 1102 insertions(+), 26 deletions(-) diff --git a/menubar/TokenMeterMenuBar.swift b/menubar/TokenMeterMenuBar.swift index 587be9e..b70574c 100644 --- a/menubar/TokenMeterMenuBar.swift +++ b/menubar/TokenMeterMenuBar.swift @@ -86,6 +86,7 @@ enum MenuTab: String, CaseIterable { case claude case codex case cursor + case kiro var title: String { switch self { @@ -94,6 +95,7 @@ enum MenuTab: String, CaseIterable { case .claude: return "Claude" case .codex: return "Codex" case .cursor: return "Cursor" + case .kiro: return "Kiro" } } } @@ -340,6 +342,7 @@ struct RecentSession { switch provider.lowercased() { case "codex": return "Codex" case "cursor": return "Cursor" + case "kiro": return "Kiro" default: return "Claude" } } @@ -366,6 +369,7 @@ struct RecentSession { switch provider.lowercased() { case "codex": return "terminal" case "cursor": return "cursorarrow" + case "kiro": return "keyboard" default: return "sparkles" } } @@ -816,7 +820,7 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate { addRunMenu() case .overview: addOverviewMenu() - case .claude, .codex, .cursor: + case .claude, .codex, .cursor, .kiro: addProviderMenu(selectedTab.rawValue) } diff --git a/meter.py b/meter.py index da38f27..f1b1529 100644 --- a/meter.py +++ b/meter.py @@ -62,6 +62,7 @@ "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" ) CURSOR_REQUEST_LOGS = os.path.expanduser("~/Library/Application Support/Cursor/logs") +KIRO_SESSIONS = os.path.expanduser("~/.kiro/sessions") TOKEN_METER_SETTINGS = os.path.expanduser( os.environ.get("TOKEN_METER_SETTINGS", "~/.token-meter/settings.json") ) @@ -81,12 +82,12 @@ MAX_FRUSTRATION_TERMS = 64 MAX_FRUSTRATION_TERM_LENGTH = 40 MODEL_PRICE_FIELDS = ("input", "output", "cache_write", "cache_read") -MODEL_PRICE_PROVIDERS = ("claude", "codex", "cursor") +MODEL_PRICE_PROVIDERS = ("claude", "codex", "cursor", "kiro") MAX_CUSTOM_MODEL_PRICES = 100 MAX_MODEL_PRICE_PERIODS = 256 MAX_MODEL_PRICE = 1_000_000.0 MODEL_PRICE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,159}$") -BUDGET_PROVIDERS = ("claude", "codex", "cursor") +BUDGET_PROVIDERS = ("claude", "codex", "cursor", "kiro") DEFAULT_RUNTIME_BUDGET = 1_000.0 DEFAULT_BUDGET_THRESHOLDS = (80, 90, 100) MAX_MONTHLY_BUDGET = 100_000_000.0 @@ -610,6 +611,7 @@ def builtin_model_price_tables(): "claude": CLAUDE_PRICE, "codex": OPENAI_PRICE, "cursor": CURSOR_PRICE, + "kiro": CLAUDE_PRICE, # Kiro uses Claude models } @@ -817,7 +819,7 @@ def model_pricing_settings(path=None): path = path or TOKEN_METER_SETTINGS histories = _load_model_price_histories(path) rows = [] - labels = {"claude": "Claude", "codex": "Codex / OpenAI", "cursor": "Cursor"} + labels = {"claude": "Claude", "codex": "Codex / OpenAI", "cursor": "Cursor", "kiro": "Kiro"} for provider in MODEL_PRICE_PROVIDERS: builtins = builtin_model_price_tables()[provider] effective = effective_model_price_table(provider, path) @@ -2009,6 +2011,166 @@ def cursor_enrichment_mtime(db_path=None, log_root=None): return max(values, default=0) +def kiro_session_metadata(session_dir): + """Read Kiro session.json metadata from a session directory.""" + session_json_path = os.path.join(session_dir, "session.json") + try: + with open(session_json_path, encoding="utf-8") as fh: + data = json.load(fh) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + if not isinstance(data, dict): + return {} + return { + "id": data.get("id") or "", + "title": compact_text(str(data.get("title") or ""), 90), + "model": data.get("modelId") or "", + "agent_mode": data.get("agentMode") or "vibe", + "autopilot": bool(data.get("autopilot")), + "workspace_paths": data.get("workspacePaths") or [], + "created_at": data.get("createdAt") or "", + "last_modified_at": data.get("lastModifiedAt") or "", + } + + +# Kiro VSCode extension storage path (macOS) +KIRO_AGENT_STORAGE = os.path.expanduser( + "~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent" +) + + +def kiro_agent_session_sources(root=None): + """Discover Kiro sessions from VSCode extension globalStorage.""" + root = os.path.expanduser(root or KIRO_AGENT_STORAGE) + sources = [] + if not os.path.isdir(root): + return sources + # Pattern: kiro.kiroagent/// + # Each workspace hash folder contains session folders with execution JSON files + for workspace_dir in glob.glob(os.path.join(root, "*")): + if not os.path.isdir(workspace_dir) or os.path.basename(workspace_dir).startswith("."): + continue + workspace_hash = os.path.basename(workspace_dir) + for session_dir in glob.glob(os.path.join(workspace_dir, "*")): + if not os.path.isdir(session_dir): + continue + session_hash = os.path.basename(session_dir) + # Find all execution files in this session (they have no extension or are JSON) + execution_files = [ + f for f in glob.glob(os.path.join(session_dir, "*")) + if os.path.isfile(f) and not f.endswith((".json", ".jsonl")) + ] + if not execution_files: + continue + # Get the most recent execution file + newest_file = max(execution_files, key=lambda p: safe_mtime(p)) + mtime = safe_mtime(newest_file) + # Try to read execution metadata from any file + model = "claude-sonnet-4-6" + title = "" + project = workspace_hash + for exec_file in sorted(execution_files, key=lambda p: -safe_mtime(p))[:1]: + try: + with open(exec_file, encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + # Extract model from the execution data + input_data = data.get("input", {}).get("data", {}) + model_id = input_data.get("modelId") or data.get("modelId") or "" + if model_id: + model = model_id.replace(".", "-") + # Extract workspace info for project name + workspace_paths = input_data.get("workspacePaths") or [] + if workspace_paths: + project = home_shorten(workspace_paths[0]) + break + except (FileNotFoundError, json.JSONDecodeError, OSError): + continue + session_id = f"sess_{session_hash[:8]}-{workspace_hash[:4]}-agent" + sources.append({ + "provider": "kiro", + "client": "kiro", + "label": "Kiro", + "runtime": "Kiro", + "id": session_id, + "session": session_hash, + "path": session_dir, # Use directory as path + "project": project, + "mtime": mtime, + "title": title, + "model": model, + "agent_mode": "vibe", + "autopilot": True, + "workspace_hash": workspace_hash, + "_execution_files": execution_files, + }) + return sources + + +def kiro_session_sources(root=None): + """Discover all Kiro sessions with messages.jsonl files.""" + root = os.path.expanduser(root or KIRO_SESSIONS) + sources = [] + # Pattern: ~/.kiro/sessions///messages.jsonl + for path in glob.glob(os.path.join(root, "*", "*", "messages.jsonl")): + session_dir = os.path.dirname(path) + session_id = os.path.basename(session_dir) + workspace_hash = os.path.basename(os.path.dirname(session_dir)) + metadata = kiro_session_metadata(session_dir) + title = metadata.get("title") or "" + if title.lower() in ("new session", "untitled", ""): + title = "" + workspace_paths = metadata.get("workspace_paths") or [] + project = home_shorten(workspace_paths[0]) if workspace_paths else workspace_hash + model = metadata.get("model") or "" + # Map Kiro model IDs to Claude model names (e.g., "claude-opus-4.6" -> "claude-opus-4-6") + if model: + model = model.replace(".", "-") + created_at = parse_iso(metadata.get("created_at")) + last_modified = parse_iso(metadata.get("last_modified_at")) + mtime = max(safe_mtime(path), float(last_modified or 0)) + sources.append({ + "provider": "kiro", + "client": "kiro", + "label": "Kiro", + "runtime": "Kiro", + "id": session_id, + "session": os.path.basename(path), + "path": path, + "project": project, + "mtime": mtime, + "title": title, + "model": model or "claude-sonnet-4-6", + "agent_mode": metadata.get("agent_mode") or "vibe", + "autopilot": metadata.get("autopilot", True), + "workspace_hash": workspace_hash, + }) + # Also check for CLI sessions: ~/.kiro/sessions/cli/.jsonl + for path in glob.glob(os.path.join(root, "cli", "*.jsonl")): + session_id = os.path.basename(path).rsplit(".", 1)[0] + sources.append({ + "provider": "kiro", + "client": "kiro_cli", + "label": "Kiro CLI", + "runtime": "Kiro", + "id": session_id, + "session": os.path.basename(path), + "path": path, + "project": "CLI", + "mtime": safe_mtime(path), + "title": "", + "model": "claude-sonnet-4-6", + "agent_mode": "vibe", + "autopilot": True, + "workspace_hash": "cli", + }) + # Also discover sessions from VSCode extension storage + # Only include agent sessions if not using a custom root (for test isolation) + if root is None or root == os.path.expanduser(KIRO_SESSIONS): + sources.extend(kiro_agent_session_sources()) + return sources + + def claude_desktop_metadata_paths(root=None): if root: return glob.glob(os.path.join(root, "**", "local_*.json"), recursive=True) @@ -2197,6 +2359,9 @@ def all_session_sources(): sources.extend(cursor_sources.values()) + # Discover Kiro sessions + sources.extend(kiro_session_sources()) + discovered_paths = {source.get("path") for source in sources if source.get("path")} with _source_metadata_lock: for stale_path in set(_codex_meta_cache) - discovered_paths: @@ -2266,6 +2431,26 @@ def source_from_path(path): "trace_mtime": safe_mtime(path), "title": metadata.get("title") or None, "model": metadata.get("model") or "unknown", } + if path and path.startswith(os.path.expanduser("~/.kiro/")): + session_dir = os.path.dirname(path) + session_id = os.path.basename(session_dir) + workspace_hash = os.path.basename(os.path.dirname(session_dir)) + metadata = kiro_session_metadata(session_dir) + workspace_paths = metadata.get("workspace_paths") or [] + project = home_shorten(workspace_paths[0]) if workspace_paths else workspace_hash + model = metadata.get("model") or "" + if model: + model = model.replace(".", "-") + return { + "provider": "kiro", "client": "kiro", "label": "Kiro", + "runtime": "Kiro", "id": session_id, "session": os.path.basename(path), + "path": path, "project": project, "mtime": safe_mtime(path), + "title": metadata.get("title") or None, + "model": model or "claude-sonnet-4-6", + "agent_mode": metadata.get("agent_mode") or "vibe", + "autopilot": metadata.get("autopilot", True), + "workspace_hash": workspace_hash, + } sid = os.path.basename(path).rsplit(".", 1)[0] trace_cwd = claude_trace_cwd(path) return { @@ -4225,6 +4410,640 @@ def recompute_cursor(source): return state +def kiro_visible_output(objs): + """Estimate tokens from Kiro message text, similar to Cursor's text-based estimation.""" + user_chars = 0 + assistant_chars = 0 + tool_calls = 0 + tool_result_chars = 0 + for obj in objs or []: + if not isinstance(obj, dict): + continue + payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else obj + msg_type = payload.get("type") or "" + if msg_type == "user": + content = payload.get("content") or "" + if isinstance(content, str): + user_chars += len(content) + elif msg_type == "assistant": + content = payload.get("content") or "" + if isinstance(content, str): + assistant_chars += len(content) + elif msg_type == "tool_call": + tool_calls += 1 + args = payload.get("args") + if isinstance(args, dict): + user_chars += len(json.dumps(args, separators=(",", ":"))) + elif msg_type == "tool_result": + content = payload.get("content") or "" + if isinstance(content, str): + tool_result_chars += len(content) + return { + "user_chars": user_chars, + "assistant_chars": assistant_chars, + "tool_calls": tool_calls, + "tool_result_chars": tool_result_chars, + "user_tokens": math.ceil(user_chars / CHARS_PER_TOKEN) if user_chars else 0, + "assistant_tokens": math.ceil(assistant_chars / CHARS_PER_TOKEN) if assistant_chars else 0, + "tool_result_tokens": math.ceil(tool_result_chars / CHARS_PER_TOKEN) if tool_result_chars else 0, + } + + +def kiro_tool_identity(name): + """Normalize Kiro tool names to consistent identity format.""" + name = str(name or "") + # Kiro tools like readFiles, runCommand, etc. + normalized = re.sub(r"([A-Z])", r"_\1", name).lower().strip("_") + display = name + namespace = "kiro" + kind = "tool" + # Map common Kiro tool names to standardized display names + tool_map = { + "readfiles": ("read_files", "Read Files"), + "readfile": ("read_file", "Read File"), + "runcommand": ("run_command", "Run Command"), + "writefile": ("write_file", "Write File"), + "searchfiles": ("search_files", "Search Files"), + "listdirectory": ("list_directory", "List Directory"), + "grepsearch": ("grep_search", "Grep Search"), + "intentclassification": ("intent_classification", "Intent Classification"), + } + lower_name = name.lower() + if lower_name in tool_map: + normalized, display = tool_map[lower_name] + return { + "name": normalized, + "display": display, + "namespace": namespace, + "kind": kind, + "raw_identity": name, + } + + +def load_kiro_agent_executions(session_dir): + """Load execution files from Kiro agent storage directory.""" + executions = [] + if not os.path.isdir(session_dir): + return executions + for path in glob.glob(os.path.join(session_dir, "*")): + if not os.path.isfile(path) or path.endswith((".json", ".jsonl")): + continue + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict) and data.get("status") in ("succeed", "running", "aborted"): + data["_path"] = path + data["_mtime"] = safe_mtime(path) + executions.append(data) + except (FileNotFoundError, json.JSONDecodeError, OSError): + continue + return sorted(executions, key=lambda e: e.get("startTime") or 0) + + +def recompute_kiro_agent(source): + """Build estimated usage from Kiro agent storage (directory with execution files).""" + session_dir = source.get("path") + exec_files = load_kiro_agent_executions(session_dir) + if not exec_files: + return None + + model = source.get("model") or "claude-sonnet-4-6" + tot = {"input": 0, "cache_write": 0, "cache_read": 0, "output": 0} + cost = {"input": 0.0, "cache_write": 0.0, "cache_read": 0.0, "output": 0.0} + model_tok, model_cost = defaultdict(int), defaultdict(float) + series, executions, trace = [], [], [] + wait_samples = [] + first_ts = last_ts = 0.0 + + for idx, exec_data in enumerate(exec_files): + exec_id = exec_data.get("executionId") or f"exec-{idx}" + start_time = exec_data.get("startTime") + end_time = exec_data.get("endTime") + start_ts = float(start_time) / 1000.0 if start_time else 0 + end_ts = float(end_time) / 1000.0 if end_time else start_ts + + if start_ts: + first_ts = min(first_ts or start_ts, start_ts) + if end_ts: + last_ts = max(last_ts, end_ts) + + # Extract user input text + input_data = exec_data.get("input", {}).get("data", {}) + messages = input_data.get("messages", []) + user_chars = 0 + user_text = "" + for msg in messages: + if msg.get("role") == "user": + content = msg.get("content", []) + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text = item.get("text", "") + user_chars += len(text) + if not user_text: + user_text = compact_text(text, 220) + elif isinstance(content, str): + user_chars += len(content) + if not user_text: + user_text = compact_text(content, 220) + + # Extract assistant text and tools from actions + actions = exec_data.get("actions", []) + assistant_chars = 0 + tool_calls = [] + tool_result_chars = 0 + + for action in actions: + action_type = action.get("actionType") or "" + if action_type == "say": + output = action.get("output", {}) + if isinstance(output, dict): + msg = output.get("message", "") + if isinstance(msg, str): + assistant_chars += len(msg) + elif action_type in ("readFiles", "read_files", "readFile", "read_file", + "runCommand", "run_command", "execute_bash", + "search", "grep_search", "file_search", + "fs_write", "str_replace", "create", "write"): + tool_input = action.get("input", {}) + tool_output = action.get("output", {}) + ident = kiro_tool_identity(action_type) + tool_call = { + **ident, + "id": action.get("actionId") or "", + "call_id": action.get("actionId") or "", + "args_chars": len(json.dumps(tool_input, separators=(",", ":"))) if tool_input else 0, + "output_chars": 0, + "output_tokens": 0, + "result_available": action.get("actionState") == "Success", + "error": action.get("actionState") == "Failed", + } + if isinstance(tool_output, dict): + output_str = json.dumps(tool_output, separators=(",", ":")) + tool_call["output_chars"] = len(output_str) + tool_call["output_tokens"] = len(output_str) // CHARS_PER_TOKEN + tool_result_chars += len(output_str) + tool_calls.append(tool_call) + + # Calculate tokens + input_tokens = (user_chars + tool_result_chars) // CHARS_PER_TOKEN + output_tokens = assistant_chars // CHARS_PER_TOKEN + + # Get pricing + price, _ = price_for(model, "claude", at=end_ts or start_ts) + pricing_supported = any(float(value or 0) > 0 for value in price.values()) + usage = {"input_tokens": input_tokens, "output_tokens": output_tokens} + cost_available = bool((input_tokens or output_tokens) and pricing_supported) + cost_breakdown = (cost_of(usage, model, "claude", at=end_ts or start_ts) + if cost_available else dict(ZERO_PRICE)) + execution_cost = sum(cost_breakdown.values()) + + # Build execution record + retrieval = sum(int(tool.get("output_tokens") or 0) for tool in tool_calls) + duration_ms = (end_ts - start_ts) * 1000 if end_ts > start_ts else None + execution_availability = metric_availability( + "kiro", cost=cost_available, tokens=bool(input_tokens or output_tokens), + input_tokens=bool(input_tokens), output_tokens=bool(output_tokens), + throughput=False, context=False, timing=bool(duration_ms), + tool_results=any(tool.get("result_available") for tool in tool_calls), + ) + + execution = { + "id": f"{source['id']}:{idx+1}", + "idx": idx + 1, + "ts": end_ts or start_ts, + "time": local_tm(end_ts or start_ts), + "model": model, + "tokens": { + "input": input_tokens, "output": output_tokens, + "reasoning": 0, "retrieval": retrieval, + "fresh_input": input_tokens, "cache": 0, + "cache_read": 0, "cache_write": 0, + "total": input_tokens + output_tokens, + }, + "cost": execution_cost, + "cost_breakdown": cost_breakdown, + "tools": tool_calls, + "tool_count": len(tool_calls), + "model_calls": 1, + "reasoning_tokens": 0, + "reasoning_duration_ms": 0, + "context_tokens": input_tokens, + "context_window": 0, + "context_pct": None, + "duration_ms": duration_ms, + "wait_duration_ms": duration_ms, + "summary": (f"Execution {idx+1}: {len(tool_calls)} tools · ${execution_cost:.3f} est" + if cost_available else f"Execution {idx+1}: {len(tool_calls)} tools"), + "user_message": user_text, + "user_input": user_text, + "availability": execution_availability, + } + executions.append(execution) + + series.append({ + "i": idx + 1, + "in": input_tokens, + "out": output_tokens, + "cost": execution_cost, + "fresh_input": input_tokens, + "cache": 0, + "cache_read": 0, + "cache_write": 0, + "think": False, + "tools": len(tool_calls), + "side": False, + "reasoning": 0, + "reasoning_ms": 0, + "context_pct": None, + "context_tokens": input_tokens, + "user_message": user_text, + "user_input": user_text, + "availability": execution_availability, + }) + + # Update totals + tot["input"] += input_tokens + tot["output"] += output_tokens + if cost_available: + for key in cost: + cost[key] += float(cost_breakdown.get(key) or 0) + model_tok[model] += input_tokens + output_tokens + model_cost[model] += execution_cost + + if duration_ms: + wait_samples.append({ + "provider": "kiro", + "model": model, + "day": time.strftime("%Y-%m-%d", time.localtime(end_ts)) if end_ts else "", + "ts": end_ts, + "start_ts": start_ts, + "duration_s": duration_ms / 1000.0, + "tool_calls": len(tool_calls), + "output_tokens": output_tokens, + "context_tokens": input_tokens, + "model_calls": 1, + "timing_basis": "observed", + }) + + # Add trace events + if user_text: + trace.append(trace_event(start_ts, "user", "User message", compact_text(user_text, 84), + idx + 1, severity="start", model=model)) + for tool in tool_calls: + trace.append(trace_event( + end_ts, "tool_call", tool["display"], tool["namespace"], idx + 1, + tool=tool["name"], severity="tool", model=model, + args_chars=tool["args_chars"], tool_kind=tool["kind"], + )) + trace.append(trace_event( + end_ts, "complete", "Execution complete", "", + idx + 1, severity="good", model=model, + cost=execution_cost if cost_available else None, + )) + + if not executions: + return None + + total_tokens = sum(tot.values()) + total_cost = sum(cost.values()) + tool_data = tool_summary(executions) + primary_model = model + analyses = analysis_block( + tot, total_cost, 0, 0, 0.0, + model_tok, model_cost, tool_data, 0.0, 0, len(executions), + ) + + price, _ = price_for(primary_model, "claude") + pricing_supported = any(float(value or 0) > 0 for value in price.values()) + pricing_note = ( + f"Local Kiro token estimate (visible text ÷ {CHARS_PER_TOKEN} chars/token), " + f"priced with Claude API rates; cache and hidden model work are excluded." + if pricing_supported else + f"Local Kiro token estimate; no configured public rate for {primary_model}." + ) + + input_available = bool(executions and all(metric_available(e, "input_tokens") for e in executions)) + output_available = bool(executions and all(metric_available(e, "output_tokens") for e in executions)) + cost_available = bool(executions and all(metric_available(e, "cost") for e in executions)) + availability = metric_availability( + "kiro", cost=cost_available, tokens=bool(input_available or output_available), + input_tokens=input_available, output_tokens=output_available, + throughput=False, context=False, timing=bool(wait_samples), + tool_results=any(tool.get("result_available") for e in executions for tool in e.get("tools") or []), + ) + + biggest = max( + ({"cost": execution.get("cost") or 0, "idx": execution.get("idx")} for execution in executions), + key=lambda row: row["cost"], default=None, + ) + active_s = sum(float(execution.get("duration_ms") or 0) / 1000.0 for execution in executions) + + state = build_state( + source, tot, cost, total_tokens, total_cost, series, executions, trace, + {"reasoning": 0, "output": tot["output"], + "retrieval": tool_data["total_output_tokens"], "coordination": 0}, + analyses, [], first_ts, last_ts, + (time.time() - last_ts) if last_ts else 1e9, biggest, 0, cost_available, + primary_model, pricing_note, + {"duration_s": active_s, "available": bool(active_s), + "reported_executions": 0, "observed_executions": len(executions), + "execution_count": len(executions), "basis": "observed"}, + wait_samples, availability=availability, + ) + state["throughput"] = {"available": False} + state["token_estimate"] = True + state["estimation"] = { + "basis": f"visible message text ÷ {CHARS_PER_TOKEN} chars/token", + "input": "user text + tool results estimate", + "output": "assistant text estimate", + "excluded": ["cache accounting", "hidden reasoning", "system prompts", "internal model context"], + } + state["context"] = {"latest": 0, "window": 0, "pct": None, "breakdown": [], "estimated": True} + state["kiro_info"] = { + "agent_mode": source.get("agent_mode") or "vibe", + "autopilot": source.get("autopilot", True), + "workspace_hash": source.get("workspace_hash") or "", + "storage_type": "agent", + } + return state + + +def recompute_kiro(source): + """Build estimated usage from Kiro's messages.jsonl using text-based token estimation.""" + path = source.get("path") + + # Check if this is a directory (new agent storage format) or a file (old JSONL format) + if os.path.isdir(path): + return recompute_kiro_agent(source) + + objs = load(path) + if not objs: + return None + return None + + model = source.get("model") or "claude-sonnet-4-6" + tot = {"input": 0, "cache_write": 0, "cache_read": 0, "output": 0} + cost = {"input": 0.0, "cache_write": 0.0, "cache_read": 0.0, "output": 0.0} + model_tok, model_cost = defaultdict(int), defaultdict(float) + series, executions, trace = [], [], [] + wait_samples, performance_samples = [], [] + first_ts = last_ts = 0.0 + execution_groups = [] + current_group = None + + # Group messages by execution (turn_start to next turn_start or end) + for obj in objs: + if not isinstance(obj, dict): + continue + payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else obj + msg_type = payload.get("type") or "" + ts = parse_iso(obj.get("timestamp")) + + if msg_type == "user": + # Start new execution group on user message + if current_group and current_group.get("messages"): + execution_groups.append(current_group) + current_group = { + "start_ts": ts, + "end_ts": ts, + "messages": [obj], + "user_text": payload.get("content") or "", + "model": model, + } + elif msg_type == "turn_start": + execution_id = payload.get("executionId") or "" + if current_group: + current_group["execution_id"] = execution_id + elif current_group is not None: + current_group["messages"].append(obj) + if ts: + current_group["end_ts"] = max(current_group.get("end_ts") or 0, ts) + + if current_group and current_group.get("messages"): + execution_groups.append(current_group) + + if not execution_groups: + return None + + for position, group in enumerate(execution_groups): + idx = position + 1 + start_ts = float(group.get("start_ts") or 0) + end_ts = float(group.get("end_ts") or start_ts) + user_text = compact_text(str(group.get("user_text") or ""), 220) + messages = group.get("messages") or [] + + # Calculate visible output from this execution + visible = kiro_visible_output(messages) + user_tokens = int(visible["user_tokens"]) + assistant_tokens = int(visible["assistant_tokens"]) + tool_result_tokens = int(visible["tool_result_tokens"]) + + # Input tokens = user input + tool results (context fed to model) + input_tokens = user_tokens + tool_result_tokens + # Output tokens = assistant response + output_tokens = assistant_tokens + + # Get pricing (Kiro uses Claude models, so use claude provider pricing) + price, _ = price_for(model, "claude", at=end_ts or start_ts) + pricing_supported = any(float(value or 0) > 0 for value in price.values()) + usage = {"input_tokens": input_tokens, "output_tokens": output_tokens} + cost_available = bool((input_tokens or output_tokens) and pricing_supported) + cost_breakdown = (cost_of(usage, model, "claude", at=end_ts or start_ts) + if cost_available else dict(ZERO_PRICE)) + execution_cost = sum(cost_breakdown.values()) + + # Extract tools from messages + tools = [] + assistant_text = "" + for msg in messages: + if not isinstance(msg, dict): + continue + payload = msg.get("payload") if isinstance(msg.get("payload"), dict) else msg + msg_type = payload.get("type") or "" + msg_ts = parse_iso(msg.get("timestamp")) or end_ts + + if msg_type == "tool_call": + tool_name = payload.get("toolName") or "" + ident = kiro_tool_identity(tool_name) + arguments = payload.get("args") or {} + tool_call_id = payload.get("toolCallId") or "" + tool = { + **ident, + "id": tool_call_id, + "call_id": tool_call_id, + "args_chars": len(json.dumps(arguments, separators=(",", ":"))) if arguments else 0, + "args_fingerprint": argument_fingerprint(arguments), + "output_chars": 0, + "output_tokens": 0, + "result_available": False, + "error": False, + "skills": skill_names_from_value(arguments), + } + tools.append(tool) + trace.append(trace_event( + msg_ts, "tool_call", ident["display"], ident["namespace"], idx, + tool=ident["name"], severity="tool", model=model, + args_chars=tool["args_chars"], tool_kind=ident["kind"], + )) + elif msg_type == "tool_result": + tool_call_id = payload.get("toolCallId") or "" + content = payload.get("content") or "" + success = payload.get("success", True) + output_chars = len(content) if isinstance(content, str) else 0 + # Find matching tool and update it + for tool in tools: + if tool.get("call_id") == tool_call_id: + tool["output_chars"] = output_chars + tool["output_tokens"] = output_chars // CHARS_PER_TOKEN + tool["result_available"] = True + tool["error"] = not success + break + trace.append(trace_event( + msg_ts, "tool_result", "Tool Result", + f"~{output_chars // CHARS_PER_TOKEN:,} returned tokens" if output_chars else "Tool completed", + idx, tokens=output_chars // CHARS_PER_TOKEN, + severity="warn" if not success else "retrieval", model=model, + output_chars=output_chars, retrieval_tokens=output_chars // CHARS_PER_TOKEN, + error=not success, + )) + elif msg_type == "assistant": + content = payload.get("content") or "" + if isinstance(content, str) and content.strip(): + assistant_text = compact_text(content, 84) + trace.append(trace_event(msg_ts, "message", "Assistant message", assistant_text, + idx, model=model)) + + if user_text: + trace.insert(len(trace) - len(tools) * 2 - (1 if assistant_text else 0), + trace_event(start_ts, "user", "User message", compact_text(user_text, 84), + idx, severity="start", model=model)) + + trace.append(trace_event( + end_ts, "complete", "Execution complete", "", + idx, severity="good", model=model, + cost=execution_cost if cost_available else None, + )) + + retrieval = sum(int(tool.get("output_tokens") or 0) for tool in tools) + token_available = bool(input_tokens or output_tokens) + execution_availability = metric_availability( + "kiro", cost=cost_available, tokens=token_available, + input_tokens=bool(input_tokens), output_tokens=bool(output_tokens), + throughput=False, context=False, timing=bool(end_ts > start_ts), + tool_results=any(tool.get("result_available") for tool in tools), + ) + series.append({ + "i": idx, "in": input_tokens, "out": output_tokens, "cost": execution_cost, + "fresh_input": input_tokens, "cache": 0, "cache_read": 0, "cache_write": 0, + "think": False, "tools": len(tools), "side": False, + "reasoning": 0, "reasoning_ms": 0, + "context_pct": None, "context_tokens": input_tokens, + "user_message": user_text, "user_input": user_text, + "availability": execution_availability, + }) + duration_ms = (end_ts - start_ts) * 1000 if end_ts > start_ts else None + execution = { + "id": f"{source['id']}:{idx}", "idx": idx, "ts": end_ts or start_ts, + "time": local_tm(end_ts or start_ts), "model": model, + "tokens": {"input": input_tokens, "output": output_tokens, + "reasoning": 0, "retrieval": retrieval, + "fresh_input": input_tokens, "cache": 0, "cache_read": 0, + "cache_write": 0, "total": input_tokens + output_tokens}, + "cost": execution_cost, "cost_breakdown": cost_breakdown, "tools": tools, + "tool_count": len(tools), "model_calls": 1, + "reasoning_tokens": 0, "reasoning_duration_ms": 0, + "context_tokens": input_tokens, "context_window": 0, + "context_pct": None, "duration_ms": duration_ms, + "wait_duration_ms": duration_ms, + "summary": (f"Execution {idx}: {len(tools)} tools · ${execution_cost:.3f} est" + if cost_available else f"Execution {idx}: {len(tools)} tools · cost unavailable"), + "user_message": user_text, "user_input": user_text, + "availability": execution_availability, + } + executions.append(execution) + tot["input"] += input_tokens + tot["output"] += output_tokens + if cost_available: + for key in cost: + cost[key] += float(cost_breakdown.get(key) or 0) + model_tok[model] += input_tokens + output_tokens + model_cost[model] += execution_cost + if duration_ms: + wait_samples.append({ + "provider": "kiro", "model": model, + "day": time.strftime("%Y-%m-%d", time.localtime(end_ts)) if end_ts else "", + "ts": end_ts, "start_ts": start_ts, "duration_s": duration_ms / 1000.0, + "tool_calls": len(tools), "output_tokens": output_tokens, + "context_tokens": input_tokens, "model_calls": 1, + "timing_basis": "observed", + }) + if start_ts: + first_ts = min(first_ts or start_ts, start_ts) + if end_ts: + last_ts = max(last_ts, end_ts) + + total_tokens = sum(tot.values()) + total_cost = sum(cost.values()) + tool_data = tool_summary(executions) + model_names = [execution.get("model") or "unknown" for execution in executions] + primary_model = max(set(model_names), key=model_names.count) if model_names else model + analyses = analysis_block( + tot, total_cost, 0, 0, 0.0, + model_tok, model_cost, tool_data, 0.0, 0, len(executions), + ) + source = dict(source) + price, _ = price_for(primary_model, "claude") + pricing_supported = any(float(value or 0) > 0 for value in price.values()) + pricing_note = ( + f"Local Kiro token estimate (visible text ÷ {CHARS_PER_TOKEN} chars/token), " + f"priced with Claude API rates; cache and hidden model work are excluded." + if pricing_supported else + f"Local Kiro token estimate; no configured public rate for {primary_model}." + ) + input_available = bool(executions and all(metric_available(e, "input_tokens") for e in executions)) + output_available = bool(executions and all(metric_available(e, "output_tokens") for e in executions)) + cost_available = bool(executions and all(metric_available(e, "cost") for e in executions)) + availability = metric_availability( + "kiro", cost=cost_available, tokens=bool(input_available or output_available), + input_tokens=input_available, output_tokens=output_available, + throughput=False, context=False, timing=bool(wait_samples), + tool_results=any(tool.get("result_available") for e in executions for tool in e.get("tools") or []), + ) + biggest = max( + ({"cost": execution.get("cost") or 0, "idx": execution.get("idx")} for execution in executions), + key=lambda row: row["cost"], default=None, + ) + active_s = sum(float(execution.get("duration_ms") or 0) / 1000.0 for execution in executions) + state = build_state( + source, tot, cost, total_tokens, total_cost, series, executions, trace, + {"reasoning": 0, "output": tot["output"], + "retrieval": tool_data["total_output_tokens"], "coordination": 0}, + analyses, [], first_ts, last_ts, + (time.time() - last_ts) if last_ts else 1e9, biggest, 0, cost_available, + primary_model, pricing_note, + {"duration_s": active_s, "available": bool(active_s), + "reported_executions": 0, "observed_executions": len(executions), + "execution_count": len(executions), "basis": "observed"}, + wait_samples, availability=availability, + ) + state["throughput"] = {"available": False} + state["token_estimate"] = True + state["estimation"] = { + "basis": f"visible message text ÷ {CHARS_PER_TOKEN} chars/token", + "input": "user text + tool results estimate", + "output": "assistant text estimate", + "excluded": ["cache accounting", "hidden reasoning", "system prompts", "internal model context"], + } + state["context"] = {"latest": 0, "window": 0, "pct": None, "breakdown": [], "estimated": True} + state["kiro_info"] = { + "agent_mode": source.get("agent_mode") or "vibe", + "autopilot": source.get("autopilot", True), + "workspace_hash": source.get("workspace_hash") or "", + } + return state + + def recompute(source): if isinstance(source, str): source = source_from_path(source) @@ -4236,6 +5055,8 @@ def recompute(source): return recompute_claude(source) if source["provider"] == "cursor": return recompute_cursor(source) + if source["provider"] == "kiro": + return recompute_kiro(source) return None @@ -5733,21 +6554,129 @@ def cursor_summary(source, objs=None): return row +def kiro_summary(source, objs=None): + """Build a cross-session Kiro row from text-based token estimation.""" + state = recompute_kiro(source) + if not state: + availability = metric_availability("kiro") + return summary_row( + source, source.get("title"), 0.0, 0, 0, set(), None, None, + {}, {}, {}, False, availability=availability, + ) + executions = state.get("executions") or [] + availability = state.get("availability") or metric_availability("kiro") + model_stats = {} + model_daily = {} + model_cost = defaultdict(float) + model_tok = defaultdict(int) + day_cost = defaultdict(float) + performance_samples = [] + wait_samples = [] + for execution in executions: + model = execution.get("model") or "unknown" + token_data = execution.get("tokens") or {} + input_tokens = int(token_data.get("input") or 0) + output_tokens = int(token_data.get("output") or 0) + execution_cost = float(execution.get("cost") or 0) + stats = model_stats.setdefault(model, { + "cost": 0.0, "tokens": 0, "input_tokens": 0, + "output_tokens": 0, "executions": 0, "availability": availability, + }) + stats["cost"] += execution_cost + stats["tokens"] += input_tokens + output_tokens + stats["input_tokens"] += input_tokens + stats["output_tokens"] += output_tokens + stats["executions"] += 1 + model_cost[model] += execution_cost + model_tok[model] += input_tokens + output_tokens + ts = float(execution.get("ts") or 0) + if ts: + day = time.strftime("%Y-%m-%d", time.localtime(ts)) + day_cost[day] += execution_cost + daily = model_daily.setdefault((model, day), { + "model": model, "day": day, "cost": 0.0, + "input_tokens": 0, "output_tokens": 0, "executions": 0, + "availability": availability, + }) + daily["cost"] += execution_cost + daily["input_tokens"] += input_tokens + daily["output_tokens"] += output_tokens + daily["executions"] += 1 + for sample in (state.get("wait_time") or {}).get("samples") or []: + ts = float(sample.get("ts") or 0) + wait_samples.append({ + **sample, + "provider": "kiro", + "day": time.strftime("%Y-%m-%d", time.localtime(ts)) if ts else "", + }) + first_ts = float((state.get("timing") or {}).get("start_ts") or 0) or None + last_ts = float((state.get("timing") or {}).get("end_ts") or 0) or None + active = { + "duration_s": float((state.get("timing") or {}).get("duration_s") or 0), + "available": bool((state.get("timing") or {}).get("duration_available")), + "basis": (state.get("timing") or {}).get("duration_basis") or "unavailable", + } + row = summary_row( + source, source.get("title"), float(state.get("total_cost") or 0), + int(state.get("total_tokens") or 0), len(executions), set(model_stats), + first_ts, last_ts, model_cost, model_tok, day_cost, bool(state.get("cost_approx")), active, + int((state.get("tokens") or {}).get("input") or 0), + int((state.get("tokens") or {}).get("output") or 0), + model_stats, list(model_daily.values()), performance_samples, wait_samples, + availability, + ) + row["token_estimate"] = bool(state.get("token_estimate")) + row["provenance"] = usage_provenance([row]) + row["usage_basis"] = row["provenance"]["usage_basis"] + turns = [] + for execution in executions: + ts = float(execution.get("ts") or 0) + turns.append({ + "ts": ts, + "text": execution.get("user_input") or "", + "model": execution.get("model") or "unknown", + }) + signal_rollups, signal_events = analyze_language_signal_turns(turns) + attach_language_signals(row, signal_rollups, signal_events) + calls = [] + for execution in executions: + for tool in execution.get("tools") or []: + calls.append({**tool, "ts": execution.get("ts") or 0}) + row["_tool_evidence"] = summarize_tool_evidence(calls) + row["context"] = state.get("context") or {} + row["_context_samples"] = [ + int(execution.get("context_tokens") or + (execution.get("tokens") or {}).get("input") or 0) + for execution in executions[-CURRENT_SESSION_CONTEXT_SAMPLES:] + if int(execution.get("context_tokens") or + (execution.get("tokens") or {}).get("input") or 0) > 0 + ] + row["primary_model"] = state.get("primary_model") or source.get("model") or "unknown" + row["terminal"] = False + row["tool_calls"] = int((state.get("tools") or {}).get("total_calls") or 0) + row["tool_errors"] = int((state.get("tools") or {}).get("total_errors") or 0) + return row + + def session_summary(source): cached = _summary_cache.get(source["path"]) mtime = source.get("signature_mtime") or source.get("mtime") or safe_mtime(source["path"]) if cached and cached.get("mtime") == mtime: return cached["row"] - objs = load(source["path"]) - if source["provider"] == "codex": - row = codex_summary(source, objs) - elif source["provider"] == "claude": - row = claude_summary(source, objs) - elif source["provider"] == "cursor": - row = cursor_summary(source, objs) + # Kiro agent sessions use directories, not files - handle specially + if source["provider"] == "kiro": + row = kiro_summary(source, None) # kiro_summary handles loading internally else: - row = summary_row(source, source.get("title"), 0.0, 0, 0, set(), None, None, - {}, {}, {}, False, availability=metric_availability("unknown")) + objs = load(source["path"]) + if source["provider"] == "codex": + row = codex_summary(source, objs) + elif source["provider"] == "claude": + row = claude_summary(source, objs) + elif source["provider"] == "cursor": + row = cursor_summary(source, objs) + else: + row = summary_row(source, source.get("title"), 0.0, 0, 0, set(), None, None, + {}, {}, {}, False, availability=metric_availability("unknown")) _summary_cache[source["path"]] = {"mtime": mtime, "row": row} return row diff --git a/page.html b/page.html index 3e01c1c..c52461a 100644 --- a/page.html +++ b/page.html @@ -165,7 +165,7 @@ .previewHead{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:3px 0 14px}.previewHeadCopy{max-width:760px}.previewEyebrow{display:flex;align-items:center;gap:8px;color:var(--orange3);font-size:10.5px;font-weight:820;text-transform:uppercase;letter-spacing:.08em}.previewEyebrow span{border:1px solid rgba(0,188,235,.3);border-radius:999px;background:rgba(0,188,235,.08);padding:3px 7px}.previewHead h1{font-size:28px;line-height:1.08;margin:7px 0 0}.previewHead p{color:var(--dim);font-size:13px;margin:6px 0 0}.previewHead p:empty{display:none}.previewSwitch{flex:0 0 auto}.previewPanel{display:none}.previewPanel.on{display:block}.previewRunMeta{display:flex;align-items:center;gap:8px;margin-bottom:10px;min-width:0}.previewRunMeta .repo{max-width:460px}.previewRunGrid{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.65fr);gap:12px;align-items:start}.previewChartSlot,.previewDecision{min-width:0}.previewChartSlot .section-h{margin:0 0 10px}.previewChartSlot #session-chart-module>.card{min-height:350px}.previewChartSlot .chart{height:286px}.previewDecision{display:grid;gap:12px}.previewStatus{overflow:hidden;padding:17px;background:linear-gradient(155deg,rgba(23,37,48,.98),rgba(12,17,23,.98))}.previewStatus:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--good),var(--accent),transparent)}.previewStatus.warn:before{background:linear-gradient(90deg,var(--bad),var(--warn),transparent)}.previewStatusTop{display:flex;align-items:center;justify-content:space-between;gap:12px}.previewState{display:inline-flex;align-items:center;gap:7px;color:var(--good);font-size:11px;font-weight:820;text-transform:uppercase;letter-spacing:.05em}.previewState:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor}.previewStatus.warn .previewState{color:var(--bad)}.previewIdle{color:var(--faint);font-size:11px}.previewCost{font-size:clamp(32px,3.4vw,44px);font-weight:850;line-height:1.02;margin-top:12px;white-space:nowrap}.previewCostNote{color:var(--dim);font-size:12px;margin-top:6px}.previewPressure{margin-top:16px;padding-top:13px;border-top:1px solid var(--line)}.previewPressureHead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;color:var(--dim);font-size:11.5px}.previewPressureHead b{color:var(--fg)}.previewPressureBar{height:7px;margin-top:7px;border-radius:999px;background:rgba(255,255,255,.07);overflow:hidden}.previewPressureBar i{display:block;width:0;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--accent),var(--orange3));transition:width .2s}.previewRunBudgetSlot .sessionBudget{margin:0;box-shadow:var(--shadow-soft)}.previewRunBudgetSlot .sessionBudgetHead{align-items:flex-start;flex-direction:column;gap:9px}.previewRunBudgetSlot .sessionBudgetExact{width:100%;justify-content:space-between}.previewKpis{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px;margin-top:12px}.previewKpi{padding:12px 13px;min-height:92px}.previewKpi .v{font-size:20px;font-weight:820;margin-top:5px;overflow-wrap:anywhere}.previewKpi .sm{color:var(--faint);font-size:10.5px;margin-top:3px}.previewBrief{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:12px;margin-top:12px}.previewBrief .card{padding:16px}.previewStartText{font-size:14px;font-weight:720;line-height:1.45;margin-top:7px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.previewActions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:13px}.previewInsightList{display:grid;gap:7px;margin-top:9px}.previewInsight{display:grid;grid-template-columns:8px minmax(0,1fr);gap:8px;align-items:start;color:var(--dim);font-size:12px}.previewInsight i{width:7px;height:7px;border-radius:50%;margin-top:5px;background:var(--accent)}.previewInsight.warn i{background:var(--warn)}.previewInsight.good i{background:var(--good)}.previewSettingsLayout{display:grid;grid-template-columns:230px minmax(0,1fr);gap:12px;align-items:start}.previewSettingsNav{position:sticky;top:88px;padding:14px}.previewSettingsNav h2{font-size:15px;margin:0}.previewSettingsNav p{color:var(--dim);font-size:11.5px;margin:5px 0 12px}.previewSettingsNav button{width:100%;display:grid;grid-template-columns:24px minmax(0,1fr);gap:8px;align-items:center;text-align:left;border:0;border-top:1px solid var(--line);background:transparent;color:var(--dim);padding:10px 2px;font:inherit;font-size:12px;font-weight:730;cursor:pointer}.previewSettingsNav button:hover{color:var(--fg)}.previewSettingsNav button span:first-child{display:grid;place-items:center;width:22px;height:22px;border:1px solid var(--line2);border-radius:6px;color:var(--orange3);font-size:10px}.previewSettingsContent{display:grid;gap:12px;min-width:0}.previewSettingsSlot{scroll-margin-top:100px;min-width:0}.previewSettingsSlot>.card{width:100%}.previewSettingsSlot .settingsLexicon p{max-width:700px}.settingsHome{display:contents}.sessionChartModule{min-width:0} .previewTimes{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:13px;padding-top:12px;border-top:1px solid var(--line)}.previewTimes div{min-width:0}.previewTimes span{display:block;color:var(--faint);font-size:9.5px;text-transform:uppercase;letter-spacing:.04em}.previewTimes b{display:block;margin-top:4px;color:var(--dim);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.previewKpis{grid-template-columns:repeat(4,minmax(0,1fr));gap:9px}.previewKpi{min-height:96px}.previewKpi .v{font-size:clamp(17px,1.55vw,21px)}.settingsPageLayout{display:grid;gap:10px}.settingsMap{display:grid;grid-template-columns:minmax(150px,.68fr) repeat(5,minmax(135px,1fr));align-items:stretch;padding:9px 11px;overflow:hidden}.settingsMap:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--accent),var(--orange3),transparent)}.settingsMapIntro{display:grid;align-content:center;padding:3px 10px 3px 2px}.settingsMap h2{font-size:14px;margin:0}.settingsMapIntro p{color:var(--faint);font-size:10.5px;margin:3px 0 0}.settingsMap button{width:100%;display:grid;grid-template-columns:25px minmax(0,1fr);gap:8px;align-items:center;text-align:left;border:0;border-left:1px solid var(--line);background:transparent;color:var(--dim);padding:7px 11px;font:inherit;cursor:pointer;border-radius:0}.settingsMap button:hover,.settingsMap button:focus-visible{color:var(--fg);background:rgba(255,255,255,.03)}.settingsMap button>span:first-child{display:grid;place-items:center;width:23px;height:23px;border:1px solid var(--line2);border-radius:6px;color:var(--orange3);font:800 9.5px/1 ui-monospace,"SF Mono",Menlo,monospace}.settingsMap button b,.settingsMap button small{display:block}.settingsMap button b{font-size:11.5px}.settingsMap button small{color:var(--faint);font-size:9.5px;font-weight:500;margin-top:1px}.settingsPageContent{display:grid;gap:10px;min-width:0}.settingsPageContent>.card{scroll-margin-top:100px;width:100%;padding:14px 16px}.settingsBudgetSection{display:grid;gap:10px;scroll-margin-top:100px}.settingsBudgetSection .learnHead{margin-bottom:0}.settingsPageContent .settingsLexicon p{max-width:760px}.settingsPageContent .agentClients{margin-top:12px}.settingsPageContent .agentPromise{margin-top:9px}.settingsSignalGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.settingsSignalGrid .settingsField{margin-top:12px}.sessionChartModule{min-width:0} .previewTabs{flex:0 0 auto}.previewSurface{display:none}.previewSurface.on{display:block}.previewSharedSlot{min-width:0}.previewSharedSlot>.session-panel{display:block}.previewStartStrip{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:center;gap:12px;margin-bottom:12px;padding:11px 14px;min-width:0;border-left:3px solid var(--accent)}.previewStartLabel{color:var(--orange3);font-size:10px;font-weight:820;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap}.previewStartStrip .previewStartText{display:block;min-width:0;margin:0;color:var(--fg);font-size:12.5px;font-weight:700;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.previewStartStrip .previewStartText:after{bottom:auto;top:calc(100% + 8px);z-index:60}.previewTokenSlot{margin-top:12px;min-width:0}.previewTokenSlot>.usageSplit{width:100%;margin:0}.previewTokenSlot .bar{height:26px}.previewKpi.fieldtip{display:block;overflow:visible}.previewKpi.fieldtip:after{bottom:auto;top:calc(100% + 8px);z-index:45}.previewKpi:hover,.previewKpi:focus{z-index:50;border-color:rgba(0,188,235,.34)}.previewSpeed .v{color:var(--accent);text-shadow:0 0 18px rgba(0,188,235,.18)}.previewAttention{grid-template-columns:1fr}.previewAttentionHead{display:flex;align-items:center;justify-content:space-between;gap:12px}.previewAttention .previewActions{margin-top:0}.sessionChartModule{min-width:0} -.currentOverviewHidden{display:none!important}.currentSessionsHead{display:flex;align-items:center;gap:12px;margin:0 0 12px}.currentSessionsCount{color:var(--faint);font:720 11px/1.2 ui-monospace,"SF Mono",Menlo,monospace}.currentSessionGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.currentSessionCard{--harness:#dfe5ec;appearance:none;display:flex;flex-direction:column;width:100%;min-width:0;min-height:218px;overflow:hidden;padding:20px 21px 17px;border:1px solid color-mix(in srgb,var(--harness) 48%,var(--line));border-radius:12px;background:linear-gradient(160deg,rgba(23,33,43,.98),rgba(11,16,22,.98));box-shadow:var(--shadow-soft);color:var(--fg);font:inherit;text-align:left;cursor:grab;transition:transform .16s,border-color .16s,box-shadow .16s,opacity .16s}.currentSessionCard:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--harness);box-shadow:0 0 18px color-mix(in srgb,var(--harness) 55%,transparent)}.currentSessionCard:hover{transform:translateY(-2px);border-color:color-mix(in srgb,var(--harness) 78%,var(--line));box-shadow:0 14px 30px rgba(0,0,0,.34)}.currentSessionCard:focus-visible{outline:0;border-color:var(--harness);box-shadow:0 0 0 3px color-mix(in srgb,var(--harness) 20%,transparent),var(--shadow)}.currentSessionCard.dragging{opacity:.42;cursor:grabbing;transform:scale(.99)}.currentSessionCard.dropBefore{box-shadow:inset 4px 0 0 var(--harness),var(--shadow)}.currentSessionCard.dropAfter{box-shadow:inset -4px 0 0 var(--harness),var(--shadow)}.currentSessionCard.provider-claude{--harness:#f59b45}.currentSessionCard.provider-codex{--harness:#b08cff}.currentSessionCard.provider-cursor{--harness:#e7edf4}.currentSessionCard.provider-gemini{--harness:#69a7ff}.currentSessionTop{display:flex;align-items:center;justify-content:space-between;gap:12px}.currentSessionRuntime{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--harness);font-size:12px;font-weight:820;letter-spacing:.012em}.currentSessionRuntime:before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:2px;background:currentColor;box-shadow:0 0 10px currentColor}.currentSessionRuntime span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionStatus{display:inline-flex;align-items:center;gap:6px;flex:0 0 auto;color:var(--faint);font-size:11px;font-weight:750}.currentSessionStatus:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.currentSessionStatus.working{color:var(--good)}.currentSessionStatus.working:before{box-shadow:0 0 9px currentColor}.currentSessionStatus.waiting{color:var(--warn)}.currentSessionStatus.recent{color:var(--faint)}.currentSessionIdentity{margin-top:19px;min-width:0}.currentSessionIdentity h3{margin:0;font-size:19px;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionIdentity p{margin:5px 0 0;color:var(--faint);font:11px/1.25 ui-monospace,"SF Mono",Menlo,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionMetrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:20px;padding-top:17px;border-top:1px solid var(--line)}.currentSessionMetric{min-width:0}.currentSessionMetric span{display:block;color:var(--faint);font-size:10px;font-weight:780;text-transform:uppercase;letter-spacing:.045em}.currentSessionMetric b{display:block;margin-top:6px;font-size:18px;line-height:1.15;font-weight:830;font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionMetric b.mono{font-size:18px;letter-spacing:-.015em}.currentSessionFoot{display:flex;align-items:center;gap:10px;margin-top:auto;padding-top:17px;color:var(--faint);font-size:11px}.currentSessionMove{display:inline-flex;align-items:center;gap:5px}.currentSessionMove b{color:var(--harness);font-size:13px;letter-spacing:-2px}.currentSessionOrderStatus{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.currentSessionsEmpty{padding:32px 20px;text-align:center}.currentSessionsEmpty h3{font-size:17px;margin:0}.currentSessionsEmpty p{margin:5px auto 0;color:var(--dim);font-size:12px} +.currentOverviewHidden{display:none!important}.currentSessionsHead{display:flex;align-items:center;gap:12px;margin:0 0 12px}.currentSessionsCount{color:var(--faint);font:720 11px/1.2 ui-monospace,"SF Mono",Menlo,monospace}.currentSessionGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.currentSessionCard{--harness:#dfe5ec;appearance:none;display:flex;flex-direction:column;width:100%;min-width:0;min-height:218px;overflow:hidden;padding:20px 21px 17px;border:1px solid color-mix(in srgb,var(--harness) 48%,var(--line));border-radius:12px;background:linear-gradient(160deg,rgba(23,33,43,.98),rgba(11,16,22,.98));box-shadow:var(--shadow-soft);color:var(--fg);font:inherit;text-align:left;cursor:grab;transition:transform .16s,border-color .16s,box-shadow .16s,opacity .16s}.currentSessionCard:before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--harness);box-shadow:0 0 18px color-mix(in srgb,var(--harness) 55%,transparent)}.currentSessionCard:hover{transform:translateY(-2px);border-color:color-mix(in srgb,var(--harness) 78%,var(--line));box-shadow:0 14px 30px rgba(0,0,0,.34)}.currentSessionCard:focus-visible{outline:0;border-color:var(--harness);box-shadow:0 0 0 3px color-mix(in srgb,var(--harness) 20%,transparent),var(--shadow)}.currentSessionCard.dragging{opacity:.42;cursor:grabbing;transform:scale(.99)}.currentSessionCard.dropBefore{box-shadow:inset 4px 0 0 var(--harness),var(--shadow)}.currentSessionCard.dropAfter{box-shadow:inset -4px 0 0 var(--harness),var(--shadow)}.currentSessionCard.provider-claude{--harness:#f59b45}.currentSessionCard.provider-codex{--harness:#b08cff}.currentSessionCard.provider-cursor{--harness:#e7edf4}.currentSessionCard.provider-gemini{--harness:#69a7ff}.currentSessionCard.provider-kiro{--harness:#00c4b4}.currentSessionTop{display:flex;align-items:center;justify-content:space-between;gap:12px}.currentSessionRuntime{display:inline-flex;align-items:center;gap:8px;min-width:0;color:var(--harness);font-size:12px;font-weight:820;letter-spacing:.012em}.currentSessionRuntime:before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:2px;background:currentColor;box-shadow:0 0 10px currentColor}.currentSessionRuntime span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionStatus{display:inline-flex;align-items:center;gap:6px;flex:0 0 auto;color:var(--faint);font-size:11px;font-weight:750}.currentSessionStatus:before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.currentSessionStatus.working{color:var(--good)}.currentSessionStatus.working:before{box-shadow:0 0 9px currentColor}.currentSessionStatus.waiting{color:var(--warn)}.currentSessionStatus.recent{color:var(--faint)}.currentSessionIdentity{margin-top:19px;min-width:0}.currentSessionIdentity h3{margin:0;font-size:19px;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionIdentity p{margin:5px 0 0;color:var(--faint);font:11px/1.25 ui-monospace,"SF Mono",Menlo,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionMetrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:20px;padding-top:17px;border-top:1px solid var(--line)}.currentSessionMetric{min-width:0}.currentSessionMetric span{display:block;color:var(--faint);font-size:10px;font-weight:780;text-transform:uppercase;letter-spacing:.045em}.currentSessionMetric b{display:block;margin-top:6px;font-size:18px;line-height:1.15;font-weight:830;font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.currentSessionMetric b.mono{font-size:18px;letter-spacing:-.015em}.currentSessionFoot{display:flex;align-items:center;gap:10px;margin-top:auto;padding-top:17px;color:var(--faint);font-size:11px}.currentSessionMove{display:inline-flex;align-items:center;gap:5px}.currentSessionMove b{color:var(--harness);font-size:13px;letter-spacing:-2px}.currentSessionOrderStatus{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.currentSessionsEmpty{padding:32px 20px;text-align:center}.currentSessionsEmpty h3{font-size:17px;margin:0}.currentSessionsEmpty p{margin:5px auto 0;color:var(--dim);font-size:12px} .budgetDashboard{display:block}.budgetLead{overflow:hidden;background:linear-gradient(145deg,rgba(22,35,46,.98),rgba(12,17,23,.98))}.budgetLead:before{content:"";position:absolute;inset:0 0 auto;height:3px;background:linear-gradient(90deg,var(--accent),var(--orange3),transparent 78%)}.budgetLead h2,.budgetConfig h2{font-size:17px;margin:4px 0 0}.budgetLeadHeadActions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}.budgetLeadBody{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(440px,.92fr);gap:18px;align-items:stretch;margin-top:14px}.budgetSpend{margin:0;display:flex;flex-direction:column;justify-content:center}.budgetSpendValue{font-size:clamp(40px,4.6vw,54px);font-weight:860;line-height:1;letter-spacing:-1.5px;margin-top:5px}.budgetState{display:inline-flex;align-items:center;gap:7px}.budgetState:before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 10px currentColor}.budgetState.on_track{color:var(--good);border-color:rgba(102,217,144,.32);background:rgba(102,217,144,.07)}.budgetState.warning{color:var(--warn);border-color:rgba(255,180,87,.34);background:rgba(255,180,87,.07)}.budgetState.over{color:var(--bad);border-color:rgba(255,111,111,.36);background:rgba(255,111,111,.08)}.budgetProgress{position:relative;height:10px;border:1px solid var(--line2);border-radius:999px;background:rgba(255,255,255,.035);overflow:hidden;margin-top:13px}.budgetProgress>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--accent),var(--orange));border-radius:999px;transition:width .2s}.budgetProgress.warn>i{background:linear-gradient(90deg,var(--warn),var(--bad))}.budgetProgressMarkers{position:absolute;inset:0;pointer-events:none}.budgetProgressMarkers span{position:absolute;top:0;bottom:0;width:1px;background:rgba(255,255,255,.58);box-shadow:0 0 0 1px rgba(0,0,0,.18);transform:translateX(-1px)}.budgetReadouts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0;padding:0}.budgetReadouts>div{min-width:0;padding:12px;border:1px solid var(--line);border-radius:8px;background:rgba(255,255,255,.025);box-shadow:var(--inset)}.budgetReadouts .v{font-size:19px;font-weight:830;margin-top:3px;overflow-wrap:anywhere}.budgetReadouts .v.warn{color:var(--warn)}.budgetDetailGrid{display:grid;grid-template-columns:minmax(0,1.28fr) minmax(340px,.72fr);gap:12px;margin-top:12px;align-items:stretch}.budgetHistory,.budgetRuntimeCard{min-width:0;overflow:hidden;padding:15px 17px}.budgetRuntimeList{display:grid;margin-top:7px}.budgetRuntime{--provider-color:var(--accent);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px 12px;padding:8px 0;border-top:1px solid var(--line)}.budgetRuntime:first-child{border-top:0;padding-top:2px}.budgetRuntimeName{display:flex;align-items:center;gap:8px;font-weight:790}.budgetRuntimeName:before{content:"";width:7px;height:7px;border-radius:50%;background:var(--provider-color);box-shadow:0 0 9px var(--provider-color)}.budgetRuntimeValue{display:flex;align-items:baseline;justify-content:flex-end;gap:7px}.budgetRuntimeValue b{font-weight:820}.budgetRuntimeValue small{color:var(--faint);font-size:9.5px;font-weight:650}.budgetRuntimeTrack{grid-column:1/-1;height:5px;border-radius:999px;background:rgba(255,255,255,.05);overflow:hidden}.budgetRuntimeTrack i{display:block;height:100%;border-radius:inherit;background:var(--provider-color)}.budgetConfig{min-width:0;margin-top:12px;transition:border-color .18s ease,box-shadow .18s ease}.budgetConfig.attention{border-color:rgba(0,188,235,.62);box-shadow:0 0 0 3px rgba(0,188,235,.13),var(--shadow-soft)}.budgetConfigHead{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.budgetConfigHead .subline{margin-top:3px}.budgetConfigBadge{color:var(--dim);font-size:11px;white-space:nowrap;border:1px solid var(--line);border-radius:999px;padding:4px 8px;background:rgba(255,255,255,.03)}.budgetForm{display:grid;grid-template-columns:minmax(300px,.75fr) minmax(440px,1.15fr) auto;gap:14px;margin-top:12px;align-items:end}.budgetFormGroup{min-width:0;margin:0;padding:0;border:0}.budgetFormGroup legend{width:100%;padding:0 0 7px;border-bottom:1px solid var(--line);color:var(--dim);font-size:10.5px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}.budgetCoreFields,.budgetAllocationFields{display:grid;gap:8px;margin-top:8px}.budgetCoreFields{grid-template-columns:repeat(2,minmax(0,1fr))}.budgetAllocationFields{grid-template-columns:repeat(3,minmax(0,1fr))}.budgetForm label{display:grid;gap:4px}.budgetForm label span{font-size:9.5px;text-transform:uppercase;color:var(--faint);font-weight:780}.budgetForm input[type=number],.budgetForm input[type=text]{width:100%;border:1px solid var(--line2);background:rgba(255,255,255,.045);color:var(--fg);border-radius:7px;padding:7px 9px;font:12px/1.2 ui-monospace,"SF Mono",Menlo,monospace}.budgetForm input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(0,188,235,.12)}.budgetNotify{grid-column:1/-1;display:flex!important;grid-template-columns:auto 1fr!important;align-items:center;gap:7px;color:var(--dim);font-size:11.5px}.budgetNotify span{font-size:11.5px!important;text-transform:none!important;font-weight:650!important}.budgetActions{display:grid;align-content:end;justify-items:start;gap:6px;min-width:128px;padding:0;border:0}.budgetActions .tbtn{white-space:nowrap}.budgetActions .settingsStatus{font-size:10px;line-height:1.25}.budgetBars{margin-top:8px;overflow-x:auto;padding-bottom:2px;scrollbar-color:var(--line2) transparent}.budgetChartInner{min-width:max(520px,100%)}.budgetPlot{position:relative;display:grid;grid-template-columns:repeat(var(--months),minmax(48px,1fr));gap:10px;height:142px;padding:24px 8px 0;border-bottom:1px solid var(--line2)}.budgetTarget{position:absolute;left:8px;right:8px;bottom:var(--target-height);z-index:2;border-top:1px dashed rgba(255,180,87,.62);pointer-events:none}.budgetTarget span{position:absolute;right:0;bottom:4px;padding:2px 5px;border-radius:4px;background:rgba(12,17,23,.88);color:var(--warn);font-size:9.5px;white-space:nowrap}.budgetBar{--bar-height:2%;position:relative;display:flex;align-items:flex-end;justify-content:center;height:100%;min-width:0}.budgetBar i{display:block;width:min(40px,66%);height:var(--bar-height);min-height:2px;border-radius:5px 5px 2px 2px;background:linear-gradient(180deg,var(--orange),var(--accent));border:1px solid rgba(0,188,235,.35);box-shadow:0 -7px 18px rgba(0,188,235,.08)}.budgetBar.current i{background:linear-gradient(180deg,#dff8ff,var(--accent));border-color:rgba(223,248,255,.72);box-shadow:0 -8px 20px rgba(0,188,235,.2)}.budgetBar b{position:absolute;bottom:min(calc(var(--bar-height) + 5px),calc(100% - 15px));font-size:10px;color:var(--dim);white-space:nowrap}.budgetAxis{display:grid;grid-template-columns:repeat(var(--months),minmax(48px,1fr));gap:10px;padding:6px 8px 0}.budgetAxis span{text-align:center;font-size:10px;color:var(--faint);white-space:nowrap}.budgetAxis span.current{color:var(--fg);font-weight:790}.budgetCoverage{margin-top:7px;color:var(--dim);font-size:11px}.budgetCoverage.warn{color:var(--warn)} .budgetDetailGrid{grid-template-columns:minmax(0,1.1fr) minmax(430px,.9fr);align-items:start}.budgetDetailGrid .budgetConfig{margin-top:0}.budgetRuntimeCard .budgetConfigBadge{display:none}.budgetForm.budgetInlineForm{grid-template-columns:1fr;gap:11px;margin-top:12px;align-items:stretch}.budgetInlineForm .budgetCoreFields{grid-template-columns:repeat(2,minmax(0,1fr))}.budgetInlineForm .budgetActions{display:flex;align-items:center;justify-content:flex-start;gap:8px;min-width:0;padding-top:9px;border-top:1px solid var(--line)}.budgetInlineForm .budgetRuntimeList{grid-template-columns:1fr;gap:0;margin-top:0}.budgetRuntimeHead{display:grid;grid-template-columns:minmax(90px,.6fr) minmax(135px,1fr) minmax(100px,.8fr);gap:12px;padding:7px 0;color:var(--faint);font-size:9.5px;font-weight:780;text-transform:uppercase}.budgetRuntime{grid-template-columns:minmax(90px,.6fr) minmax(135px,1fr) minmax(100px,.8fr);grid-template-areas:"name value input";gap:4px 12px;align-items:center;padding:9px 0;border:0;border-top:1px solid var(--line);border-radius:0;background:transparent}.budgetRuntime:first-child{border-top:1px solid var(--line);padding-top:9px}.budgetRuntimeName{grid-area:name}.budgetRuntimeValue{grid-area:value;align-self:start;display:flex;gap:6px;justify-content:flex-start;justify-items:start}.budgetRuntimeTrack{grid-area:value;grid-column:auto;align-self:end;height:3px;transform:translateY(2px)}.budgetRuntimeInput{grid-area:input;align-self:center;display:grid;gap:4px}.budgetRuntimeInput span{display:none;font-size:9.5px;text-transform:uppercase;color:var(--faint);font-weight:780} .budgetSpendSummary{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(170px,.65fr);gap:20px;align-items:end}.budgetSpendLimit{min-width:0;padding-left:18px;border-left:1px solid var(--line)}.budgetSpendBudget{font-size:clamp(27px,3.1vw,36px);font-weight:850;line-height:1.05;letter-spacing:-.8px;margin-top:5px}.budgetLeadBody .budgetReadouts{grid-template-columns:repeat(2,minmax(0,1fr))}.budgetInlineForm .budgetCoreFields{grid-template-columns:1fr} @@ -903,6 +903,12 @@

+
+ Kiro + $0.000% of spend + + +
Saved machine-wide
@@ -1033,7 +1039,7 @@

Delete session?

const estimateSuffix=row=>usageBasis(row)==='local_estimate'?' est':usageBasis(row)==='mixed'?' incl. est':''; const appBadgeClass=session=>{ const client=session?.client||session?.provider||''; - return client==='codex'?'codex':client==='cursor'?'cursor':client==='chatgpt_classic'?'chatgpt':'claude'; + return client==='codex'?'codex':client==='cursor'?'cursor':client==='kiro'?'kiro':client==='chatgpt_classic'?'chatgpt':'claude'; }; const appFilterGroup=session=>{ const client=session?.client||session?.provider||'unknown'; @@ -1992,9 +1998,9 @@

Delete session?

$('budget-coverage').textContent=status.lower_bound?'Partial cost coverage: recorded spend is a lower bound.':status.estimated?'Includes labeled local cost estimates.':'Cost coverage is complete for discovered sessions.'; const runtimes=status.runtimes||[],hasAllocations=runtimes.some(row=>Number(row.allocation||0)>0),runtimeSpend=Math.max(0,Number(status.spend||0)); $('budget-allocation-note').textContent=hasAllocations?`${lower}${money(status.spend||0)} / ${money(status.budget)}`:'Add runtime budgets'; - const runtimeColors={claude:'#b78cff',codex:'var(--accent)',cursor:'var(--good)'}; + const runtimeColors={claude:'#b78cff',codex:'var(--accent)',cursor:'var(--good)',kiro:'#00c4b4'}; const runtimeRows=new Map(runtimes.map(row=>[row.provider,row])); - ['claude','codex','cursor'].forEach(provider=>{ + ['claude','codex','cursor','kiro'].forEach(provider=>{ const row=runtimeRows.get(provider)||{provider,spend:0,allocation:Number(allocations[provider]||0)}; const spend=Number(row.spend||0),allocated=Number(row.allocation||0),percent=allocated?spend/allocated:(runtimeSpend?spend/runtimeSpend:0),over=row.exceeded===true||Boolean(allocated&&spend>=allocated); const shareLabel=allocated?`${pct(percent)} of budget`:`${pct(percent)} of spend`; @@ -4085,7 +4091,7 @@

Delete session?

const contextSpark=currentSessionContextSparkline(row); const cardDescription=`${contextSpark.summary} Drag to reorder. Press ⌥ and an arrow key to move.`; return ``; - }).join(''):'

No recent sessions

Start Claude, Codex, or Cursor to see one here.

'; + }).join(''):'

No recent sessions

Start Claude, Codex, Cursor, or Kiro to see one here.

'; if(focusedId)requestAnimationFrame(()=>focusCurrentSessionCard(focusedId)); } const currentSessionGrid=$('current-session-grid'); diff --git a/tests/test_meter.py b/tests/test_meter.py index a9e55d4..4ffdbee 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -148,6 +148,7 @@ def test_cursor_discovery_uses_sqlite_metadata_and_keeps_activity_order_session_ mock.patch.object(meter, "CLAUDE_PROJECTS", str(root / "no-claude")), \ mock.patch.object(meter, "CODEX_SESSIONS", str(root / "no-codex")), \ mock.patch.object(meter, "CODEX_INDEX", str(root / "no-index")), \ + mock.patch.object(meter, "KIRO_SESSIONS", str(root / "no-kiro")), \ mock.patch.object(meter, "claude_desktop_index", return_value={}): sources = meter.all_session_sources() self.assertEqual({row["id"] for row in sources}, {"older", "newer"}) @@ -4256,7 +4257,7 @@ def test_settings_derive_total_from_allocations_and_preserve_other_machine_setti "native_notifications": True, }, str(path)) result = meter.set_budget_settings({ - "allocations": {"claude": 50, "codex": 30, "cursor": 0}, + "allocations": {"claude": 50, "codex": 30, "cursor": 0, "kiro": 0}, "thresholds": [75, 90, 100], "native_notifications": False, }, str(path)) @@ -4266,7 +4267,7 @@ def test_settings_derive_total_from_allocations_and_preserve_other_machine_setti self.assertEqual(stored["budgets"]["monthly_total"], 80) self.assertEqual( stored["budgets"]["allocations"], - {"claude": 50, "codex": 30, "cursor": 0}, + {"claude": 50, "codex": 30, "cursor": 0, "kiro": 0}, ) self.assertIn("model_pricing", stored) @@ -4283,20 +4284,20 @@ def test_missing_runtime_budgets_default_to_1000_and_explicit_zero_is_preserved( path.write_text(json.dumps({"budgets": legacy})) loaded = meter.budget_settings(str(path)) saved = meter.set_budget_settings({ - "allocations": {"claude": 0, "codex": 1490, "cursor": 0}, + "allocations": {"claude": 0, "codex": 1490, "cursor": 0, "kiro": 0}, "thresholds": [80, 90, 100], "native_notifications": True, }, str(path)) - self.assertEqual(loaded["monthly_total"], 3000) + self.assertEqual(loaded["monthly_total"], 4000) # 4 providers x 1000 default self.assertEqual( loaded["allocations"], - {"claude": 1000, "codex": 1000, "cursor": 1000}, + {"claude": 1000, "codex": 1000, "cursor": 1000, "kiro": 1000}, ) self.assertTrue(saved["ok"]) self.assertEqual(saved["budgets"]["monthly_total"], 1490) self.assertEqual( saved["budgets"]["allocations"], - {"claude": 0, "codex": 1490, "cursor": 0}, + {"claude": 0, "codex": 1490, "cursor": 0, "kiro": 0}, ) def test_monthly_rollup_keeps_runtime_costs_and_partial_coverage(self): @@ -4347,7 +4348,7 @@ def test_budget_status_projects_after_three_spend_days_and_marks_lower_bound(sel }, }] status = meter.monthly_budget_status(months, { - "allocations": {"claude": 50, "codex": 30, "cursor": 0}, + "allocations": {"claude": 50, "codex": 30, "cursor": 0, "kiro": 0}, "thresholds": [50, 80, 100], "native_notifications": True, }, now=meter.datetime.datetime(2026, 7, 10, tzinfo=meter.datetime.timezone.utc)) @@ -4372,7 +4373,7 @@ def test_runtime_overrun_is_reported_while_overall_budget_is_on_track(self): "provenance": {"estimated_sessions": 0}, }] status = meter.monthly_budget_status(months, { - "allocations": {"claude": 1000, "codex": 1490, "cursor": 1000}, + "allocations": {"claude": 1000, "codex": 1490, "cursor": 1000, "kiro": 1000}, "thresholds": [80, 90, 100], "native_notifications": True, }, now=meter.datetime.datetime(2026, 7, 10, tzinfo=meter.datetime.timezone.utc)) @@ -4386,5 +4387,141 @@ def test_runtime_overrun_is_reported_while_overall_budget_is_on_track(self): self.assertTrue(codex["exceeded"]) +class KiroTraceTests(unittest.TestCase): + def source(self, path="/tmp/kiro-session/messages.jsonl", session_id="kiro-session"): + return { + "provider": "kiro", "client": "kiro", "label": "Kiro", + "runtime": "Kiro", "id": session_id, + "session": Path(path).name, "path": path, "project": "/repo", + "mtime": 1, "title": "Kiro session", "model": "claude-sonnet-4-6", + "agent_mode": "vibe", "autopilot": True, "workspace_hash": "abc123", + } + + def test_kiro_visible_output_estimates_tokens_from_text(self): + objs = [ + {"payload": {"type": "user", "content": "Hello world"}}, + {"payload": {"type": "assistant", "content": "Hi there, how can I help?"}}, + {"payload": {"type": "tool_call", "toolName": "readFile", "args": {"path": "/test.py"}}}, + {"payload": {"type": "tool_result", "content": "file contents here"}}, + ] + visible = meter.kiro_visible_output(objs) + self.assertGreater(visible["user_chars"], 0) + self.assertGreater(visible["assistant_chars"], 0) + self.assertEqual(visible["tool_calls"], 1) + self.assertGreater(visible["tool_result_chars"], 0) + self.assertEqual(visible["user_tokens"], (visible["user_chars"] + 3) // 4) + self.assertEqual(visible["assistant_tokens"], (visible["assistant_chars"] + 3) // 4) + + def test_kiro_tool_identity_normalizes_names(self): + ident = meter.kiro_tool_identity("readFiles") + self.assertEqual(ident["name"], "read_files") + self.assertEqual(ident["display"], "Read Files") + self.assertEqual(ident["namespace"], "kiro") + self.assertEqual(ident["kind"], "tool") + + ident2 = meter.kiro_tool_identity("runCommand") + self.assertEqual(ident2["name"], "run_command") + self.assertEqual(ident2["display"], "Run Command") + + def test_kiro_recompute_produces_estimated_usage(self): + messages = [ + {"id": "msg-1", "timestamp": "2026-07-15T10:00:00.000Z", + "payload": {"type": "user", "content": "Hello, can you help me?"}}, + {"id": "turn-1", "timestamp": "2026-07-15T10:00:00.000Z", + "payload": {"type": "turn_start", "executionId": "exec-1"}}, + {"id": "tool-1-call", "timestamp": "2026-07-15T10:00:05.000Z", + "payload": {"type": "tool_call", "toolCallId": "tool-1", + "toolName": "readFile", "args": {"path": "/test.py"}, + "status": "completed", "kind": "read", "executionId": "exec-1"}}, + {"id": "tool-1-result", "timestamp": "2026-07-15T10:00:05.500Z", + "payload": {"type": "tool_result", "toolCallId": "tool-1", + "content": "def test(): pass", "success": True, + "durationMs": 500, "executionId": "exec-1"}}, + {"id": "msg-2", "timestamp": "2026-07-15T10:00:10.000Z", + "payload": {"type": "assistant", "content": "I found the test file.", + "executionId": "exec-1"}}, + ] + with tempfile.TemporaryDirectory() as tmp: + session_dir = Path(tmp) / "workspace-hash" / "session-id" + session_dir.mkdir(parents=True) + messages_path = session_dir / "messages.jsonl" + messages_path.write_text("\n".join(json.dumps(m) for m in messages) + "\n") + source = self.source(str(messages_path), "session-id") + with mock.patch.object(meter, "load", return_value=messages): + state = meter.recompute_kiro(source) + + self.assertIsNotNone(state) + self.assertEqual(state["provider"], "kiro") + self.assertEqual(state["turns"], 1) + self.assertTrue(state["token_estimate"]) + self.assertIn("estimation", state) + self.assertEqual(state["estimation"]["basis"], f"visible message text ÷ {meter.CHARS_PER_TOKEN} chars/token") + self.assertTrue(state["availability"]["cost"]) + self.assertTrue(state["availability"]["tokens"]) + self.assertEqual(len(state["executions"]), 1) + self.assertEqual(state["executions"][0]["tool_count"], 1) + self.assertEqual(state["executions"][0]["tools"][0]["name"], "read_file") + self.assertTrue(state["executions"][0]["tools"][0]["result_available"]) + self.assertIn("kiro_info", state) + self.assertEqual(state["kiro_info"]["agent_mode"], "vibe") + + def test_kiro_session_discovery_finds_workspace_sessions(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + session_dir = root / "workspace-abc" / "session-123" + session_dir.mkdir(parents=True) + workspace_path = os.path.expanduser("~/project") + (session_dir / "session.json").write_text(json.dumps({ + "schemaVersion": "1.0.0", + "id": "session-123", + "title": "Test Session", + "agentMode": "spec", + "workspacePaths": [workspace_path], + "modelId": "claude-opus-4.6", + "autopilot": True, + })) + (session_dir / "messages.jsonl").write_text( + json.dumps({"payload": {"type": "user", "content": "test"}}) + "\n" + ) + sources = meter.kiro_session_sources(str(root)) + + self.assertEqual(len(sources), 1) + source = sources[0] + self.assertEqual(source["provider"], "kiro") + self.assertEqual(source["id"], "session-123") + self.assertEqual(source["title"], "Test Session") + self.assertEqual(source["model"], "claude-opus-4-6") + self.assertEqual(source["agent_mode"], "spec") + self.assertEqual(source["project"], "~/project") + + def test_kiro_session_metadata_reads_session_json(self): + with tempfile.TemporaryDirectory() as tmp: + session_dir = Path(tmp) + (session_dir / "session.json").write_text(json.dumps({ + "schemaVersion": "1.0.0", + "id": "meta-test", + "title": "My Session", + "agentMode": "vibe", + "workspacePaths": ["/path/to/workspace"], + "modelId": "claude-haiku-4.5", + "autopilot": False, + })) + metadata = meter.kiro_session_metadata(str(session_dir)) + + self.assertEqual(metadata["id"], "meta-test") + self.assertEqual(metadata["title"], "My Session") + self.assertEqual(metadata["model"], "claude-haiku-4.5") + self.assertEqual(metadata["agent_mode"], "vibe") + self.assertFalse(metadata["autopilot"]) + self.assertEqual(metadata["workspace_paths"], ["/path/to/workspace"]) + + def test_kiro_provider_uses_claude_pricing(self): + # Kiro uses Claude models, so pricing should match Claude + price, approximate = meter.price_for("claude-sonnet-4-6", "claude") + self.assertEqual(price["input"], 3.0) + self.assertEqual(price["output"], 15.0) + self.assertFalse(approximate) + + if __name__ == "__main__": unittest.main()