diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 6d0e585d498..f1c0bcfd53a 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -138,6 +138,11 @@ kimi) command=(kimi --yolo) fi ;; +mcode) + # The TUI has no no-approval flag; permission options belong to exec mode. + command=(mcode) + [[ -n ${prompt:-} ]] && command+=(-- "$prompt") + ;; *) echo "Unsupported default agent: $agent" >&2 exit 1 diff --git a/bin/omarchy-agent-usage-mcode b/bin/omarchy-agent-usage-mcode new file mode 100755 index 00000000000..f4127ee8b36 --- /dev/null +++ b/bin/omarchy-agent-usage-mcode @@ -0,0 +1,712 @@ +#!/usr/bin/python3 +# omarchy:summary=Print the MiniMax Code usage record as JSON +# omarchy:args=[--force] [--limits-only] +# omarchy:hidden=true +"""Read MiniMax Code transcripts and personal token-plan quotas. + +The active production OAuth record and saved region select the account. +Quota requests use the personal billing workspace; credentials are never changed. +""" + +from __future__ import annotations + +import argparse +import base64 +import math +import fcntl +import hashlib +import json +import os +import sys +import tempfile +import time +import urllib.error +import urllib.request +import urllib.parse +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +AGENT_ID = "mcode" +AGENT_NAME = "MiniMax Code" +AUTH_HELP = "Run `mcode login` to authenticate." + +# A scan this recent is only reused to dedup concurrent collector runs (the +# update command backgrounds one per agent while the panel refreshes on its +# own); every periodic widget refresh lands a real rescan, however low +# refreshIntervalSec is set. --limits-only promises only fresh limits, so it +# may reuse a scan for up to 15 minutes. +SCAN_REUSE_SECONDS = 20 +LIMITS_ONLY_REUSE_SECONDS = 900 + +# The platform coding-plan endpoint is cheap but not free, and the rate +# windows only move on a 5-hour / weekly cadence; a 15-minute cache keeps the +# panel from re-hitting it on every widget tick. --limits-only bypasses the +# cache so refreshLimits() always sees the freshest window. +LIMITS_REUSE_SECONDS = 900 + + +def expand_path(value: str) -> Path: + return Path(os.path.abspath(value)) + + +def config_root() -> Path: + for key in ("MINIMAX_DATA_DIR", "MAVIS_DATA_DIR"): + value = os.environ.get(key, "").strip() + if value: + return expand_path(value) + profile = os.environ.get("__MAVIS_RUNTIME_PROFILE", "").strip() if os.environ.get("__MAVIS_ALLOW_LEGACY_RUNTIME_ENV") == "1" else "" + return Path(os.path.abspath(Path.home() / (".minimax-" + profile if profile else ".minimax"))) + + +def session_root() -> Path: + return config_root() / "v2" / "sessions" + + +def cache_root() -> Path: + root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "agent-usage" + root.mkdir(parents=True, exist_ok=True) + return root + + +def local_day(value: Any) -> str: + today = datetime.now().strftime("%Y-%m-%d") + if value is None: + return today + if isinstance(value, (int, float)): + # mcode timestamps are milliseconds since the epoch. + seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value) + try: + return datetime.fromtimestamp(seconds).strftime("%Y-%m-%d") + except Exception: + return today + text = str(value).strip() + if not text: + return today + try: + if text.endswith("Z"): + parsed = datetime.fromisoformat(text[:-1] + "+00:00") + else: + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is not None: + parsed = parsed.astimezone() + return parsed.strftime("%Y-%m-%d") + except Exception: + return today + + +def number(value: Any) -> int: + try: + return max(0, int(value or 0)) + except Exception: + return 0 + + +def model_name(raw: Any) -> str: + value = str(raw or "mcode").strip() + return value if value else "mcode" + + +def recent_dates() -> list[str]: + today = datetime.now().date() + return [(today - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)] + + +def empty_bucket() -> dict[str, int]: + return { + "inputTokens": 0, + "outputTokens": 0, + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + } + + +def collect_usage(entry: dict[str, Any], state: dict[str, Any]) -> None: + message = entry.get("message") + if not isinstance(message, dict): + return + if message.get("role") == "user": + day = local_day(message.get("timestamp") or entry.get("timestamp")) + state["totalPrompts"] += 1 + if day == state["today"]: + state["todayPrompts"] += 1 + return + if message.get("role") != "assistant": + return + usage = message.get("usage") + if not isinstance(usage, dict): + return + + cache_read = number(usage.get("cacheRead")) + cache_write = number(usage.get("cacheWrite")) + # MiniMax input excludes cache tokens, which have their own buckets. + raw_input = number(usage.get("input")) + raw_output = number(usage.get("output")) + reported_total = number(usage.get("totalTokens")) + if "input" in usage or "output" in usage: + input_tokens = raw_input + output_tokens = raw_output + elif reported_total > 0: + # No breakdown available; attribute it all to input so the model row + # still has something to show, and leave output at zero. This is rare + # in practice: the assistant usage payload always carries a split. + input_tokens = max(0, reported_total - cache_read - cache_write) + output_tokens = 0 + elif cache_read > 0 or cache_write > 0: + input_tokens = output_tokens = 0 + else: + return + + total = input_tokens + output_tokens + cache_read + cache_write + if total <= 0: + return + + model = model_name(message.get("model") or entry.get("model")) + day = local_day(message.get("timestamp") or entry.get("timestamp")) + # Sessions are 1:1 with the directory containing messages.jsonl; the + # directory name carries the mcode session id and is stable across + # rescans. The message payload itself has no session id field, so the + # file path is the only reliable session key. + session_key = str(state.get("current_session_key") or "") + + if session_key: + state["totalSessions"].add(session_key) + state["activeDays"].add(day) + + bucket = state["modelUsage"].setdefault(model, empty_bucket()) + bucket["inputTokens"] += input_tokens + bucket["outputTokens"] += output_tokens + bucket["cacheReadInputTokens"] += cache_read + bucket["cacheCreationInputTokens"] += cache_write + + recent = state["recent"] + if day in recent: + # recentDays.messageCount is actually a token total, despite the legacy + # name shared with synced snapshots. + recent[day]["messageCount"] += total + + if day == state["today"]: + if session_key: + state["todaySessions"].add(session_key) + state["todayTotalTokens"] += total + state["todayTokensByModel"][model] = state["todayTokensByModel"].get(model, 0) + total + + +def scan_sessions(roots: list[Path], cutoff_seconds: float) -> dict[str, Any]: + today = datetime.now().strftime("%Y-%m-%d") + recent_map = {day: {"date": day, "messageCount": 0} for day in recent_dates()} + state: dict[str, Any] = { + "today": today, + "todayPrompts": 0, + "todaySessions": set(), + "todayTotalTokens": 0, + "todayTokensByModel": {}, + "recent": recent_map, + "modelUsage": {}, + "totalPrompts": 0, + "totalSessions": set(), + "activeDays": set(), + } + + files: list[Path] = [] + for root in roots: + if not root.exists(): + continue + for path in root.rglob("messages.jsonl"): + try: + if path.stat().st_mtime >= cutoff_seconds: + files.append(path) + except OSError: + continue + + for path in files: + # Use the session directory name as the session key; messages.jsonl is + # the only file the collector reads under that directory. + state["current_session_key"] = str(path.parent) + try: + with path.open("r", encoding="utf-8", errors="replace") as handle: + for raw in handle: + try: + entry = json.loads(raw) + except Exception: + continue + if not isinstance(entry, dict): + continue + collect_usage(entry, state) + except Exception as exc: + print(f"omarchy-agent-usage-mcode: ignoring unreadable session {path}: {exc}", file=sys.stderr) + + return { + "todayPrompts": state["todayPrompts"], + "todaySessions": len(state["todaySessions"]), + "todayTotalTokens": state["todayTotalTokens"], + "todayTokensByModel": state["todayTokensByModel"], + "recentDays": [state["recent"][day] for day in recent_dates()], + "modelUsage": state["modelUsage"], + "totalPrompts": state["totalPrompts"], + "totalSessions": len(state["totalSessions"]), + "activeDays": len(state["activeDays"]), + "activeDates": sorted(state["activeDays"]), + } + + +def scan_cache_paths() -> tuple[Path, Path]: + digest = hashlib.sha1(str(session_root()).encode("utf-8")).hexdigest()[:16] + root = cache_root() + return root / f"mcode-scan-{digest}.json", root / f"mcode-scan-{digest}.lock" + + +def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None: + if max_age_seconds <= 0 or not path.exists(): + return None + try: + if time.time() - path.stat().st_mtime <= max_age_seconds: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return None + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + handle_fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: + handle.write(json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n") + tmp.chmod(0o644) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def cached_local_stats(max_age_seconds: float) -> dict[str, Any]: + """Local stats, with the cache as a pure optimization. + + The cache must never take the collector down: any cache-layer failure + (unwritable cache root, lock errors, disk full) degrades to a direct scan + and a warning on stderr. The JSON record is the contract; the cache is not. + """ + try: + cache_file, lock_file = scan_cache_paths() + except OSError: + return scan_sessions([session_root()], 0) + + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + + try: + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = read_fresh_json(cache_file, max_age_seconds) + if cached is not None: + return cached + cutoff = 0 + stats = scan_sessions([session_root()], cutoff) + write_json(cache_file, stats) + return stats + except Exception as exc: + print(f"omarchy-agent-usage-mcode: cache unavailable ({exc}); scanning directly", file=sys.stderr) + cutoff = 0 + return scan_sessions([session_root()], cutoff) + + +def auth_status() -> dict[str, str]: + if load_access_token(): + return {"usageStatusText": "", "authHelpText": ""} + return {"usageStatusText": "Waiting for auth", "authHelpText": AUTH_HELP} + + +def resolve_region() -> str: + inherited = os.environ.get("MAVIS_REGION") + if inherited in ("cn", "en"): + return inherited + try: + prefs = json.loads((config_root() / "preferences" / "mcode-region.json").read_text()) + if prefs.get("version") == 1: + value = prefs.get("regions", {}).get("prod") + if value in ("cn", "en"): + return value + except (OSError, ValueError, AttributeError): + pass + reusable = [] + for region in ("cn", "en"): + try: + path = config_root() / "auth" / "prod" / region / "mcode-public" / "auth-state.json" + state = json.loads(path.read_text()) + if state.get("buildEnv") == "prod" and state.get("region") == region and state.get("status") in ( + "authorizing", "authenticated", "refreshing", "scope_upgrade_required", "expired" + ): + reusable.append(region) + except (OSError, ValueError, AttributeError): + pass + return reusable[0] if len(reusable) == 1 else "cn" + + +def auth_record_dir() -> tuple[Path, str]: + region = resolve_region() + return config_root() / "auth" / "prod" / region / "mcode-public", region + + +def load_access_token() -> str: + directory, region = auth_record_dir() + account = base64.urlsafe_b64encode(hashlib.sha256( + (str(config_root() / "auth") + "\0mcode-public").encode() + ).digest()).decode().rstrip("=") + key = f"com.minimax.mcode.oauth.prod.{region}\0{account}" + try: + payload = json.loads((directory / "auth.json").read_text()) + record = payload.get("records", {}).get(key, {}) + token = record.get("accessToken") + if record.get("clientId") == "mcode-public" and isinstance(token, str): + return token.strip() + except (OSError, ValueError, AttributeError): + pass + return "" + + +def platform_base_url(region: str) -> str: + """Openplatform host for the given region (mirrors mcode's `MCodeClient`). + + The openplatform host is what serves `/v1/api/openplatform/...`; the + agent host (`agent.minimax.io` / `agent.minimaxi.com`) serves the + signed `/matrix/api/v1/...` paths instead. The coding-plan endpoint + lives on the openplatform host. + """ + if region == "cn": + return "https://www.minimaxi.com" + return "https://platform.minimax.io" + + +# Status code emitted by `/v1/api/openplatform/coding_plan/remains` when the +# matching window has no cap on the user's account. The collector surfaces it +# as `unlimited: true` and skips the percent so the panel renders the +# "Unlimited" badge instead of a meter. +UNLIMITED_STATUS = 3 + +# Media side quotas the API reports as separate `model_remains` entries. They +# ride on independent allowances (video generation, image gen, …) and are not +# the user's coding quota, so the collector ignores them when picking the +# primary entry. +MEDIA_MODEL_NAMES = frozenset({"video", "image", "audio", "vision"}) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def request_json(request: urllib.request.Request) -> dict[str, Any]: + with urllib.request.build_opener(NoRedirect()).open(request, timeout=8) as response: + payload = json.load(response) + if not isinstance(payload, dict): + raise ValueError("Invalid MiniMax response") + base = payload.get("base_resp", {}) + status = payload.get("statusInfo", {}) + if base.get("status_code", 0) != 0 or status.get("code", 0) != 0: + raise ValueError("MiniMax request failed") + return payload + + +def personal_group(token: str, region: str) -> str: + origin = "https://agent.minimaxi.com" if region == "cn" else "https://agent.minimax.io" + + def matrix(path: str, user_id: str = "0", body: bytes | None = None) -> dict[str, Any]: + now = int(time.time() * 1000) + second = str(now // 1000) + language = "zh" if region == "cn" else "en" + query = urllib.parse.urlencode({ + "device_platform": "mcode", "biz_id": "3", "app_id": "3001", + "version_code": "22201", "unix": str(now), + "timezone_offset": str(-time.timezone), "sys_language": language, + "lang": language, "device_id": "0", "os_name": "linux", + "browser_name": "mcode", "user_id": user_id, "client": "mcode", + }) + path_query = path + "?" + query + text = body.decode() if body is not None else "{}" + md5 = lambda value: hashlib.md5(value.encode()).hexdigest() + # Public first-party attribution constants; the bearer authorizes the request. + headers = {"Accept": "application/json", "Content-Type": "application/json", + "User-Agent": "MiniMaxCode", "Authorization": "Bearer " + token, + "yy": md5(urllib.parse.quote(path_query, safe="~()*!.'-") + "_" + text + md5(str(now)) + "ooui"), + "x-timestamp": second, + "x-signature": md5(second + "I*7Cf%WZ#S&%1RlZJ&C2" + (text if body is not None else "")), + } + return request_json(urllib.request.Request(origin + path_query, data=body, headers=headers)) + + identity = matrix("/v1/api/user/info") + data = identity.get("data", identity) + info = data.get("userInfo") or data.get("user_info") or identity.get("userInfo") or identity.get("user_info") or {} + user_id = info.get("realUserID") or info.get("real_user_id") + if not user_id: + raise ValueError("MiniMax account identity unavailable") + workspace = matrix("/matrix/api/v1/user/get_user_extra_info", str(user_id), b"{}") + groups = workspace.get("workspaces") or workspace.get("data", {}).get("workspaces") or [] + for group in groups: + if isinstance(group, dict) and group.get("workspace_type") == 0: + group_id = group.get("op_group_id") or group.get("data", {}).get("op_group_id") + if group_id: + return str(group_id) + raise ValueError("MiniMax personal billing group unavailable") + + +def fetch_token_plan_quota(token: str, base_url: str) -> dict[str, Any] | None: + empty = { + "limits": [], + "usageStatusText": "", + "authHelpText": "", + "retryAdvised": False, + "transportFailure": False, + } + if not token: + # The probe has nothing to say here; auth_status reports "Waiting for + # auth" if the directory is also missing. Returning empty lets the + # caller decide the right message based on the broader view. + return empty + + url = f"{base_url}/v1/api/openplatform/coding_plan/remains" + try: + region = "cn" if base_url == platform_base_url("cn") else "en" + group = personal_group(token, region) + req = urllib.request.Request(url, headers={ + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "X-Group-Id": group, + }, method="GET") + try: + with urllib.request.build_opener(NoRedirect()).open(req, timeout=8) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + # The platform returns 401/403 when the bearer lapses. The user has + # to rerun `mcode login` to mint a new one — there is no refresh + # flow the collector can drive on its own. Saying so keeps the + # empty meter from looking like a quiet outage. + if exc.code in (401, 403): + empty["usageStatusText"] = "Sign-in expired" + empty["authHelpText"] = ( + "MiniMax Code's saved sign-in expired. " + "Start MiniMax Code, or run `mcode login`, to refresh it." + ) + return empty + # Any other HTTP error is a transport-shaped failure the panel + # should retry sooner for. + empty["retryAdvised"] = True + empty["transportFailure"] = True + return empty + except Exception: + empty["retryAdvised"] = True + empty["transportFailure"] = True + return empty + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + empty["usageStatusText"] = "Sign-in expired" + empty["authHelpText"] = AUTH_HELP + else: + empty["retryAdvised"] = empty["transportFailure"] = True + return empty + except Exception: + empty["retryAdvised"] = empty["transportFailure"] = True + return empty + + try: + payload = json.loads(raw) + except Exception: + empty["retryAdvised"] = True + empty["transportFailure"] = True + return empty + + if not isinstance(payload, dict): + empty["retryAdvised"] = True + empty["transportFailure"] = True + return empty + base_resp = payload.get("base_resp") + if isinstance(base_resp, dict): + code = base_resp.get("status_code") + if isinstance(code, int) and code != 0: + empty["retryAdvised"] = True + empty["transportFailure"] = True + return empty + remains = payload.get("model_remains") + if not isinstance(remains, list) or not remains: + return empty + + # Media quotas have separate allowances from coding. + chosen: dict[str, Any] | None = None + for entry in remains: + if not isinstance(entry, dict): + continue + name = str(entry.get("model_name") or "").strip().lower() + if name in MEDIA_MODEL_NAMES: + continue + chosen = entry + break + if chosen is None: + return empty + + def window(prefix: str, end_key: str) -> dict[str, Any] | None: + status = number(chosen.get(f"{prefix}_status")) + if status == UNLIMITED_STATUS: + # The panel filters `percent >= 0`, so an unlimited entry needs a + # numeric percent to survive the filter. Zero keeps the meter empty + # without lying about a cap, and `unlimited: True` lets the panel + # label it accordingly. + out: dict[str, Any] = {"percent": 0.0, "unlimited": True} + else: + remaining = chosen.get(f"{prefix}_remaining_percent") + if remaining is None: + total = chosen.get(f"{prefix}_total_count") + left = chosen.get(f"{prefix}_usage_count") + if isinstance(total, (int, float)) and total > 0 and isinstance(left, (int, float)): + remaining = left / total * 100 + if not isinstance(remaining, (int, float)) or not math.isfinite(remaining): + return None + out = {"percent": max(0.0, min(1.0, (100 - remaining) / 100.0))} + try: + reset_at_ms = int(chosen.get(end_key, 0)) + except Exception: + reset_at_ms = 0 + # The panel reads `entry.resetsAt` (with an `s`) and feeds it straight + # into `new Date(...)`. Both ISO strings and millisecond numbers work, + # but every other collector emits an ISO string, so the field name and + # shape match here too. Without this, the reset countdown shows "now". + if reset_at_ms > 0: + try: + out["resetsAt"] = format_reset_iso(reset_at_ms) + except (ValueError, OverflowError, OSError): + pass + return out or None + + limits: list[dict[str, Any]] = [] + five_hour = window("current_interval", "end_time") + if five_hour is not None: + limits.append({"label": "5h", **five_hour}) + weekly = window("current_weekly", "weekly_end_time") + if weekly is not None: + limits.append({"label": "Weekly", **weekly}) + empty["limits"] = limits + return empty + + +def format_reset_iso(reset_at_ms: int) -> str: + """Render a millisecond timestamp as an ISO-8601 string in UTC. + + The collector feeds this to `new Date(...)` in the panel's `resetMsFor`; + the string format is irrelevant as long as `Date` can parse it. Matching + the Claude/Codex convention (ISO with explicit timezone) keeps the field + greppable across collectors. + """ + if reset_at_ms <= 0: + return "" + return datetime.fromtimestamp(reset_at_ms / 1000.0, tz=timezone.utc).isoformat() + + +def limits_cache_paths(token: str, region: str) -> tuple[Path, Path]: + identity = str(config_root()) + "\0" + region + "\0" + token + digest = hashlib.sha256(identity.encode()).hexdigest()[:24] + root = cache_root() + return root / f"mcode-limits-{digest}.json", root / f"mcode-limits-{digest}.lock" + + +def cached_limits(max_age_seconds: float) -> dict[str, Any]: + token = load_access_token() + _, region = auth_record_dir() + if not token: + return {"limits": []} + + def probe(cache_file: Path | None = None) -> dict[str, Any]: + cached = None + if cache_file is not None: + try: + cached = json.loads(cache_file.read_text()) + if not isinstance(cached, dict) or not isinstance(cached.get("limits"), list): + cached = None + except (OSError, ValueError): + pass + if cached and max_age_seconds > 0 and time.time() * 1000 - cached.get("limitsFetchedAt", 0) < max_age_seconds * 1000: + return cached + result = fetch_token_plan_quota(token, platform_base_url(region)) + if not result.get("transportFailure"): + result["limitsFetchedAt"] = int(time.time() * 1000) + if cache_file is not None: + try: + write_json(cache_file, result) + except OSError: + pass + elif cached and cached.get("limits"): + result["limits"] = cached["limits"] + result["limitsFetchedAt"] = cached.get("limitsFetchedAt", 0) + result["limitsStale"] = True + if result.get("transportFailure"): + result["usageStatusText"] = "MiniMax limits unavailable" + return result + + try: + cache_file, lock_file = limits_cache_paths(token, region) + with lock_file.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + return probe(cache_file) + except OSError: + return probe() + + +def main() -> int: + parser = argparse.ArgumentParser() + # --force rescans everything and rewrites both caches. --limits-only is kept + # for CLI compatibility with the panel's refreshLimits() call: only the + # limits probe must be fresh, so it may reuse a scan for far longer than a + # normal run, whose short window exists purely to dedup concurrent + # collector runs. + parser.add_argument("--force", action="store_true") + parser.add_argument("--limits-only", action="store_true") + args = parser.parse_args() + + if args.force: + scan_age = 0 + limits_age = 0 + elif args.limits_only: + # refreshLimits() wants fresh windows without paying for a transcript + # rescan; reuse the cached stats and bypass the limits cache too. + scan_age = LIMITS_ONLY_REUSE_SECONDS + limits_age = 0 + else: + scan_age = SCAN_REUSE_SECONDS + limits_age = LIMITS_REUSE_SECONDS + + stats = cached_local_stats(scan_age) + limits_record = cached_limits(limits_age) + # auth_status reports the broadest state ("Waiting for auth" when no login + # exists at all); the live probe can override it with finer-grained messages + # ("Sign-in expired", "MiniMax limits unavailable") when a login exists. + auth = auth_status() + usage_status = limits_record.get("usageStatusText") or auth.get("usageStatusText", "") + auth_help = limits_record.get("authHelpText") or auth.get("authHelpText", "") + + record = { + "schemaVersion": 1, + "id": AGENT_ID, + "name": AGENT_NAME, + "updatedAt": datetime.now(timezone.utc).isoformat(), + "ready": bool(stats.get("modelUsage")) or bool(limits_record.get("limits")), + "hasLocalStats": True, + "scope": "device", + "limits": limits_record.get("limits", []), + "tierLabel": "", + } + # Set after the merge so the probe's finer-grained status overrides the + # auth_status default; auth_help uses `or` so the broader "run mcode login" + # hint falls back when the probe didn't have a more specific one. + record["usageStatusText"] = usage_status + record["authHelpText"] = auth_help + for field in ("limitsStale", "limitsFetchedAt"): + if field in limits_record: + record[field] = limits_record[field] + if limits_record.get("retryAdvised"): + record["retryAdvised"] = True + record.update(stats) + print(json.dumps(record, separators=(",", ":"), sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 7675424ba80..6807df5df79 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Set and launch the default coding agent -# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse|kimi] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse|kimi|mcode] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -43,8 +43,9 @@ muse | muse-code | musecode) ;; cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;; kimi | kimi-code) agent="kimi"; name="Kimi"; agent_package="npm:@moonshot-ai/kimi-code" ;; +mcode | minimax-code | minimax) agent="mcode"; name="MiniMax Code"; agent_package="npm:@minimax-ai/code[allow_builds=better-sqlite3]" ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index 74454a88b90..b4da0662cd9 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -17,6 +17,12 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w ~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \ ~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk + # Preserve a user-managed MiniMax launcher at the same path. + if [[ -f ~/.local/bin/mcode && ! -L ~/.local/bin/mcode ]] && + grep -Eq '^mise use -g .*"npm:@minimax-ai/code\[allow_builds=better-sqlite3\]"' ~/.local/bin/mcode; then + rm -f ~/.local/bin/mcode + fi + # Preserve a user-managed Kimi launcher at the same path. if [[ -f ~/.local/bin/kimi && ! -L ~/.local/bin/kimi ]] && grep -Eq '^mise use -g .*"npm:@moonshot-ai/kimi-code"' ~/.local/bin/kimi; then diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index df93aa555ae..15b3630fa94 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -17,6 +17,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG - `U+E90D` — Cursor, from - `U+E90E` — Claude, from +- `U+E911` — MiniMax Code, from the official MiniMax mark in `mcode.svg`, rendered monochrome The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/mcode.svg b/default/fonts/omarchy/mcode.svg new file mode 100644 index 00000000000..605a31aa154 --- /dev/null +++ b/default/fonts/omarchy/mcode.svg @@ -0,0 +1 @@ + diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 3c70d8031f1..077487f6ec9 100644 Binary files a/default/fonts/omarchy/omarchy.ttf and b/default/fonts/omarchy/omarchy.ttf differ diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index aeefe19fd3a..974abacc440 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -191,6 +191,7 @@ "setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"}, "setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"}, "setup.default.agent.kimi": {"icon":"󰚩","label":"Kimi","checked":"[[ \"$(omarchy-default-agent)\" == \"kimi\" ]]","action":"omarchy-default-agent kimi"}, + "setup.default.agent.mcode": {"icon":"","iconFont":"omarchy","label":"MiniMax Code","checked":"[[ \"$(omarchy-default-agent)\" == \"mcode\" ]]","action":"omarchy-default-agent mcode"}, "setup.default.agent.muse": {"icon":"󰛤","label":"Muse Code","checked":"[[ \"$(omarchy-default-agent)\" == \"muse\" ]]","action":"omarchy-default-agent muse"}, "setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"}, "setup.default.agent.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","checked":"[[ \"$(omarchy-default-agent)\" == \"openclaw\" ]]","action":"omarchy-default-agent openclaw"}, diff --git a/install/user/mise.sh b/install/user/mise.sh index 7923c8227d9..37140e5695a 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -5,6 +5,8 @@ mise settings set upgrade.auto_prune false omarchy-mise-install codex omarchy-mise-install claude omarchy-mise-install crush +# SQLite needs its native dependency build, scoped to this package. +omarchy-mise-install "npm:@minimax-ai/code[allow_builds=better-sqlite3]" mcode omarchy-mise-install antigravity-cli agy omarchy-mise-install gh omarchy-mise-install copilot diff --git a/manual/17-ai.md b/manual/17-ai.md index 0eba2b148e5..abb60878b4e 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -11,6 +11,7 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f | `copilot` | [GitHub Copilot CLI](https://github.com/github/copilot-cli) | | `crush` | [Crush](https://github.com/charmbracelet/crush) | | `grok` | [Grok Build](https://x.ai/build) from xAI | +| `mcode` | [MiniMax Code](https://github.com/MiniMax-AI/minimax-code), MiniMax’s coding agent | | `kimi` | [Kimi Code](https://www.kimi.com/code/docs/en/) | | `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) | | `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | diff --git a/migrations/1791940802.sh b/migrations/1791940802.sh new file mode 100644 index 00000000000..3b5d7724e7a --- /dev/null +++ b/migrations/1791940802.sh @@ -0,0 +1,5 @@ +echo "Install the MiniMax Code agent stub" + +if omarchy-cmd-missing mcode && [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install "npm:@minimax-ai/code[allow_builds=better-sqlite3]" mcode +fi diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 14b7b453733..7de635568d3 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -421,7 +421,14 @@ Panel { // A lapsed or missing sign-in is something you can fix from here; any other // trouble is only reported. + function supportsSignIn(p) { + for (var i = 0; i < addProviders.length; i++) + if (p && addProviders[i].providerId === p.providerId) return true + return false + } + function needsSignIn(item) { + if (item && item.providerId && !supportsSignIn(item)) return false var status = String(item && item.usageStatusText || "") return status === "Sign-in expired" || status === "Waiting for auth" } @@ -438,7 +445,7 @@ Panel { // Sign an account that's already here in again, following along in the // add view just like adding one. function signInAgain(p, account) { - if (!p || addStage === "running") return + if (!supportsSignIn(p) || addStage === "running") return addProvider = p.providerId addPrivate = !!account && account.primary !== true addStatus = "Starting…" @@ -511,13 +518,14 @@ Panel { // and that beats reading it back out of the label: a model-scoped limit is // titled after its model, and a name like "Opus 5 (1M context)" would parse // as a one-minute window. - function limitWindow(label, percent, resetAt, title) { + function limitWindow(label, percent, resetAt, title, unlimited) { var reset = new Date(String(resetAt || "")).getTime() var expired = isFinite(reset) && reset <= root.nowMs return { title: String(title || "") !== "" ? String(title) : windowTitle(label), percent: expired ? 0 : Number(percent), - resetAt: expired ? "" : String(resetAt || "") + resetAt: expired ? "" : String(resetAt || ""), + unlimited: unlimited === true } } @@ -528,7 +536,7 @@ Panel { for (var i = 0; i < list.length; i++) { var entry = list[i] || {} var percent = Number(entry.percent) - if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title)) + if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title, entry.unlimited)) } return out } @@ -1906,7 +1914,7 @@ Panel { text: { var lines = [] if (compact.window) { - lines.push(compact.window.title + ": " + Math.round(compact.window.percent * 100) + "% used" + lines.push(compact.window.title + ": " + (compact.window.unlimited ? "Unlimited" : Math.round(compact.window.percent * 100) + "% used") + (compact.resetMs > 0 ? " · resets in " + root.formatDuration(compact.resetMs) : "")) } if (compact.stale) @@ -1948,7 +1956,7 @@ Panel { id: compactValueMetrics font.family: root.fontFamily font.pixelSize: Style.font.bodySmall - text: "00h 00m" + text: compact.window && compact.window.unlimited ? "Unlimited" : "00h 00m" } // How long until the window resets. The meter says how full it is; the @@ -1960,7 +1968,7 @@ Panel { anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText horizontalAlignment: Text.AlignRight - text: compact.resetMs > 0 ? root.formatDuration(compact.resetMs) : "" + text: compact.window && compact.window.unlimited ? "Unlimited" : compact.resetMs > 0 ? root.formatDuration(compact.resetMs) : "" color: root.dim font.family: root.fontFamily font.pixelSize: Style.font.bodySmall diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index d05eb8aec1f..9bbe295212c 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -71,6 +71,7 @@ Adding an agent needs no QML changes: ship a collector that prints the record co | `codex` | The Codex app-server RPC | native Codex CLI session files on the built-in `openai` provider (plus pi and opencode sessions) | | `grok` | The credits endpoint behind Grok's `/usage` view (the billing period's included usage) | Each session's `usage.json` (the ledger `grok usage` prints: tokens by model per finished turn), plus `summary.json` for sessions | | `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days | +| `mcode` | MiniMax personal token-plan session and weekly quotas | `v2/sessions` JSONL transcripts in the active MiniMax data directory | | `kimi` | Kimi Code quotas (5-hour session, weekly, and monthly allowances) | none; quota only | When `~/.local/state/omarchy/agents/accounts/.json` @@ -206,3 +207,9 @@ One caveat on "all-time": the Codex collector only reads native session files touched in the last 30 days, and Fireworks requests the last 30 days from its billing API, so their totals and day counts cover that window. Claude's cover every transcript still on disk. + +## MiniMax Code + +MiniMax Code reads `MINIMAX_DATA_DIR`, then `MAVIS_DATA_DIR`, otherwise `~/.minimax` (or an explicitly enabled legacy runtime profile’s directory). It follows MiniMax’s inherited region, saved China/global preference, or sole reusable sign-in, reads the exact active OAuth record, and resolves the personal billing workspace before fetching token-plan limits. Credentials are read only and requests never follow redirects. Start MiniMax Code or run `mcode login` when sign-in expires; the panel displays that guidance without offering the account manager’s unsupported login action. + +Local transcript statistics add across synced devices. Limits remain local account measurements, cached separately by data directory, region, and bearer. An ordinary refresh reuses recent measurements; `--force` and `--limits-only` request fresh limits. Failed requests retain the same credential’s previous limits with their original timestamp and stale marker. diff --git a/shell/plugins/agents/assets/mcode-light.svg b/shell/plugins/agents/assets/mcode-light.svg new file mode 100644 index 00000000000..290d7c2411f --- /dev/null +++ b/shell/plugins/agents/assets/mcode-light.svg @@ -0,0 +1 @@ + diff --git a/shell/plugins/agents/assets/mcode.svg b/shell/plugins/agents/assets/mcode.svg new file mode 100644 index 00000000000..74a90280532 --- /dev/null +++ b/shell/plugins/agents/assets/mcode.svg @@ -0,0 +1 @@ + diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 21dc959dcf2..1d03d430163 100644 --- a/shell/plugins/agents/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -5,7 +5,7 @@ "version": "1.0.0", "author": "Omarchy", "license": "MIT", - "description": "Claude Code, Codex, Grok, Fireworks, and Kimi usage, limits, and pace in a native Omarchy bar panel.", + "description": "Claude Code, Codex, Grok, Fireworks, Kimi, and MiniMax Code usage, limits, and pace in a native Omarchy bar panel.", "kinds": ["bar-widget"], "activation": "on-demand", "entryPoints": { @@ -23,7 +23,8 @@ "codex": { "enabled": true }, "grok": { "enabled": true }, "fireworks": { "enabled": true }, - "kimi": { "enabled": true } + "kimi": { "enabled": true }, + "mcode": { "enabled": true } }, "refreshIntervalSec": 900, "syncMode": "Off", diff --git a/test/shell.d/agent-usage-mcode-limits-test.sh b/test/shell.d/agent-usage-mcode-limits-test.sh new file mode 100755 index 00000000000..ab4f8c78dc1 --- /dev/null +++ b/test/shell.d/agent-usage-mcode-limits-test.sh @@ -0,0 +1,283 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +# The collector reaches the openplatform host, so the reader that interprets +# its answer is exercised on its own: the collector loads as a module, and a +# recorded payload stands in for the response. The same stub records every +# request it sees so the test can assert on URL, headers, and method. +# +# Each call gets its own throwaway MINIMAX_DATA_DIR so a region preference and an +# auth record can be planted without leaking between cases. The wrapper +# emits one JSON envelope per run with `record` (the collector output) and +# `requests` (every URL the stub saw), so tests can assert on either side +# without parsing stdout twice. +run_with_stub() { + TEST_HOME=$(mktemp -d) + COLLECTOR="$ROOT/bin/omarchy-agent-usage-mcode" \ + PAYLOAD="$1" STATUS_CODE="${2:-0}" REGION="${3:-en}" \ + PREFERENCES="${4:-}" AUTH_PRESENT="${5:-yes}" \ + TRANSPORT_MODE="${7-ok}" \ + CACHE_HOME="$TEST_CACHE_HOME" MINIMAX_DATA_DIR="$TEST_HOME" \ + ARGS="${6---force}" \ + python3 - <<'PY' +import importlib.machinery, importlib.util, contextlib, io, json, os, sys, hashlib, base64 +from pathlib import Path + +loader = importlib.machinery.SourceFileLoader("collector", os.environ["COLLECTOR"]) +spec = importlib.util.spec_from_loader(loader.name, loader) +collector = importlib.util.module_from_spec(spec) +loader.exec_module(collector) + +home = Path(os.environ["MINIMAX_DATA_DIR"]) +preferences = os.environ.get("PREFERENCES") +if not preferences: + preferences = json.dumps({"version": 1, "regions": {"prod": os.environ["REGION"]}}) +if preferences: + (home / "preferences").mkdir(parents=True, exist_ok=True) + (home / "preferences" / "mcode-region.json").write_text(preferences, encoding="utf-8") +if os.environ.get("AUTH_PRESENT") == "yes": + auth_dir = home / "auth" / "prod" / os.environ["REGION"] / "mcode-public" + auth_dir.mkdir(parents=True, exist_ok=True) + (auth_dir / "auth.json").write_text(json.dumps({ + "schemaVersion": 1, + "records": {"com.minimax.mcode.oauth.prod." + os.environ["REGION"] + "\0" + base64.urlsafe_b64encode(hashlib.sha256((str(home / "auth") + "\0mcode-public").encode()).digest()).decode().rstrip("="): {"accessToken": "fake-token", "clientId": "mcode-public"}}, + }), encoding="utf-8") + +requests = [] +payload = os.environ["PAYLOAD"] +status_code = int(os.environ["STATUS_CODE"]) +transport_mode = os.environ["TRANSPORT_MODE"] +import urllib.error + +def stub(request, timeout=None): + requests.append({ + "url": request.full_url, + "method": request.get_method(), + "authorization": request.get_header("Authorization"), + "accept": request.get_header("Accept"), + }) + if transport_mode == "http_error": + raise urllib.error.HTTPError(request.full_url, status_code, "stub", {}, io.BytesIO(b"")) + if transport_mode == "conn_error": + raise OSError("simulated transport failure") + body = json.dumps({ + "base_resp": {"status_code": status_code, "status_msg": "ok" if status_code == 0 else "fail"}, + **json.loads(payload), + }) if payload else '{"base_resp":{"status_code":0,"status_msg":"empty"}}' + return io.BytesIO(body.encode("utf-8")) + +collector.personal_group = lambda token, region: "personal-group" +class Opener: + open = staticmethod(stub) +collector.urllib.request.build_opener = lambda *handlers: Opener() + +sys.argv = ["omarchy-agent-usage-mcode", *os.environ["ARGS"].split()] +stdout = io.StringIO() +with contextlib.redirect_stdout(stdout): + collector.main() +print(json.dumps({"record": json.loads(stdout.getvalue() or "{}"), "requests": requests})) +PY + rm -rf "$TEST_HOME" +} + +TEST_CACHE_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_CACHE_HOME"' EXIT + +# Happy path: a `general` entry plus a side `video` entry. The collector must +# pick the general entry's 5-hour and weekly windows and ignore the video side +# quota entirely. +happy=$(run_with_stub '{ + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 500, + "current_interval_usage_count": 110, + "current_interval_remaining_percent": 78, + "current_interval_status": 1, + "end_time": 1789171200000, + "current_weekly_total_count": 10000, + "current_weekly_usage_count": 1200, + "current_weekly_remaining_percent": 88, + "current_weekly_status": 1, + "weekly_end_time": 1789344000000 + }, + { + "model_name": "video", + "current_interval_total_count": 50, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 100, + "current_interval_status": 3, + "end_time": 1789171200000, + "current_weekly_total_count": 200, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 100, + "current_weekly_status": 3, + "weekly_end_time": 1789344000000 + } + ] +}') + +[[ $(jq -r '.record.limits | length' <<<"$happy") == "2" ]] || + fail "mcode collector emits one entry per window" "$happy" +pass "mcode collector emits one entry per window" + +[[ $(jq -r '.record.limits[0].label' <<<"$happy") == "5h" ]] || + fail "mcode collector labels the 5-hour window" "$happy" +# The panel clamps the meter to [0,1] and prints percent × 100, so the +# collector must emit a fraction (0.22) rather than an integer percent (22). +[[ $(jq -r '.record.limits[0].percent' <<<"$happy") == "0.22" ]] || + fail "mcode collector reports percent as a fraction (0.22, not 22)" "$happy" +[[ $(jq -r '.record.limits[0].resetsAt' <<<"$happy") == "2026-09-12T00:00:00+00:00" ]] || + fail "mcode collector pins 5-hour reset to end_time as ISO" "$happy" + +[[ $(jq -r '.record.limits[1].label' <<<"$happy") == "Weekly" ]] || + fail "mcode collector labels the weekly window" "$happy" +[[ $(jq -r '.record.limits[1].percent' <<<"$happy") == "0.12" ]] || + fail "mcode collector reports weekly percent as a fraction" "$happy" +[[ $(jq -r '.record.limits[1].resetsAt' <<<"$happy") == "2026-09-14T00:00:00+00:00" ]] || + fail "mcode collector pins weekly reset to weekly_end_time as ISO" "$happy" +pass "mcode collector reads 5-hour and weekly windows from the general entry" + +# An unlimited window carries no percent and flags itself so the panel can +# draw the right glyph. +unlimited=$(run_with_stub '{ + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 78, + "current_interval_status": 3, + "end_time": 1789171200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 100, + "current_weekly_status": 3, + "weekly_end_time": 1789344000000 + } + ] +}') + +# Bash `[[ == ]]` treats the right side as a glob, so the `.` in `0.0` +# matches any character. Quote the right side with `==` to do a literal +# string compare (the `==` inside `[[ ]]` already does that when both +# sides are quoted, but the explicit literal makes the intent clear). +expected='[{"label":"5h","percent":0.0,"resetsAt":"2026-09-12T00:00:00+00:00","unlimited":true},{"label":"Weekly","percent":0.0,"resetsAt":"2026-09-14T00:00:00+00:00","unlimited":true}]' +[[ $(jq -c '.record.limits' <<<"$unlimited") == "$expected" ]] || + fail "mcode collector flags an unlimited window without a percent" "$unlimited" +pass "mcode collector flags an unlimited window without a percent" + +# The MiniMax coding-plan endpoint reports account-wide windows, so the +# record must declare `scope: "account"` so sync merge keeps a single +# machine's view rather than summing machines (which would double-count). +[[ $(jq -r '.record.scope' <<<"$happy") == "device" ]] || + fail "mcode collector declares local stats device-scoped" "$happy" +pass "mcode collector declares local stats device-scoped" + +# When the platform returns 401/403 (the access token has lapsed), the +# collector must surface "Sign-in expired" so the panel can hint at +# `mcode login` rather than waiting the regular interval for a meter that +# will never refresh. +expired=$(run_with_stub '{}' 401 en "" yes "--force" http_error) + +[[ $(jq -r '.record.usageStatusText' <<<"$expired") == "Sign-in expired" ]] || + fail "mcode collector surfaces a Sign-in expired message on 401" "$expired" +[[ $(jq -r '.record.limits | length' <<<"$expired") == "0" ]] || + fail "mcode collector returns empty limits on 401" "$expired" +# An expired bearer won't fix itself with another probe; only `mcode login` +# can mint a new one, so retry advice would just churn the panel. +[[ $(jq -r '.record.retryAdvised // false' <<<"$expired") == "false" ]] || + fail "mcode collector does not advise retry on 401 (token can't self-refresh)" "$expired" +pass "mcode collector surfaces Sign-in expired without retry advice on 401" + +# A real transport failure (no HTTP response at all) is a separate path +# from a 4xx: the API is reachable but the bearer is fine, so the hint +# is "limits unavailable", not "Sign-in expired". The retry advice is +# the same shape so the panel knows to poll sooner. +conn_dead=$(run_with_stub '{}' 0 en "" yes "--force" conn_error) + +[[ $(jq -r '.record.usageStatusText' <<<"$conn_dead") == "MiniMax limits unavailable" ]] || + fail "mcode collector surfaces MiniMax limits unavailable on a connection error" "$conn_dead" +[[ $(jq -r '.record.retryAdvised // false' <<<"$conn_dead") == "true" ]] || + fail "mcode collector advises retry on a connection error" "$conn_dead" +[[ $(jq -r '.record.limits | length' <<<"$conn_dead") == "0" ]] || + fail "mcode collector returns empty limits on a connection error" "$conn_dead" +pass "mcode collector handles a connection error without crashing" + +# When the live probe succeeds after a failure, retryAdvised clears. The +# collector never replays a stale status, even if the cache file is +# still on disk from a prior auth-expired run. + +# A media-only response leaves the user with no coding quota; the collector +# must surface empty limits rather than fabricate a window off the video side. +media_only=$(run_with_stub '{ + "model_remains": [ + { + "model_name": "video", + "current_interval_status": 1, + "current_interval_remaining_percent": 50, + "end_time": 1789171200000, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 25, + "weekly_end_time": 1789344000000 + } + ] +}') + +[[ $(jq -c '.record.limits' <<<"$media_only") == "[]" ]] || + fail "mcode collector drops media-only responses" "$media_only" +pass "mcode collector drops media-only responses" + +# An empty `model_remains` array is the "no quota yet" response, not an error. +empty=$(run_with_stub '{"model_remains": []}') + +[[ $(jq -c '.record.limits' <<<"$empty") == "[]" ]] || + fail "mcode collector handles an empty model_remains" "$empty" +pass "mcode collector handles an empty model_remains" + +# An endpoint that says "no" with a non-zero base_resp status code is not a +# crash; the collector returns empty limits and lets the panel show whatever +# it last cached. +failed=$(run_with_stub '{"model_remains": [{"model_name":"general","current_interval_status":1,"current_interval_remaining_percent":78,"end_time":1,"current_weekly_status":1,"current_weekly_remaining_percent":88,"weekly_end_time":1}]}' "401") + +[[ $(jq -c '.record.limits' <<<"$failed") == "[]" ]] || + fail "mcode collector drops a non-zero base_resp" "$failed" +pass "mcode collector drops a non-zero base_resp" + +# Region: cn preference must steer the request at the cn host, en at the en +# one. The Authorization header is the only auth header the endpoint reads. +cn=$(run_with_stub '{ + "model_remains": [{"model_name":"general","current_interval_status":1,"current_interval_remaining_percent":90,"end_time":1,"current_weekly_status":1,"current_weekly_remaining_percent":80,"weekly_end_time":2}] +}' 0 cn '{"version":1,"regions":{"prod":"cn"}}' yes "--force") + +en=$(run_with_stub '{ + "model_remains": [{"model_name":"general","current_interval_status":1,"current_interval_remaining_percent":90,"end_time":1,"current_weekly_status":1,"current_weekly_remaining_percent":80,"weekly_end_time":2}] +}' 0 en '{"version":1,"regions":{"prod":"en"}}' yes "--force") + +cn_url=$(jq -r '.requests[0].url' <<<"$cn") +en_url=$(jq -r '.requests[0].url' <<<"$en") + +[[ $cn_url == https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains ]] || + fail "mcode collector hits the cn openplatform host when region is cn" "$cn_url" +[[ $en_url == https://platform.minimax.io/v1/api/openplatform/coding_plan/remains ]] || + fail "mcode collector hits the en openplatform host when region is en" "$en_url" + +[[ $(jq -r '.requests[0].authorization' <<<"$cn") == "Bearer fake-token" ]] || + fail "mcode collector sends the bearer token in Authorization" "$cn" +[[ $(jq -r '.requests[0].method' <<<"$cn") == "GET" ]] || + fail "mcode collector uses GET, not POST, for the coding-plan probe" "$cn" +pass "mcode collector routes the probe by region and signs it with the bearer" + +# --limits-only is the panel's refreshLimits() path: it must still emit a +# record with limits under the right key. +limits_only=$(run_with_stub '{ + "model_remains": [{"model_name":"general","current_interval_status":1,"current_interval_remaining_percent":80,"end_time":1,"current_weekly_status":1,"current_weekly_remaining_percent":70,"weekly_end_time":2}] +}' 0 en "" yes "--limits-only") + +[[ $(jq -r '.record.limits[0].percent' <<<"$limits_only") == "0.2" ]] || + fail "mcode collector returns a fresh probe under --limits-only" "$limits_only" +pass "mcode collector returns a fresh probe under --limits-only" diff --git a/test/shell.d/agent-usage-mcode-regression-test.sh b/test/shell.d/agent-usage-mcode-regression-test.sh new file mode 100755 index 00000000000..f9e884a3294 --- /dev/null +++ b/test/shell.d/agent-usage-mcode-regression-test.sh @@ -0,0 +1,237 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +require_command python3 + +COLLECTOR="$ROOT/bin/omarchy-agent-usage-mcode" python3 - <<'PY' +import base64, contextlib, hashlib, http.client, importlib.machinery, importlib.util +import io, json, os, tempfile, threading, time, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +loader = importlib.machinery.SourceFileLoader('mcode', os.environ['COLLECTOR']) +spec = importlib.util.spec_from_loader(loader.name, loader) +c = importlib.util.module_from_spec(spec) +loader.exec_module(c) +scratch = tempfile.TemporaryDirectory() +root = Path(scratch.name) +os.environ['HOME'] = str(root / 'home') +os.environ['XDG_CACHE_HOME'] = str(root / 'cache') +for key in ('MCODE_HOME', 'MINIMAX_DATA_DIR', 'MAVIS_DATA_DIR', '__MAVIS_RUNTIME_PROFILE', '__MAVIS_ALLOW_LEGACY_RUNTIME_ENV', 'MAVIS_REGION'): + os.environ.pop(key, None) +home = root / 'data' +os.environ['MINIMAX_DATA_DIR'] = str(home) +state = {'requests': [], 'mode': 'ok', 'payload': {'model_remains': [{ + 'model_name': 'general', 'current_interval_remaining_percent': 78, + 'end_time': int((time.time() + 18000) * 1000), + 'current_weekly_remaining_percent': 88, 'weekly_end_time': int((time.time() + 604800) * 1000), +}]}} + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_POST(self): self.do_GET() + def do_GET(self): + state['requests'].append((self.command, self.path, dict(self.headers))) + if self.path.startswith('/v1/api/user/info'): + payload = {'data': {'userInfo': {'realUserID': 'fixture-user'}}} + elif self.path.startswith('/matrix/api/v1/user/get_user_extra_info'): + assert self.command == 'POST' + assert self.rfile.read(int(self.headers.get('Content-Length', 0))) == b'{}' + payload = {'workspaces': [{'workspace_type': 1, 'op_group_id': 'team'}, + {'workspace_type': 0, 'op_group_id': 'personal'}]} + else: + if state['mode'] == 'redirect': + self.send_response(302) + self.send_header('Location', 'https://untrusted.example/stolen') + self.end_headers() + return + if state['mode'] == 'failure': + self.send_response(503) + self.end_headers() + return + payload = state['payload'] + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(payload).encode()) + +server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) +threading.Thread(target=server.serve_forever, daemon=True).start() +class FixtureHTTPS(urllib.request.HTTPSHandler): + def https_open(self, req): + return self.do_open(lambda host, timeout=8, **kwargs: + http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=timeout), req) +real_build_opener = urllib.request.build_opener +c.urllib.request.build_opener = lambda *handlers: real_build_opener(FixtureHTTPS(), *handlers) + +def credential(token='fixture-A', region='en', directory=None): + directory = Path(os.path.abspath(directory or os.environ['MINIMAX_DATA_DIR'])) + (directory / 'preferences').mkdir(parents=True, exist_ok=True) + (directory / 'preferences/mcode-region.json').write_text(json.dumps({'version': 1, 'regions': {'prod': region}})) + auth = directory / 'auth/prod' / region / 'mcode-public' + auth.mkdir(parents=True, exist_ok=True) + account = base64.urlsafe_b64encode(hashlib.sha256((str(directory / 'auth') + '\0mcode-public').encode()).digest()).decode().rstrip('=') + key = 'com.minimax.mcode.oauth.prod.' + region + '\0' + account + (auth / 'auth.json').write_text(json.dumps({'schemaVersion': 1, 'records': { + 'unrelated': {'clientId': 'mcode-public', 'accessToken': 'wrong-account'}, + key: {'schemaVersion': 1, 'clientId': 'mcode-public', 'accessToken': token}, + }})) + return auth / 'auth.json' + +def run(*args): + import sys + sys.argv = ['collector', *args] + output = io.StringIO() + with contextlib.redirect_stdout(output): c.main() + return json.loads(output.getvalue()) + +failures = 0 +def check(label, function): + global failures + try: + function() + print('ok - ' + label) + except Exception as exc: + failures += 1 + print('not ok - ' + label + ': ' + repr(exc)) + +def assert_true(value): assert value +credential() +check('MiniMax honors its real data-directory override', lambda: assert_true(c.config_root() == home)) +os.environ['MAVIS_DATA_DIR'] = str(root / 'other') +check('MiniMax prefers MINIMAX_DATA_DIR to MAVIS_DATA_DIR', lambda: assert_true(c.config_root() == home)) +check('MiniMax selects the exact native OAuth record', lambda: assert_true(c.load_access_token() == 'fixture-A')) + +def first_quota(): + record = run('--force') + assert len(record['limits']) == 2 and record['limits'][0]['percent'] == .22 + assert record['scope'] == 'device' and record['ready'] + request = state['requests'][-1] + assert request[0] == 'GET' and request[2]['Authorization'] == 'Bearer fixture-A' + assert request[2]['X-Group-Id'] == 'personal' + identity = state['requests'][-3] + assert 'x-signature' in {key.lower() for key in identity[2]} +check('MiniMax resolves its personal billing group and fetches real HTTP quota', first_quota) + +def cached(): + before = len(state['requests']) + record = run() + assert len(state['requests']) == before and len(record['limits']) == 2 +check('MiniMax reuses recent quota without another request', cached) +def fresh(): + before = len(state['requests']) + run('--limits-only') + assert len(state['requests']) == before + 3 +check('MiniMax limits-only bypasses the quota cache', fresh) + +def stale(): + previous = run('--force') + state['mode'] = 'failure' + record = run('--force') + assert record['limits'] == previous['limits'] and record['limitsStale'] + assert record['limitsFetchedAt'] == previous['limitsFetchedAt'] and record['retryAdvised'] +check('MiniMax preserves dated stale limits on a same-account failure', stale) +def changed_account(): + credential('fixture-B') + record = run('--force') + assert record['limits'] == [] and not record.get('limitsStale') +check('MiniMax never reuses another bearer’s quota', changed_account) +state['mode'] = 'ok' +credential() +def changed_home(): + os.environ['MINIMAX_DATA_DIR'] = str(root / 'second-data') + credential() + state['mode'] = 'failure' + assert run('--force')['limits'] == [] +check('MiniMax isolates the quota cache by data directory', changed_home) +os.environ['MINIMAX_DATA_DIR'] = str(home) +state['mode'] = 'ok' + +def wrong_region(): + (home / 'preferences/mcode-region.json').write_text('{"version":1,"regions":{"prod":"cn"}}') + before = len(state['requests']) + assert run('--force')['limits'] == [] and len(state['requests']) == before +check('MiniMax never borrows another region’s sign-in', wrong_region) +credential() +def missing_window(): + state['payload'] = {'model_remains': [{'model_name': 'general', 'current_interval_remaining_percent': 75}]} + record = run('--force') + assert record['limits'] == [{'label': '5h', 'percent': .25}] +check('MiniMax omits quota windows absent from the API', missing_window) +def legacy_counts(): + state['payload'] = {'model_remains': [{'model_name': 'general', 'current_interval_total_count': 200, 'current_interval_usage_count': 150}]} + assert run('--force')['limits'] == [{'label': '5h', 'percent': .25}] +check('MiniMax understands the upstream legacy remaining-count fields', legacy_counts) +def redirect(): + state['mode'] = 'redirect' + before = len(state['requests']) + run('--force') + assert len(state['requests']) == before + 3 + assert all('/stolen' not in req[1] for req in state['requests']) +check('MiniMax refuses credential-bearing HTTP redirects', redirect) +state['mode'] = 'ok' + +def all_time(): + directory = home / 'v2/sessions/2020/01/01/old-session' + directory.mkdir(parents=True, exist_ok=True) + messages = [ + {'message_id': 'user', 'message': {'role': 'user', 'timestamp': 1577836800000}}, + {'message_id': 'assistant', 'message': {'role': 'assistant', 'model': 'fixture-model', 'timestamp': 1577836800000, 'usage': {'input': 10, 'output': 5}}}, + {'message_id': 'tool-turn', 'message': {'role': 'assistant', 'model': 'fixture-model', 'timestamp': 1577836800000, 'usage': {'input': 0, 'output': 0, 'cacheRead': 20}}}, + ] + path = directory / 'messages.jsonl' + path.write_text('\n'.join(json.dumps(message) for message in messages)) + os.utime(path, (1577836800, 1577836800)) + record = run('--force') + assert record['totalPrompts'] == 1 and record['totalSessions'] == 1 + assert record['modelUsage']['fixture-model']['cacheReadInputTokens'] == 20 + assert record['modelUsage']['fixture-model']['inputTokens'] == 10 +check('MiniMax keeps old history, user prompt counts, and cache-only turns', all_time) +def cache_unavailable(): + cache_file = root / 'not-a-directory' + cache_file.write_text('file') + os.environ['XDG_CACHE_HOME'] = str(cache_file) + assert run('--force')['modelUsage']['fixture-model']['inputTokens'] == 10 +check('MiniMax still reports usage when cache creation fails', cache_unavailable) +def no_credentials(): + path = credential() + path.write_text('{"schemaVersion":1,"records":{}}') + record = run('--force') + assert record['limits'] == [] and record['usageStatusText'] == 'Waiting for auth' +check('MiniMax recognizes logout despite a surviving auth directory', no_credentials) +os.environ['XDG_CACHE_HOME'] = str(root / 'cache') + +def lexical_symlink(): + linked = root / 'linked-data' + linked.symlink_to(home, target_is_directory=True) + os.environ['MINIMAX_DATA_DIR'] = str(linked) + credential('linked-account') + assert c.config_root() == linked and c.load_access_token() == 'linked-account' +check('MiniMax preserves the native lexical credential identity through symlinks', lexical_symlink) +os.environ['MINIMAX_DATA_DIR'] = str(home) +def inherited_region(): + credential('global-account', 'en') + credential('china-account', 'cn') + os.environ['MAVIS_REGION'] = 'en' + assert c.load_access_token() == 'global-account' +check('MiniMax inherited runtime region takes precedence over saved preferences', inherited_region) +os.environ.pop('MAVIS_REGION') +def reusable_region(): + (home / 'preferences/mcode-region.json').unlink() + state_path = home / 'auth/prod/en/mcode-public/auth-state.json' + state_path.write_text('{"buildEnv":"prod","region":"en","status":"authenticated"}') + assert c.load_access_token() == 'global-account' +check('MiniMax follows a sole reusable region when preferences are absent', reusable_region) + +os.environ.pop('MINIMAX_DATA_DIR') +os.environ.pop('MAVIS_DATA_DIR') +os.environ['__MAVIS_RUNTIME_PROFILE'] = 'work' +check('MiniMax ignores inactive legacy profile variables', lambda: assert_true(c.config_root() == Path.home() / '.minimax')) +os.environ['__MAVIS_ALLOW_LEGACY_RUNTIME_ENV'] = '1' +check('MiniMax honors an explicitly enabled legacy runtime profile', lambda: assert_true(c.config_root() == Path.home() / '.minimax-work')) + +server.shutdown() +scratch.cleanup() +raise SystemExit(bool(failures)) +PY diff --git a/test/shell.d/agent-usage-mcode-scanner-test.sh b/test/shell.d/agent-usage-mcode-scanner-test.sh new file mode 100755 index 00000000000..589a02cfcaf --- /dev/null +++ b/test/shell.d/agent-usage-mcode-scanner-test.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +require_command jq +require_command python3 + +TEST_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME"' EXIT + +# The collector reads ~/.minimax/v2/sessions///messages.jsonl. +# A user without mcode installed has no session directory at all; the +# collector must still print a valid record with an empty auth prompt +# instead of crashing the whole usage update. +mkdir -p "$TEST_HOME/.minimax/v2/sessions" + +result=$(HOME="$TEST_HOME" XDG_CACHE_HOME="$TEST_HOME/.cache" \ + "$ROOT/bin/omarchy-agent-usage-mcode" --force 2>/dev/null) || + fail "mcode collector runs without any session directory" + +[[ $(jq -r '.id' <<<"$result") == "mcode" ]] || + fail "mcode collector identifies as mcode" "$result" +pass "mcode collector identifies as mcode" + +[[ $(jq -r '.name' <<<"$result") == "MiniMax Code" ]] || + fail "mcode collector labels as MiniMax Code" "$result" +pass "mcode collector labels as MiniMax Code" + +[[ $(jq -r '.ready' <<<"$result") == "false" ]] || + fail "mcode collector reports ready=false with no usage" "$result" +pass "mcode collector reports ready=false with no usage" + +[[ $(jq -r '.authHelpText' <<<"$result") == "Run \`mcode login\` to authenticate." ]] || + fail "mcode collector surfaces the auth prompt when no sessions exist" "$result" +pass "mcode collector surfaces the auth prompt when no sessions exist" + +# A fresh install leaves an auth record on disk: the auth prompt should +# quiet down even though no usage has been recorded yet. +mkdir -p "$TEST_HOME/.minimax/auth/prod/en/mcode-public" +echo '{}' >"$TEST_HOME/.minimax/auth/prod/en/mcode-public/placeholder.json" + +result=$(HOME="$TEST_HOME" XDG_CACHE_HOME="$TEST_HOME/.cache" \ + "$ROOT/bin/omarchy-agent-usage-mcode" --force 2>/dev/null) || + fail "mcode collector runs with an auth record present" + +[[ $(jq -r '.authHelpText' <<<"$result") == 'Run `mcode login` to authenticate.' ]] || + fail "mcode collector requires a valid active credential" "$result" +pass "mcode collector requires a valid active credential" + +# A session directory with two messages.jsonl files: the collector must +# count each assistant turn once and attribute tokens by model. +session_dir="$TEST_HOME/.minimax/v2/sessions/$(date +%Y/%m/%d)/session_a" +mkdir -p "$session_dir" +today_ms=$(($(date +%s) * 1000)) +cat >"$session_dir/messages.jsonl" <"$session_dir_b/messages.jsonl" </dev/null) || + fail "mcode collector scans a populated session directory" + +# Each session's messages.jsonl counts as one session; the two turns are +# counted separately because they live in different session directories. +[[ $(jq -r '.todayPrompts' <<<"$result") == "1" ]] || + fail "mcode collector counts user prompts" "$result" +pass "mcode collector counts user prompts" + +[[ $(jq -r '.todaySessions' <<<"$result") == "2" ]] || + fail "mcode collector attributes sessions by directory" "$result" +pass "mcode collector attributes sessions by directory" + +[[ $(jq -r '.todayTotalTokens' <<<"$result") == "300" ]] || + fail "mcode collector sums input + output + cache" "$result" +pass "mcode collector sums input + output + cache" + +[[ $(jq -r '.modelUsage["minimax-m3"].inputTokens' <<<"$result") == "150" ]] || + fail "mcode collector reports non-cached input separately from cache reads" "$result" +pass "mcode collector reports non-cached input separately from cache reads" + +[[ $(jq -r '.modelUsage["minimax-m3"].cacheReadInputTokens' <<<"$result") == "90" ]] || + fail "mcode collector reports cache reads under their own bucket" "$result" +pass "mcode collector reports cache reads under their own bucket" + +[[ $(jq -r '.ready' <<<"$result") == "true" ]] || + fail "mcode collector flips ready to true once usage exists" "$result" +pass "mcode collector flips ready to true once usage exists" + +# A usage payload with no breakdown but a positive totalTokens should +# still count toward input so the model row has something to show. +session_dir_c="$TEST_HOME/.minimax/v2/sessions/$(date +%Y/%m/%d)/session_c" +mkdir -p "$session_dir_c" +cat >"$session_dir_c/messages.jsonl" </dev/null) || + fail "mcode collector scans a totals-only usage payload" + +[[ $(jq -r '.modelUsage["minimax-m3"].inputTokens' <<<"$result") == "200" ]] || + fail "mcode collector attributes totals-only to input" "$result" +pass "mcode collector attributes totals-only to input" + +# User messages must not be counted as prompts. +session_dir_d="$TEST_HOME/.minimax/v2/sessions/$(date +%Y/%m/%d)/session_d" +mkdir -p "$session_dir_d" +cat >"$session_dir_d/messages.jsonl" </dev/null) || + fail "mcode collector scans a user-only session" + +[[ $(jq -r '.todayPrompts' <<<"$result") == "2" ]] || + fail "mcode collector counts user-only prompts" "$result" +pass "mcode collector counts user-only prompts" + +# MINIMAX_DATA_DIR overrides the default ~/.minimax root. +OVERRIDE_HOME=$(mktemp -d) +trap 'rm -rf "$TEST_HOME" "$OVERRIDE_HOME"' EXIT + +mkdir -p "$OVERRIDE_HOME/v2/sessions/$(date +%Y/%m/%d)/session_override" +cat >"$OVERRIDE_HOME/v2/sessions/$(date +%Y/%m/%d)/session_override/messages.jsonl" </dev/null) || + fail "mcode collector honors MINIMAX_DATA_DIR override" + +[[ $(jq -r '.todayPrompts' <<<"$result") == "0" ]] || + fail "mcode collector reads from MINIMAX_DATA_DIR" "$result" +pass "mcode collector reads from MINIMAX_DATA_DIR" diff --git a/test/shell.d/agents-panel-test.sh b/test/shell.d/agents-panel-test.sh index e79ca340229..9a98666b788 100755 --- a/test/shell.d/agents-panel-test.sh +++ b/test/shell.d/agents-panel-test.sh @@ -7,7 +7,29 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' const fs = require('fs') const panelSource = fs.readFileSync(root + '/shell/plugins/agents/Panel.qml', 'utf8') +const limitWindowSource = panelSource.match(/function limitWindow\([^]*?\n \}/)[0] +const limitWindow = new Function('root', 'windowTitle', 'return (' + limitWindowSource + ')')( + { nowMs: Date.parse('2026-10-14T07:00:00Z') }, label => label +) +assert(limitWindow('5h', 0, '', '', true).unlimited === true, 'agents panel preserves an unlimited allowance') +assert(limitWindow('Weekly', .5, '', '', false).unlimited === false, 'agents panel retains ordinary metered allowances') +const expiredUnlimited = limitWindow('5h', 0, '2020-01-01T00:00:00Z', '', true) +assert(expiredUnlimited.unlimited && expiredUnlimited.resetAt === '', 'expired reset timestamps do not invent a cap for unlimited allowances') +const signInFunctions = ['supportsSignIn', 'needsSignIn', 'otherTrouble'].map(name => { + const match = panelSource.match(new RegExp('function ' + name + '\\([^]*?\\n \\}')) + return match ? match[0] : '' +}).join('\n') +const signIn = new Function('addProviders', signInFunctions + '\nreturn {needsSignIn, otherTrouble}')( + [{providerId: 'claude'}, {providerId: 'codex'}, {providerId: 'grok'}] +) +for (const id of ['claude', 'codex', 'grok']) + assert(signIn.needsSignIn({providerId: id, usageStatusText: 'Sign-in expired'}), id + ' retains its supported account sign-in action') +assert(signIn.needsSignIn({usageStatusText: 'Sign-in expired'}), 'managed account rows retain their sign-in action') +for (const status of ['Sign-in expired', 'Waiting for auth']) { + assert(!signIn.needsSignIn({providerId: 'mcode', usageStatusText: status}), 'MiniMax does not invoke an unsupported account login for ' + status) + assert(signIn.otherTrouble({providerId: 'mcode', usageStatusText: status}) === status, 'MiniMax keeps its login guidance visible for ' + status) +} assert(/function launchAgent\(\)/.test(panelSource), 'agents panel launches the default agent') assert(/root\.bar\.run\("omarchy-agent --pick"\)/.test(panelSource), 'agents panel uses the desktop agent launcher') assert(/if \(buttonCode === Qt\.RightButton\) root\.launchAgent\(\)/.test(panelSource), 'agents right click launches the agent') diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index d3bdc792b9b..1b0f25af5c2 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -131,6 +131,7 @@ omp_package="github:can1357/oh-my-pi" crush_package="crush" agy_package="antigravity-cli" ori_package="github:OpenRouterLabs/ori-releases" +mcode_package="npm:@minimax-ai/code[allow_builds=better-sqlite3]" kimi_package="npm:@moonshot-ai/kimi-code" cursor_agent_package="cursor-agent" muse_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]" @@ -152,6 +153,7 @@ assert_lazy_stub "$grok_package" grok assert_lazy_stub "$omp_package" omp assert_lazy_stub "$crush_package" crush assert_lazy_stub "$ori_package" ori +assert_lazy_stub "$mcode_package" mcode assert_lazy_stub "$kimi_package" kimi assert_lazy_stub "$cursor_agent_package" cursor-agent assert_lazy_stub "$muse_package" muse @@ -512,6 +514,9 @@ declare -A expected_agents=( [gemini-cli]="agy" [copilot]="copilot" [github-copilot]="copilot" + [mcode]="mcode" + [minimax]="mcode" + [minimax-code]="mcode" [kimi]="kimi" [kimi-code]="kimi" [cursor]="cursor-agent" @@ -532,6 +537,7 @@ declare -A expected_packages=( [grok]="$grok_package" [agy]="$agy_package" [copilot]="copilot" + [mcode]="$mcode_package" [kimi]="$kimi_package" [cursor-agent]="$cursor_agent_package" [muse]="$muse_package" @@ -789,6 +795,7 @@ assert_bypass() { assert_launched "$agent" "skips permission prompts" "$@" } +assert_launch mcode mcode -- "Review this project" assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" @@ -803,6 +810,10 @@ assert_launch hermes env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--qu assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" assert_launch kimi kimi --prompt "Review this project" +printf '%s\n' mcode >"$agent_file" +omarchy-agent-prompt '-n --help; echo literal' +assert_launched mcode "keeps an option-like prompt literal" mcode -- "-n --help; echo literal" + pass "agent launcher adapts initial prompts for every supported agent" literal_muse_prompt=$'--disable-sandbox !Crash {$(touch must-not-run)}\ntrailing\\ ' @@ -818,6 +829,9 @@ assert_launched hermes "binds its literal initial prompt" env -u HERMES_SESSION_ hermes chat --yolo --tui "--query=$literal_hermes_prompt" pass "Hermes receives prompted launches as one literal query argument" +printf '%s\n' mcode >"$agent_file" +omarchy-agent +assert_launched mcode "starts its interactive TUI" mcode assert_bypass pi pi assert_bypass omp omp --auto-approve assert_bypass opencode opencode --auto diff --git a/test/shell.d/mcode-agent-test.sh b/test/shell.d/mcode-agent-test.sh new file mode 100755 index 00000000000..71de2075c9c --- /dev/null +++ b/test/shell.d/mcode-agent-test.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mock_bin="$test_tmp/bin" +export HOME="$test_tmp/home with spaces" +export PATH="$mock_bin:$HOME/.local/bin:$ROOT/bin:/usr/local/bin:/usr/bin" +export OMARCHY_PATH="$ROOT" +export OMARCHY_TEST_MISE_LOG="$test_tmp/mise-log" +mkdir -p "$mock_bin" "$HOME/.local/bin" +mcode="$HOME/.local/bin/mcode" +package="npm:@minimax-ai/code[allow_builds=better-sqlite3]" + +cat >"$mock_bin/mise" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" +SH +cat >"$mock_bin/noop" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$mock_bin/mise" "$mock_bin/noop" +for command in gum hyprctl omarchy-webapp-remove-all omarchy-tui-remove-all omarchy-pkg-drop; do + ln -s noop "$mock_bin/$command" +done + +apply_mcode() { + case $1 in + migration) source "$ROOT/migrations/1791940802.sh" >/dev/null ;; + removal) omarchy-remove-preinstalls >/dev/null ;; + esac +} + +apply_mcode migration +[[ -x $mcode && ! -L $mcode ]] || fail "MiniMax Code migration creates a missing wrapper" +[[ ! -s $OMARCHY_TEST_MISE_LOG ]] || fail "MiniMax Code migration installs the package before first launch" +cp "$mcode" "$test_tmp/wrapper" +apply_mcode migration +cmp -s "$mcode" "$test_tmp/wrapper" || fail "MiniMax Code migration is idempotent" +"$mcode" --prompt 'Review this project' +mapfile -d '' -t args <"$OMARCHY_TEST_MISE_LOG" +[[ ${args[*]} == "use -g --quiet $package x $package -- mcode --prompt Review this project" ]] || + fail "MiniMax Code migration wrapper installs the official package and forwards arguments" +pass "MiniMax Code migration creates a lazy, repeatable wrapper" + +rm -f "$mcode" +mkdir -p "$HOME/.local/state/omarchy" +touch "$HOME/.local/state/omarchy/preinstalls-removed" +apply_mcode migration +[[ ! -e $mcode ]] || fail "MiniMax Code migration respects removed preinstalls with a missing command" +rm "$HOME/.local/state/omarchy/preinstalls-removed" +pass "MiniMax Code migration respects the preinstall opt-out" + +for operation in migration removal; do + for kind in script binary symlink; do + rm -f "$mcode" + case $kind in + script) printf '#!/bin/bash\necho user-managed mcode\n' >"$mcode" ;; + binary) cp /usr/bin/true "$mcode" ;; + symlink) ln -s /usr/bin/true "$mcode" ;; + esac + [[ $kind == "symlink" ]] || chmod +x "$mcode" + rm -f "$test_tmp/saved-$kind" + cp -P "$mcode" "$test_tmp/saved-$kind" + for attempt in 1 2; do + apply_mcode "$operation" + cmp -s "$mcode" "$test_tmp/saved-$kind" || fail "MiniMax Code $operation preserves a user $kind" + if [[ $kind == "symlink" ]]; then + [[ -L $mcode && $(readlink "$mcode") == "/usr/bin/true" ]] || + fail "MiniMax Code $operation preserves the user's symlink" + else + [[ ! -L $mcode ]] || fail "MiniMax Code $operation keeps the user's $kind as a regular file" + fi + done + pass "MiniMax Code $operation preserves a user $kind on repeated runs" + done +done + +omarchy-mise-install "$package" mcode +apply_mcode removal +[[ ! -e $mcode ]] || fail "Remove Preinstalls removes Omarchy's MiniMax Code wrapper" +pass "Remove Preinstalls removes Omarchy's MiniMax Code wrapper" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index c8c67a86780..f3d604b2a58 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -251,6 +251,7 @@ const expectedAgents = { claude: { icon: '󰛄', label: 'Claude' }, codex: { icon: '\ue905', iconFont: 'omarchy', label: 'Codex' }, grok: { icon: '\ue904', iconFont: 'omarchy', label: 'Grok' }, + mcode: { icon: '\ue911', iconFont: 'omarchy', label: 'MiniMax Code' }, kimi: { icon: '\u{f06a9}', label: 'Kimi' }, hermes: { icon: '\ue90a', iconFont: 'omarchy', label: 'Hermes' }, openclaw: { icon: '\ue90c', iconFont: 'omarchy', label: 'OpenClaw' }, @@ -277,7 +278,7 @@ assertDeepEqual( defaultItems .filter(item => item.parent === 'setup.default.agent') .map(item => item.label), - ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Cursor CLI', 'Grok', 'Hermes', 'Kimi', 'Muse Code', 'omp', 'OpenClaw', 'OpenCode', 'Ori', 'Pi'], + ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Cursor CLI', 'Grok', 'Hermes', 'Kimi', 'MiniMax Code', 'Muse Code', 'omp', 'OpenClaw', 'OpenCode', 'Ori', 'Pi'], 'menu sorts coding agents alphabetically' ) const expectedDefaults = { @@ -863,5 +864,5 @@ assert( JS font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf") -[[ $font_charset == *"e900-e90e"* ]] || fail "Omarchy icon font includes every custom menu glyph" +[[ $font_charset == *"e900-e90e"* && $font_charset == *"e911"* ]] || fail "Omarchy icon font includes every custom menu glyph" pass "Omarchy icon font includes the official agent marks" diff --git a/test/shell.d/mise-provision-test.sh b/test/shell.d/mise-provision-test.sh index f19a31eb6aa..f89e8b47732 100644 --- a/test/shell.d/mise-provision-test.sh +++ b/test/shell.d/mise-provision-test.sh @@ -17,7 +17,7 @@ SH chmod +x "$mock_bin/mise" source "$ROOT/install/user/mise.sh" -for agent in kimi cursor-agent muse; do +for agent in kimi cursor-agent muse mcode; do [[ -x $HOME/.local/bin/$agent && ! -L $HOME/.local/bin/$agent ]] || fail "provisioning creates the missing $agent wrapper" cp "$HOME/.local/bin/$agent" "$test_tmp/$agent-wrapper" @@ -27,7 +27,7 @@ mapfile -d '' -t args <"$OMARCHY_TEST_MISE_LOG" fail "provisioning creates lazy wrappers without installing packages" pass "provisioning creates lazy Kimi, Cursor, and Muse wrappers" -for agent in kimi cursor-agent muse; do +for agent in kimi cursor-agent muse mcode; do launcher="$HOME/.local/bin/$agent" for kind in script binary symlink; do rm -f "$launcher"